use std::fs;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
const EMBEDDED_HOOKS: &[(&str, &str)] = &[
(
"codegraph-pretool-bash.sh",
include_str!("hooks/codegraph-pretool-bash.sh"),
),
(
"codegraph-pretool-search.sh",
include_str!("hooks/codegraph-pretool-search.sh"),
),
];
const BASH_HOOK_PATH: &str = ".claude/hooks/codegraph-pretool-bash.sh";
const SEARCH_HOOK_PATH: &str = ".claude/hooks/codegraph-pretool-search.sh";
const PERMISSION_ENTRY: &str = "Bash(code-graph *)";
pub fn run(global: bool, uninstall: bool) -> Result<()> {
let base_dir = resolve_base_dir(global)?;
if uninstall {
run_uninstall(&base_dir)?;
} else {
run_install(&base_dir, global)?;
}
Ok(())
}
fn resolve_base_dir(global: bool) -> Result<PathBuf> {
if global {
let home = std::env::var("HOME").context("HOME environment variable not set")?;
Ok(PathBuf::from(home).join(".claude"))
} else {
Ok(PathBuf::from(".claude"))
}
}
fn run_install(base_dir: &Path, global: bool) -> Result<()> {
let hooks_dir = base_dir.join("hooks");
let settings_path = base_dir.join("settings.json");
fs::create_dir_all(&hooks_dir)
.with_context(|| format!("Failed to create hooks directory: {}", hooks_dir.display()))?;
let mut hooks_installed = Vec::new();
for &(hook_file, content) in EMBEDDED_HOOKS {
let dest = hooks_dir.join(hook_file);
fs::write(&dest, content)
.with_context(|| format!("Failed to write hook script: {}", dest.display()))?;
set_executable(&dest)?;
hooks_installed.push(hook_file);
}
let settings_modified = merge_settings(&settings_path, global)?;
let mut mcp_actions = Vec::new();
if !global {
mcp_actions = cleanup_mcp(base_dir)?;
}
println!("code-graph setup complete!\n");
println!(
" Target: {}",
if global {
"global (~/.claude/)"
} else {
"project (.claude/)"
}
);
println!("\n Hooks installed:");
for hook in &hooks_installed {
println!(" + {}/hooks/{}", base_dir.display(), hook);
}
if settings_modified {
println!("\n Settings updated:");
println!(" ~ {}", settings_path.display());
}
if !mcp_actions.is_empty() {
println!("\n MCP cleanup:");
for action in &mcp_actions {
println!(" - {action}");
}
}
Ok(())
}
fn run_uninstall(base_dir: &Path) -> Result<()> {
let hooks_dir = base_dir.join("hooks");
let settings_path = base_dir.join("settings.json");
let mut removed = Vec::new();
for &(hook_file, _) in EMBEDDED_HOOKS {
let path = hooks_dir.join(hook_file);
if path.exists() {
fs::remove_file(&path)
.with_context(|| format!("Failed to remove hook file: {}", path.display()))?;
removed.push(hook_file);
}
}
let settings_modified = remove_from_settings(&settings_path)?;
println!("code-graph uninstall complete!\n");
if !removed.is_empty() {
println!(" Hooks removed:");
for hook in &removed {
println!(" - {}/hooks/{}", base_dir.display(), hook);
}
}
if settings_modified {
println!("\n Settings updated:");
println!(" ~ {}", settings_path.display());
}
Ok(())
}
#[cfg(unix)]
fn set_executable(path: &Path) -> Result<()> {
use std::os::unix::fs::PermissionsExt;
let mut perms = fs::metadata(path)?.permissions();
perms.set_mode(perms.mode() | 0o111);
fs::set_permissions(path, perms)?;
Ok(())
}
#[cfg(not(unix))]
fn set_executable(_path: &Path) -> Result<()> {
Ok(())
}
fn merge_settings(settings_path: &Path, global: bool) -> Result<bool> {
let mut settings: serde_json::Value = if settings_path.exists() {
let content = fs::read_to_string(settings_path)?;
serde_json::from_str(&content).with_context(|| {
format!(
"{} contains invalid JSON — fix or delete it first",
settings_path.display()
)
})?
} else {
serde_json::json!({})
};
let mut modified = false;
let (bash_cmd, search_cmd) = if global {
(
"~/.claude/hooks/codegraph-pretool-bash.sh".to_string(),
"~/.claude/hooks/codegraph-pretool-search.sh".to_string(),
)
} else {
(BASH_HOOK_PATH.to_string(), SEARCH_HOOK_PATH.to_string())
};
let settings_obj = settings
.as_object_mut()
.ok_or_else(|| anyhow::anyhow!("settings.json root is not a JSON object"))?;
let hooks = settings_obj
.entry("hooks")
.or_insert_with(|| serde_json::json!({}));
let hooks_obj = hooks
.as_object_mut()
.ok_or_else(|| anyhow::anyhow!("settings.json \"hooks\" is not a JSON object"))?;
let pre_tool_use = hooks_obj
.entry("PreToolUse")
.or_insert_with(|| serde_json::json!([]));
let arr = pre_tool_use
.as_array_mut()
.ok_or_else(|| anyhow::anyhow!("settings.json \"hooks.PreToolUse\" is not a JSON array"))?;
modified |= ensure_hook_entry(arr, "Bash", &bash_cmd)?;
modified |= ensure_hook_entry(arr, "Grep|Glob", &search_cmd)?;
let settings_obj = settings
.as_object_mut()
.ok_or_else(|| anyhow::anyhow!("settings.json root is not a JSON object"))?;
let permissions = settings_obj
.entry("permissions")
.or_insert_with(|| serde_json::json!({}));
let permissions_obj = permissions
.as_object_mut()
.ok_or_else(|| anyhow::anyhow!("settings.json \"permissions\" is not a JSON object"))?;
let allow = permissions_obj
.entry("allow")
.or_insert_with(|| serde_json::json!([]));
let allow_arr = allow.as_array_mut().ok_or_else(|| {
anyhow::anyhow!("settings.json \"permissions.allow\" is not a JSON array")
})?;
let perm_str = serde_json::Value::String(PERMISSION_ENTRY.to_string());
if !allow_arr.contains(&perm_str) {
allow_arr.push(perm_str);
modified = true;
}
let before_len = allow_arr.len();
allow_arr.retain(|v| {
v.as_str()
.is_none_or(|s| !s.starts_with("mcp__code-graph__"))
});
if allow_arr.len() != before_len {
modified = true;
}
if modified {
let content = serde_json::to_string_pretty(&settings)?;
fs::write(settings_path, content + "\n")?;
}
Ok(modified)
}
fn ensure_hook_entry(
pre_tool_use: &mut Vec<serde_json::Value>,
matcher: &str,
command: &str,
) -> Result<bool> {
let hook_entry = serde_json::json!({
"type": "command",
"command": command
});
for entry in pre_tool_use.iter_mut() {
if entry.get("matcher").and_then(|m| m.as_str()) == Some(matcher) {
let entry_obj = entry.as_object_mut().ok_or_else(|| {
anyhow::anyhow!("PreToolUse entry for matcher \"{matcher}\" is not a JSON object")
})?;
let hooks = entry_obj
.entry("hooks")
.or_insert_with(|| serde_json::json!([]));
let hooks_arr = hooks.as_array_mut().ok_or_else(|| {
anyhow::anyhow!("\"hooks\" for matcher \"{matcher}\" is not a JSON array")
})?;
let already_has = hooks_arr.iter().any(|h| {
h.get("command")
.and_then(|c| c.as_str())
.is_some_and(|c| c.contains("codegraph-pretool"))
});
if !already_has {
hooks_arr.push(hook_entry);
return Ok(true);
}
return Ok(false);
}
}
pre_tool_use.push(serde_json::json!({
"matcher": matcher,
"hooks": [hook_entry]
}));
Ok(true)
}
fn remove_from_settings(settings_path: &Path) -> Result<bool> {
if !settings_path.exists() {
return Ok(false);
}
let content = fs::read_to_string(settings_path)?;
let mut settings: serde_json::Value =
serde_json::from_str(&content).unwrap_or_else(|_| serde_json::json!({}));
let mut modified = false;
if let Some(hooks) = settings.get_mut("hooks")
&& let Some(pre_tool_use) = hooks.get_mut("PreToolUse")
&& let Some(arr) = pre_tool_use.as_array_mut()
{
for entry in arr.iter_mut() {
if let Some(hooks_arr) = entry.get_mut("hooks").and_then(|h| h.as_array_mut()) {
let before = hooks_arr.len();
hooks_arr.retain(|h| {
h.get("command")
.and_then(|c| c.as_str())
.is_none_or(|c| !c.contains("codegraph-pretool"))
});
if hooks_arr.len() != before {
modified = true;
}
}
}
let before = arr.len();
arr.retain(|entry| {
entry
.get("hooks")
.and_then(|h| h.as_array())
.is_some_and(|a| !a.is_empty())
});
if arr.len() != before {
modified = true;
}
}
if let Some(permissions) = settings.get_mut("permissions")
&& let Some(allow) = permissions.get_mut("allow")
&& let Some(arr) = allow.as_array_mut()
{
let before = arr.len();
arr.retain(|v| v.as_str() != Some(PERMISSION_ENTRY));
if arr.len() != before {
modified = true;
}
}
if modified {
let content = serde_json::to_string_pretty(&settings)?;
fs::write(settings_path, content + "\n")?;
}
Ok(modified)
}
fn cleanup_mcp(base_dir: &Path) -> Result<Vec<String>> {
let mut actions = Vec::new();
let mcp_path = base_dir
.parent()
.unwrap_or(Path::new("."))
.join(".mcp.json");
if mcp_path.exists() {
let content = fs::read_to_string(&mcp_path)?;
if let Ok(mut mcp) = serde_json::from_str::<serde_json::Value>(&content)
&& let Some(servers) = mcp.get_mut("mcpServers").and_then(|s| s.as_object_mut())
&& servers.remove("code-graph").is_some()
{
actions.push(format!(
"Removed 'code-graph' server from {}",
mcp_path.display()
));
if servers.is_empty() {
fs::remove_file(&mcp_path)?;
actions.push(format!("Deleted empty {}", mcp_path.display()));
} else {
let content = serde_json::to_string_pretty(&mcp)?;
fs::write(&mcp_path, content + "\n")?;
}
}
}
let settings_local_path = base_dir.join("settings.local.json");
if settings_local_path.exists() {
let content = fs::read_to_string(&settings_local_path)?;
if let Ok(mut settings) = serde_json::from_str::<serde_json::Value>(&content) {
let mut local_modified = false;
if let Some(permissions) = settings.get_mut("permissions")
&& let Some(allow) = permissions.get_mut("allow")
&& let Some(arr) = allow.as_array_mut()
{
let before = arr.len();
arr.retain(|v| {
v.as_str()
.is_none_or(|s| !s.starts_with("mcp__code-graph__"))
});
if arr.len() != before {
local_modified = true;
actions.push(format!(
"Removed {} mcp__code-graph__* permission(s) from {}",
before - arr.len(),
settings_local_path.display()
));
}
}
if let Some(enabled) = settings.get_mut("enabledMcpjsonServers")
&& let Some(arr) = enabled.as_array_mut()
{
let before = arr.len();
arr.retain(|v| v.as_str() != Some("code-graph"));
if arr.len() != before {
local_modified = true;
actions.push(format!(
"Removed 'code-graph' from enabledMcpjsonServers in {}",
settings_local_path.display()
));
}
}
if local_modified {
let content = serde_json::to_string_pretty(&settings)?;
fs::write(&settings_local_path, content + "\n")?;
}
}
}
Ok(actions)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cli::Cli;
use clap::Parser;
#[test]
fn test_setup_parses() {
let cli = Cli::parse_from(["code-graph", "setup"]);
match cli.command {
crate::cli::Commands::Setup { global, uninstall } => {
assert!(!global, "--global should default to false");
assert!(!uninstall, "--uninstall should default to false");
}
_ => panic!("expected Setup command"),
}
}
#[test]
fn test_setup_global_flag() {
let cli = Cli::parse_from(["code-graph", "setup", "--global"]);
match cli.command {
crate::cli::Commands::Setup { global, uninstall } => {
assert!(global, "--global should be true");
assert!(!uninstall, "--uninstall should default to false");
}
_ => panic!("expected Setup command"),
}
}
#[test]
fn test_setup_uninstall_flag() {
let cli = Cli::parse_from(["code-graph", "setup", "--uninstall"]);
match cli.command {
crate::cli::Commands::Setup { global, uninstall } => {
assert!(!global, "--global should default to false");
assert!(uninstall, "--uninstall should be true");
}
_ => panic!("expected Setup command"),
}
}
#[test]
fn test_ensure_hook_entry_adds_new_matcher() {
let mut arr: Vec<serde_json::Value> = vec![];
let modified = ensure_hook_entry(&mut arr, "Bash", "some/hook.sh").unwrap();
assert!(modified);
assert_eq!(arr.len(), 1);
assert_eq!(arr[0]["matcher"], "Bash");
}
#[test]
fn test_ensure_hook_entry_appends_to_existing() {
let mut arr: Vec<serde_json::Value> = vec![serde_json::json!({
"matcher": "Bash",
"hooks": [
{ "type": "command", "command": "other-hook.sh" }
]
})];
let modified = ensure_hook_entry(&mut arr, "Bash", "codegraph-pretool-bash.sh").unwrap();
assert!(modified);
assert_eq!(arr.len(), 1); let hooks = arr[0]["hooks"].as_array().unwrap();
assert_eq!(hooks.len(), 2); }
#[test]
fn test_ensure_hook_entry_idempotent() {
let mut arr: Vec<serde_json::Value> = vec![serde_json::json!({
"matcher": "Bash",
"hooks": [
{ "type": "command", "command": "codegraph-pretool-bash.sh" }
]
})];
let modified = ensure_hook_entry(&mut arr, "Bash", "codegraph-pretool-bash.sh").unwrap();
assert!(!modified); let hooks = arr[0]["hooks"].as_array().unwrap();
assert_eq!(hooks.len(), 1); }
#[test]
fn test_merge_settings_creates_new() {
let dir = tempfile::tempdir().unwrap();
let settings_path = dir.path().join("settings.json");
let modified = merge_settings(&settings_path, false).unwrap();
assert!(modified);
let content = fs::read_to_string(&settings_path).unwrap();
let settings: serde_json::Value = serde_json::from_str(&content).unwrap();
assert!(settings.get("hooks").is_some());
assert!(settings.get("permissions").is_some());
let allow = settings["permissions"]["allow"].as_array().unwrap();
assert!(allow.contains(&serde_json::Value::String(PERMISSION_ENTRY.to_string())));
}
#[test]
fn test_merge_settings_preserves_existing() {
let dir = tempfile::tempdir().unwrap();
let settings_path = dir.path().join("settings.json");
let existing = serde_json::json!({
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{ "type": "command", "command": "rtk-rewrite.sh" }
]
}
]
},
"permissions": {
"allow": ["Bash(git *)"],
"deny": []
}
});
fs::write(
&settings_path,
serde_json::to_string_pretty(&existing).unwrap(),
)
.unwrap();
merge_settings(&settings_path, false).unwrap();
let content = fs::read_to_string(&settings_path).unwrap();
let settings: serde_json::Value = serde_json::from_str(&content).unwrap();
let bash_hooks = &settings["hooks"]["PreToolUse"][0]["hooks"];
let has_rtk = bash_hooks.as_array().unwrap().iter().any(|h| {
h.get("command")
.and_then(|c| c.as_str())
.is_some_and(|c| c == "rtk-rewrite.sh")
});
assert!(has_rtk, "RTK hook should be preserved");
let has_ours = bash_hooks.as_array().unwrap().iter().any(|h| {
h.get("command")
.and_then(|c| c.as_str())
.is_some_and(|c| c.contains("codegraph-pretool"))
});
assert!(has_ours, "codegraph hook should be added");
let allow = settings["permissions"]["allow"].as_array().unwrap();
assert!(allow.contains(&serde_json::Value::String("Bash(git *)".to_string())));
}
#[test]
fn test_merge_settings_removes_mcp_permissions() {
let dir = tempfile::tempdir().unwrap();
let settings_path = dir.path().join("settings.json");
let existing = serde_json::json!({
"permissions": {
"allow": [
"mcp__code-graph__find_symbol",
"mcp__code-graph__get_stats",
"Bash(git *)"
]
}
});
fs::write(
&settings_path,
serde_json::to_string_pretty(&existing).unwrap(),
)
.unwrap();
merge_settings(&settings_path, false).unwrap();
let content = fs::read_to_string(&settings_path).unwrap();
let settings: serde_json::Value = serde_json::from_str(&content).unwrap();
let allow = settings["permissions"]["allow"].as_array().unwrap();
assert!(
!allow.iter().any(|v| v
.as_str()
.is_some_and(|s| s.starts_with("mcp__code-graph__"))),
"MCP permissions should be removed"
);
assert!(allow.contains(&serde_json::Value::String("Bash(git *)".to_string())));
}
#[test]
fn test_remove_from_settings() {
let dir = tempfile::tempdir().unwrap();
let settings_path = dir.path().join("settings.json");
let existing = serde_json::json!({
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{ "type": "command", "command": "rtk-rewrite.sh" },
{ "type": "command", "command": ".claude/hooks/codegraph-pretool-bash.sh" }
]
},
{
"matcher": "Grep|Glob",
"hooks": [
{ "type": "command", "command": ".claude/hooks/codegraph-pretool-search.sh" }
]
}
]
},
"permissions": {
"allow": ["Bash(code-graph *)", "Bash(git *)"]
}
});
fs::write(
&settings_path,
serde_json::to_string_pretty(&existing).unwrap(),
)
.unwrap();
let modified = remove_from_settings(&settings_path).unwrap();
assert!(modified);
let content = fs::read_to_string(&settings_path).unwrap();
let settings: serde_json::Value = serde_json::from_str(&content).unwrap();
let bash_hooks = &settings["hooks"]["PreToolUse"][0]["hooks"];
assert_eq!(bash_hooks.as_array().unwrap().len(), 1);
assert_eq!(bash_hooks[0]["command"], "rtk-rewrite.sh");
let pre_tool_use = settings["hooks"]["PreToolUse"].as_array().unwrap();
assert_eq!(
pre_tool_use.len(),
1,
"Empty Grep|Glob matcher should be removed"
);
let allow = settings["permissions"]["allow"].as_array().unwrap();
assert!(!allow.contains(&serde_json::Value::String(PERMISSION_ENTRY.to_string())));
assert!(allow.contains(&serde_json::Value::String("Bash(git *)".to_string())));
}
}