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}