diff --git a/crates/miyabi-cli/src/main.rs b/crates/miyabi-cli/src/main.rs index 314abfb..f3ce284 100644 --- a/crates/miyabi-cli/src/main.rs +++ b/crates/miyabi-cli/src/main.rs @@ -8,6 +8,7 @@ use std::fs; use std::io::{self, BufRead, BufReader, Write}; use std::net::{TcpListener, TcpStream}; use std::path::PathBuf; +use std::process::Command; use tracing_subscriber::EnvFilter; /// Global feature flags manager @@ -282,6 +283,17 @@ struct AssignExecutionPlan { next_steps: Vec, } +#[derive(Debug)] +struct InitStatus { + initialized: bool, + current_dir: String, + created_path: String, + git_repo: bool, + github_remote: Option, + gitignore_updated: bool, + github_project_detected: bool, +} + /// Collab canvas subcommands — wraps the collab CLI at ~/.local/bin/collab #[derive(Subcommand)] enum CollabCommand { @@ -1662,37 +1674,49 @@ fn initialize_gate_project( format: &OutputFormat, store_path: &std::path::Path, ) -> anyhow::Result<()> { - if store_path.exists() { - if matches!(format, OutputFormat::Json) { - println!( - "{}", - serde_json::to_string_pretty(&serde_json::json!({ - "status": "already_initialized", - "store_path": store_path.display().to_string(), - }))? - ); - } else { - println!("Already initialized"); - } - return Ok(()); - } - - if let Some(parent) = store_path.parent() { - fs::create_dir_all(parent)?; - } - - let snapshot = miyabi_core::store::TasksSnapshot::default(); - fs::write(store_path, serde_json::to_vec_pretty(&snapshot)?)?; - let current_dir = std::env::current_dir()?; let created_path = store_path.display().to_string(); + let initialized = if store_path.exists() { + false + } else { + if let Some(parent) = store_path.parent() { + fs::create_dir_all(parent)?; + } + + let snapshot = miyabi_core::store::TasksSnapshot::default(); + fs::write(store_path, serde_json::to_vec_pretty(&snapshot)?)?; + true + }; + + let git_repo = is_git_repository(); + let github_remote = git_origin_github_remote(); + let gitignore_updated = + ensure_project_memory_gitignore_entries(¤t_dir.join(".gitignore"))?; + let github_project_detected = github_remote + .as_deref() + .and_then(github_owner_from_remote) + .is_some_and(is_github_project_detected); + let status = InitStatus { + initialized, + current_dir: current_dir.display().to_string(), + created_path, + git_repo, + github_remote, + gitignore_updated, + github_project_detected, + }; + if matches!(format, OutputFormat::Json) { println!( "{}", serde_json::to_string_pretty(&serde_json::json!({ - "status": "initialized", - "current_dir": current_dir.display().to_string(), - "created": created_path, + "status": if status.initialized { "initialized" } else { "already_initialized" }, + "current_dir": status.current_dir, + "created": status.created_path, + "git_repository": status.git_repo, + "github_remote": status.github_remote, + "gitignore_updated": status.gitignore_updated, + "github_project_detected": status.github_project_detected, "next_steps": [ "miyabi gate register --issue --title ...", "miyabi gate status", @@ -1701,17 +1725,129 @@ fn initialize_gate_project( }))? ); } else { - println!("Polaris initialized in {}", current_dir.display()); - println!("Created: {}", created_path); + if status.initialized { + println!("Polaris initialized in {}", status.current_dir); + println!("Created: {}", status.created_path); + } else { + println!("Already initialized"); + } + if !status.git_repo { + println!("⚠️ Not a git repository. Run: git init"); + } + if status.github_remote.is_none() { + println!("⚠️ No GitHub remote. Run: gh repo create --private"); + } println!("Next steps:"); println!(" miyabi gate register --issue --title ..."); println!(" miyabi gate status"); println!(" miyabi gate --help"); + print_init_checklist(&status); } Ok(()) } +fn print_init_checklist(status: &InitStatus) { + if status.git_repo { + println!("✅ Git repository"); + } else { + println!("⚠️ Git repository"); + } + + if let Some(remote) = &status.github_remote { + println!("✅ GitHub remote: {}", remote); + } else { + println!("⚠️ GitHub remote"); + } + + println!("✅ project_memory/tasks.json initialized"); + + println!("✅ .gitignore updated"); + + if status.github_project_detected { + println!("✅ GitHub Project detected"); + } else { + println!("⚠️ GitHub Project not detected (optional: gh project create)"); + } +} + +fn is_git_repository() -> bool { + command_stdout("git", &["rev-parse", "--git-dir"]).is_some() +} + +fn git_origin_github_remote() -> Option { + let remote = command_stdout("git", &["remote", "get-url", "origin"])?; + parse_github_remote(&remote) +} + +fn parse_github_remote(remote: &str) -> Option { + let trimmed = remote.trim(); + let slug = if let Some(rest) = trimmed.strip_prefix("git@github.com:") { + rest + } else if let Some(index) = trimmed.find("github.com/") { + &trimmed[(index + "github.com/".len())..] + } else { + return None; + }; + + Some(slug.trim_end_matches(".git").trim_matches('/').to_string()) +} + +fn github_owner_from_remote(remote: &str) -> Option<&str> { + remote.split('/').next().filter(|owner| !owner.is_empty()) +} + +fn is_github_project_detected(owner: &str) -> bool { + command_stdout("gh", &["project", "list", "--owner", owner, "--limit", "1"]) + .is_some_and(|stdout| !stdout.trim().is_empty()) +} + +fn ensure_project_memory_gitignore_entries(path: &std::path::Path) -> anyhow::Result { + let required_entries = [ + "project_memory/task-events.jsonl", + "project_memory/tasks.snapshot.json", + "project_memory/.tasks.lock", + ]; + + let mut content = if path.exists() { + fs::read_to_string(path)? + } else { + String::new() + }; + let original = content.clone(); + + for entry in required_entries { + if !content.lines().any(|line| line.trim() == entry) { + if !content.is_empty() && !content.ends_with('\n') { + content.push('\n'); + } + content.push_str(entry); + content.push('\n'); + } + } + + if content != original { + fs::write(path, content)?; + Ok(true) + } else { + Ok(false) + } +} + +fn command_stdout(program: &str, args: &[&str]) -> Option { + let output = Command::new(program).args(args).output().ok()?; + if !output.status.success() { + return None; + } + + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if stdout.is_empty() { + None + } else { + Some(stdout) + } +} + fn assign_plan_to_json(plan: &AssignExecutionPlan) -> serde_json::Value { serde_json::json!({ "task_title": plan.task_title,