use std::path::PathBuf;
use serde_json::Value;
use crate::config::Paths;
use crate::error::{Error, Result};
pub(crate) mod backup;
pub(crate) mod hooks;
pub(crate) mod mcp;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InstallScope {
User,
Project,
}
#[derive(Debug, Clone)]
pub struct InstallPlan {
pub scope: InstallScope,
pub dotenv: Option<PathBuf>,
pub with_hooks: bool,
pub force: bool,
pub apply: bool,
}
#[derive(Debug, Clone)]
pub struct UninstallPlan {
pub scope: InstallScope,
pub keep_mcp: bool,
pub keep_hooks: bool,
pub apply: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ChangeKind {
Added,
Updated,
Removed,
NoOp,
}
#[derive(Debug, Clone)]
pub struct MutationChange {
pub target: PathBuf,
pub kind: ChangeKind,
pub description: String,
}
#[derive(Debug, Clone)]
pub struct InstallReport {
pub mcp_change: Option<MutationChange>,
pub hooks_changes: Vec<MutationChange>,
pub backups: Vec<PathBuf>,
pub applied: bool,
}
pub fn install(paths: &Paths, plan: &InstallPlan) -> Result<InstallReport> {
let mcp_path = paths.user_home.join(".claude.json");
let hooks_path = paths.user_home.join(".claude/settings.json");
let mut mcp_root = load_json_or_empty(&mcp_path)?;
let mcp_kind = mcp::add_mcp_entry(
&mut mcp_root,
plan.scope,
plan.dotenv.as_deref(),
plan.force,
);
let mcp_change = Some(MutationChange {
target: mcp_path.clone(),
kind: mcp_kind.clone(),
description: format!("MCP entry (scope={:?}): {:?}", plan.scope, mcp_kind),
});
let mut hooks_root = if plan.with_hooks {
load_json_or_empty(&hooks_path)?
} else {
Value::Null
};
let raw_hook_kinds = if plan.with_hooks {
hooks::add_hooks(&mut hooks_root, plan.force)
} else {
vec![]
};
let hook_names = ["user-prompt-submit", "session-start"];
let hooks_changes: Vec<MutationChange> = raw_hook_kinds
.into_iter()
.enumerate()
.map(|(i, kind)| {
let name = hook_names.get(i).copied().unwrap_or("unknown");
MutationChange {
target: hooks_path.clone(),
kind: kind.clone(),
description: format!("hook `agentsec hook {name}`: {kind:?}"),
}
})
.collect();
let mcp_dirty = mcp_kind != ChangeKind::NoOp;
let hooks_dirty = hooks_changes.iter().any(|c| c.kind != ChangeKind::NoOp);
let mut backups = Vec::new();
if plan.apply {
if mcp_dirty {
let bak = backup::backup(&mcp_path)?;
if !bak.as_os_str().is_empty() {
backups.push(bak);
}
write_json(&mcp_path, &mcp_root)?;
}
if plan.with_hooks && hooks_dirty {
if let Some(parent) = hooks_path.parent() {
std::fs::create_dir_all(parent)?;
}
let bak = backup::backup(&hooks_path)?;
if !bak.as_os_str().is_empty() {
backups.push(bak);
}
write_json(&hooks_path, &hooks_root)?;
}
}
Ok(InstallReport {
mcp_change,
hooks_changes,
backups,
applied: plan.apply,
})
}
pub fn uninstall(paths: &Paths, plan: &UninstallPlan) -> Result<InstallReport> {
let mcp_path = paths.user_home.join(".claude.json");
let hooks_path = paths.user_home.join(".claude/settings.json");
let mcp_change = if plan.keep_mcp {
None
} else {
let mut mcp_root = load_json_or_empty(&mcp_path)?;
let kind = mcp::remove_mcp_entry(&mut mcp_root, plan.scope);
Some((
mcp_root,
MutationChange {
target: mcp_path.clone(),
kind: kind.clone(),
description: format!("MCP entry (scope={:?}): {:?}", plan.scope, kind),
},
))
};
let hooks_result = if plan.keep_hooks {
None
} else {
let mut hooks_root = load_json_or_empty(&hooks_path)?;
let raw_kinds = hooks::remove_hooks(&mut hooks_root);
Some((hooks_root, raw_kinds))
};
let hook_names = ["user-prompt-submit", "session-start"];
let hooks_changes: Vec<MutationChange> = hooks_result
.as_ref()
.map(|(_, kinds)| {
kinds
.iter()
.enumerate()
.map(|(i, kind)| {
let name = hook_names.get(i).copied().unwrap_or("unknown");
MutationChange {
target: hooks_path.clone(),
kind: kind.clone(),
description: format!("hook `agentsec hook {name}`: {kind:?}"),
}
})
.collect()
})
.unwrap_or_default();
let mcp_change_report = mcp_change.as_ref().map(|(_, c)| c.clone());
let mcp_dirty = mcp_change
.as_ref()
.is_some_and(|(_, c)| c.kind != ChangeKind::NoOp);
let hooks_dirty = hooks_changes.iter().any(|c| c.kind != ChangeKind::NoOp);
let mut backups = Vec::new();
if plan.apply {
if mcp_dirty && let Some((mcp_root, _)) = &mcp_change {
let bak = backup::backup(&mcp_path)?;
if !bak.as_os_str().is_empty() {
backups.push(bak);
}
write_json(&mcp_path, mcp_root)?;
}
if hooks_dirty && let Some((hooks_root, _)) = &hooks_result {
let bak = backup::backup(&hooks_path)?;
if !bak.as_os_str().is_empty() {
backups.push(bak);
}
write_json(&hooks_path, hooks_root)?;
}
}
Ok(InstallReport {
mcp_change: mcp_change_report,
hooks_changes,
backups,
applied: plan.apply,
})
}
fn load_json_or_empty(path: &std::path::Path) -> Result<Value> {
if !path.exists() {
return Ok(Value::Object(serde_json::Map::new()));
}
let body = std::fs::read_to_string(path)?;
if body.trim().is_empty() {
return Ok(Value::Object(serde_json::Map::new()));
}
serde_json::from_str(&body)
.map_err(|e| Error::Installer(format!("JSON parse error in {}: {e}", path.display())))
}
fn write_json(path: &std::path::Path, value: &Value) -> Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let content = serde_json::to_string_pretty(value)?;
std::fs::write(path, content)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::LlmConfig;
use crate::{Config, Paths};
use tempfile::TempDir;
fn test_paths(tmp: &TempDir) -> Paths {
Paths {
home: tmp.path().to_path_buf(),
user_home: tmp.path().to_path_buf(),
}
}
fn test_cfg(tmp: &TempDir) -> Config {
Config {
paths: test_paths(tmp),
llm: LlmConfig {
api_key: None,
model: "claude-sonnet-4-5".to_string(),
},
paste: crate::config::PasteConfig::default(),
web: crate::config::WebConfig::default(),
dotenv_path: None,
}
}
#[test]
fn install_dry_run_no_file_mutation() {
let tmp = TempDir::new().unwrap();
let paths = test_paths(&tmp);
let plan = InstallPlan {
scope: InstallScope::User,
dotenv: None,
with_hooks: true,
force: false,
apply: false,
};
let report = install(&paths, &plan).unwrap();
assert!(!report.applied);
assert!(report.backups.is_empty());
assert!(!tmp.path().join(".claude.json").exists());
assert!(!tmp.path().join(".claude/settings.json").exists());
}
#[test]
fn install_apply_creates_files() {
let tmp = TempDir::new().unwrap();
let paths = test_paths(&tmp);
let plan = InstallPlan {
scope: InstallScope::User,
dotenv: None,
with_hooks: true,
force: false,
apply: true,
};
let report = install(&paths, &plan).unwrap();
assert!(report.applied);
assert!(tmp.path().join(".claude.json").exists());
assert!(tmp.path().join(".claude/settings.json").exists());
let mcp_content = std::fs::read_to_string(tmp.path().join(".claude.json")).unwrap();
let mcp_json: Value = serde_json::from_str(&mcp_content).unwrap();
assert!(mcp_json["mcpServers"]["agentsec"].is_object());
}
#[test]
fn install_twice_is_idempotent() {
let tmp = TempDir::new().unwrap();
let paths = test_paths(&tmp);
let plan = InstallPlan {
scope: InstallScope::User,
dotenv: None,
with_hooks: true,
force: false,
apply: true,
};
install(&paths, &plan).unwrap();
install(&paths, &plan).unwrap();
let hooks_content =
std::fs::read_to_string(tmp.path().join(".claude/settings.json")).unwrap();
let hooks_json: Value = serde_json::from_str(&hooks_content).unwrap();
let ups_len = hooks_json["hooks"]["UserPromptSubmit"]
.as_array()
.map_or(0, Vec::len);
assert_eq!(ups_len, 1, "should not duplicate hook entries");
}
#[test]
fn install_apply_creates_backup_when_file_existed() {
let tmp = TempDir::new().unwrap();
let paths = test_paths(&tmp);
std::fs::write(tmp.path().join(".claude.json"), "{}").unwrap();
std::fs::create_dir_all(tmp.path().join(".claude")).unwrap();
std::fs::write(tmp.path().join(".claude/settings.json"), "{}").unwrap();
let plan = InstallPlan {
scope: InstallScope::User,
dotenv: None,
with_hooks: true,
force: false,
apply: true,
};
let report = install(&paths, &plan).unwrap();
assert_eq!(report.backups.len(), 2, "should create 2 backups");
for bak in &report.backups {
assert!(bak.exists());
}
}
#[test]
fn install_apply_skips_backup_when_all_changes_are_noop() {
let tmp = TempDir::new().unwrap();
let paths = test_paths(&tmp);
let plan = InstallPlan {
scope: InstallScope::User,
dotenv: None,
with_hooks: true,
force: false,
apply: true,
};
let first = install(&paths, &plan).unwrap();
assert!(first.applied);
let second = install(&paths, &plan).unwrap();
assert!(second.applied);
assert_eq!(
second.mcp_change.as_ref().unwrap().kind,
super::ChangeKind::NoOp
);
assert!(
second
.hooks_changes
.iter()
.all(|c| c.kind == super::ChangeKind::NoOp),
"all hooks must report NoOp on second apply"
);
assert_eq!(
second.backups.len(),
0,
"second apply must not create backups when nothing changes"
);
}
#[test]
fn uninstall_apply_skips_backup_when_nothing_to_remove() {
let tmp = TempDir::new().unwrap();
let paths = test_paths(&tmp);
std::fs::write(tmp.path().join(".claude.json"), "{}").unwrap();
std::fs::create_dir_all(tmp.path().join(".claude")).unwrap();
std::fs::write(tmp.path().join(".claude/settings.json"), "{}").unwrap();
let plan = UninstallPlan {
scope: InstallScope::User,
keep_mcp: false,
keep_hooks: false,
apply: true,
};
let report = uninstall(&paths, &plan).unwrap();
assert!(report.applied);
assert_eq!(
report.mcp_change.as_ref().unwrap().kind,
super::ChangeKind::NoOp
);
assert!(
report
.hooks_changes
.iter()
.all(|c| c.kind == super::ChangeKind::NoOp)
);
assert_eq!(
report.backups.len(),
0,
"uninstall on clean host must not create backups"
);
}
#[allow(dead_code)]
fn _use_cfg(tmp: &TempDir) {
let _ = test_cfg(tmp);
}
}