use std::path::PathBuf;
use super::{
Status, TargetOutcome, TargetStatus,
common::{
claude_plugin_installed, cwd_path, error_outcome, home_path, probe_cli_mcp,
probe_json_install,
},
goose_yaml::{merge_goose_yaml_config, probe_goose_install, remove_goose_yaml_config},
hooks_install::{
install_claude_code_hooks, install_cursor_hooks, install_gemini_cli_hooks,
install_windsurf_hooks, probe_json_hooks_by_command, probe_json_hooks_by_group,
probe_json_hooks_by_name, probe_json_hooks_by_nested_command, uninstall_claude_code_hooks,
uninstall_cursor_hooks, uninstall_gemini_cli_hooks, uninstall_windsurf_hooks,
},
json_config::{finish_json_install, finish_json_uninstall},
};
pub(super) const MCP_JSON_BLOCK_VERSION: u32 = 1;
pub(super) const HOOKS_JSON_BLOCK_VERSION: u32 = 1;
pub(super) const GOOSE_YAML_BLOCK_VERSION: u32 = 1;
pub(super) const CLI_DELEGATE_BLOCK_VERSION: u32 = 1;
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub(super) enum BlockKind {
McpJson,
HooksJson,
GooseYaml,
ExternalCli,
}
impl BlockKind {
pub(super) const fn as_str(self) -> &'static str {
match self {
Self::McpJson => "mcp_json",
Self::HooksJson => "hooks_json",
Self::GooseYaml => "goose_yaml",
Self::ExternalCli => "external_cli",
}
}
#[allow(dead_code)]
pub(super) fn from_str(s: &str) -> Option<Self> {
match s {
"mcp_json" => Some(Self::McpJson),
"hooks_json" => Some(Self::HooksJson),
"goose_yaml" => Some(Self::GooseYaml),
"external_cli" => Some(Self::ExternalCli),
_ => None,
}
}
pub(super) const fn current_version(self) -> u32 {
match self {
Self::McpJson => MCP_JSON_BLOCK_VERSION,
Self::HooksJson => HOOKS_JSON_BLOCK_VERSION,
Self::GooseYaml => GOOSE_YAML_BLOCK_VERSION,
Self::ExternalCli => CLI_DELEGATE_BLOCK_VERSION,
}
}
}
pub(super) const fn block_kind_of(spec: &AgentSpec) -> BlockKind {
match &spec.format {
ConfigFormat::Json { .. } => BlockKind::McpJson,
ConfigFormat::Yaml => BlockKind::GooseYaml,
ConfigFormat::Hooks { .. } => BlockKind::HooksJson,
ConfigFormat::CliDelegate { .. } => BlockKind::ExternalCli,
}
}
pub(super) const fn servers_key_of(spec: &AgentSpec) -> Option<&'static str> {
match &spec.format {
ConfigFormat::Json { servers_key } => Some(servers_key),
_ => None,
}
}
pub(super) const fn hook_surface_of(spec: &AgentSpec) -> Option<HookSurface> {
match &spec.format {
ConfigFormat::Hooks { surface } => Some(*surface),
_ => None,
}
}
pub(super) enum PathScope {
Home,
Cwd,
}
#[derive(Clone, Copy)]
pub(super) enum HookSurface {
Claude,
Cursor,
Gemini,
Windsurf,
}
pub(super) enum ConfigFormat {
Json { servers_key: &'static str },
Yaml,
CliDelegate {
cli: &'static str,
add_args: &'static [&'static str],
remove_args: &'static [&'static str],
get_args: &'static [&'static str],
add_dry_run: &'static str,
remove_dry_run: &'static str,
installed_detail: &'static str,
},
Hooks { surface: HookSurface },
}
pub(super) enum DetectSignal {
ParentDir { skip_reason: &'static str },
ParentDirOrCli {
cli: &'static str,
skip_reason: &'static str,
},
SiblingDir {
segments: &'static [&'static [&'static str]],
skip_reason: &'static str,
},
Cli {
cli: &'static str,
skip_reason: &'static str,
},
DirOrManual { manual_hint: &'static str },
RidesAlong,
}
pub(super) struct AgentSpec {
pub name: &'static str,
pub client: &'static str,
pub scope: PathScope,
pub segments: &'static [&'static str],
pub display: &'static str,
pub format: ConfigFormat,
pub detect: DetectSignal,
pub skip_if_plugin: bool,
}
pub(super) static AGENTS: &[AgentSpec] = &[
AgentSpec {
name: "Claude Code",
client: "Claude Code",
scope: PathScope::Home,
segments: &[],
display: "claude mcp add -s user difflore",
format: ConfigFormat::CliDelegate {
cli: "claude",
add_args: &[
"mcp",
"add",
"-s",
"user",
"difflore",
"{bin}",
"mcp-server",
],
remove_args: &["mcp", "remove", "-s", "user", "difflore"],
get_args: &["mcp", "get", "difflore"],
add_dry_run: "would run: claude mcp add -s user difflore difflore mcp-server",
remove_dry_run: "would run: claude mcp remove -s user difflore",
installed_detail: "user-scope MCP via `claude mcp add`",
},
detect: DetectSignal::Cli {
cli: "claude",
skip_reason: "`claude` CLI not on PATH",
},
skip_if_plugin: true,
},
AgentSpec {
name: "Claude Code hooks",
client: "Claude Code",
scope: PathScope::Home,
segments: &[".claude", "settings.json"],
display: "~/.claude/settings.json",
format: ConfigFormat::Hooks {
surface: HookSurface::Claude,
},
detect: DetectSignal::RidesAlong,
skip_if_plugin: false,
},
AgentSpec {
name: "Codex",
client: "Codex",
scope: PathScope::Home,
segments: &[],
display: "codex mcp add difflore",
format: ConfigFormat::CliDelegate {
cli: "codex",
add_args: &["mcp", "add", "difflore", "--", "{bin}", "mcp-server"],
remove_args: &["mcp", "remove", "difflore"],
get_args: &["mcp", "get", "difflore"],
add_dry_run: "would run: codex mcp add difflore -- difflore mcp-server",
remove_dry_run: "would run: codex mcp remove difflore",
installed_detail: "~/.codex/config.toml via `codex mcp add`",
},
detect: DetectSignal::Cli {
cli: "codex",
skip_reason: "`codex` CLI not on PATH",
},
skip_if_plugin: false,
},
AgentSpec {
name: "Cursor",
client: "Cursor",
scope: PathScope::Home,
segments: &[".cursor", "mcp.json"],
display: "~/.cursor/mcp.json",
format: ConfigFormat::Json {
servers_key: "mcpServers",
},
detect: DetectSignal::ParentDir {
skip_reason: "~/.cursor/ not found",
},
skip_if_plugin: false,
},
AgentSpec {
name: "Cursor hooks",
client: "Cursor",
scope: PathScope::Cwd,
segments: &[".cursor", "hooks.json"],
display: "./.cursor/hooks.json",
format: ConfigFormat::Hooks {
surface: HookSurface::Cursor,
},
detect: DetectSignal::RidesAlong,
skip_if_plugin: false,
},
AgentSpec {
name: "Gemini",
client: "Gemini CLI",
scope: PathScope::Home,
segments: &[".gemini", "settings.json"],
display: "~/.gemini/settings.json",
format: ConfigFormat::Json {
servers_key: "mcpServers",
},
detect: DetectSignal::ParentDirOrCli {
cli: "gemini",
skip_reason: "~/.gemini/ not found and `gemini` CLI not on PATH",
},
skip_if_plugin: false,
},
AgentSpec {
name: "Gemini hooks",
client: "Gemini CLI",
scope: PathScope::Home,
segments: &[".gemini", "settings.json"],
display: "~/.gemini/settings.json",
format: ConfigFormat::Hooks {
surface: HookSurface::Gemini,
},
detect: DetectSignal::RidesAlong,
skip_if_plugin: false,
},
AgentSpec {
name: "Copilot CLI",
client: "Copilot CLI",
scope: PathScope::Home,
segments: &[".github", "copilot", "mcp.json"],
display: "~/.github/copilot/mcp.json",
format: ConfigFormat::Json {
servers_key: "servers",
},
detect: DetectSignal::ParentDirOrCli {
cli: "copilot",
skip_reason: "~/.github/copilot/ not found and `copilot` CLI not on PATH",
},
skip_if_plugin: false,
},
AgentSpec {
name: "Antigravity",
client: "Antigravity",
scope: PathScope::Home,
segments: &[".gemini", "antigravity", "mcp_config.json"],
display: "~/.gemini/antigravity/mcp_config.json",
format: ConfigFormat::Json {
servers_key: "mcpServers",
},
detect: DetectSignal::SiblingDir {
segments: &[&[".gemini"]],
skip_reason: "~/.gemini/antigravity/ not found",
},
skip_if_plugin: false,
},
AgentSpec {
name: "Goose",
client: "Goose",
scope: PathScope::Home,
segments: &[".config", "goose", "config.yaml"],
display: "~/.config/goose/config.yaml",
format: ConfigFormat::Yaml,
detect: DetectSignal::ParentDirOrCli {
cli: "goose",
skip_reason: "~/.config/goose/ not found and `goose` CLI not on PATH",
},
skip_if_plugin: false,
},
AgentSpec {
name: "Crush",
client: "Crush",
scope: PathScope::Home,
segments: &[".config", "crush", "mcp.json"],
display: "~/.config/crush/mcp.json",
format: ConfigFormat::Json {
servers_key: "mcpServers",
},
detect: DetectSignal::ParentDirOrCli {
cli: "crush",
skip_reason: "~/.config/crush/ not found and `crush` CLI not on PATH",
},
skip_if_plugin: false,
},
AgentSpec {
name: "Roo Code",
client: "Roo Code",
scope: PathScope::Cwd,
segments: &[".roo", "mcp.json"],
display: "./.roo/mcp.json",
format: ConfigFormat::Json {
servers_key: "mcpServers",
},
detect: DetectSignal::ParentDir {
skip_reason: "./.roo/ not found in current workspace (Roo Code is project-local)",
},
skip_if_plugin: false,
},
AgentSpec {
name: "Warp",
client: "Warp",
scope: PathScope::Home,
segments: &[".warp", "mcp.json"],
display: "~/.warp/mcp.json",
format: ConfigFormat::Json {
servers_key: "mcpServers",
},
detect: DetectSignal::DirOrManual {
#[allow(clippy::literal_string_with_formatting_args)]
manual_hint: "~/.warp/ not found. In Warp, open Settings → AI → Manage MCP servers and add: \
command=`{bin}`, args=[\"mcp-server\"]",
},
skip_if_plugin: false,
},
AgentSpec {
name: "Windsurf hooks",
client: "Windsurf",
scope: PathScope::Home,
segments: &[".codeium", "windsurf", "hooks.json"],
display: "~/.codeium/windsurf/hooks.json",
format: ConfigFormat::Hooks {
surface: HookSurface::Windsurf,
},
detect: DetectSignal::RidesAlong,
skip_if_plugin: false,
},
];
pub(super) fn find_spec(name: &str) -> Option<&'static AgentSpec> {
AGENTS.iter().find(|spec| spec.name == name)
}
pub(super) fn resolve_path(spec: &AgentSpec) -> Result<PathBuf, String> {
match spec.scope {
PathScope::Home => home_path(spec.segments),
PathScope::Cwd => cwd_path(spec.segments),
}
}
pub(super) fn detect(spec: &AgentSpec, bin: &str) -> TargetStatus {
match &spec.format {
ConfigFormat::CliDelegate { cli, get_args, .. } => probe_cli_mcp(spec.name, cli, get_args),
ConfigFormat::Json { servers_key } => with_path(spec, |path| {
probe_json_install(spec.name, path, servers_key, bin)
}),
ConfigFormat::Yaml => with_path(spec, |path| probe_goose_install(spec.name, path, bin)),
ConfigFormat::Hooks { surface } => with_path(spec, |path| match surface {
HookSurface::Claude => {
probe_json_hooks_by_nested_command(spec.name, path, "claude-code")
}
HookSurface::Cursor => probe_json_hooks_by_name(spec.name, path),
HookSurface::Gemini => probe_json_hooks_by_group(spec.name, path),
HookSurface::Windsurf => probe_json_hooks_by_command(spec.name, path, "windsurf"),
}),
}
}
fn with_path<F: FnOnce(&PathBuf) -> TargetStatus>(spec: &AgentSpec, f: F) -> TargetStatus {
match resolve_path(spec) {
Ok(path) => f(&path),
Err(_) => TargetStatus {
name: spec.name,
detected: false,
state: super::InstallState::Unknown,
detail: Some(format!("could not resolve {}", spec.display)),
},
}
}
pub(super) fn install(
spec: &AgentSpec,
mcp_bin: &str,
cli_bin: &str,
dry_run: bool,
) -> TargetOutcome {
if let Some(skip) = detect_gate(spec) {
return skip;
}
if spec.skip_if_plugin && claude_plugin_installed() {
return TargetOutcome {
name: spec.name,
status: Status::Skipped(
"DiffLore plugin already installed — MCP + hooks auto-registered".into(),
),
detail: "~/.claude/plugins/cache/.../difflore/".into(),
};
}
match &spec.format {
ConfigFormat::CliDelegate {
cli,
add_args,
remove_args,
add_dry_run,
installed_detail,
..
} => install_cli_delegate(
spec,
cli,
add_args,
remove_args,
add_dry_run,
installed_detail,
mcp_bin,
cli_bin,
dry_run,
),
ConfigFormat::Json { servers_key } => match resolve_path(spec) {
Ok(path) => finish_json_install(spec.name, &path, mcp_bin, servers_key, dry_run),
Err(e) => error_outcome(spec.name, e),
},
ConfigFormat::Yaml => match resolve_path(spec) {
Ok(path) => match merge_goose_yaml_config(&path, mcp_bin, dry_run) {
Ok(existed) => TargetOutcome {
name: spec.name,
status: if existed {
Status::Updated
} else {
Status::Installed
},
detail: path.display().to_string(),
},
Err(e) => error_outcome(spec.name, e),
},
Err(e) => error_outcome(spec.name, e),
},
ConfigFormat::Hooks { surface } => match surface {
HookSurface::Claude => TargetOutcome {
name: spec.name,
status: Status::Skipped("installed with Claude Code MCP".into()),
detail: String::new(),
},
HookSurface::Cursor => install_cursor_hooks(cli_bin, dry_run),
HookSurface::Gemini => install_gemini_cli_hooks(cli_bin, dry_run),
HookSurface::Windsurf => install_windsurf_hooks(cli_bin, dry_run),
},
}
}
#[allow(clippy::too_many_arguments)]
fn install_cli_delegate(
spec: &AgentSpec,
cli: &str,
add_args: &[&str],
remove_args: &[&str],
add_dry_run: &str,
installed_detail: &str,
mcp_bin: &str,
cli_bin: &str,
dry_run: bool,
) -> TargetOutcome {
if dry_run {
return TargetOutcome {
name: spec.name,
status: Status::Installed,
detail: add_dry_run.to_owned(),
};
}
let _ = std::process::Command::new(cli).args(remove_args).output();
let resolved: Vec<String> = add_args
.iter()
.map(|a| {
if *a == "{bin}" {
mcp_bin.to_owned()
} else {
(*a).to_owned()
}
})
.collect();
let out = std::process::Command::new(cli).args(&resolved).output();
match out {
Ok(o) if o.status.success() => {
if spec.skip_if_plugin {
let hook_summary = match install_claude_code_hooks(cli_bin) {
Ok(installed) if installed > 0 => format!(
"user-scope MCP + {installed} lifecycle hooks merged into ~/.claude/settings.json"
),
Ok(_) => {
"user-scope MCP via `claude mcp add` (hooks already up-to-date)".to_owned()
}
Err(err) => format!(
"user-scope MCP via `claude mcp add` (hook merge failed: {err}; rerun later or use `/plugin install difflore` inside Claude Code)"
),
};
return TargetOutcome {
name: spec.name,
status: Status::Installed,
detail: hook_summary,
};
}
TargetOutcome {
name: spec.name,
status: Status::Installed,
detail: installed_detail.to_owned(),
}
}
Ok(o) => TargetOutcome {
name: spec.name,
status: Status::Error(format!(
"`{cli} {}` exit {}: {}",
add_command_label(add_args),
o.status,
String::from_utf8_lossy(&o.stderr).trim()
)),
detail: String::new(),
},
Err(e) => TargetOutcome {
name: spec.name,
status: Status::Error(format!("could not invoke `{cli}`: {e}")),
detail: String::new(),
},
}
}
fn add_command_label(add_args: &[&str]) -> String {
add_args
.iter()
.take(2)
.copied()
.collect::<Vec<_>>()
.join(" ")
}
fn detect_gate(spec: &AgentSpec) -> Option<TargetOutcome> {
let skip_reason: Option<String> = match &spec.detect {
DetectSignal::Cli { cli, skip_reason } => which::which(cli)
.is_err()
.then(|| (*skip_reason).to_owned()),
DetectSignal::ParentDir { skip_reason } => {
(!parent_exists(spec)).then(|| (*skip_reason).to_owned())
}
DetectSignal::ParentDirOrCli { cli, skip_reason } => {
(!parent_exists(spec) && which::which(cli).is_err()).then(|| (*skip_reason).to_owned())
}
DetectSignal::SiblingDir {
segments,
skip_reason,
} => (!any_sibling_exists(segments)).then(|| (*skip_reason).to_owned()),
DetectSignal::DirOrManual { manual_hint } => (!parent_exists(spec)).then(|| {
#[allow(clippy::literal_string_with_formatting_args)]
manual_hint.replace("{bin}", "difflore")
}),
DetectSignal::RidesAlong => None,
};
skip_reason.map(|reason| TargetOutcome {
name: spec.name,
status: Status::Skipped(reason),
detail: String::new(),
})
}
fn parent_exists(spec: &AgentSpec) -> bool {
resolve_path(spec)
.ok()
.and_then(|path| path.parent().map(std::path::Path::exists))
.unwrap_or(false)
}
fn any_sibling_exists(segments: &[&[&str]]) -> bool {
segments
.iter()
.any(|seg| home_path(seg).ok().is_some_and(|p| p.exists()))
}
pub(super) fn uninstall(spec: &AgentSpec, dry_run: bool) -> TargetOutcome {
match &spec.format {
ConfigFormat::CliDelegate {
cli,
remove_args,
remove_dry_run,
..
} => uninstall_cli_delegate(spec, cli, remove_args, remove_dry_run, dry_run),
ConfigFormat::Json { servers_key } => match resolve_path(spec) {
Ok(path) => finish_json_uninstall(spec.name, &path, servers_key, dry_run),
Err(e) => error_outcome(spec.name, e),
},
ConfigFormat::Yaml => match resolve_path(spec) {
Ok(path) => match remove_goose_yaml_config(&path, dry_run) {
Ok(true) => TargetOutcome {
name: spec.name,
status: Status::Removed,
detail: path.display().to_string(),
},
Ok(false) => TargetOutcome {
name: spec.name,
status: Status::Skipped("no difflore block to remove".into()),
detail: String::new(),
},
Err(e) => error_outcome(spec.name, e),
},
Err(e) => error_outcome(spec.name, e),
},
ConfigFormat::Hooks { surface } => match surface {
HookSurface::Claude => uninstall_claude_code_combined(spec, dry_run),
HookSurface::Cursor => uninstall_cursor_hooks(dry_run),
HookSurface::Gemini => uninstall_gemini_cli_hooks(dry_run),
HookSurface::Windsurf => uninstall_windsurf_hooks(dry_run),
},
}
}
fn uninstall_cli_delegate(
spec: &AgentSpec,
cli: &str,
remove_args: &[&str],
remove_dry_run: &str,
dry_run: bool,
) -> TargetOutcome {
if spec.skip_if_plugin {
return uninstall_claude_code_combined(spec, dry_run);
}
if which::which(cli).is_err() {
return TargetOutcome {
name: spec.name,
status: Status::Skipped(format!("`{cli}` CLI not on PATH")),
detail: String::new(),
};
}
if dry_run {
return TargetOutcome {
name: spec.name,
status: Status::Removed,
detail: remove_dry_run.to_owned(),
};
}
match std::process::Command::new(cli).args(remove_args).output() {
Ok(o) if o.status.success() => TargetOutcome {
name: spec.name,
status: Status::Removed,
detail: format!("~/.codex/config.toml via `{cli} mcp remove`"),
},
Ok(o) => TargetOutcome {
name: spec.name,
status: Status::Skipped(format!(
"`{cli} mcp remove` exit {} (likely no difflore entry): {}",
o.status,
String::from_utf8_lossy(&o.stderr).trim()
)),
detail: String::new(),
},
Err(e) => TargetOutcome {
name: spec.name,
status: Status::Error(format!("could not invoke `{cli}`: {e}")),
detail: String::new(),
},
}
}
fn uninstall_claude_code_combined(_spec: &AgentSpec, dry_run: bool) -> TargetOutcome {
let name = "Claude Code";
if claude_plugin_installed() {
return TargetOutcome {
name,
status: Status::Skipped(
"DiffLore plugin manages MCP + hooks — remove with `/plugin uninstall difflore` inside Claude Code".into(),
),
detail: "~/.claude/plugins/cache/.../difflore/".into(),
};
}
let hooks_removed = match uninstall_claude_code_hooks(dry_run) {
Ok(removed) => removed,
Err(err) => {
return TargetOutcome {
name,
status: Status::Error(format!("hook removal failed: {err}")),
detail: String::new(),
};
}
};
let verb = if dry_run { "would remove" } else { "removed" };
let hook_summary = if hooks_removed > 0 {
format!("{verb} {hooks_removed} lifecycle hook group(s) from ~/.claude/settings.json")
} else {
"no DiffLore hooks in ~/.claude/settings.json".to_owned()
};
if which::which("claude").is_err() {
let status = if hooks_removed > 0 {
Status::Removed
} else {
Status::Skipped("`claude` CLI not on PATH and no DiffLore hooks to remove".into())
};
return TargetOutcome {
name,
status,
detail: if hooks_removed > 0 {
hook_summary
} else {
String::new()
},
};
}
if dry_run {
return TargetOutcome {
name,
status: Status::Removed,
detail: format!("would run: claude mcp remove -s user difflore; {hook_summary}"),
};
}
let _ = std::process::Command::new("claude")
.args(["mcp", "remove", "-s", "user", "difflore"])
.output();
TargetOutcome {
name,
status: Status::Removed,
detail: format!("user-scope MCP removed via `claude mcp remove`; {hook_summary}"),
}
}
pub(super) fn canonical_target_key(name: &str) -> String {
let trimmed = name.trim();
let lower = trimmed.to_ascii_lowercase();
match lower.as_str() {
"claude" | "claude code" => return "claude".into(),
"claude hooks" | "claude code hooks" => return "claude hooks".into(),
_ => {}
}
if let Some(spec) = AGENTS.iter().find(|spec| spec.name == trimmed) {
return surface_key(spec.name);
}
lower
}
fn surface_key(name: &str) -> String {
match name {
"Claude Code" => "claude".into(),
"Claude Code hooks" => "claude hooks".into(),
other => other.to_ascii_lowercase(),
}
}
pub(super) fn client_name_for_surface(surface: &str) -> &'static str {
let key = canonical_target_key(surface);
for spec in AGENTS {
if surface_key(spec.name) == key {
return spec.client;
}
}
"unknown client"
}