From 8eb08cb9dcad7f5308f7c54341fbdf154a1e35c1 Mon Sep 17 00:00:00 2001 From: Brezn Date: Thu, 21 May 2026 18:46:34 -0400 Subject: [PATCH] Add local tool integration commands --- README.md | 45 +- src/commands/alias.rs | 2 + src/commands/doctor.rs | 44 +- src/commands/integrations.rs | 1003 ++++++++++++++++++++++++++++++++++ src/commands/mod.rs | 1 + src/config.rs | 22 +- src/main.rs | 125 ++++- tests/integration.rs | 402 ++++++++++++++ 8 files changed, 1599 insertions(+), 45 deletions(-) create mode 100644 src/commands/integrations.rs diff --git a/README.md b/README.md index 50ab322..47755aa 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,11 @@ curl -fsSL https://headsdown.app/install.sh | sh # Authenticate (opens browser for approval) hd auth +# Install HeadsDown into local agent tools +hd install claude +hd install --all +hd doctor claude + # Check your current status and availability hd status hd availability @@ -93,8 +98,13 @@ hd watch | `hd limited [duration]` | Set mode to limited | | `hd verdict "desc"` | Submit a task proposal and get a verdict | | `hd watch` | Live-updating status dashboard | -| `hd doctor` | Check CLI health and connectivity | -| `hd update` | Self-update to the latest version | +| `hd install ` | Install the HeadsDown integration for a supported local tool, such as `claude`, `pi`, or `codex` | +| `hd install --all` | Install HeadsDown integrations for every detected supported tool | +| `hd doctor [tool]` | Check CLI health or local integration health | +| `hd doctor --all` | Check every supported local integration without printing sensitive local content | +| `hd update [tool]` | Refresh installed HeadsDown integrations, or use `--cli` to self-update the CLI binary | +| `hd update --all` | Refresh HeadsDown integrations for detected supported tools | +| `hd remove ` | Remove the HeadsDown integration from a tool without uninstalling the tool itself | | `hd hook install` | Install git hooks (auto-busy on branch switch) | | `hd hook uninstall` | Remove git hooks | | `hd hook status` | Show git hook status | @@ -141,6 +151,37 @@ hd focus hd standup ``` +## Local Agent Integrations + +Install means “install the HeadsDown integration for this tool,” not “install Claude Code, Pi, or Codex itself.” The installer only writes HeadsDown-owned integration artifacts and is safe to re-run: + +```sh +hd install claude +hd install pi +hd install codex +hd install --all --dry-run +hd install --all --yes +``` + +Refresh or remove integrations with the same short command shape: + +```sh +hd update +hd update claude +hd update --all --yes +hd remove claude +``` + +Doctor reports derived health facts only: + +```sh +hd doctor +hd doctor claude +hd doctor --all +``` + +Integration diagnostics do not print prompts, transcripts, source code, diffs, file paths, repository names, logs, tokens, or raw config content. Local integrations keep execution context local; hosted HeadsDown receives only structured routing metadata when hosted features are used. + ## Git Hooks Auto-set your availability based on git activity: diff --git a/src/commands/alias.rs b/src/commands/alias.rs index eaa2ab5..654003f 100644 --- a/src/commands/alias.rs +++ b/src/commands/alias.rs @@ -17,8 +17,10 @@ pub fn set(name: &str, command: &str) -> Result<()> { "presets", "preset", "watch", + "install", "doctor", "update", + "remove", "hook", "telemetry", "alias", diff --git a/src/commands/doctor.rs b/src/commands/doctor.rs index f265431..1f35bc2 100644 --- a/src/commands/doctor.rs +++ b/src/commands/doctor.rs @@ -5,39 +5,29 @@ use crate::config; use crate::format; pub async fn run(api_url: &str, json: bool) -> Result<()> { - let mut checks: Vec<(&str, bool, String)> = Vec::new(); + let mut checks: Vec<(String, bool, String)> = Vec::new(); // Check 1: CLI version let version = env!("CARGO_PKG_VERSION"); - checks.push(("CLI version", true, version.to_string())); + checks.push(("CLI version".to_string(), true, version.to_string())); // Check 2: Config directory let config_ok = config::config_dir().is_ok(); let config_detail = match config::config_dir() { - Ok(dir) => format!("{}", dir.display()), + Ok(_) => "Available".to_string(), Err(e) => format!("Error: {}", e), }; - checks.push(("Config directory", config_ok, config_detail)); + checks.push(("Config directory".to_string(), config_ok, config_detail)); // Check 3: Credentials let creds = auth::load_token(); let creds_ok = matches!(&creds, Ok(Some(_))); let creds_detail = match &creds { - Ok(Some(token)) => { - if token.starts_with("hd_") { - format!( - "{}...{}", - &token[..6], - &token[token.len().saturating_sub(4)..] - ) - } else { - "Present (unknown format)".to_string() - } - } + Ok(Some(_)) => "Present".to_string(), Ok(None) => "Not found. Run `hd auth`".to_string(), - Err(e) => format!("Error: {}", e), + Err(_) => "Error reading credentials".to_string(), }; - checks.push(("Credentials", creds_ok, creds_detail)); + checks.push(("Credentials".to_string(), creds_ok, creds_detail)); // Check 4: API connectivity let http = reqwest::Client::builder() @@ -55,7 +45,7 @@ pub async fn run(api_url: &str, json: bool) -> Result<()> { } Err(e) => (false, format!("Cannot reach {}: {}", api_url, e)), }; - checks.push(("API connectivity", api_ok, api_detail)); + checks.push(("API connectivity".to_string(), api_ok, api_detail)); // Check 5: Authentication validity let auth_ok; @@ -66,11 +56,9 @@ pub async fn run(api_url: &str, json: bool) -> Result<()> { .execute(r#"query { profile { name email } }"#, None) .await { - Ok(data) => { - let name = data["profile"]["name"].as_str().unwrap_or("Unknown"); - let email = data["profile"]["email"].as_str().unwrap_or("Unknown"); + Ok(_) => { auth_ok = true; - auth_detail = format!("{} ({})", name, email); + auth_detail = "Valid".to_string(); } Err(e) => { auth_ok = false; @@ -81,23 +69,27 @@ pub async fn run(api_url: &str, json: bool) -> Result<()> { auth_ok = false; auth_detail = "Skipped (no credentials)".to_string(); }; - checks.push(("Authentication", auth_ok, auth_detail)); + checks.push(("Authentication".to_string(), auth_ok, auth_detail)); // Check 6: Config file let cfg_result = config::load(); let (cfg_ok, cfg_detail) = match cfg_result { Ok(_) => (true, "Valid".to_string()), - Err(e) => (false, format!("Error: {}", e)), + Err(_) => (false, "Invalid or unreadable config file".to_string()), }; - checks.push(("Config file", cfg_ok, cfg_detail)); + checks.push(("Config file".to_string(), cfg_ok, cfg_detail)); // Check 7: OS/Arch checks.push(( - "Platform", + "Platform".to_string(), true, format!("{}/{}", std::env::consts::OS, std::env::consts::ARCH), )); + for (name, ok, detail) in crate::commands::integrations::default_doctor_checks()? { + checks.push((name, ok, detail)); + } + if json { let json_checks: Vec = checks .iter() diff --git a/src/commands/integrations.rs b/src/commands/integrations.rs new file mode 100644 index 0000000..7ba2b45 --- /dev/null +++ b/src/commands/integrations.rs @@ -0,0 +1,1003 @@ +use anyhow::{anyhow, bail, Result}; +use clap::ValueEnum; +use serde::Serialize; +use serde_json::Value; +use std::fs; +use std::io::{self, Write}; +use std::path::PathBuf; + +use crate::format; + +const CLAUDE_REFEREE_COMMAND: &str = r#"--- +description: Verify this run locally against a HeadsDown Referee contract and print a privacy-safe receipt +allowed-tools: Bash(headsdown-claude:*), Bash(npx:*) +argument-hint: "" +--- + +# HeadsDown Referee + + + +Run `headsdown-claude referee` and print only the returned receipt. If that command is unavailable, run `npx -y headsdown-claude referee`. + +Do not add prompts, code, logs, file paths, repository names, branch names, terminal output, or message contents to the receipt. +"#; + +const CLAUDE_MARKER: &str = "headsdown-cli managed: claude-referee-command v1"; +const PI_PACKAGE: &str = "git:github.com/headsdownapp/headsdown-pi"; +const CODEX_MARKER_BEGIN: &str = "# "; +const CODEX_MARKER_END: &str = "# "; +const CODEX_BLOCK: &str = r#"# +[mcp_servers.headsdown] +command = "npx" +args = ["-y", "headsdown-claude"] +# +"#; + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, ValueEnum, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum IntegrationTool { + Claude, + Pi, + Codex, +} + +impl IntegrationTool { + fn label(self) -> &'static str { + match self { + IntegrationTool::Claude => "Claude Code", + IntegrationTool::Pi => "Pi", + IntegrationTool::Codex => "Codex", + } + } + + fn slug(self) -> &'static str { + match self { + IntegrationTool::Claude => "claude", + IntegrationTool::Pi => "pi", + IntegrationTool::Codex => "codex", + } + } + + fn executable(self) -> &'static str { + match self { + IntegrationTool::Claude => "claude", + IntegrationTool::Pi => "pi", + IntegrationTool::Codex => "codex", + } + } +} + +#[derive(Debug)] +pub struct IntegrationCommandOptions { + pub tool: Option, + pub all: bool, + pub dry_run: bool, + pub yes: bool, + pub json: bool, +} + +#[derive(Debug)] +pub struct DoctorOptions { + pub tool: Option, + pub all: bool, + pub json: bool, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +enum ActionStatus { + Planned, + Installed, + Updated, + AlreadyCurrent, + Removed, + NotInstalled, + MissingTool, + Skipped, +} + +#[derive(Debug, Serialize)] +struct ActionReport { + tool: IntegrationTool, + status: ActionStatus, + message: String, + detected: bool, + installed: bool, +} + +#[derive(Debug, Serialize)] +struct HealthReport { + tool: IntegrationTool, + detected: bool, + config_present: bool, + installed: bool, + current: bool, + auth_status: &'static str, + repair_suggestion: String, +} + +#[derive(Debug)] +struct ToolState { + detected: bool, + config_present: bool, + installed: bool, + current: bool, +} + +pub fn install(options: IntegrationCommandOptions) -> Result<()> { + let tools = resolve_install_tools(options.tool, options.all)?; + if options.all { + if !options.json { + print_detected_tools(&tools)?; + } + if options.json && !options.dry_run && !options.yes { + return print_action_reports( + vec![ActionReport { + tool: IntegrationTool::Claude, + status: ActionStatus::Skipped, + message: "Pass --yes to install detected integrations in JSON mode".to_string(), + detected: false, + installed: false, + }], + true, + ); + } + if !options.dry_run + && !options.yes + && !confirm("Install HeadsDown integrations for detected tools?")? + { + return print_action_reports( + vec![ActionReport { + tool: IntegrationTool::Claude, + status: ActionStatus::Skipped, + message: "No changes made".to_string(), + detected: false, + installed: false, + }], + options.json, + ); + } + } + + let mut reports = Vec::new(); + for tool in tools { + reports.push(install_tool(tool, options.dry_run)?); + } + print_action_reports(reports, options.json) +} + +pub fn update(options: IntegrationCommandOptions) -> Result<()> { + let tools = resolve_update_tools(options.tool, options.all)?; + if options.all { + if !options.json { + print_detected_tools(&tools)?; + } + if options.json && !options.dry_run && !options.yes { + return print_action_reports( + vec![ActionReport { + tool: IntegrationTool::Claude, + status: ActionStatus::Skipped, + message: "Pass --yes to update detected integrations in JSON mode".to_string(), + detected: false, + installed: false, + }], + true, + ); + } + if !options.dry_run + && !options.yes + && !confirm("Update HeadsDown integrations for detected tools?")? + { + return print_action_reports( + vec![ActionReport { + tool: IntegrationTool::Claude, + status: ActionStatus::Skipped, + message: "No changes made".to_string(), + detected: false, + installed: false, + }], + options.json, + ); + } + } + + let mut reports = Vec::new(); + for tool in tools { + reports.push(update_tool(tool, options.dry_run)?); + } + print_action_reports(reports, options.json) +} + +pub fn remove(options: IntegrationCommandOptions) -> Result<()> { + let tool = options.tool.ok_or_else(|| { + anyhow!("hd remove requires a supported tool, for example: hd remove claude") + })?; + let report = remove_tool(tool, options.dry_run)?; + print_action_reports(vec![report], options.json) +} + +pub fn default_doctor_checks() -> Result> { + let tools = installed_tools().unwrap_or_default(); + if tools.is_empty() { + return Ok(vec![( + "HeadsDown integrations".to_string(), + true, + "No installed integrations found".to_string(), + )]); + } + + let mut checks = Vec::new(); + for tool in tools { + let report = health_report(tool)?; + checks.push(( + format!("{} integration", tool.label()), + report.current, + if report.current { + "Current".to_string() + } else { + report.repair_suggestion + }, + )); + } + Ok(checks) +} + +pub fn doctor(options: DoctorOptions) -> Result<()> { + let tools = resolve_doctor_tools(options.tool, options.all)?; + let reports: Vec = tools + .into_iter() + .map(health_report) + .collect::>()?; + + if options.json { + println!("{}", serde_json::to_string_pretty(&reports)?); + return Ok(()); + } + + println!(); + println!( + " {} HeadsDown integration health", + format::styled_bold("HeadsDown") + ); + println!(); + + for report in reports { + let icon = if report.current { + format::styled_green_bold("✓") + } else { + format::styled_yellow_bold("!") + }; + println!(" {} {}", icon, report.tool.label()); + println!( + " {} {}", + format::styled_dimmed("Tool detected:"), + yes_no(report.detected) + ); + println!( + " {} {}", + format::styled_dimmed("Config present:"), + yes_no(report.config_present) + ); + println!( + " {} {}", + format::styled_dimmed("HeadsDown integration installed:"), + yes_no(report.installed) + ); + println!( + " {} {}", + format::styled_dimmed("Integration current:"), + yes_no(report.current) + ); + println!( + " {} {}", + format::styled_dimmed("Auth status:"), + report.auth_status + ); + println!( + " {} {}", + format::styled_dimmed("Suggestion:"), + report.repair_suggestion + ); + println!(); + } + + Ok(()) +} + +fn resolve_install_tools(tool: Option, all: bool) -> Result> { + if all && tool.is_some() { + bail!("Pass either a tool or --all, not both."); + } + if let Some(tool) = tool { + return Ok(vec![tool]); + } + if all { + return detected_tools(); + } + bail!("hd install requires a supported tool or --all, for example: hd install claude"); +} + +fn resolve_update_tools(tool: Option, all: bool) -> Result> { + if all && tool.is_some() { + bail!("Pass either a tool or --all, not both."); + } + if let Some(tool) = tool { + return Ok(vec![tool]); + } + if all { + return detected_tools(); + } + installed_tools() +} + +fn resolve_doctor_tools(tool: Option, all: bool) -> Result> { + if all && tool.is_some() { + bail!("Pass either a tool or --all, not both."); + } + if let Some(tool) = tool { + return Ok(vec![tool]); + } + if all { + return Ok(all_tools()); + } + installed_tools().or_else(|_| Ok(all_tools())) +} + +fn all_tools() -> Vec { + vec![ + IntegrationTool::Claude, + IntegrationTool::Pi, + IntegrationTool::Codex, + ] +} + +fn detected_tools() -> Result> { + Ok(all_tools() + .into_iter() + .filter(|tool| { + state_for(*tool) + .map(|state| state.detected) + .unwrap_or(false) + }) + .collect()) +} + +fn installed_tools() -> Result> { + let tools: Vec = all_tools() + .into_iter() + .filter(|tool| { + state_for(*tool) + .map(|state| state.installed) + .unwrap_or(false) + }) + .collect(); + if tools.is_empty() { + bail!("No installed HeadsDown integrations found. Run `hd install ` first."); + } + Ok(tools) +} + +fn install_tool(tool: IntegrationTool, dry_run: bool) -> Result { + let state = state_for(tool)?; + if !state.detected { + return Ok(ActionReport { + tool, + status: ActionStatus::MissingTool, + message: format!("{} was not detected; no changes made", tool.label()), + detected: false, + installed: state.installed, + }); + } + if state.current { + return Ok(ActionReport { + tool, + status: ActionStatus::AlreadyCurrent, + message: "HeadsDown integration is already current".to_string(), + detected: true, + installed: true, + }); + } + if dry_run { + return Ok(ActionReport { + tool, + status: ActionStatus::Planned, + message: format!( + "Would install the HeadsDown integration for {}", + tool.label() + ), + detected: true, + installed: state.installed, + }); + } + if unmanaged_conflict(tool)? { + return Ok(ActionReport { + tool, + status: ActionStatus::Skipped, + message: "Existing user-owned integration artifact was preserved; no changes made" + .to_string(), + detected: true, + installed: false, + }); + } + write_integration(tool)?; + Ok(ActionReport { + tool, + status: ActionStatus::Installed, + message: format!("Installed the HeadsDown integration for {}", tool.label()), + detected: true, + installed: true, + }) +} + +fn update_tool(tool: IntegrationTool, dry_run: bool) -> Result { + let state = state_for(tool)?; + if !state.detected { + return Ok(ActionReport { + tool, + status: ActionStatus::MissingTool, + message: format!("{} was not detected; no changes made", tool.label()), + detected: false, + installed: state.installed, + }); + } + if !state.installed { + return Ok(ActionReport { + tool, + status: ActionStatus::NotInstalled, + message: "HeadsDown integration is not installed".to_string(), + detected: true, + installed: false, + }); + } + if state.current { + return Ok(ActionReport { + tool, + status: ActionStatus::AlreadyCurrent, + message: "HeadsDown integration is already current".to_string(), + detected: true, + installed: true, + }); + } + if dry_run { + return Ok(ActionReport { + tool, + status: ActionStatus::Planned, + message: format!( + "Would refresh the HeadsDown integration for {}", + tool.label() + ), + detected: true, + installed: state.installed, + }); + } + if unmanaged_conflict(tool)? { + return Ok(ActionReport { + tool, + status: ActionStatus::Skipped, + message: "Existing user-owned integration artifact was preserved; no changes made" + .to_string(), + detected: true, + installed: false, + }); + } + write_integration(tool)?; + Ok(ActionReport { + tool, + status: ActionStatus::Updated, + message: format!("Updated the HeadsDown integration for {}", tool.label()), + detected: true, + installed: true, + }) +} + +fn remove_tool(tool: IntegrationTool, dry_run: bool) -> Result { + let state = state_for(tool)?; + if !state.installed { + return Ok(ActionReport { + tool, + status: ActionStatus::NotInstalled, + message: "No HeadsDown integration was installed".to_string(), + detected: state.detected, + installed: false, + }); + } + if dry_run { + return Ok(ActionReport { + tool, + status: ActionStatus::Planned, + message: format!( + "Would remove the HeadsDown integration for {}", + tool.label() + ), + detected: state.detected, + installed: true, + }); + } + remove_integration(tool)?; + Ok(ActionReport { + tool, + status: ActionStatus::Removed, + message: format!("Removed the HeadsDown integration for {}", tool.label()), + detected: state.detected, + installed: false, + }) +} + +fn health_report(tool: IntegrationTool) -> Result { + let state = state_for(tool)?; + let auth_status = if crate::auth::load_token().ok().flatten().is_some() { + "present" + } else { + "not_found" + }; + let repair_suggestion = if state.current { + "No repair needed".to_string() + } else if state.installed { + format!("Run `hd update {}`", tool.slug()) + } else if state.detected { + format!("Run `hd install {}`", tool.slug()) + } else { + format!( + "Install {} first, then run `hd install {}`", + tool.label(), + tool.slug() + ) + }; + + Ok(HealthReport { + tool, + detected: state.detected, + config_present: state.config_present, + installed: state.installed, + current: state.current, + auth_status, + repair_suggestion, + }) +} + +fn state_for(tool: IntegrationTool) -> Result { + match tool { + IntegrationTool::Claude => claude_state(), + IntegrationTool::Pi => pi_state(), + IntegrationTool::Codex => codex_state(), + } +} + +fn unmanaged_conflict(tool: IntegrationTool) -> Result { + match tool { + IntegrationTool::Claude => { + let path = claude_command_path()?; + Ok(path.exists() + && fs::read_to_string(path) + .map(|content| !content.contains(CLAUDE_MARKER)) + .unwrap_or(true)) + } + IntegrationTool::Pi => Ok(false), + IntegrationTool::Codex => { + let path = codex_config_path()?; + let content = fs::read_to_string(path).unwrap_or_default(); + let unmanaged_content = content_without_managed_codex_block(&content)?; + Ok(unmanaged_content.contains("[mcp_servers.headsdown]")) + } + } +} + +fn write_integration(tool: IntegrationTool) -> Result<()> { + match tool { + IntegrationTool::Claude => write_claude(), + IntegrationTool::Pi => write_pi(), + IntegrationTool::Codex => write_codex(), + } +} + +fn remove_integration(tool: IntegrationTool) -> Result<()> { + match tool { + IntegrationTool::Claude => remove_claude(), + IntegrationTool::Pi => remove_pi(), + IntegrationTool::Codex => remove_codex(), + } +} + +fn claude_state() -> Result { + let command_path = claude_command_path()?; + let config_dir = claude_config_dir()?; + let content = fs::read_to_string(&command_path).ok(); + let installed = content + .as_ref() + .map(|value| value.contains(CLAUDE_MARKER)) + .unwrap_or(false); + let current = content + .as_ref() + .map(|value| value == CLAUDE_REFEREE_COMMAND) + .unwrap_or(false); + Ok(ToolState { + detected: config_dir.exists() || executable_exists(IntegrationTool::Claude.executable()), + config_present: config_dir.exists(), + installed, + current, + }) +} + +fn write_claude() -> Result<()> { + let path = claude_command_path()?; + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + fs::write(path, CLAUDE_REFEREE_COMMAND)?; + Ok(()) +} + +fn remove_claude() -> Result<()> { + let path = claude_command_path()?; + if let Ok(content) = fs::read_to_string(&path) { + if content.contains(CLAUDE_MARKER) { + fs::remove_file(path)?; + } + } + Ok(()) +} + +fn pi_state() -> Result { + let dir = pi_config_dir()?; + let path = dir.join("settings.json"); + let content = fs::read_to_string(&path).ok(); + let installed = content + .as_deref() + .and_then(|raw| serde_json::from_str::(raw).ok()) + .and_then(|value| { + value + .get("packages") + .and_then(|packages| packages.as_array()) + .cloned() + }) + .map(|packages| { + packages + .iter() + .any(|package| package.as_str() == Some(PI_PACKAGE)) + }) + .unwrap_or(false); + Ok(ToolState { + detected: dir.exists() + || path.exists() + || executable_exists(IntegrationTool::Pi.executable()), + config_present: dir.exists() || path.exists(), + installed, + current: installed, + }) +} + +fn write_pi() -> Result<()> { + let path = pi_settings_path()?; + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + let mut value = if path.exists() { + serde_json::from_str::(&fs::read_to_string(&path)?)? + } else { + serde_json::json!({}) + }; + let object = value + .as_object_mut() + .ok_or_else(|| anyhow!("Pi settings must be a JSON object."))?; + let packages_value = object + .entry("packages".to_string()) + .or_insert_with(|| serde_json::json!([])); + let packages = packages_value + .as_array_mut() + .ok_or_else(|| anyhow!("Pi settings packages must be a JSON array."))?; + if !packages + .iter() + .any(|package| package.as_str() == Some(PI_PACKAGE)) + { + packages.push(Value::String(PI_PACKAGE.to_string())); + } + fs::write(path, serde_json::to_string_pretty(&value)? + "\n")?; + Ok(()) +} + +fn remove_pi() -> Result<()> { + let path = pi_settings_path()?; + if !path.exists() { + return Ok(()); + } + let mut value = serde_json::from_str::(&fs::read_to_string(&path)?)?; + if let Some(packages) = value + .get_mut("packages") + .and_then(|packages| packages.as_array_mut()) + { + packages.retain(|package| package.as_str() != Some(PI_PACKAGE)); + fs::write(path, serde_json::to_string_pretty(&value)? + "\n")?; + } + Ok(()) +} + +fn codex_state() -> Result { + let dir = codex_config_dir()?; + let path = dir.join("config.toml"); + let content = fs::read_to_string(&path).ok(); + let installed = content + .as_ref() + .map(|value| value.contains(CODEX_MARKER_BEGIN) && value.contains(CODEX_MARKER_END)) + .unwrap_or(false); + let current = content + .as_ref() + .map(|value| { + value.contains(CODEX_BLOCK) + && content_without_managed_codex_block(value) + .map(|stripped| !stripped.contains("[mcp_servers.headsdown]")) + .unwrap_or(false) + }) + .unwrap_or(false); + Ok(ToolState { + detected: dir.exists() + || path.exists() + || executable_exists(IntegrationTool::Codex.executable()), + config_present: dir.exists() || path.exists(), + installed, + current, + }) +} + +fn write_codex() -> Result<()> { + let path = codex_config_path()?; + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + let existing = fs::read_to_string(&path).unwrap_or_default(); + let stripped = remove_managed_block(&existing)?; + let mut next = stripped.trim_end().to_string(); + if !next.is_empty() { + next.push_str("\n\n"); + } + next.push_str(CODEX_BLOCK); + fs::write(path, next)?; + Ok(()) +} + +fn remove_codex() -> Result<()> { + let path = codex_config_path()?; + if !path.exists() { + return Ok(()); + } + let existing = fs::read_to_string(&path)?; + let next = remove_managed_block(&existing)?; + fs::write(path, next.trim_start())?; + Ok(()) +} + +fn content_without_managed_codex_block(content: &str) -> Result { + if content.contains(CODEX_MARKER_BEGIN) || content.contains(CODEX_MARKER_END) { + remove_managed_block(content) + } else { + Ok(content.to_string()) + } +} + +fn remove_managed_block(content: &str) -> Result { + let begin = content.find(CODEX_MARKER_BEGIN); + let Some(begin) = begin else { + if content.contains(CODEX_MARKER_END) { + bail!("Found an incomplete HeadsDown-managed Codex block; no changes made."); + } + return Ok(content.to_string()); + }; + + let search_start = begin + CODEX_MARKER_BEGIN.len(); + let Some(relative_end) = content[search_start..].find(CODEX_MARKER_END) else { + bail!("Found an incomplete HeadsDown-managed Codex block; no changes made."); + }; + let end = search_start + relative_end + CODEX_MARKER_END.len(); + + let mut output = String::new(); + output.push_str(content[..begin].trim_end()); + if !output.is_empty() { + output.push('\n'); + } + let suffix = content[end..].trim_start(); + if !suffix.is_empty() { + if !output.is_empty() { + output.push('\n'); + } + output.push_str(suffix); + } + Ok(output) +} + +fn print_action_reports(reports: Vec, json: bool) -> Result<()> { + if json { + println!("{}", serde_json::to_string_pretty(&reports)?); + return Ok(()); + } + println!(); + for report in reports { + let icon = match report.status { + ActionStatus::Installed + | ActionStatus::Updated + | ActionStatus::AlreadyCurrent + | ActionStatus::Removed => format::styled_green_bold("✓"), + ActionStatus::Planned | ActionStatus::Skipped => format::styled_cyan_bold("→"), + ActionStatus::MissingTool | ActionStatus::NotInstalled => { + format::styled_yellow_bold("!") + } + }; + println!(" {} {}", icon, report.message); + } + println!(); + Ok(()) +} + +fn print_detected_tools(tools: &[IntegrationTool]) -> Result<()> { + println!(); + if tools.is_empty() { + println!( + " {} No supported local tools were detected", + format::styled_yellow_bold("!") + ); + } else { + println!( + " {} Detected supported tools:", + format::styled_cyan_bold("→") + ); + for tool in tools { + println!(" - {}", tool.label()); + } + } + println!(); + Ok(()) +} + +fn confirm(prompt: &str) -> Result { + print!(" {} {} [y/N] ", format::styled_cyan_bold("?"), prompt); + io::stdout().flush()?; + let mut input = String::new(); + io::stdin().read_line(&mut input)?; + Ok(matches!( + input.trim().to_ascii_lowercase().as_str(), + "y" | "yes" + )) +} + +fn yes_no(value: bool) -> &'static str { + if value { + "yes" + } else { + "no" + } +} + +fn executable_exists(name: &str) -> bool { + let path_var = std::env::var_os("PATH").unwrap_or_default(); + std::env::split_paths(&path_var).any(|dir| { + let candidate = dir.join(name); + candidate.is_file() || candidate.with_extension("exe").is_file() + }) +} + +fn claude_config_dir() -> Result { + env_or_home("CLAUDE_CONFIG_HOME", ".claude") +} + +fn claude_command_path() -> Result { + Ok(claude_config_dir()? + .join("commands") + .join("headsdown") + .join("referee.md")) +} + +fn pi_config_dir() -> Result { + env_or_home("PI_AGENT_CONFIG_HOME", ".pi/agent") +} + +fn pi_settings_path() -> Result { + Ok(pi_config_dir()?.join("settings.json")) +} + +fn codex_config_dir() -> Result { + env_or_home("CODEX_HOME", ".codex") +} + +fn codex_config_path() -> Result { + Ok(codex_config_dir()?.join("config.toml")) +} + +fn env_or_home(env_name: &str, relative: &str) -> Result { + if let Some(value) = std::env::var_os(env_name) { + return Ok(PathBuf::from(value)); + } + let home = + std::env::var_os("HOME").ok_or_else(|| anyhow!("Could not determine home directory."))?; + Ok(PathBuf::from(home).join(relative)) +} + +#[cfg(test)] +mod tests { + use super::*; + use serial_test::serial; + use tempfile::TempDir; + + fn with_home(f: impl FnOnce(&TempDir) -> T) -> T { + let dir = tempfile::tempdir().unwrap(); + let old_home = std::env::var_os("HOME"); + let old_claude = std::env::var_os("CLAUDE_CONFIG_HOME"); + let old_pi = std::env::var_os("PI_AGENT_CONFIG_HOME"); + let old_codex = std::env::var_os("CODEX_HOME"); + std::env::set_var("HOME", dir.path()); + std::env::remove_var("CLAUDE_CONFIG_HOME"); + std::env::remove_var("PI_AGENT_CONFIG_HOME"); + std::env::remove_var("CODEX_HOME"); + let result = f(&dir); + restore_env("HOME", old_home); + restore_env("CLAUDE_CONFIG_HOME", old_claude); + restore_env("PI_AGENT_CONFIG_HOME", old_pi); + restore_env("CODEX_HOME", old_codex); + result + } + + fn restore_env(name: &str, value: Option) { + if let Some(value) = value { + std::env::set_var(name, value); + } else { + std::env::remove_var(name); + } + } + + #[test] + #[serial] + fn claude_install_is_idempotent_and_removable() { + with_home(|dir| { + fs::create_dir_all(dir.path().join(".claude")).unwrap(); + let first = install_tool(IntegrationTool::Claude, false).unwrap(); + let second = install_tool(IntegrationTool::Claude, false).unwrap(); + assert_eq!(first.status, ActionStatus::Installed); + assert_eq!(second.status, ActionStatus::AlreadyCurrent); + assert!(claude_command_path().unwrap().exists()); + let removed = remove_tool(IntegrationTool::Claude, false).unwrap(); + assert_eq!(removed.status, ActionStatus::Removed); + assert!(!claude_command_path().unwrap().exists()); + }); + } + + #[test] + #[serial] + fn pi_install_preserves_existing_packages() { + with_home(|dir| { + let agent_dir = dir.path().join(".pi/agent"); + fs::create_dir_all(&agent_dir).unwrap(); + fs::write( + agent_dir.join("settings.json"), + r#"{"packages":["existing"]}"#, + ) + .unwrap(); + install_tool(IntegrationTool::Pi, false).unwrap(); + let raw = fs::read_to_string(agent_dir.join("settings.json")).unwrap(); + let value: Value = serde_json::from_str(&raw).unwrap(); + let packages = value["packages"].as_array().unwrap(); + assert!(packages + .iter() + .any(|package| package.as_str() == Some("existing"))); + assert!(packages + .iter() + .any(|package| package.as_str() == Some(PI_PACKAGE))); + remove_tool(IntegrationTool::Pi, false).unwrap(); + let raw = fs::read_to_string(agent_dir.join("settings.json")).unwrap(); + assert!(!raw.contains(PI_PACKAGE)); + assert!(raw.contains("existing")); + }); + } + + #[test] + fn codex_remove_only_managed_block() { + let raw = "keep = true\n\n# \nremove = true\n# \n\nkeep_again = true\n"; + let stripped = remove_managed_block(raw).unwrap(); + assert!(stripped.contains("keep = true")); + assert!(stripped.contains("keep_again = true")); + assert!(!stripped.contains("remove = true")); + } + + #[test] + fn codex_incomplete_marker_is_not_stripped() { + let raw = "keep = true\n# \nuser_owned = true\n"; + assert!(remove_managed_block(raw).is_err()); + } +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs index a984af9..629cf6b 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -7,6 +7,7 @@ pub mod digest; pub mod doctor; pub mod grants; pub mod hooks; +pub mod integrations; pub mod interrupt; pub mod mode; pub mod outcome; diff --git a/src/config.rs b/src/config.rs index 6bd6922..abb8368 100644 --- a/src/config.rs +++ b/src/config.rs @@ -118,12 +118,14 @@ mod tests { #[serial] fn save_then_load_round_trips() { with_temp_config(|| { - let mut cfg = Config::default(); - cfg.default_duration = Some(120); - cfg.default_model = Some("gpt-4".to_string()); - cfg.api_url = Some("https://custom.example.com".to_string()); - cfg.telemetry.enabled = true; - cfg.calibration.enabled = false; // Test non-default value + let mut cfg = Config { + default_duration: Some(120), + default_model: Some("gpt-4".to_string()), + api_url: Some("https://custom.example.com".to_string()), + telemetry: TelemetryConfig { enabled: true }, + calibration: CalibrationConfig { enabled: false }, + ..Default::default() + }; cfg.aliases .insert("focus".to_string(), "busy 2h".to_string()); cfg.aliases.insert("brb".to_string(), "offline".to_string()); @@ -154,9 +156,11 @@ mod tests { #[serial] fn update_modifies_existing_config() { with_temp_config(|| { - let mut cfg = Config::default(); - cfg.default_duration = Some(60); - cfg.default_model = Some("claude".to_string()); + let cfg = Config { + default_duration: Some(60), + default_model: Some("claude".to_string()), + ..Default::default() + }; save(&cfg).unwrap(); update(|c| c.default_duration = Some(120)).unwrap(); diff --git a/src/main.rs b/src/main.rs index 9fd1b2c..5b4b2b6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8,6 +8,7 @@ mod telemetry; use clap::{CommandFactory, Parser, Subcommand}; use clap_complete::{generate, Shell}; +use commands::integrations::IntegrationTool; use std::io; /// HeadsDown CLI — manage your availability from the terminal @@ -148,11 +149,65 @@ enum Commands { /// Live-updating status dashboard Watch, - /// Check CLI health and connectivity - Doctor, + /// Install a HeadsDown integration for a local tool + Install { + /// Supported local tool to install HeadsDown into + tool: Option, - /// Update the CLI to the latest version - Update, + /// Install HeadsDown into every detected supported tool + #[arg(long)] + all: bool, + + /// Show planned changes without writing files + #[arg(long)] + dry_run: bool, + + /// Skip confirmation prompts for bulk installs + #[arg(long, short = 'y')] + yes: bool, + }, + + /// Check CLI health, connectivity, and integration health + Doctor { + /// Supported local tool to check + tool: Option, + + /// Check every supported tool, including missing tools + #[arg(long)] + all: bool, + }, + + /// Refresh installed HeadsDown integrations + Update { + /// Supported local tool to update + tool: Option, + + /// Update every detected supported tool + #[arg(long)] + all: bool, + + /// Show planned changes without writing files + #[arg(long)] + dry_run: bool, + + /// Skip confirmation prompts for bulk updates + #[arg(long, short = 'y')] + yes: bool, + + /// Update the hd CLI binary itself instead of integrations + #[arg(long)] + cli: bool, + }, + + /// Remove a HeadsDown integration from a local tool + Remove { + /// Supported local tool to remove HeadsDown from + tool: IntegrationTool, + + /// Show planned changes without writing files + #[arg(long)] + dry_run: bool, + }, /// Manage git hook integration Hook { @@ -824,8 +879,60 @@ async fn dispatch(cli: Cli) -> anyhow::Result<()> { model, } => commands::verdict::run(&api_url, &description, files, minutes, model, json).await, Commands::Watch => commands::watch::run(&api_url).await, - Commands::Doctor => commands::doctor::run(&api_url, json).await, - Commands::Update => commands::update::run().await, + Commands::Install { + tool, + all, + dry_run, + yes, + } => commands::integrations::install(commands::integrations::IntegrationCommandOptions { + tool, + all, + dry_run, + yes, + json, + }), + Commands::Doctor { tool, all } => { + if tool.is_some() || all { + commands::integrations::doctor(commands::integrations::DoctorOptions { + tool, + all, + json, + }) + } else { + commands::doctor::run(&api_url, json).await + } + } + Commands::Update { + tool, + all, + dry_run, + yes, + cli, + } => { + if cli { + if tool.is_some() || all || dry_run || yes { + anyhow::bail!("--cli cannot be combined with integration update options."); + } + commands::update::run().await + } else { + commands::integrations::update(commands::integrations::IntegrationCommandOptions { + tool, + all, + dry_run, + yes, + json, + }) + } + } + Commands::Remove { tool, dry_run } => { + commands::integrations::remove(commands::integrations::IntegrationCommandOptions { + tool: Some(tool), + all: false, + dry_run, + yes: true, + json, + }) + } Commands::Hook { action } => match action { HookAction::Install => commands::hooks::install(), HookAction::Uninstall => commands::hooks::uninstall(), @@ -910,8 +1017,10 @@ fn command_name(cmd: &Commands) -> &'static str { Commands::Limited { .. } => "limited", Commands::Verdict { .. } => "verdict", Commands::Watch => "watch", - Commands::Doctor => "doctor", - Commands::Update => "update", + Commands::Install { .. } => "install", + Commands::Doctor { .. } => "doctor", + Commands::Update { .. } => "update", + Commands::Remove { .. } => "remove", Commands::Hook { .. } => "hook", Commands::Telemetry { .. } => "telemetry", Commands::Calibration { .. } => "calibration", diff --git a/tests/integration.rs b/tests/integration.rs index 0490368..220fa63 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -68,8 +68,10 @@ fn subcommand_help_works() { "override", "preset", "watch", + "install", "doctor", "update", + "remove", "hook", "telemetry", "calibration", @@ -191,6 +193,406 @@ fn windows_create_missing_required_args_fails_at_parse() { )); } +#[test] +fn install_claude_dry_run_does_not_write() { + let dir = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(dir.path().join(".claude")).unwrap(); + + Command::cargo_bin("hd") + .unwrap() + .env("HOME", dir.path()) + .args(["install", "claude", "--dry-run"]) + .assert() + .success() + .stdout(predicate::str::contains( + "Would install the HeadsDown integration for Claude Code", + )); + + assert!(!dir + .path() + .join(".claude/commands/headsdown/referee.md") + .exists()); +} + +#[test] +fn install_claude_preserves_user_owned_command() { + let dir = tempfile::tempdir().unwrap(); + let command_dir = dir.path().join(".claude/commands/headsdown"); + std::fs::create_dir_all(&command_dir).unwrap(); + let command_path = command_dir.join("referee.md"); + std::fs::write(&command_path, "user-owned command").unwrap(); + + Command::cargo_bin("hd") + .unwrap() + .env("HOME", dir.path()) + .args(["install", "claude"]) + .assert() + .success() + .stdout(predicate::str::contains( + "user-owned integration artifact was preserved", + )); + + assert_eq!( + std::fs::read_to_string(command_path).unwrap(), + "user-owned command" + ); +} + +#[test] +fn install_claude_is_idempotent() { + let dir = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(dir.path().join(".claude")).unwrap(); + + Command::cargo_bin("hd") + .unwrap() + .env("HOME", dir.path()) + .args(["install", "claude"]) + .assert() + .success() + .stdout(predicate::str::contains( + "Installed the HeadsDown integration for Claude Code", + )); + + Command::cargo_bin("hd") + .unwrap() + .env("HOME", dir.path()) + .args(["install", "claude"]) + .assert() + .success() + .stdout(predicate::str::contains( + "HeadsDown integration is already current", + )); + + let command = + std::fs::read_to_string(dir.path().join(".claude/commands/headsdown/referee.md")).unwrap(); + assert!(command.contains("headsdown-cli managed")); + assert!(!command.contains("<<'HEADSDOWN_REFEREE_EVIDENCE'")); + assert!(!command.contains("printf '%s' \"$ARGUMENTS\"")); +} + +#[test] +fn install_all_dry_run_shows_detected_tools() { + let dir = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(dir.path().join(".claude")).unwrap(); + std::fs::create_dir_all(dir.path().join(".pi/agent")).unwrap(); + std::fs::create_dir_all(dir.path().join(".codex")).unwrap(); + + Command::cargo_bin("hd") + .unwrap() + .env("HOME", dir.path()) + .args(["install", "--all", "--dry-run"]) + .assert() + .success() + .stdout(predicate::str::contains("Detected supported tools")) + .stdout(predicate::str::contains("Claude Code")) + .stdout(predicate::str::contains("Pi")) + .stdout(predicate::str::contains("Codex")); +} + +#[test] +fn install_all_json_outputs_valid_json_without_human_preamble() { + let dir = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(dir.path().join(".claude")).unwrap(); + + let assert = Command::cargo_bin("hd") + .unwrap() + .env("HOME", dir.path()) + .args(["install", "--all", "--dry-run", "--json"]) + .assert() + .success(); + + let output = String::from_utf8(assert.get_output().stdout.clone()).unwrap(); + let value: serde_json::Value = serde_json::from_str(&output).unwrap(); + assert_eq!(value[0]["tool"], "claude"); + assert_eq!(value[0]["status"], "planned"); +} + +#[test] +fn bulk_install_json_requires_yes_without_prompting() { + let dir = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(dir.path().join(".claude")).unwrap(); + + let assert = Command::cargo_bin("hd") + .unwrap() + .env("HOME", dir.path()) + .args(["install", "--all", "--json"]) + .assert() + .success(); + + let output = String::from_utf8(assert.get_output().stdout.clone()).unwrap(); + let value: serde_json::Value = serde_json::from_str(&output).unwrap(); + assert_eq!(value[0]["status"], "skipped"); + assert!(!output.contains("[y/N]")); +} + +#[test] +fn bulk_install_prompts_and_does_not_write_on_no() { + let dir = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(dir.path().join(".claude")).unwrap(); + + Command::cargo_bin("hd") + .unwrap() + .env("HOME", dir.path()) + .args(["install", "--all"]) + .write_stdin("n\n") + .assert() + .success() + .stdout(predicate::str::contains("No changes made")); + + assert!(!dir + .path() + .join(".claude/commands/headsdown/referee.md") + .exists()); +} + +#[test] +fn bulk_update_prompts_and_does_not_write_on_no() { + let dir = tempfile::tempdir().unwrap(); + let command_dir = dir.path().join(".claude/commands/headsdown"); + std::fs::create_dir_all(&command_dir).unwrap(); + let command_path = command_dir.join("referee.md"); + std::fs::write( + &command_path, + "\nstale", + ) + .unwrap(); + + Command::cargo_bin("hd") + .unwrap() + .env("HOME", dir.path()) + .args(["update", "--all"]) + .write_stdin("n\n") + .assert() + .success() + .stdout(predicate::str::contains("No changes made")); + + assert_eq!( + std::fs::read_to_string(command_path).unwrap(), + "\nstale" + ); +} + +#[test] +fn conflicting_tool_and_all_flags_fail() { + Command::cargo_bin("hd") + .unwrap() + .args(["install", "claude", "--all"]) + .assert() + .failure() + .stderr(predicate::str::contains("Pass either a tool or --all")); + + Command::cargo_bin("hd") + .unwrap() + .args(["update", "claude", "--all"]) + .assert() + .failure() + .stderr(predicate::str::contains("Pass either a tool or --all")); +} + +#[test] +fn update_cli_rejects_integration_flags() { + Command::cargo_bin("hd") + .unwrap() + .args(["update", "claude", "--cli", "--dry-run"]) + .assert() + .failure() + .stderr(predicate::str::contains( + "--cli cannot be combined with integration update options", + )); +} + +#[test] +fn update_claude_repairs_stale_managed_command() { + let dir = tempfile::tempdir().unwrap(); + let command_dir = dir.path().join(".claude/commands/headsdown"); + std::fs::create_dir_all(&command_dir).unwrap(); + let command_path = command_dir.join("referee.md"); + std::fs::write( + &command_path, + "\nstale", + ) + .unwrap(); + + Command::cargo_bin("hd") + .unwrap() + .env("HOME", dir.path()) + .args(["update", "claude"]) + .assert() + .success() + .stdout(predicate::str::contains( + "Updated the HeadsDown integration for Claude Code", + )); + + let command = std::fs::read_to_string(command_path).unwrap(); + assert!(command.contains("Run `headsdown-claude referee`")); + assert!(!command.contains("stale")); +} + +#[test] +fn remove_pi_preserves_user_packages() { + let dir = tempfile::tempdir().unwrap(); + let agent_dir = dir.path().join(".pi/agent"); + std::fs::create_dir_all(&agent_dir).unwrap(); + std::fs::write( + agent_dir.join("settings.json"), + r#"{"packages":["existing","git:github.com/headsdownapp/headsdown-pi"]}"#, + ) + .unwrap(); + + Command::cargo_bin("hd") + .unwrap() + .env("HOME", dir.path()) + .args(["remove", "pi"]) + .assert() + .success() + .stdout(predicate::str::contains( + "Removed the HeadsDown integration for Pi", + )); + + let raw = std::fs::read_to_string(agent_dir.join("settings.json")).unwrap(); + assert!(raw.contains("existing")); + assert!(!raw.contains("headsdown-pi")); +} + +#[test] +fn codex_install_preserves_unmanaged_headsdown_table() { + let dir = tempfile::tempdir().unwrap(); + let codex_dir = dir.path().join(".codex"); + std::fs::create_dir_all(&codex_dir).unwrap(); + let config_path = codex_dir.join("config.toml"); + std::fs::write( + &config_path, + "[mcp_servers.headsdown]\ncommand = \"custom\"\n", + ) + .unwrap(); + + Command::cargo_bin("hd") + .unwrap() + .env("HOME", dir.path()) + .args(["install", "codex"]) + .assert() + .success() + .stdout(predicate::str::contains( + "user-owned integration artifact was preserved", + )); + + assert_eq!( + std::fs::read_to_string(config_path).unwrap(), + "[mcp_servers.headsdown]\ncommand = \"custom\"\n" + ); +} + +#[test] +fn codex_doctor_flags_unmanaged_duplicate_headsdown_table() { + let dir = tempfile::tempdir().unwrap(); + let codex_dir = dir.path().join(".codex"); + std::fs::create_dir_all(&codex_dir).unwrap(); + std::fs::write( + codex_dir.join("config.toml"), + "[mcp_servers.headsdown]\ncommand = \"custom\"\n\n# \n[mcp_servers.headsdown]\ncommand = \"npx\"\nargs = [\"-y\", \"headsdown-claude\"]\n# \n", + ) + .unwrap(); + + let assert = Command::cargo_bin("hd") + .unwrap() + .env("HOME", dir.path()) + .args(["doctor", "codex", "--json"]) + .assert() + .success(); + + let output = String::from_utf8(assert.get_output().stdout.clone()).unwrap(); + let value: serde_json::Value = serde_json::from_str(&output).unwrap(); + assert_eq!(value[0]["installed"], true); + assert_eq!(value[0]["current"], false); +} + +#[test] +fn remove_codex_preserves_user_config() { + let dir = tempfile::tempdir().unwrap(); + let codex_dir = dir.path().join(".codex"); + std::fs::create_dir_all(&codex_dir).unwrap(); + std::fs::write( + codex_dir.join("config.toml"), + "keep = true\n\n# \n[mcp_servers.headsdown]\ncommand = \"npx\"\nargs = [\"-y\", \"headsdown-claude\"]\n# \n\nkeep_again = true\n", + ) + .unwrap(); + + Command::cargo_bin("hd") + .unwrap() + .env("HOME", dir.path()) + .args(["remove", "codex"]) + .assert() + .success() + .stdout(predicate::str::contains( + "Removed the HeadsDown integration for Codex", + )); + + let raw = std::fs::read_to_string(codex_dir.join("config.toml")).unwrap(); + assert!(raw.contains("keep = true")); + assert!(raw.contains("keep_again = true")); + assert!(!raw.contains("mcp_servers.headsdown")); +} + +#[test] +fn doctor_all_json_reports_supported_tools_without_paths() { + let dir = tempfile::tempdir().unwrap(); + let assert = Command::cargo_bin("hd") + .unwrap() + .env("HOME", dir.path()) + .args(["doctor", "--all", "--json"]) + .assert() + .success(); + + let output = String::from_utf8(assert.get_output().stdout.clone()).unwrap(); + let value: serde_json::Value = serde_json::from_str(&output).unwrap(); + assert_eq!(value.as_array().unwrap().len(), 3); + assert!(!output.contains(dir.path().to_string_lossy().as_ref())); +} + +#[test] +fn base_doctor_json_does_not_print_sensitive_local_content() { + let dir = tempfile::tempdir().unwrap(); + let config_dir = dir.path().join("headsdown"); + std::fs::create_dir_all(&config_dir).unwrap(); + std::fs::write( + config_dir.join("credentials.json"), + r#"{"api_key":"hd_secret_token_123456"}"#, + ) + .unwrap(); + + let assert = Command::cargo_bin("hd") + .unwrap() + .env("XDG_CONFIG_HOME", dir.path()) + .args(["--api-url", "http://127.0.0.1:9", "doctor", "--json"]) + .assert() + .success(); + + let output = String::from_utf8(assert.get_output().stdout.clone()).unwrap(); + serde_json::from_str::(&output).unwrap(); + assert!(!output.contains(dir.path().to_string_lossy().as_ref())); + assert!(!output.contains("hd_secret_token")); + assert!(!output.contains("123456")); +} + +#[test] +fn doctor_claude_json_does_not_print_sensitive_local_content() { + let dir = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(dir.path().join(".claude")).unwrap(); + + let assert = Command::cargo_bin("hd") + .unwrap() + .env("HOME", dir.path()) + .args(["doctor", "claude", "--json"]) + .assert() + .success(); + + let output = String::from_utf8(assert.get_output().stdout.clone()).unwrap(); + assert!(!output.contains(dir.path().to_string_lossy().as_ref())); + assert!(!output.contains("prompt")); + assert!(!output.contains("transcript")); + assert!(!output.contains("token")); +} + #[test] fn completions_generates_output() { for shell in &["bash", "zsh", "fish"] {