Skip to main content

codegraph/
installer.rs

1use anyhow::{anyhow, Context, Result};
2use serde_json::{json, Value};
3use std::fs;
4use std::path::{Path, PathBuf};
5
6const CODEGRAPH_SERVER: &str = "codegraph";
7const SECTION_START: &str = "<!-- CODEGRAPH_START -->";
8const SECTION_END: &str = "<!-- CODEGRAPH_END -->";
9const CLAUDE_MD_SECTION: &str = r#"<!-- CODEGRAPH_START -->
10## CodeGraph
11
12CodeGraph builds a local semantic graph for source exploration.
13
14- Start with `cgz status` before relying on indexed results.
15- Use `cgz query <term>` to find symbols by name.
16- Use `cgz context <task>` for task-oriented evidence.
17- Use `cgz affected <files>` before changing files with likely tests.
18- Treat CodeGraph output as navigation evidence; final validation still comes from the project's tests, type checks, or build checks.
19<!-- CODEGRAPH_END -->"#;
20
21const CODEGRAPH_PERMISSIONS: &[&str] = &[
22    "mcp__codegraph__codegraph_status",
23    "mcp__codegraph__codegraph_files",
24    "mcp__codegraph__codegraph_search",
25    "mcp__codegraph__codegraph_context",
26    "mcp__codegraph__codegraph_callers",
27    "mcp__codegraph__codegraph_callees",
28    "mcp__codegraph__codegraph_impact",
29    "mcp__codegraph__codegraph_node",
30    "mcp__codegraph__codegraph_explore",
31];
32
33#[derive(Debug, Clone, Default)]
34pub struct InstallOptions {
35    pub global: bool,
36    pub local: bool,
37    pub yes: bool,
38    pub no_init: bool,
39    pub allow_permissions: bool,
40    pub project_path: Option<PathBuf>,
41    pub home_dir: Option<PathBuf>,
42}
43
44#[derive(Debug)]
45pub struct InstallResult {
46    pub claude_json_path: PathBuf,
47    pub claude_json_changed: bool,
48    pub settings_json_path: Option<PathBuf>,
49    pub settings_json_changed: bool,
50    pub claude_md_path: PathBuf,
51    pub claude_md_changed: bool,
52    pub initialized: bool,
53    pub init_message: String,
54}
55
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57pub enum InstallTarget {
58    Global,
59    Local,
60}
61
62pub fn install(options: &InstallOptions) -> Result<InstallResult> {
63    let target = match (options.global, options.local) {
64        (true, true) => return Err(anyhow!("install target must be either --global or --local")),
65        (true, false) => InstallTarget::Global,
66        (false, true) => InstallTarget::Local,
67        (false, false) => return Err(anyhow!("install target must be --global or --local")),
68    };
69    let project_path = options
70        .project_path
71        .clone()
72        .unwrap_or(std::env::current_dir()?)
73        .canonicalize()
74        .unwrap_or_else(|_| {
75            options
76                .project_path
77                .clone()
78                .unwrap_or_else(|| PathBuf::from("."))
79        });
80    let paths = install_paths(target, &project_path, options.home_dir.as_deref())?;
81
82    let claude_json_changed = write_mcp_config(&paths.claude_json)?;
83    let settings_json_changed = if options.allow_permissions {
84        write_permissions(&paths.settings_json)?
85    } else {
86        false
87    };
88    let claude_md_changed = write_claude_md(&paths.claude_md)?;
89
90    let mut initialized = false;
91    let mut init_message = String::new();
92    if target == InstallTarget::Local && !options.no_init {
93        if crate::is_initialized(&project_path) {
94            init_message = format!(
95                "CodeGraph already initialized in {}",
96                project_path.display()
97            );
98        } else if options.yes {
99            let mut cg = crate::CodeGraph::init(&project_path)?;
100            let result = cg.index_all()?;
101            initialized = true;
102            init_message = format!(
103                "Initialized and indexed {} files ({} nodes, {} edges)",
104                result.files_indexed, result.nodes_created, result.edges_created
105            );
106        } else {
107            init_message =
108                "Skipped project initialization. Re-run with --yes or run `cgz init -i` manually."
109                    .to_string();
110        }
111    }
112
113    Ok(InstallResult {
114        claude_json_path: paths.claude_json,
115        claude_json_changed,
116        settings_json_path: options.allow_permissions.then_some(paths.settings_json),
117        settings_json_changed,
118        claude_md_path: paths.claude_md,
119        claude_md_changed,
120        initialized,
121        init_message,
122    })
123}
124
125struct InstallPaths {
126    claude_json: PathBuf,
127    settings_json: PathBuf,
128    claude_md: PathBuf,
129}
130
131fn install_paths(
132    target: InstallTarget,
133    project_path: &Path,
134    home_dir: Option<&Path>,
135) -> Result<InstallPaths> {
136    match target {
137        InstallTarget::Global => {
138            let home = home_dir
139                .map(Path::to_path_buf)
140                .or_else(|| std::env::var_os("HOME").map(PathBuf::from))
141                .ok_or_else(|| anyhow!("Could not determine HOME directory"))?;
142            Ok(InstallPaths {
143                claude_json: home.join(".claude.json"),
144                settings_json: home.join(".claude").join("settings.json"),
145                claude_md: home.join(".claude").join("CLAUDE.md"),
146            })
147        }
148        InstallTarget::Local => Ok(InstallPaths {
149            claude_json: project_path.join(".claude.json"),
150            settings_json: project_path.join(".claude").join("settings.json"),
151            claude_md: project_path.join(".claude").join("CLAUDE.md"),
152        }),
153    }
154}
155
156fn write_mcp_config(path: &Path) -> Result<bool> {
157    let mut config = read_json_object(path)?;
158    let mcp_servers = object_entry(&mut config, "mcpServers", path)?;
159    let server_config = json!({
160        "type": "stdio",
161        "command": "cgz",
162        "args": ["serve", "--mcp"],
163    });
164    let changed = mcp_servers.get(CODEGRAPH_SERVER) != Some(&server_config);
165    mcp_servers.insert(CODEGRAPH_SERVER.to_string(), server_config);
166    write_json_if_changed(path, &config, changed)?;
167    Ok(changed)
168}
169
170fn write_permissions(path: &Path) -> Result<bool> {
171    let mut settings = read_json_object(path)?;
172    let permissions = object_entry(&mut settings, "permissions", path)?;
173    let allow = permissions
174        .entry("allow".to_string())
175        .or_insert_with(|| json!([]));
176    let allow = allow
177        .as_array_mut()
178        .ok_or_else(|| anyhow!("permissions.allow in {} is not an array", path.display()))?;
179
180    let mut changed = false;
181    for permission in CODEGRAPH_PERMISSIONS {
182        if !allow.iter().any(|entry| entry.as_str() == Some(permission)) {
183            allow.push(json!(permission));
184            changed = true;
185        }
186    }
187    write_json_if_changed(path, &settings, changed)?;
188    Ok(changed)
189}
190
191fn write_claude_md(path: &Path) -> Result<bool> {
192    let content = match fs::read_to_string(path) {
193        Ok(content) => content,
194        Err(err) if err.kind() == std::io::ErrorKind::NotFound => String::new(),
195        Err(err) => return Err(err).with_context(|| format!("reading {}", path.display())),
196    };
197    let updated = upsert_claude_section(&content);
198    if updated == content {
199        return Ok(false);
200    }
201    atomic_write(path, updated.as_bytes())?;
202    Ok(true)
203}
204
205fn read_json_object(path: &Path) -> Result<Value> {
206    let content = match fs::read_to_string(path) {
207        Ok(content) => content,
208        Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(json!({})),
209        Err(err) => return Err(err).with_context(|| format!("reading {}", path.display())),
210    };
211    let parsed: Value =
212        serde_json::from_str(&content).with_context(|| format!("parsing {}", path.display()))?;
213    if parsed.is_object() {
214        Ok(parsed)
215    } else {
216        Err(anyhow!("{} must contain a JSON object", path.display()))
217    }
218}
219
220fn object_entry<'a>(
221    value: &'a mut Value,
222    key: &str,
223    path: &Path,
224) -> Result<&'a mut serde_json::Map<String, Value>> {
225    value
226        .as_object_mut()
227        .expect("read_json_object returns a JSON object")
228        .entry(key.to_string())
229        .or_insert_with(|| json!({}))
230        .as_object_mut()
231        .ok_or_else(|| anyhow!("{key} in {} is not a JSON object", path.display()))
232}
233
234fn write_json_if_changed(path: &Path, value: &Value, changed: bool) -> Result<()> {
235    if changed || !path.exists() {
236        let output = serde_json::to_string_pretty(value)? + "\n";
237        atomic_write(path, output.as_bytes())?;
238    }
239    Ok(())
240}
241
242fn atomic_write(path: &Path, bytes: &[u8]) -> Result<()> {
243    if let Some(parent) = path.parent() {
244        fs::create_dir_all(parent)
245            .with_context(|| format!("creating directory {}", parent.display()))?;
246    }
247    let tmp = path.with_extension(format!(
248        "{}tmp",
249        path.extension()
250            .and_then(|ext| ext.to_str())
251            .map(|ext| format!("{ext}."))
252            .unwrap_or_default()
253    ));
254    fs::write(&tmp, bytes).with_context(|| format!("writing {}", tmp.display()))?;
255    fs::rename(&tmp, path).with_context(|| format!("renaming {}", path.display()))?;
256    Ok(())
257}
258
259fn upsert_claude_section(content: &str) -> String {
260    if content.is_empty() {
261        return format!("{CLAUDE_MD_SECTION}\n");
262    }
263
264    if let (Some(start), Some(end)) = (content.find(SECTION_START), content.find(SECTION_END)) {
265        if start < end {
266            let section_end = end + SECTION_END.len();
267            return join_sections(
268                &content[..start],
269                CLAUDE_MD_SECTION,
270                &content[section_end..],
271            );
272        }
273    }
274
275    if let Some((start, header_len)) = find_unmarked_codegraph_section(content) {
276        let after_start = start + header_len;
277        let end = content[after_start..]
278            .find("\n## ")
279            .map(|offset| after_start + offset)
280            .unwrap_or(content.len());
281        return join_sections(&content[..start], CLAUDE_MD_SECTION, &content[end..]);
282    }
283
284    format!("{}\n\n{}\n", content.trim_end(), CLAUDE_MD_SECTION)
285}
286
287fn find_unmarked_codegraph_section(content: &str) -> Option<(usize, usize)> {
288    if content.starts_with("## CodeGraph") {
289        return Some((0, "## CodeGraph".len()));
290    }
291    content
292        .find("\n## CodeGraph")
293        .map(|start| (start, "\n## CodeGraph".len()))
294}
295
296fn join_sections(before: &str, section: &str, after: &str) -> String {
297    let before = before.trim_end_matches('\n');
298    let after = after.trim_start_matches('\n');
299    match (before.is_empty(), after.is_empty()) {
300        (true, true) => format!("{section}\n"),
301        (true, false) => format!("{section}\n\n{after}"),
302        (false, true) => format!("{before}\n\n{section}\n"),
303        (false, false) => format!("{before}\n\n{section}\n\n{after}"),
304    }
305}
306
307#[cfg(test)]
308mod tests {
309    use super::*;
310    use tempfile::TempDir;
311
312    #[test]
313    fn local_install_writes_claude_json_and_claude_md() {
314        let dir = TempDir::new().unwrap();
315        let project_path = dir.path().canonicalize().unwrap();
316        let result = install(&InstallOptions {
317            local: true,
318            no_init: true,
319            project_path: Some(dir.path().to_path_buf()),
320            ..Default::default()
321        })
322        .unwrap();
323
324        assert_eq!(result.claude_json_path, project_path.join(".claude.json"));
325        assert!(result.claude_json_changed);
326        assert!(result.claude_md_changed);
327        assert!(dir.path().join(".claude.json").exists());
328        assert!(dir.path().join(".claude").join("CLAUDE.md").exists());
329    }
330
331    #[test]
332    fn global_install_uses_home_paths() {
333        let home = TempDir::new().unwrap();
334        let project = TempDir::new().unwrap();
335        let result = install(&InstallOptions {
336            global: true,
337            no_init: true,
338            project_path: Some(project.path().to_path_buf()),
339            home_dir: Some(home.path().to_path_buf()),
340            ..Default::default()
341        })
342        .unwrap();
343
344        assert_eq!(result.claude_json_path, home.path().join(".claude.json"));
345        assert_eq!(
346            result.claude_md_path,
347            home.path().join(".claude").join("CLAUDE.md")
348        );
349    }
350
351    #[test]
352    fn rejects_multiple_targets() {
353        let err = install(&InstallOptions {
354            global: true,
355            local: true,
356            no_init: true,
357            ..Default::default()
358        })
359        .unwrap_err();
360        assert!(err.to_string().contains("either --global or --local"));
361    }
362
363    #[test]
364    fn mcp_config_preserves_existing_servers_and_is_idempotent() {
365        let dir = TempDir::new().unwrap();
366        let path = dir.path().join(".claude.json");
367        fs::write(
368            &path,
369            serde_json::to_string_pretty(&json!({
370                "mcpServers": { "other": { "command": "other-bin", "args": ["--flag"] } }
371            }))
372            .unwrap(),
373        )
374        .unwrap();
375
376        assert!(write_mcp_config(&path).unwrap());
377        assert!(!write_mcp_config(&path).unwrap());
378        let config: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
379        let servers = config.get("mcpServers").unwrap();
380        assert!(servers.get("other").is_some());
381        assert_eq!(
382            servers.get("codegraph").unwrap(),
383            &json!({"type": "stdio", "command": "cgz", "args": ["serve", "--mcp"]})
384        );
385    }
386
387    #[test]
388    fn permissions_are_explicit_and_preserve_existing_allow_entries() {
389        let dir = TempDir::new().unwrap();
390        let path = dir.path().join(".claude").join("settings.json");
391        fs::create_dir_all(path.parent().unwrap()).unwrap();
392        fs::write(
393            &path,
394            serde_json::to_string_pretty(&json!({
395                "permissions": { "allow": ["mcp__other__tool"] }
396            }))
397            .unwrap(),
398        )
399        .unwrap();
400
401        assert!(write_permissions(&path).unwrap());
402        assert!(!write_permissions(&path).unwrap());
403        let settings: Value = serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
404        let allow = settings["permissions"]["allow"].as_array().unwrap();
405        assert!(allow.iter().any(|v| v == "mcp__other__tool"));
406        assert!(allow
407            .iter()
408            .any(|v| v == "mcp__codegraph__codegraph_status"));
409    }
410
411    #[test]
412    fn invalid_json_is_not_overwritten() {
413        let dir = TempDir::new().unwrap();
414        let path = dir.path().join(".claude.json");
415        fs::write(&path, "{").unwrap();
416
417        let err = write_mcp_config(&path).unwrap_err();
418        assert!(err.to_string().contains("parsing"));
419        assert_eq!(fs::read_to_string(&path).unwrap(), "{");
420    }
421
422    #[test]
423    fn claude_md_replaces_marked_section_and_preserves_content() {
424        let before = "# Project\n\n";
425        let old = "<!-- CODEGRAPH_START -->\nold\n<!-- CODEGRAPH_END -->";
426        let after = "\n\n## Other\ntext\n";
427        let updated = upsert_claude_section(&format!("{before}{old}{after}"));
428
429        assert!(updated.starts_with(before));
430        assert!(updated.contains("cgz status"));
431        assert!(updated.contains("## Other"));
432        assert!(!updated.contains("\nold\n"));
433    }
434
435    #[test]
436    fn claude_md_replaces_unmarked_codegraph_section() {
437        let updated = upsert_claude_section("intro\n\n## CodeGraph\nold\n\n## Next\nkeep\n");
438
439        assert!(updated.contains("intro"));
440        assert!(updated.contains("cgz query"));
441        assert!(updated.contains("## Next\nkeep"));
442        assert!(!updated.contains("\nold\n"));
443    }
444}