diff --git a/README.md b/README.md index 0146147ca8..a00f216acd 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ Agents that consult the semantic graph instead of grepping the filesystem make * - **Fewer tool calls** — agents navigate call chains (`codegraph_flow`), not grep - **Local & fast** — full re-index 139 files in ~190 ms, nothing leaves your machine -- **Works everywhere** — 14 languages, 6 storage backends, 24 MCP tools, one binary +- **Works everywhere** — 17 languages, 6 storage backends, 42 MCP tools, one binary - **Semantic, not syntactic** — symbols have global IDs; edges derived from call chains with markers (`LOOP`, `IF_TRUE`, `RETURN`, …) - **Binary analysis** — builds call graphs directly from ELF/PE/Mach-O binaries via [radare2](docs/binary-analysis.md), no source needed @@ -59,7 +59,7 @@ The agent binds the workspace with `codegraph_init {"path": ...}` and gets tools CodeGraph supports parsing both **source code** (via tree-sitter) and **configuration/document** files: ### Source Code Languages -14 languages: TypeScript · TSX · JavaScript · Python · Go · Rust · Java · C · C++ · C# · Ruby · PHP · Scala · Swift · Lua +17 languages: TypeScript · TSX · JavaScript · Python · Go · Rust · Java · C · C++ · C# · Ruby · PHP · Scala · Swift · Lua · Svelte · Astro ### Configuration/Document Formats diff --git a/crates/codegraph-installer/src/lib.rs b/crates/codegraph-installer/src/lib.rs index c10fd0e077..67b04c1b9b 100644 --- a/crates/codegraph-installer/src/lib.rs +++ b/crates/codegraph-installer/src/lib.rs @@ -1,6 +1,8 @@ //! Multi-agent installer. One target per file in `targets/`. +pub mod skills; pub mod targets; +pub mod workflow; use anyhow::Result; use camino::Utf8PathBuf; @@ -51,6 +53,11 @@ pub trait AgentTarget: Send + Sync { fn detect(&self, opts: &InstallOpts) -> DetectStatus; fn install(&self, opts: &InstallOpts) -> Result; fn uninstall(&self, opts: &InstallOpts) -> Result; + /// Thư mục cài skills của agent (None = agent không hỗ trợ skills). + fn skills_dir(&self, opts: &InstallOpts) -> Option { + let _ = opts; + None + } } pub fn registry() -> Vec> { @@ -61,13 +68,11 @@ pub fn registry() -> Vec> { Arc::new(targets::opencode::OpencodeTarget), Arc::new(targets::hermes::HermesTarget), Arc::new(targets::antigravity::AntigravityTarget), + Arc::new(targets::zcode::ZcodeTarget), ] } -/// Project-scoped targets only (skip ones that only support global config). +/// Alias của `registry()` — mọi target hiện đều hỗ trợ cả hai scope. pub fn project_registry() -> Vec> { registry() - .into_iter() - .filter(|t| t.id() != "codex") - .collect() } diff --git a/crates/codegraph-installer/src/skills.rs b/crates/codegraph-installer/src/skills.rs new file mode 100644 index 0000000000..4480aa4f53 --- /dev/null +++ b/crates/codegraph-installer/src/skills.rs @@ -0,0 +1,91 @@ +//! CodeGraph skills shipped with the binary (`skills/*/SKILL.md` in the repo, +//! embedded at compile time) and helpers to install/uninstall them into an +//! agent's skills directory. + +use anyhow::Result; +use camino::Utf8PathBuf; + +/// `(skill_name, SKILL.md content)` for every skill shipped with codegraph. +pub const SKILLS: &[(&str, &str)] = &[ + ( + "codegraph-code-review", + include_str!("../../../skills/codegraph-code-review/SKILL.md"), + ), + ( + "codegraph-development-workflow", + include_str!("../../../skills/codegraph-development-workflow/SKILL.md"), + ), + ( + "codegraph-figma-to-frontend", + include_str!("../../../skills/codegraph-figma-to-frontend/SKILL.md"), + ), + ( + "codegraph-reverse-binary", + include_str!("../../../skills/codegraph-reverse-binary/SKILL.md"), + ), + ( + "codegraph-security-review", + include_str!("../../../skills/codegraph-security-review/SKILL.md"), + ), +]; + +/// Ghi mọi skill vào `dir//SKILL.md` (skip nếu nội dung đã khớp). +/// Trả về danh sách file thực sự ghi. +pub fn install_skills(dir: &Utf8PathBuf) -> Result> { + let mut written = Vec::new(); + for (name, content) in SKILLS { + let skill_md = dir.join(name).join("SKILL.md"); + let existing = std::fs::read_to_string(skill_md.as_std_path()).ok(); + if existing.as_deref() == Some(content) { + continue; + } + if let Some(parent) = skill_md.parent() { + std::fs::create_dir_all(parent.as_std_path())?; + } + std::fs::write(skill_md.as_std_path(), content)?; + written.push(skill_md); + } + Ok(written) +} + +/// Xoá thư mục skill nếu SKILL.md trong đó khớp nội dung mình ship (chỉ xoá +/// những gì codegraph đã cài; skill do user tự viết thì giữ nguyên). +pub fn uninstall_skills(dir: &Utf8PathBuf) -> Result> { + let mut removed = Vec::new(); + for (name, content) in SKILLS { + let skill_dir = dir.join(name); + let skill_md = skill_dir.join("SKILL.md"); + let existing = std::fs::read_to_string(skill_md.as_std_path()).ok(); + if existing.as_deref() != Some(content) { + continue; + } + match std::fs::remove_dir_all(skill_dir.as_std_path()) { + Ok(()) => removed.push(skill_dir), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => return Err(e.into()), + } + } + Ok(removed) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn skills_roundtrip() { + let d = tempfile::tempdir().unwrap(); + let dir = Utf8PathBuf::from_path_buf(d.path().to_path_buf()).unwrap(); + assert_eq!(install_skills(&dir).unwrap().len(), SKILLS.len()); + // Idempotent. + assert!(install_skills(&dir).unwrap().is_empty()); + assert_eq!(uninstall_skills(&dir).unwrap().len(), SKILLS.len()); + assert!(uninstall_skills(&dir).unwrap().is_empty()); + // Skill do user tự sửa thì không bị xoá. + let custom = dir.join(SKILLS[0].0).join("SKILL.md"); + std::fs::create_dir_all(custom.parent().unwrap().as_std_path()).unwrap(); + std::fs::write(custom.as_std_path(), "# my own version\n").unwrap(); + assert!(uninstall_skills(&dir).unwrap().is_empty()); + assert!(custom.exists()); + } +} diff --git a/crates/codegraph-installer/src/targets/antigravity.rs b/crates/codegraph-installer/src/targets/antigravity.rs index 765c668bb6..307ba8f905 100644 --- a/crates/codegraph-installer/src/targets/antigravity.rs +++ b/crates/codegraph-installer/src/targets/antigravity.rs @@ -150,6 +150,16 @@ impl AgentTarget for AntigravityTarget { Ok(InstallReport::Unchanged) } } + + fn skills_dir(&self, opts: &InstallOpts) -> Option { + if opts.global { + None + } else { + opts.project_root + .as_ref() + .map(|r| r.join(".agents").join("skills")) + } + } } const AGENTS_MD_CONTENT: &str = r#"--- diff --git a/crates/codegraph-installer/src/targets/claude.rs b/crates/codegraph-installer/src/targets/claude.rs index 0801497c45..538ee32737 100644 --- a/crates/codegraph-installer/src/targets/claude.rs +++ b/crates/codegraph-installer/src/targets/claude.rs @@ -10,7 +10,7 @@ pub struct ClaudeTarget; impl ClaudeTarget { fn settings_path(&self, opts: &InstallOpts) -> Option { if opts.global { - let home = dirs::home_dir()?; + let home = opts.home_dir()?; Utf8PathBuf::from_path_buf(home.join(".claude").join("settings.json")).ok() } else { let root = opts.project_root.as_ref()?; @@ -19,7 +19,7 @@ impl ClaudeTarget { } fn instructions_path(&self, opts: &InstallOpts) -> Option { if opts.global { - let home = dirs::home_dir()?; + let home = opts.home_dir()?; Utf8PathBuf::from_path_buf(home.join(".claude").join("CLAUDE.md")).ok() } else { opts.project_root.as_ref().map(|r| r.join("CLAUDE.md")) @@ -134,6 +134,17 @@ impl AgentTarget for ClaudeTarget { Ok(InstallReport::Updated(removed)) } } + + fn skills_dir(&self, opts: &InstallOpts) -> Option { + if opts.global { + let home = opts.home_dir()?; + Utf8PathBuf::from_path_buf(home.join(".claude").join("skills")).ok() + } else { + opts.project_root + .as_ref() + .map(|r| r.join(".claude").join("skills")) + } + } } fn serve_args(opts: &InstallOpts) -> Value { diff --git a/crates/codegraph-installer/src/targets/codex.rs b/crates/codegraph-installer/src/targets/codex.rs index cdeae9b270..115db099dd 100644 --- a/crates/codegraph-installer/src/targets/codex.rs +++ b/crates/codegraph-installer/src/targets/codex.rs @@ -1,5 +1,7 @@ -//! Codex CLI — config at `~/.codex/config.toml`, instructions at `~/.codex/AGENTS.md`. -//! Uses toml_edit to preserve sibling tables and user formatting. +//! Codex CLI — config at `~/.codex/config.toml` (global) hoặc +//! `/.codex/config.toml` (project-scoped), instructions at +//! `AGENTS.md` cạnh đó. Uses toml_edit to preserve sibling tables and user +//! formatting. use crate::{AgentTarget, DetectStatus, InstallOpts, InstallReport, INSTRUCTIONS_MD}; use anyhow::Result; @@ -9,13 +11,26 @@ use toml_edit::{value, Array, DocumentMut, Item, Table}; pub struct CodexTarget; impl CodexTarget { - fn config_path(&self) -> Option { - let home = dirs::home_dir()?; - Utf8PathBuf::from_path_buf(home.join(".codex").join("config.toml")).ok() + /// Thư mục `.codex` cho scope hiện tại (global = trong home). + fn codex_dir(&self, opts: &InstallOpts) -> Option { + if opts.global { + let home = opts.home_dir()?; + Utf8PathBuf::from_path_buf(home.join(".codex")).ok() + } else { + opts.project_root.as_ref().map(|r| r.join(".codex")) + } } - fn agents_path(&self) -> Option { - let home = dirs::home_dir()?; - Utf8PathBuf::from_path_buf(home.join(".codex").join("AGENTS.md")).ok() + fn config_path(&self, opts: &InstallOpts) -> Option { + self.codex_dir(opts).map(|d| d.join("config.toml")) + } + /// AGENTS.md: global thì nằm trong `.codex/`, project thì ở root (dùng + /// chung cho mọi agent đọc AGENTS.md của repo). + fn agents_path(&self, opts: &InstallOpts) -> Option { + if opts.global { + self.codex_dir(opts).map(|d| d.join("AGENTS.md")) + } else { + opts.project_root.as_ref().map(|r| r.join("AGENTS.md")) + } } } @@ -27,13 +42,19 @@ impl AgentTarget for CodexTarget { "Codex CLI" } - fn detect(&self, _opts: &InstallOpts) -> DetectStatus { - let Some(p) = self.config_path() else { + fn detect(&self, opts: &InstallOpts) -> DetectStatus { + let Some(dir) = self.codex_dir(opts) else { return DetectStatus::NotFound; }; - if !p.exists() { + if !dir.exists() { return DetectStatus::NotFound; } + let Some(p) = self.config_path(opts) else { + return DetectStatus::Found; + }; + if !p.exists() { + return DetectStatus::Found; + } let Ok(text) = std::fs::read_to_string(p.as_std_path()) else { return DetectStatus::Found; }; @@ -54,7 +75,7 @@ impl AgentTarget for CodexTarget { fn install(&self, opts: &InstallOpts) -> Result { let config = self - .config_path() + .config_path(opts) .ok_or_else(|| anyhow::anyhow!("no codex config path"))?; let text = std::fs::read_to_string(config.as_std_path()).unwrap_or_default(); let mut doc: DocumentMut = if text.is_empty() { @@ -98,13 +119,21 @@ impl AgentTarget for CodexTarget { std::fs::write(config.as_std_path(), doc.to_string())?; written.push(config); } - if let Some(md) = self.agents_path() { + if let Some(md) = self.agents_path(opts) { + let want = INSTRUCTIONS_MD; let existing = std::fs::read_to_string(md.as_std_path()).ok(); - if existing.as_deref() != Some(INSTRUCTIONS_MD) { + // AGENTS.md ở project root dùng chung với các agent khác — chỉ ghi + // khi chưa tồn tại, không đè nội dung user đang có. + let should_write = if opts.global { + existing.as_deref() != Some(want) + } else { + existing.is_none() + }; + if should_write { if let Some(parent) = md.parent() { std::fs::create_dir_all(parent.as_std_path())?; } - std::fs::write(md.as_std_path(), INSTRUCTIONS_MD)?; + std::fs::write(md.as_std_path(), want)?; written.push(md); } } @@ -115,8 +144,8 @@ impl AgentTarget for CodexTarget { } } - fn uninstall(&self, _opts: &InstallOpts) -> Result { - let Some(config) = self.config_path() else { + fn uninstall(&self, opts: &InstallOpts) -> Result { + let Some(config) = self.config_path(opts) else { return Ok(InstallReport::Unchanged); }; if !config.exists() { @@ -137,4 +166,8 @@ impl AgentTarget for CodexTarget { Ok(InstallReport::Unchanged) } } + + fn skills_dir(&self, opts: &InstallOpts) -> Option { + self.codex_dir(opts).map(|d| d.join("skills")) + } } diff --git a/crates/codegraph-installer/src/targets/cursor.rs b/crates/codegraph-installer/src/targets/cursor.rs index 2e60808333..5383c77caf 100644 --- a/crates/codegraph-installer/src/targets/cursor.rs +++ b/crates/codegraph-installer/src/targets/cursor.rs @@ -139,4 +139,15 @@ impl AgentTarget for CursorTarget { Ok(InstallReport::Updated(removed)) } } + + fn skills_dir(&self, opts: &InstallOpts) -> Option { + if opts.global { + let home = opts.home_dir()?; + Utf8PathBuf::from_path_buf(home.join(".cursor").join("skills")).ok() + } else { + opts.project_root + .as_ref() + .map(|r| r.join(".cursor").join("skills")) + } + } } diff --git a/crates/codegraph-installer/src/targets/mod.rs b/crates/codegraph-installer/src/targets/mod.rs index 6a4933e587..f79db83417 100644 --- a/crates/codegraph-installer/src/targets/mod.rs +++ b/crates/codegraph-installer/src/targets/mod.rs @@ -5,3 +5,4 @@ pub mod cursor; pub mod hermes; pub(crate) mod jsonutil; pub mod opencode; +pub mod zcode; diff --git a/crates/codegraph-installer/src/targets/opencode.rs b/crates/codegraph-installer/src/targets/opencode.rs index 323502e8cc..bc3981fc14 100644 --- a/crates/codegraph-installer/src/targets/opencode.rs +++ b/crates/codegraph-installer/src/targets/opencode.rs @@ -219,4 +219,15 @@ impl AgentTarget for OpencodeTarget { Ok(InstallReport::Unchanged) } } + + fn skills_dir(&self, opts: &InstallOpts) -> Option { + if opts.global { + let cfg = dirs::config_dir()?; + Utf8PathBuf::from_path_buf(cfg.join("opencode").join("skills")).ok() + } else { + opts.project_root + .as_ref() + .map(|r| r.join(".opencode").join("skills")) + } + } } diff --git a/crates/codegraph-installer/src/targets/zcode.rs b/crates/codegraph-installer/src/targets/zcode.rs new file mode 100644 index 0000000000..0279174791 --- /dev/null +++ b/crates/codegraph-installer/src/targets/zcode.rs @@ -0,0 +1,157 @@ +//! ZCode CLI — MCP config at `~/.zcode/cli/config.json` under `mcp.servers` +//! (project: `/.zcode/config.json`, cùng cấu trúc). Skills dùng thư mục +//! `AGENTS` chung: `~/.agents/skills` (global) / `/.agents/skills` +//! (project). Entry MCP đã tồn tại hợp lệ (`serve --mcp`) được giữ nguyên +//! — không đè extra args (vd `--mermaid`) mà user đã tự chỉnh. + +use crate::{targets::jsonutil, AgentTarget, DetectStatus, InstallOpts, InstallReport}; +use anyhow::Result; +use camino::Utf8PathBuf; +use serde_json::{json, Value}; + +pub struct ZcodeTarget; + +impl ZcodeTarget { + fn config_path(&self, opts: &InstallOpts) -> Option { + if opts.global { + let home = opts.home_dir()?; + Utf8PathBuf::from_path_buf(home.join(".zcode").join("cli").join("config.json")).ok() + } else { + opts.project_root + .as_ref() + .map(|r| r.join(".zcode").join("config.json")) + } + } + + /// `codegraph` entry hợp lệ đã có sẵn trong `mcp.servers` (command trỏ + /// codegraph và chạy `serve --mcp`) — khi đó không ghi đè. + fn existing_entry(v: &Value) -> Option { + let entry = v.pointer("/mcp/servers/codegraph")?; + let command = entry.get("command").and_then(|c| c.as_str())?; + let args = entry.get("args").and_then(|a| a.as_array())?; + let args: Vec<&str> = args.iter().filter_map(|a| a.as_str()).collect(); + if command.ends_with("codegraph") && args.len() >= 2 && args[..2] == ["serve", "--mcp"] { + Some(entry.clone()) + } else { + None + } + } +} + +impl AgentTarget for ZcodeTarget { + fn id(&self) -> &'static str { + "zcode" + } + fn label(&self) -> &'static str { + "ZCode CLI" + } + + fn detect(&self, opts: &InstallOpts) -> DetectStatus { + let Some(config) = self.config_path(opts) else { + return DetectStatus::NotFound; + }; + // Marker presence: thư mục chứa config phải tồn tại. + let Some(parent) = config.parent() else { + return DetectStatus::NotFound; + }; + if !parent.exists() { + return DetectStatus::NotFound; + } + if !config.exists() { + return DetectStatus::Found; + } + let Ok(v) = jsonutil::read_or_default(&config) else { + return DetectStatus::Found; + }; + if Self::existing_entry(&v).is_some() { + DetectStatus::AlreadyConfigured + } else { + DetectStatus::Found + } + } + + fn install(&self, opts: &InstallOpts) -> Result { + let config = self + .config_path(opts) + .ok_or_else(|| anyhow::anyhow!("no zcode config path"))?; + let v = if config.exists() { + jsonutil::read_or_default(&config)? + } else { + Value::Object(Default::default()) + }; + if Self::existing_entry(&v).is_some() { + return Ok(InstallReport::Unchanged); + } + + let mut args = vec![Value::String("serve".into()), Value::String("--mcp".into())]; + if let Some(root) = &opts.project_root { + args.push(Value::String("--path".into())); + args.push(Value::String(root.to_string())); + } + let entry = json!({ + "type": "stdio", + "command": opts.binary_path.as_str(), + "args": args, + }); + + let mut v = v; + let obj = v + .as_object_mut() + .ok_or_else(|| anyhow::anyhow!("zcode config not an object"))?; + let mcp = obj + .entry("mcp") + .or_insert_with(|| Value::Object(Default::default())); + let mcp = mcp + .as_object_mut() + .ok_or_else(|| anyhow::anyhow!("mcp not an object"))?; + let servers = mcp + .entry("servers") + .or_insert_with(|| Value::Object(Default::default())); + let servers = servers + .as_object_mut() + .ok_or_else(|| anyhow::anyhow!("mcp.servers not an object"))?; + servers.insert("codegraph".into(), entry); + + if let Some(parent) = config.parent() { + std::fs::create_dir_all(parent.as_std_path())?; + } + jsonutil::write_pretty(&config, &v)?; + Ok(InstallReport::Installed(vec![config])) + } + + fn uninstall(&self, opts: &InstallOpts) -> Result { + let Some(config) = self.config_path(opts) else { + return Ok(InstallReport::Unchanged); + }; + if !config.exists() { + return Ok(InstallReport::Unchanged); + } + let mut v = jsonutil::read_or_default(&config)?; + let mut changed = false; + if let Some(servers) = v + .pointer_mut("/mcp/servers") + .and_then(|s| s.as_object_mut()) + { + if servers.remove("codegraph").is_some() { + changed = true; + } + } + if changed { + jsonutil::write_pretty(&config, &v)?; + Ok(InstallReport::Updated(vec![config])) + } else { + Ok(InstallReport::Unchanged) + } + } + + fn skills_dir(&self, opts: &InstallOpts) -> Option { + if opts.global { + let home = opts.home_dir()?; + Utf8PathBuf::from_path_buf(home.join(".agents").join("skills")).ok() + } else { + opts.project_root + .as_ref() + .map(|r| r.join(".agents").join("skills")) + } + } +} diff --git a/crates/codegraph-installer/src/workflow.rs b/crates/codegraph-installer/src/workflow.rs new file mode 100644 index 0000000000..d344e9a1cb --- /dev/null +++ b/crates/codegraph-installer/src/workflow.rs @@ -0,0 +1,171 @@ +//! `codegraph init --agent `: sinh file hướng dẫn riêng cho agent với một +//! workflow viết code tối giản dựa trên CodeGraph (mô hình hoá theo AGENTS.md +//! của chính repo codegraph: MCP-first trước grep, verify có thứ tự). + +use camino::Utf8PathBuf; + +/// Nội dung AGENTS.md cho workflow viết code. Marker đầu file để installer/init +/// nhận ra nội dung do mình sinh (idempotent update). +pub const WORKFLOW_MD: &str = r#" + +# CodeGraph — Coding Workflow + +This project has a CodeGraph MCP server (`.codegraph/` index). CodeGraph gives +sub-millisecond structural answers (symbols, call chains, impact) from a full +AST parse. **Always query CodeGraph before grep/read** when the question is +about code structure — grep only for literal text. + +## Workflow for any code change + +1. **Understand** — before editing, ground the task in the graph: + - `codegraph_context {query: ""}` — packed context, one call. + - `codegraph_impact {node}` on hot symbols — what breaks downstream. +2. **Edit** — write code that reads like the surrounding code. Read the + target file before editing; don't edit from search snippets alone. +3. **Verify** — in order, after every meaningful change: + - formatter (`cargo fmt` / project equivalent) + - linter (`cargo clippy` / project equivalent) + - the tests covering the changed area first, then the full suite + - never claim "verified" from a subset of passing tests +4. **Report** — cite `file:line` for every claim; state what was run and what + failed, plainly. No hedging on green results. + +## Notes + +- The index is a **snapshot**. After editing code, re-index (`codegraph_index {}`) + or accept stale results; read the file to confirm current state. +- Duplicate symbol names return `ambiguous: true` — retry with the numeric `id`. +- If tools refuse: the session is unbound. Call `codegraph_init {"path": ...}` + then `codegraph_index {}`. +"#; + +/// Các target được hỗ trợ bởi `codegraph init --agent`. +pub const KNOWN_AGENTS: &[&str] = &[ + "claude", + "codex", + "opencode", + "zcode", + "cursor", + "hermes", + "antigravity", +]; + +/// Frontmatter riêng cho từng agent (`.mdc` của Cursor, Antigravity cần +/// trigger header). `None` = ghi plain markdown. +fn frontmatter(target: &str) -> Option<&'static str> { + match target { + "cursor" => Some("---\ndescription: CodeGraph coding workflow\nalwaysApply: true\n---\n\n"), + "antigravity" => Some( + "---\nname: codegraph-workflow\ndescription: Coding workflow via CodeGraph MCP\ntrigger: auto\n---\n\n", + ), + _ => None, + } +} + +/// Đường dẫn file hướng dẫn cho target ở project scope. Trả `None` với +/// target lạ hoặc target không có file tương ứng. +pub fn project_md_path(target: &str, root: &Utf8PathBuf) -> Option { + let p = match target { + "claude" => root.join("CLAUDE.md"), + "codex" | "opencode" | "zcode" => root.join("AGENTS.md"), + "cursor" => root.join(".cursor").join("rules").join("codegraph.mdc"), + "hermes" => root.join(".hermes").join("AGENTS.md"), + "antigravity" => root.join(".agents").join("AGENTS.md"), + _ => return None, + }; + Some(p) +} + +/// Nội dung workflow hoàn chỉnh cho target (kèm frontmatter nếu cần). +/// `None` nếu target không hợp lệ. +pub fn workflow_md(target: &str) -> Option { + if !KNOWN_AGENTS.contains(&target) { + return None; + } + Some(frontmatter(target).unwrap_or_default().to_string() + WORKFLOW_MD) +} + +/// Ghi workflow vào project (create_dir_all). Nếu file đã tồn tại với nội dung +/// khác (user tự viết) thì KHÔNG đè — trả `Ok(None)`. +/// Trả về path đã ghi, hoặc `Some(path)` khi đã khớp sẵn (no-op). +pub fn write_workflow( + target: &str, + root: &Utf8PathBuf, +) -> Result, anyhow::Error> { + use anyhow::Context; + let Some(path) = project_md_path(target, root) else { + anyhow::bail!( + "unknown agent '{target}' (known: {})", + KNOWN_AGENTS.join(", ") + ); + }; + let want = workflow_md(target).expect("path implies content"); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent.as_std_path()) + .with_context(|| format!("create {}", parent))?; + } + let existing = std::fs::read_to_string(path.as_std_path()).ok(); + if existing.as_deref() == Some(want.as_str()) { + return Ok(Some(path)); + } + if existing.is_some() { + // File đã có nội dung user — không đè. + return Ok(None); + } + std::fs::write(path.as_std_path(), want)?; + Ok(Some(path)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn paths_per_target() { + let root = Utf8PathBuf::from("/proj"); + assert_eq!( + project_md_path("claude", &root).unwrap(), + root.join("CLAUDE.md") + ); + assert_eq!( + project_md_path("codex", &root).unwrap(), + root.join("AGENTS.md") + ); + assert_eq!( + project_md_path("zcode", &root).unwrap(), + root.join("AGENTS.md") + ); + assert_eq!( + project_md_path("cursor", &root).unwrap(), + root.join(".cursor/rules/codegraph.mdc") + ); + assert!(project_md_path("nope", &root).is_none()); + } + + #[test] + fn cursor_has_frontmatter_others_dont() { + let c = workflow_md("cursor").unwrap(); + assert!(c.starts_with("---\n")); + assert!(workflow_md("codex") + .unwrap() + .starts_with("