Skip to main content

seshat_cli/
uninstall.rs

1//! Implementation of the `seshat uninstall` command.
2//!
3//! Removes all Seshat configuration from detected AI clients:
4//! - MCP entries from config files
5//! - Instruction sections from AGENTS.md/CLAUDE.md/.cursorrules
6//! - Skill directories
7//! - Hook scripts and entries
8//!
9//! Reverses the operations performed by `seshat init`.
10
11use std::fs;
12use std::io::{self, Write};
13use std::path::{Path, PathBuf};
14
15use owo_colors::OwoColorize;
16
17use crate::error::CliError;
18use crate::format::{color_enabled, format_copy_block, format_section_header};
19pub use crate::init::ScopeRequest;
20use crate::init::{ClientKind, ConfigFormat};
21
22// ══════════════════════════════════════════════════════════════════════
23// Types
24// ══════════════════════════════════════════════════════════════════════
25
26/// A single item that can be removed during uninstall.
27#[derive(Debug, Clone)]
28pub enum UninstallTarget {
29    /// MCP config entry to remove.
30    McpEntry {
31        path: PathBuf,
32        format: ConfigFormat,
33        is_project: bool,
34        client: ClientKind,
35    },
36    /// Instruction file section to remove.
37    Instructions { path: PathBuf },
38    /// Skill directory to remove.
39    SkillDir { path: PathBuf },
40    /// Hook script to remove.
41    HookScript { path: PathBuf },
42    /// Hook entries in settings.json to remove.
43    HookEntries { settings_path: PathBuf },
44}
45
46/// Result of an uninstall operation.
47#[derive(Debug, Clone, PartialEq, Eq)]
48pub enum UninstallResult {
49    /// Item was removed.
50    Removed,
51    /// Item did not exist (no-op).
52    NotExists,
53    /// Dry-run mode — path that would have been affected.
54    DryRun(PathBuf),
55    /// Skipped — no action needed.
56    Skipped(String),
57}
58
59/// What would be removed for a specific client.
60#[derive(Debug)]
61pub struct ClientUninstallPlan {
62    pub client: ClientKind,
63    pub targets: Vec<UninstallTarget>,
64}
65
66// ══════════════════════════════════════════════════════════════════════
67// Detection
68// ══════════════════════════════════════════════════════════════════════
69
70/// Detect all uninstall targets for detected clients.
71pub fn detect_all_targets(
72    client: Option<&str>,
73    scope: ScopeRequest,
74    project_root: &Path,
75) -> Vec<ClientUninstallPlan> {
76    let mut plans = Vec::new();
77
78    if let Some(name) = client {
79        if let Some(kind) = ClientKind::from_cli_name(name) {
80            let targets = detect_client_targets(kind, scope, project_root);
81            if !targets.is_empty() {
82                plans.push(ClientUninstallPlan {
83                    client: kind,
84                    targets,
85                });
86            }
87        }
88    } else {
89        // Auto-detect: check which clients are installed.
90        let cwd = std::env::current_dir().unwrap_or_default();
91        let proj_root = crate::db::sync_root_for(&cwd);
92
93        // Claude Code
94        if which::which("claude").is_ok() {
95            let targets = detect_claude_code_targets(scope, &proj_root);
96            if !targets.is_empty() {
97                plans.push(ClientUninstallPlan {
98                    client: ClientKind::ClaudeCode,
99                    targets,
100                });
101            }
102        }
103
104        // Claude Desktop (macOS only)
105        #[cfg(target_os = "macos")]
106        {
107            if claude_desktop_config_exists() {
108                let targets = detect_claude_desktop_targets();
109                if !targets.is_empty() {
110                    plans.push(ClientUninstallPlan {
111                        client: ClientKind::ClaudeDesktop,
112                        targets,
113                    });
114                }
115            }
116        }
117
118        // OpenCode
119        if which::which("opencode").is_ok() {
120            let targets = detect_opencode_targets(scope, &proj_root);
121            if !targets.is_empty() {
122                plans.push(ClientUninstallPlan {
123                    client: ClientKind::OpenCode,
124                    targets,
125                });
126            }
127        }
128
129        // Cursor
130        if which::which("cursor").is_ok() {
131            let targets = detect_cursor_targets(scope, &proj_root);
132            if !targets.is_empty() {
133                plans.push(ClientUninstallPlan {
134                    client: ClientKind::Cursor,
135                    targets,
136                });
137            }
138        }
139    }
140
141    plans
142}
143
144/// Detect uninstall targets for a single client.
145fn detect_client_targets(
146    client: ClientKind,
147    scope: ScopeRequest,
148    project_root: &Path,
149) -> Vec<UninstallTarget> {
150    let mut targets = Vec::new();
151
152    match client {
153        ClientKind::ClaudeCode => {
154            targets.extend(detect_claude_code_targets(scope, project_root));
155        }
156        ClientKind::ClaudeDesktop => {
157            targets.extend(detect_claude_desktop_targets());
158        }
159        ClientKind::OpenCode => {
160            targets.extend(detect_opencode_targets(scope, project_root));
161        }
162        ClientKind::Cursor => {
163            targets.extend(detect_cursor_targets(scope, project_root));
164        }
165    }
166
167    targets
168}
169
170fn detect_claude_code_targets(scope: ScopeRequest, project_root: &Path) -> Vec<UninstallTarget> {
171    let mut targets = Vec::new();
172    let Some(home) = dirs::home_dir() else {
173        return targets;
174    };
175
176    let claude_dir = home.join(".claude");
177
178    // CLAUDE.md instruction file
179    let claude_md = claude_dir.join("CLAUDE.md");
180    if claude_md.exists() {
181        targets.push(UninstallTarget::Instructions { path: claude_md });
182    }
183
184    // Skill directory
185    let skill_dir = claude_dir.join("skills").join("seshat");
186    if skill_dir.exists() {
187        targets.push(UninstallTarget::SkillDir { path: skill_dir });
188    }
189
190    // Hook scripts
191    let hooks_dir = claude_dir.join("hooks");
192    let session_start = hooks_dir.join("seshat-session-start");
193    if session_start.exists() {
194        targets.push(UninstallTarget::HookScript {
195            path: session_start,
196        });
197    }
198    let pre_tool = hooks_dir.join("seshat-pre-tool");
199    if pre_tool.exists() {
200        targets.push(UninstallTarget::HookScript { path: pre_tool });
201    }
202
203    // Hook entries in settings.json
204    let settings_path = claude_dir.join("settings.json");
205    if settings_path.exists() {
206        targets.push(UninstallTarget::HookEntries {
207            settings_path: settings_path.clone(),
208        });
209    }
210
211    // MCP entry in ~/.claude.json
212    let claude_json = home.join(".claude.json");
213    match scope {
214        ScopeRequest::Global => {
215            if claude_json.exists() {
216                targets.push(UninstallTarget::McpEntry {
217                    path: claude_json,
218                    format: ConfigFormat::Json,
219                    is_project: false,
220                    client: ClientKind::ClaudeCode,
221                });
222            }
223        }
224        ScopeRequest::Project => {
225            // For project scope, look for .mcp.json in project root
226            let mcp_json = project_root.join(".mcp.json");
227            if mcp_json.exists() {
228                targets.push(UninstallTarget::McpEntry {
229                    path: mcp_json,
230                    format: ConfigFormat::Json,
231                    is_project: true,
232                    client: ClientKind::ClaudeCode,
233                });
234            }
235        }
236        ScopeRequest::Auto => {
237            // Check both global and project
238            if claude_json.exists() {
239                targets.push(UninstallTarget::McpEntry {
240                    path: claude_json,
241                    format: ConfigFormat::Json,
242                    is_project: false,
243                    client: ClientKind::ClaudeCode,
244                });
245            }
246            let mcp_json = project_root.join(".mcp.json");
247            if mcp_json.exists() {
248                targets.push(UninstallTarget::McpEntry {
249                    path: mcp_json,
250                    format: ConfigFormat::Json,
251                    is_project: true,
252                    client: ClientKind::ClaudeCode,
253                });
254            }
255        }
256    }
257
258    targets
259}
260
261#[cfg(target_os = "macos")]
262fn claude_desktop_config_exists() -> bool {
263    dirs::home_dir()
264        .map(|home| {
265            home.join("Library")
266                .join("Application Support")
267                .join("Claude")
268                .join("claude_desktop_config.json")
269                .exists()
270        })
271        .unwrap_or(false)
272}
273
274fn detect_claude_desktop_targets() -> Vec<UninstallTarget> {
275    let mut targets = Vec::new();
276
277    let Some(home) = dirs::home_dir() else {
278        return targets;
279    };
280    let config_path = home
281        .join("Library")
282        .join("Application Support")
283        .join("Claude")
284        .join("claude_desktop_config.json");
285
286    if config_path.exists() {
287        targets.push(UninstallTarget::McpEntry {
288            path: config_path,
289            format: ConfigFormat::Json,
290            is_project: false,
291            client: ClientKind::ClaudeDesktop,
292        });
293    }
294
295    targets
296}
297
298fn detect_opencode_targets(scope: ScopeRequest, project_root: &Path) -> Vec<UninstallTarget> {
299    let mut targets = Vec::new();
300
301    // Skill directory in global config
302    if let Some(opencode_dir) = opencode_config_dir() {
303        let skill_dir = opencode_dir.join("skills").join("seshat");
304        if skill_dir.exists() {
305            targets.push(UninstallTarget::SkillDir { path: skill_dir });
306        }
307
308        // AGENTS.md in global
309        let agents_md = opencode_dir.join("AGENTS.md");
310        if agents_md.exists() {
311            targets.push(UninstallTarget::Instructions { path: agents_md });
312        }
313    }
314
315    // Project-level AGENTS.md
316    let proj_agents = project_root.join("AGENTS.md");
317    if proj_agents.exists() {
318        targets.push(UninstallTarget::Instructions { path: proj_agents });
319    }
320
321    // MCP entry in opencode config
322    match scope {
323        ScopeRequest::Global => {
324            if let Some(opencode_dir) = opencode_config_dir() {
325                let json_path = opencode_dir.join("opencode.json");
326                if json_path.exists() {
327                    targets.push(UninstallTarget::McpEntry {
328                        path: json_path,
329                        format: ConfigFormat::Json,
330                        is_project: false,
331                        client: ClientKind::OpenCode,
332                    });
333                }
334                let jsonc_path = opencode_dir.join("opencode.jsonc");
335                if jsonc_path.exists() {
336                    targets.push(UninstallTarget::McpEntry {
337                        path: jsonc_path,
338                        format: ConfigFormat::Jsonc,
339                        is_project: false,
340                        client: ClientKind::OpenCode,
341                    });
342                }
343            }
344        }
345        ScopeRequest::Project => {
346            let json_path = project_root.join("opencode.json");
347            if json_path.exists() {
348                targets.push(UninstallTarget::McpEntry {
349                    path: json_path,
350                    format: ConfigFormat::Json,
351                    is_project: true,
352                    client: ClientKind::OpenCode,
353                });
354            }
355            let jsonc_path = project_root.join("opencode.jsonc");
356            if jsonc_path.exists() {
357                targets.push(UninstallTarget::McpEntry {
358                    path: jsonc_path,
359                    format: ConfigFormat::Jsonc,
360                    is_project: true,
361                    client: ClientKind::OpenCode,
362                });
363            }
364        }
365        ScopeRequest::Auto => {
366            // Check project first, then global
367            let json_path = project_root.join("opencode.json");
368            if json_path.exists() {
369                targets.push(UninstallTarget::McpEntry {
370                    path: json_path,
371                    format: ConfigFormat::Json,
372                    is_project: true,
373                    client: ClientKind::OpenCode,
374                });
375            }
376            let jsonc_path = project_root.join("opencode.jsonc");
377            if jsonc_path.exists() {
378                targets.push(UninstallTarget::McpEntry {
379                    path: jsonc_path,
380                    format: ConfigFormat::Jsonc,
381                    is_project: true,
382                    client: ClientKind::OpenCode,
383                });
384            }
385            if let Some(opencode_dir) = opencode_config_dir() {
386                let json_path = opencode_dir.join("opencode.json");
387                if json_path.exists() {
388                    targets.push(UninstallTarget::McpEntry {
389                        path: json_path,
390                        format: ConfigFormat::Json,
391                        is_project: false,
392                        client: ClientKind::OpenCode,
393                    });
394                }
395                let jsonc_path = opencode_dir.join("opencode.jsonc");
396                if jsonc_path.exists() {
397                    targets.push(UninstallTarget::McpEntry {
398                        path: jsonc_path,
399                        format: ConfigFormat::Jsonc,
400                        is_project: false,
401                        client: ClientKind::OpenCode,
402                    });
403                }
404            }
405        }
406    }
407
408    targets
409}
410
411fn detect_cursor_targets(scope: ScopeRequest, project_root: &Path) -> Vec<UninstallTarget> {
412    let mut targets = Vec::new();
413
414    match scope {
415        ScopeRequest::Global => {
416            if let Some(home) = dirs::home_dir() {
417                let path = home.join(".cursor").join("mcp.json");
418                if path.exists() {
419                    targets.push(UninstallTarget::McpEntry {
420                        path,
421                        format: ConfigFormat::Json,
422                        is_project: false,
423                        client: ClientKind::Cursor,
424                    });
425                }
426            }
427        }
428        ScopeRequest::Project => {
429            let path = project_root.join(".cursor").join("mcp.json");
430            if path.exists() {
431                targets.push(UninstallTarget::McpEntry {
432                    path,
433                    format: ConfigFormat::Json,
434                    is_project: true,
435                    client: ClientKind::Cursor,
436                });
437            }
438        }
439        ScopeRequest::Auto => {
440            let project_path = project_root.join(".cursor").join("mcp.json");
441            if project_path.exists() {
442                targets.push(UninstallTarget::McpEntry {
443                    path: project_path,
444                    format: ConfigFormat::Json,
445                    is_project: true,
446                    client: ClientKind::Cursor,
447                });
448            }
449            if let Some(home) = dirs::home_dir() {
450                let global_path = home.join(".cursor").join("mcp.json");
451                if global_path.exists() {
452                    targets.push(UninstallTarget::McpEntry {
453                        path: global_path,
454                        format: ConfigFormat::Json,
455                        is_project: false,
456                        client: ClientKind::Cursor,
457                    });
458                }
459            }
460        }
461    }
462
463    targets
464}
465
466/// Resolve the OpenCode global config directory.
467fn opencode_config_dir() -> Option<PathBuf> {
468    if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") {
469        if !xdg.is_empty() {
470            return Some(PathBuf::from(xdg).join("opencode"));
471        }
472    }
473    dirs::home_dir().map(|h| h.join(".config").join("opencode"))
474}
475
476// ══════════════════════════════════════════════════════════════════════
477// Removal functions
478// ══════════════════════════════════════════════════════════════════════
479
480/// Remove ALL `<!-- seshat:start -->...<!-- seshat:end -->` blocks from a file.
481pub fn remove_instructions(path: &Path, dry_run: bool) -> Result<UninstallResult, CliError> {
482    const MARKER_START: &str = "<!-- seshat:start -->";
483    const MARKER_END: &str = "<!-- seshat:end -->";
484
485    if dry_run {
486        return Ok(UninstallResult::DryRun(path.to_path_buf()));
487    }
488
489    if !path.exists() {
490        return Ok(UninstallResult::NotExists);
491    }
492
493    let existing = fs::read_to_string(path).map_err(|e| CliError::IoWithPath {
494        message: format!("failed to read instruction file: {e}"),
495        path: path.to_path_buf(),
496    })?;
497
498    let mut result = String::with_capacity(existing.len());
499    let mut last_end = 0;
500    let mut count = 0;
501
502    while let Some(start_pos) = existing[last_end..].find(MARKER_START) {
503        let abs_start = last_end + start_pos;
504        let search_from = abs_start;
505        if let Some(end_marker_pos) = existing[search_from..].find(MARKER_END) {
506            let abs_end = search_from + end_marker_pos + MARKER_END.len();
507
508            // Consume trailing newline after end marker.
509            let abs_end = if existing.as_bytes().get(abs_end) == Some(&b'\n') {
510                abs_end + 1
511            } else {
512                abs_end
513            };
514
515            // Consume leading newline before start marker.
516            let prefix_end =
517                if abs_start > 0 && existing.as_bytes().get(abs_start - 1) == Some(&b'\n') {
518                    abs_start - 1
519                } else {
520                    abs_start
521                };
522
523            result.push_str(&existing[last_end..prefix_end]);
524            last_end = abs_end;
525            count += 1;
526        } else {
527            // Malformed: start without end — skip it and continue.
528            result.push_str(&existing[last_end..abs_start + MARKER_START.len()]);
529            last_end = abs_start + MARKER_START.len();
530            count += 1;
531        }
532    }
533
534    result.push_str(&existing[last_end..]);
535
536    if count == 0 {
537        return Ok(UninstallResult::NotExists);
538    }
539
540    // Clean up double newlines that may result from removal.
541    let new_content = clean_double_newlines(&result);
542
543    fs::write(path, new_content).map_err(|e| CliError::IoWithPath {
544        message: format!("failed to update instruction file: {e}"),
545        path: path.to_path_buf(),
546    })?;
547
548    Ok(UninstallResult::Removed)
549}
550
551/// Remove `seshat` entry from a JSON config file.
552pub fn remove_mcp_entry(
553    path: &Path,
554    client: ClientKind,
555    format: ConfigFormat,
556    dry_run: bool,
557) -> Result<UninstallResult, CliError> {
558    if dry_run {
559        return Ok(UninstallResult::DryRun(path.to_path_buf()));
560    }
561
562    if !path.exists() {
563        return Ok(UninstallResult::NotExists);
564    }
565
566    // JSONC — show what to remove, don't patch.
567    if format == ConfigFormat::Jsonc {
568        let entry = client.seshat_entry_json();
569        let formatted = serde_json::to_string_pretty(&entry).unwrap_or_else(|_| "{}".to_string());
570        let lines: Vec<&str> = formatted.lines().collect();
571        let refs: Vec<&str> = lines.to_vec();
572        eprintln!(
573            "  {} Remove from \"{}\":",
574            "snippet:".dimmed(),
575            client.mcp_key()
576        );
577        eprintln!();
578        eprint!("{}", format_copy_block(&refs, color_enabled()));
579        eprintln!();
580        return Ok(UninstallResult::NotExists);
581    }
582
583    let content = fs::read_to_string(path).map_err(|e| CliError::IoWithPath {
584        message: format!("failed to read config: {e}"),
585        path: path.to_path_buf(),
586    })?;
587
588    let mut value: serde_json::Value =
589        serde_json::from_str(&content).map_err(|e| CliError::CommandFailed {
590            command: "seshat uninstall".to_owned(),
591            reason: format!(
592                "config file at {} is not valid JSON: {e}. \
593                 Cannot remove seshat entry automatically.",
594                path.display()
595            ),
596        })?;
597
598    // Remove the seshat key from mcpServers/mcp.
599    let mcp_key = client.mcp_key();
600    if let Some(mcp_obj) = value.get_mut(mcp_key) {
601        if mcp_obj.is_object() {
602            if mcp_obj.get("seshat").is_some() {
603                mcp_obj.as_object_mut().unwrap().remove("seshat");
604
605                // If mcp object is now empty, remove the key too.
606                if mcp_obj.as_object().unwrap().is_empty() {
607                    value.as_object_mut().unwrap().remove(mcp_key);
608                }
609            } else {
610                return Ok(UninstallResult::NotExists);
611            }
612        }
613    } else {
614        return Ok(UninstallResult::NotExists);
615    }
616
617    let updated = serde_json::to_string_pretty(&value).map_err(|e| CliError::CommandFailed {
618        command: "seshat uninstall".to_owned(),
619        reason: format!("failed to serialize config: {e}"),
620    })?;
621
622    fs::write(path, updated.as_bytes()).map_err(|e| CliError::IoWithPath {
623        message: format!("failed to write config: {e}"),
624        path: path.to_path_buf(),
625    })?;
626
627    Ok(UninstallResult::Removed)
628}
629
630/// Remove a skill directory.
631pub fn remove_skill_dir(skill_dir: &Path, dry_run: bool) -> Result<UninstallResult, CliError> {
632    if dry_run {
633        return Ok(UninstallResult::DryRun(skill_dir.to_path_buf()));
634    }
635
636    if !skill_dir.exists() {
637        return Ok(UninstallResult::NotExists);
638    }
639
640    fs::remove_dir_all(skill_dir).map_err(|e| CliError::IoWithPath {
641        message: format!("failed to remove skill directory: {e}"),
642        path: skill_dir.to_path_buf(),
643    })?;
644
645    Ok(UninstallResult::Removed)
646}
647
648/// Remove hook scripts and hook entries from settings.json.
649pub fn remove_hooks(
650    hooks_dir: &Path,
651    settings_path: &Path,
652    dry_run: bool,
653) -> Result<UninstallResult, CliError> {
654    let mut any_removed = false;
655
656    // Remove hook scripts.
657    for name in &["seshat-session-start", "seshat-pre-tool"] {
658        let hook_path = hooks_dir.join(name);
659        if hook_path.exists() {
660            if dry_run {
661                // Collect all dry-run paths in the caller; just skip here.
662                continue;
663            }
664            fs::remove_file(&hook_path).map_err(|e| CliError::IoWithPath {
665                message: format!("failed to remove hook script: {e}"),
666                path: hook_path.clone(),
667            })?;
668            any_removed = true;
669        }
670    }
671
672    // Remove hook entries from settings.json.
673    if settings_path.exists() {
674        let result = remove_hook_entries_from_settings(settings_path, dry_run)?;
675        if matches!(result, UninstallResult::Removed) {
676            any_removed = true;
677        }
678    }
679
680    // Remove empty hooks directory.
681    if hooks_dir.exists() {
682        if dry_run {
683            // Check if it would become empty.
684            let mut has_non_seshat = false;
685            if let Ok(mut entries) = fs::read_dir(hooks_dir) {
686                while let Some(Ok(entry)) = entries.next() {
687                    let fname = entry.file_name();
688                    let fname_str = fname.to_string_lossy();
689                    if !fname_str.starts_with("seshat-") {
690                        has_non_seshat = true;
691                        break;
692                    }
693                }
694            }
695            if !has_non_seshat {
696                return Ok(UninstallResult::DryRun(hooks_dir.to_path_buf()));
697            }
698        } else if fs::read_dir(hooks_dir).is_ok_and(|mut r| r.next().is_none()) {
699            fs::remove_dir(hooks_dir).ok();
700        }
701    }
702
703    if any_removed {
704        Ok(UninstallResult::Removed)
705    } else {
706        Ok(UninstallResult::NotExists)
707    }
708}
709
710/// Remove seshat hook entries from settings.json.
711fn remove_hook_entries_from_settings(
712    settings_path: &Path,
713    dry_run: bool,
714) -> Result<UninstallResult, CliError> {
715    if dry_run {
716        return Ok(UninstallResult::DryRun(settings_path.to_path_buf()));
717    }
718
719    if !settings_path.exists() {
720        return Ok(UninstallResult::NotExists);
721    }
722
723    let content = fs::read_to_string(settings_path).map_err(|e| CliError::IoWithPath {
724        message: format!("failed to read settings: {e}"),
725        path: settings_path.to_path_buf(),
726    })?;
727
728    let mut root: serde_json::Value =
729        serde_json::from_str(&content).map_err(|e| CliError::CommandFailed {
730            command: "seshat uninstall".to_owned(),
731            reason: format!(
732                "settings.json at {} is not valid JSON: {e}",
733                settings_path.display()
734            ),
735        })?;
736
737    if !root.is_object() {
738        return Err(CliError::CommandFailed {
739            command: "seshat uninstall".to_owned(),
740            reason: format!(
741                "settings.json at {} is not a JSON object.",
742                settings_path.display()
743            ),
744        });
745    }
746
747    let mut modified = false;
748
749    // Remove seshat entries from PreToolUse.
750    if let Some(hooks) = root.get_mut("hooks") {
751        if hooks.is_object() {
752            // PreToolUse
753            if let Some(arr) = hooks.get_mut("PreToolUse") {
754                if let Some(array) = arr.as_array_mut() {
755                    let before = array.len();
756                    array.retain(|entry| {
757                        entry
758                            .get("hooks")
759                            .and_then(|h| h.as_array())
760                            .map(|hooks| {
761                                hooks.iter().all(|hook| {
762                                    hook.get("command")
763                                        .and_then(|c| c.as_str())
764                                        .map(|cmd| !is_seshat_hook_path(cmd, "seshat-pre-tool"))
765                                        .unwrap_or(true)
766                                })
767                            })
768                            .unwrap_or(true)
769                    });
770                    if array.len() < before {
771                        modified = true;
772                        if array.is_empty() {
773                            hooks.as_object_mut().unwrap().remove("PreToolUse");
774                        }
775                    }
776                }
777            }
778
779            // SessionStart
780            if let Some(arr) = hooks.get_mut("SessionStart") {
781                if let Some(array) = arr.as_array_mut() {
782                    let before = array.len();
783                    array.retain(|entry| {
784                        entry
785                            .get("hooks")
786                            .and_then(|h| h.as_array())
787                            .map(|hooks| {
788                                hooks.iter().all(|hook| {
789                                    hook.get("command")
790                                        .and_then(|c| c.as_str())
791                                        .map(|cmd| {
792                                            !is_seshat_hook_path(cmd, "seshat-session-start")
793                                        })
794                                        .unwrap_or(true)
795                                })
796                            })
797                            .unwrap_or(true)
798                    });
799                    if array.len() < before {
800                        modified = true;
801                        if array.is_empty() {
802                            hooks.as_object_mut().unwrap().remove("SessionStart");
803                        }
804                    }
805                }
806            }
807        }
808    }
809
810    if modified {
811        let json_str =
812            serde_json::to_string_pretty(&root).map_err(|e| CliError::CommandFailed {
813                command: "seshat uninstall".to_owned(),
814                reason: format!("failed to serialize settings.json: {e}"),
815            })?;
816
817        fs::write(settings_path, json_str).map_err(|e| CliError::IoWithPath {
818            message: format!("failed to write settings: {e}"),
819            path: settings_path.to_path_buf(),
820        })?;
821
822        Ok(UninstallResult::Removed)
823    } else {
824        Ok(UninstallResult::NotExists)
825    }
826}
827
828/// Try to remove seshat via `claude mcp remove seshat` CLI command.
829/// Falls back to JSON patch of ~/.claude.json if the CLI command is not available.
830fn run_claude_mcp_remove(dry_run: bool) -> Result<String, CliError> {
831    let cmd_display = "claude mcp remove seshat".to_string();
832
833    if dry_run {
834        return Ok(cmd_display);
835    }
836
837    // Try CLI first.
838    let status = std::process::Command::new("claude")
839        .args(["mcp", "remove", "seshat"])
840        .status();
841
842    if let Ok(status) = status {
843        if status.success() {
844            return Ok(cmd_display);
845        }
846    }
847
848    // Fallback: JSON patch ~/.claude.json.
849    if let Some(home) = dirs::home_dir() {
850        let claude_json = home.join(".claude.json");
851        if let Ok(result) = remove_mcp_entry(
852            &claude_json,
853            ClientKind::ClaudeCode,
854            ConfigFormat::Json,
855            false,
856        ) {
857            if matches!(result, UninstallResult::Removed) {
858                let fallback = format!(
859                    "claude mcp remove seshat (JSON patch: {})",
860                    claude_json.display()
861                );
862                return Ok(fallback);
863            }
864        }
865    }
866
867    Err(CliError::CommandFailed {
868        command: "claude mcp remove".to_owned(),
869        reason: "CLI command not available and fallback failed".to_owned(),
870    })
871}
872
873// ══════════════════════════════════════════════════════════════════════
874// Output helpers
875// ══════════════════════════════════════════════════════════════════════
876
877fn print_ok(message: &str, color: bool) {
878    if color {
879        eprintln!("  {} {message}", "✓".green().bold());
880    } else {
881        eprintln!("  ✓ {message}");
882    }
883}
884
885fn print_info(message: &str) {
886    eprintln!("  {message}");
887}
888
889fn print_error(message: &str, color: bool) {
890    if color {
891        eprintln!("  {} {message}", "✗".red().bold());
892    } else {
893        eprintln!("  ✗ {message}");
894    }
895}
896
897/// Ask a yes/no question on stderr, read answer from stdin.
898fn ask_yn(prompt: &str, dry_run: bool) -> bool {
899    if dry_run {
900        eprintln!("  {prompt} [dry-run — no changes]");
901        return true;
902    }
903    eprint!("  {prompt} [y/N] ");
904    io::stderr().flush().ok();
905    let mut input = String::new();
906    io::stdin().read_line(&mut input).ok();
907    matches!(input.trim(), "y" | "Y")
908}
909
910/// Check if a command path references a seshat hook script.
911/// Matches by filename or path segment, not by substring, to avoid
912/// false positives like `/usr/local/bin/my-seshat-pre-tool-hook`.
913fn is_seshat_hook_path(cmd: &str, hook_name: &str) -> bool {
914    // Check if the command ends with the hook name (with optional leading path separator).
915    if cmd == hook_name {
916        return true;
917    }
918    if cmd.ends_with(&format!("/{hook_name}")) {
919        return true;
920    }
921    // Check if the command contains the hook name as a path segment.
922    if cmd.contains(&format!("/{hook_name}/")) {
923        return true;
924    }
925    // Check for common patterns like "/hooks/seshat-pre-tool" or "hooks/seshat-pre-tool".
926    if cmd.contains(&format!("hooks/{hook_name}")) {
927        return true;
928    }
929    false
930}
931
932/// Clean up double newlines that may result from block removal.
933fn clean_double_newlines(s: &str) -> String {
934    // Replace 3+ consecutive newlines with 2 (single blank line).
935    let mut result = String::with_capacity(s.len());
936    let mut consecutive = 0;
937
938    for ch in s.chars() {
939        if ch == '\n' {
940            consecutive += 1;
941            if consecutive <= 2 {
942                result.push(ch);
943            }
944        } else {
945            consecutive = 0;
946            result.push(ch);
947        }
948    }
949
950    // Trim trailing whitespace/newlines.
951    result.trim_end().to_string()
952}
953
954// ══════════════════════════════════════════════════════════════════════
955// Per-client uninstall handling
956// ══════════════════════════════════════════════════════════════════════
957
958/// Handle uninstall for a single client.
959/// Returns `true` if there was an error.
960fn handle_client_uninstall(plan: &ClientUninstallPlan, dry_run: bool, color: bool) -> bool {
961    let mut had_error = false;
962
963    eprintln!(
964        "{}",
965        format_section_header(plan.client.display_name(), color)
966    );
967    eprintln!();
968
969    // Show what will be removed.
970    let mut items_shown = Vec::new();
971    for target in &plan.targets {
972        match target {
973            UninstallTarget::McpEntry {
974                path,
975                format,
976                is_project,
977                ..
978            } => {
979                let scope = if *is_project { "project" } else { "global" };
980                let fmt = if *format == ConfigFormat::Jsonc {
981                    " (JSONC — snippet only)"
982                } else {
983                    ""
984                };
985                items_shown.push(format!(
986                    "  MCP: {} → remove \"seshat\" from mcpServers{} ({})",
987                    path.display(),
988                    fmt,
989                    scope
990                ));
991            }
992            UninstallTarget::Instructions { path } => {
993                items_shown.push(format!(
994                    "  Instructions: {} → remove <!-- seshat:start -->...<!-- seshat:end -->",
995                    path.display()
996                ));
997            }
998            UninstallTarget::SkillDir { path } => {
999                items_shown.push(format!("  Skill: {} → delete", path.display()));
1000            }
1001            UninstallTarget::HookScript { path } => {
1002                items_shown.push(format!("  Hook: {} → delete", path.display()));
1003            }
1004            UninstallTarget::HookEntries { settings_path } => {
1005                items_shown.push(format!(
1006                    "  Hooks: {} → remove seshat entries",
1007                    settings_path.display()
1008                ));
1009            }
1010        }
1011    }
1012
1013    if items_shown.is_empty() {
1014        print_info("Nothing to remove (Seshat not configured).");
1015        eprintln!();
1016        return false;
1017    }
1018
1019    for item in &items_shown {
1020        eprintln!("{item}");
1021    }
1022    eprintln!();
1023
1024    if dry_run {
1025        print_info("[dry-run — no changes will be made]");
1026        eprintln!();
1027        return false;
1028    }
1029
1030    // Ask for confirmation.
1031    if !ask_yn("Remove Seshat configuration?", false) {
1032        print_info("Skipped.");
1033        eprintln!();
1034        return false;
1035    }
1036
1037    // Perform removal.
1038    for target in &plan.targets {
1039        match target {
1040            UninstallTarget::McpEntry {
1041                path,
1042                format,
1043                client,
1044                is_project,
1045                ..
1046            } => {
1047                // For Claude Code global MCP config, try `claude mcp remove` first.
1048                if *client == ClientKind::ClaudeCode
1049                    && !*is_project
1050                    && path.ends_with(".claude.json")
1051                {
1052                    match run_claude_mcp_remove(false) {
1053                        Ok(cmd) => {
1054                            print_ok(&format!("Removed via: {cmd}"), color);
1055                        }
1056                        Err(e) => {
1057                            print_error(&format!("Failed to remove via CLI: {e}"), color);
1058                            had_error = true;
1059                        }
1060                    }
1061                } else {
1062                    match remove_mcp_entry(path, *client, *format, false) {
1063                        Ok(UninstallResult::Removed) => {
1064                            print_ok(&format!("MCP entry removed from {}", path.display()), color);
1065                        }
1066                        Ok(UninstallResult::NotExists) => {
1067                            print_info(&format!("MCP entry not found in {}", path.display()));
1068                        }
1069                        Ok(UninstallResult::DryRun(p)) => {
1070                            print_info(&format!("Would remove MCP entry from {}", p.display()));
1071                        }
1072                        Ok(UninstallResult::Skipped(msg)) => {
1073                            print_info(&format!("MCP: {msg}"));
1074                        }
1075                        Err(e) => {
1076                            print_error(&format!("Failed to remove MCP entry: {e}"), color);
1077                            had_error = true;
1078                        }
1079                    }
1080                }
1081            }
1082            UninstallTarget::Instructions { path } => match remove_instructions(path, false) {
1083                Ok(UninstallResult::Removed) => {
1084                    print_ok(
1085                        &format!("Instructions removed from {}", path.display()),
1086                        color,
1087                    );
1088                }
1089                Ok(UninstallResult::NotExists) => {
1090                    print_info(&format!("No seshat section found in {}", path.display()));
1091                }
1092                Ok(UninstallResult::DryRun(p)) => {
1093                    print_info(&format!("Would remove instructions from {}", p.display()));
1094                }
1095                Ok(UninstallResult::Skipped(msg)) => {
1096                    print_info(&format!("Instructions: {msg}"));
1097                }
1098                Err(e) => {
1099                    print_error(&format!("Failed to remove instructions: {e}"), color);
1100                    had_error = true;
1101                }
1102            },
1103            UninstallTarget::SkillDir { path } => match remove_skill_dir(path, false) {
1104                Ok(UninstallResult::Removed) => {
1105                    print_ok(
1106                        &format!("Skill directory removed: {}", path.display()),
1107                        color,
1108                    );
1109                }
1110                Ok(UninstallResult::NotExists) => {
1111                    print_info(&format!("Skill directory not found: {}", path.display()));
1112                }
1113                Ok(UninstallResult::DryRun(p)) => {
1114                    print_info(&format!("Would remove skill directory: {}", p.display()));
1115                }
1116                Ok(UninstallResult::Skipped(msg)) => {
1117                    print_info(&format!("Skill: {msg}"));
1118                }
1119                Err(e) => {
1120                    print_error(&format!("Failed to remove skill directory: {e}"), color);
1121                    had_error = true;
1122                }
1123            },
1124            UninstallTarget::HookScript { path } => {
1125                if path.exists() {
1126                    if dry_run {
1127                        print_info(&format!("Would remove hook: {}", path.display()));
1128                    } else {
1129                        match fs::remove_file(path) {
1130                            Ok(()) => {
1131                                print_ok(&format!("Hook removed: {}", path.display()), color);
1132                            }
1133                            Err(e) => {
1134                                print_error(&format!("Failed to remove hook: {e}"), color);
1135                                had_error = true;
1136                            }
1137                        }
1138                    }
1139                } else {
1140                    print_info(&format!("Hook not found: {}", path.display()));
1141                }
1142            }
1143            UninstallTarget::HookEntries { settings_path } => {
1144                let hooks_dir = settings_path
1145                    .parent()
1146                    .unwrap_or(Path::new(""))
1147                    .join("hooks");
1148                match remove_hooks(&hooks_dir, settings_path, false) {
1149                    Ok(UninstallResult::Removed) => {
1150                        print_ok(
1151                            &format!("Hook entries removed from {}", settings_path.display()),
1152                            color,
1153                        );
1154                    }
1155                    Ok(UninstallResult::NotExists) => {
1156                        print_info(&format!(
1157                            "No seshat hook entries in {}",
1158                            settings_path.display()
1159                        ));
1160                    }
1161                    Ok(UninstallResult::DryRun(p)) => {
1162                        print_info(&format!("Would remove hooks from {}", p.display()));
1163                    }
1164                    Ok(UninstallResult::Skipped(msg)) => {
1165                        print_info(&format!("Hooks: {msg}"));
1166                    }
1167                    Err(e) => {
1168                        print_error(&format!("Failed to remove hooks: {e}"), color);
1169                        had_error = true;
1170                    }
1171                }
1172            }
1173        }
1174    }
1175
1176    eprintln!();
1177    had_error
1178}
1179
1180// ══════════════════════════════════════════════════════════════════════
1181// Entry point
1182// ══════════════════════════════════════════════════════════════════════
1183
1184/// Run the `seshat uninstall` command.
1185pub fn run_uninstall(
1186    client: Option<&str>,
1187    scope: ScopeRequest,
1188    dry_run: bool,
1189) -> Result<(), CliError> {
1190    let color = color_enabled();
1191
1192    // Show warning.
1193    eprintln!(
1194        "{}",
1195        format_section_header(if dry_run { "DRY RUN" } else { "WARNING" }, color)
1196    );
1197    eprintln!();
1198    if dry_run {
1199        eprintln!("  No changes will be made. This shows what would be removed.");
1200    } else {
1201        eprintln!("  This will permanently remove all Seshat configuration.");
1202        eprintln!("  This action cannot be undone. Use backups to restore.");
1203    }
1204    eprintln!();
1205
1206    // Resolve project root.
1207    let cwd = std::env::current_dir().map_err(|e| CliError::IoWithPath {
1208        message: format!("cannot determine current directory: {e}"),
1209        path: PathBuf::from("."),
1210    })?;
1211    let project_root = crate::db::sync_root_for(&cwd);
1212
1213    // Detect targets.
1214    let plans = detect_all_targets(client, scope, &project_root);
1215
1216    if plans.is_empty() {
1217        eprintln!("  No Seshat configuration found to remove.");
1218        eprintln!("  Run `seshat init` to set up Seshat first.");
1219        return Ok(());
1220    }
1221
1222    // Show scope hint.
1223    match scope {
1224        ScopeRequest::Auto => {}
1225        ScopeRequest::Project => {
1226            if color {
1227                eprintln!(
1228                    "  {} project ({})",
1229                    "Scope:".dimmed(),
1230                    project_root.display()
1231                );
1232            } else {
1233                eprintln!("  Scope: project ({})", project_root.display());
1234            }
1235        }
1236        ScopeRequest::Global => {
1237            if color {
1238                eprintln!("  {} global\n", "Scope:".dimmed());
1239            } else {
1240                eprintln!("  Scope: global\n");
1241            }
1242        }
1243    }
1244
1245    // Perform uninstall for each client.
1246    let mut any_error = false;
1247
1248    for plan in &plans {
1249        let error = handle_client_uninstall(plan, dry_run, color);
1250        if error {
1251            any_error = true;
1252        }
1253    }
1254
1255    if any_error {
1256        Err(CliError::CommandFailed {
1257            command: "uninstall".to_owned(),
1258            reason: "one or more removals failed".to_owned(),
1259        })
1260    } else {
1261        Ok(())
1262    }
1263}
1264
1265// ══════════════════════════════════════════════════════════════════════
1266// Tests
1267// ══════════════════════════════════════════════════════════════════════
1268
1269#[cfg(test)]
1270mod tests {
1271    use super::*;
1272    use std::fs;
1273    use tempfile::TempDir;
1274
1275    fn tmp() -> TempDir {
1276        tempfile::tempdir().expect("create temp dir")
1277    }
1278
1279    // ── remove_instructions ──────────────────────────────────────
1280
1281    #[test]
1282    fn remove_instructions_removes_block() {
1283        let dir = tmp();
1284        let path = dir.path().join("CLAUDE.md");
1285        let content = "# Header\n\n<!-- seshat:start -->\nSome seshat content\n<!-- seshat:end -->\n\n# Footer\n".to_string();
1286        fs::write(&path, &content).unwrap();
1287
1288        let result = remove_instructions(&path, false).unwrap();
1289        assert_eq!(result, UninstallResult::Removed);
1290
1291        let new_content = fs::read_to_string(&path).unwrap();
1292        assert!(!new_content.contains("seshat:start"));
1293        assert!(!new_content.contains("seshat:end"));
1294        assert!(new_content.contains("# Header"));
1295        assert!(new_content.contains("# Footer"));
1296    }
1297
1298    #[test]
1299    fn remove_instructions_returns_not_exists_when_no_markers() {
1300        let dir = tmp();
1301        let path = dir.path().join("CLAUDE.md");
1302        fs::write(&path, "# Just a regular file\n").unwrap();
1303
1304        let result = remove_instructions(&path, false).unwrap();
1305        assert_eq!(result, UninstallResult::NotExists);
1306    }
1307
1308    #[test]
1309    fn remove_instructions_returns_not_exists_when_file_absent() {
1310        let dir = tmp();
1311        let path = dir.path().join("CLAUDE.md");
1312
1313        let result = remove_instructions(&path, false).unwrap();
1314        assert_eq!(result, UninstallResult::NotExists);
1315    }
1316
1317    #[test]
1318    fn remove_instructions_dry_run_does_not_modify() {
1319        let dir = tmp();
1320        let path = dir.path().join("CLAUDE.md");
1321        let content = "# Header\n\n<!-- seshat:start -->\ncontent\n<!-- seshat:end -->\n";
1322        fs::write(&path, content).unwrap();
1323
1324        let result = remove_instructions(&path, true).unwrap();
1325        assert!(matches!(result, UninstallResult::DryRun(_)));
1326
1327        let new_content = fs::read_to_string(&path).unwrap();
1328        assert_eq!(
1329            new_content, content,
1330            "file should not be modified in dry-run"
1331        );
1332    }
1333
1334    #[test]
1335    fn remove_instructions_clean_double_newlines() {
1336        let dir = tmp();
1337        let path = dir.path().join("CLAUDE.md");
1338        let content =
1339            "# Header\n\n\n\n<!-- seshat:start -->\ncontent\n<!-- seshat:end -->\n\n\n# Footer\n"
1340                .to_string();
1341        fs::write(&path, &content).unwrap();
1342
1343        remove_instructions(&path, false).unwrap();
1344
1345        let new_content = fs::read_to_string(&path).unwrap();
1346        // Should not have 3+ consecutive newlines.
1347        assert!(
1348            !new_content.contains("\n\n\n"),
1349            "should not have triple newlines, got: {:?}",
1350            new_content
1351        );
1352    }
1353
1354    // ── remove_mcp_entry ─────────────────────────────────────────
1355
1356    #[test]
1357    fn remove_mcp_entry_removes_seshat_from_json() {
1358        let dir = tmp();
1359        let path = dir.path().join("settings.json");
1360        fs::write(
1361            &path,
1362            r#"{"mcpServers": {"seshat": {"command": "seshat"}, "other": {"command": "other"}}}"#,
1363        )
1364        .unwrap();
1365
1366        let result =
1367            remove_mcp_entry(&path, ClientKind::ClaudeCode, ConfigFormat::Json, false).unwrap();
1368        assert_eq!(result, UninstallResult::Removed);
1369
1370        let content = fs::read_to_string(&path).unwrap();
1371        let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
1372        assert!(
1373            parsed["mcpServers"].get("seshat").is_none(),
1374            "seshat removed"
1375        );
1376        assert!(
1377            parsed["mcpServers"]["other"].is_object(),
1378            "other entry preserved"
1379        );
1380    }
1381
1382    #[test]
1383    fn remove_mcp_entry_removes_empty_mcp_key() {
1384        let dir = tmp();
1385        let path = dir.path().join("settings.json");
1386        fs::write(
1387            &path,
1388            r#"{"mcpServers": {"seshat": {"command": "seshat"}}}"#,
1389        )
1390        .unwrap();
1391
1392        remove_mcp_entry(&path, ClientKind::ClaudeCode, ConfigFormat::Json, false).unwrap();
1393
1394        let content = fs::read_to_string(&path).unwrap();
1395        let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
1396        assert!(
1397            parsed.get("mcpServers").is_none(),
1398            "empty mcpServers key should be removed"
1399        );
1400    }
1401
1402    #[test]
1403    fn remove_mcp_entry_returns_not_exists_when_no_seshat() {
1404        let dir = tmp();
1405        let path = dir.path().join("settings.json");
1406        fs::write(&path, r#"{"mcpServers": {"other": {"command": "other"}}}"#).unwrap();
1407
1408        let result =
1409            remove_mcp_entry(&path, ClientKind::ClaudeCode, ConfigFormat::Json, false).unwrap();
1410        assert_eq!(result, UninstallResult::NotExists);
1411    }
1412
1413    #[test]
1414    fn remove_mcp_entry_dry_run_does_not_modify() {
1415        let dir = tmp();
1416        let path = dir.path().join("settings.json");
1417        let content = r#"{"mcpServers": {"seshat": {"command": "seshat"}}}"#;
1418        fs::write(&path, content).unwrap();
1419
1420        let result =
1421            remove_mcp_entry(&path, ClientKind::ClaudeCode, ConfigFormat::Json, true).unwrap();
1422        assert!(matches!(result, UninstallResult::DryRun(_)));
1423
1424        let new_content = fs::read_to_string(&path).unwrap();
1425        assert_eq!(new_content, content);
1426    }
1427
1428    #[test]
1429    fn remove_mcp_entry_handles_invalid_json() {
1430        let dir = tmp();
1431        let path = dir.path().join("settings.json");
1432        fs::write(&path, "{invalid json}").unwrap();
1433
1434        let result = remove_mcp_entry(&path, ClientKind::ClaudeCode, ConfigFormat::Json, false);
1435        assert!(result.is_err());
1436    }
1437
1438    // ── remove_skill_dir ─────────────────────────────────────────
1439
1440    #[test]
1441    fn remove_skill_dir_removes_directory() {
1442        let dir = tmp();
1443        let skill_dir = dir.path().join("skills").join("seshat");
1444        fs::create_dir_all(&skill_dir).unwrap();
1445        fs::write(skill_dir.join("SKILL.md"), "content").unwrap();
1446
1447        let result = remove_skill_dir(&skill_dir, false).unwrap();
1448        assert_eq!(result, UninstallResult::Removed);
1449        assert!(!skill_dir.exists());
1450    }
1451
1452    #[test]
1453    fn remove_skill_dir_returns_not_exists_when_absent() {
1454        let dir = tmp();
1455        let skill_dir = dir.path().join("skills").join("seshat");
1456
1457        let result = remove_skill_dir(&skill_dir, false).unwrap();
1458        assert_eq!(result, UninstallResult::NotExists);
1459    }
1460
1461    #[test]
1462    fn remove_skill_dir_dry_run_does_not_remove() {
1463        let dir = tmp();
1464        let skill_dir = dir.path().join("skills").join("seshat");
1465        fs::create_dir_all(&skill_dir).unwrap();
1466        fs::write(skill_dir.join("SKILL.md"), "content").unwrap();
1467
1468        let result = remove_skill_dir(&skill_dir, true).unwrap();
1469        assert!(matches!(result, UninstallResult::DryRun(_)));
1470        assert!(
1471            skill_dir.exists(),
1472            "directory should not be removed in dry-run"
1473        );
1474    }
1475
1476    // ── remove_hooks ─────────────────────────────────────────────
1477
1478    #[test]
1479    fn remove_hooks_removes_scripts_and_entries() {
1480        let dir = tmp();
1481        let hooks_dir = dir.path().join("hooks");
1482        let settings = dir.path().join("settings.json");
1483        fs::create_dir_all(&hooks_dir).unwrap();
1484        fs::write(
1485            hooks_dir.join("seshat-session-start"),
1486            "#!/bin/bash\necho hello",
1487        )
1488        .unwrap();
1489        fs::write(hooks_dir.join("seshat-pre-tool"), "#!/bin/bash\necho nudge").unwrap();
1490        fs::write(
1491            &settings,
1492            r#"{"hooks":{"PreToolUse":[{"matcher":"Grep","hooks":[{"type":"command","command":"/hooks/seshat-pre-tool"}]}],"SessionStart":[{"matcher":"startup","hooks":[{"type":"command","command":"/hooks/seshat-session-start"}]}]}}"#,
1493        )
1494        .unwrap();
1495
1496        let result = remove_hooks(&hooks_dir, &settings, false).unwrap();
1497        assert_eq!(result, UninstallResult::Removed);
1498
1499        assert!(
1500            !hooks_dir.exists(),
1501            "hooks dir should be removed (was empty)"
1502        );
1503        let content = fs::read_to_string(&settings).unwrap();
1504        let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
1505        assert!(
1506            parsed["hooks"].get("PreToolUse").is_none(),
1507            "PreToolUse removed"
1508        );
1509        assert!(
1510            parsed["hooks"].get("SessionStart").is_none(),
1511            "SessionStart removed"
1512        );
1513    }
1514
1515    #[test]
1516    fn remove_hooks_preserves_other_hooks() {
1517        let dir = tmp();
1518        let hooks_dir = dir.path().join("hooks");
1519        let settings = dir.path().join("settings.json");
1520        fs::create_dir_all(&hooks_dir).unwrap();
1521        fs::write(hooks_dir.join("seshat-pre-tool"), "#!/bin/bash\necho nudge").unwrap();
1522        fs::write(
1523            &settings,
1524            r#"{"hooks":{"PreToolUse":[{"matcher":"Grep","hooks":[{"type":"command","command":"/hooks/seshat-pre-tool"}]},{"matcher":"Glob","hooks":[{"type":"command","command":"/hooks/other-hook"}]}]}}"#,
1525        )
1526        .unwrap();
1527
1528        let result = remove_hooks(&hooks_dir, &settings, false).unwrap();
1529        assert_eq!(result, UninstallResult::Removed);
1530
1531        let content = fs::read_to_string(&settings).unwrap();
1532        let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
1533        let pre_tool = parsed["hooks"]["PreToolUse"].as_array().unwrap();
1534        assert_eq!(pre_tool.len(), 1, "only one entry should remain");
1535        assert!(
1536            pre_tool[0]["hooks"][0]["command"]
1537                .as_str()
1538                .unwrap()
1539                .contains("other-hook"),
1540            "other hook preserved"
1541        );
1542    }
1543
1544    #[test]
1545    fn remove_hooks_returns_not_exists_when_nothing_to_remove() {
1546        let dir = tmp();
1547        let hooks_dir = dir.path().join("hooks");
1548        let settings = dir.path().join("settings.json");
1549        fs::create_dir_all(&hooks_dir).unwrap();
1550        fs::write(
1551            &settings,
1552            r#"{"hooks":{"PreToolUse":[{"matcher":"Grep","hooks":[{"type":"command","command":"/hooks/other-hook"}]}]}}"#,
1553        )
1554        .unwrap();
1555
1556        let result = remove_hooks(&hooks_dir, &settings, false).unwrap();
1557        assert_eq!(result, UninstallResult::NotExists);
1558    }
1559
1560    #[test]
1561    fn remove_hooks_dry_run_does_not_modify() {
1562        let dir = tmp();
1563        let hooks_dir = dir.path().join("hooks");
1564        let settings = dir.path().join("settings.json");
1565        fs::create_dir_all(&hooks_dir).unwrap();
1566        fs::write(hooks_dir.join("seshat-pre-tool"), "#!/bin/bash\necho nudge").unwrap();
1567        fs::write(
1568            &settings,
1569            r#"{"hooks":{"PreToolUse":[{"matcher":"Grep","hooks":[{"type":"command","command":"/hooks/seshat-pre-tool"}]}]}}"#,
1570        )
1571        .unwrap();
1572
1573        let result = remove_hooks(&hooks_dir, &settings, true).unwrap();
1574        assert!(matches!(result, UninstallResult::DryRun(_)));
1575        assert!(
1576            hooks_dir.join("seshat-pre-tool").exists(),
1577            "hook should not be removed in dry-run"
1578        );
1579    }
1580
1581    // ── clean_double_newlines ────────────────────────────────────
1582
1583    #[test]
1584    fn clean_double_newlines_reduces_triple_newlines() {
1585        let input = "a\n\n\nb\n\n\nc";
1586        let result = clean_double_newlines(input);
1587        assert_eq!(result, "a\n\nb\n\nc");
1588    }
1589
1590    #[test]
1591    fn clean_double_newlines_leaves_double_newlines() {
1592        let input = "a\n\nb";
1593        let result = clean_double_newlines(input);
1594        assert_eq!(result, "a\n\nb");
1595    }
1596
1597    #[test]
1598    fn clean_double_newlines_trims_trailing() {
1599        let input = "a\n\n\n";
1600        let result = clean_double_newlines(input);
1601        assert_eq!(result, "a");
1602    }
1603
1604    // ── detect_all_targets ─────────────────────────────────────────
1605
1606    #[test]
1607    fn detect_all_targets_unknown_client_returns_empty() {
1608        let dir = tmp();
1609        let plans = detect_all_targets(Some("unknown-ai"), ScopeRequest::Auto, dir.path());
1610        assert!(plans.is_empty());
1611    }
1612
1613    // ── detect_client_targets ──────────────────────────────────────
1614
1615    #[test]
1616    fn detect_client_targets_opencode_returns_targets() {
1617        let dir = tmp();
1618        // ScopeRequest::Auto looks for opencode.json[c] directly in project_root
1619        // (and AGENTS.md), so place the marker file there to make the test
1620        // independent of any global ~/.config/opencode state on the runner.
1621        fs::write(dir.path().join("opencode.jsonc"), "{}").unwrap();
1622
1623        let targets = detect_client_targets(ClientKind::OpenCode, ScopeRequest::Auto, dir.path());
1624        assert!(!targets.is_empty());
1625    }
1626
1627    // ── is_seshat_hook_path ────────────────────────────────────────
1628
1629    #[test]
1630    fn is_seshat_hook_path_positive() {
1631        assert!(is_seshat_hook_path(
1632            "/some/path/.claude/hooks/seshat-pre-tool",
1633            "seshat-pre-tool"
1634        ));
1635    }
1636
1637    #[test]
1638    fn is_seshat_hook_path_negative() {
1639        assert!(!is_seshat_hook_path(
1640            "/some/path/.claude/hooks/other-pre-tool",
1641            "seshat-pre-tool"
1642        ));
1643    }
1644
1645    // ── UninstallTarget & UninstallResult ──────────────────────────
1646
1647    #[test]
1648    fn uninstall_result_equality() {
1649        assert_eq!(UninstallResult::Removed, UninstallResult::Removed);
1650        assert_eq!(UninstallResult::NotExists, UninstallResult::NotExists);
1651        assert_ne!(UninstallResult::Removed, UninstallResult::NotExists);
1652    }
1653
1654    #[test]
1655    fn uninstall_target_clone() {
1656        let t = UninstallTarget::Instructions {
1657            path: PathBuf::from("/tmp/CLAUDE.md"),
1658        };
1659        let t2 = t.clone();
1660        if let UninstallTarget::Instructions { path } = &t2 {
1661            assert_eq!(path.to_str().unwrap(), "/tmp/CLAUDE.md");
1662        } else {
1663            unreachable!();
1664        }
1665    }
1666
1667    #[test]
1668    fn run_uninstall_no_clients_output() {
1669        let result = run_uninstall(Some("opencode"), ScopeRequest::Auto, true);
1670        assert!(result.is_ok());
1671    }
1672
1673    #[test]
1674    fn run_uninstall_unknown_client_errors() {
1675        // unknown-client in run_uninstall goes through detect_all_targets
1676        // which returns empty plan — actually it just returns Ok with empty result
1677        let result = run_uninstall(Some("unknown-client"), ScopeRequest::Auto, false);
1678        assert!(result.is_ok());
1679    }
1680
1681    #[test]
1682    fn remove_instructions_multiple_blocks_are_all_removed() {
1683        let dir = tmp();
1684        let path = dir.path().join("CLAUDE.md");
1685        let content = concat!(
1686            "# Header\n",
1687            "\n",
1688            "<!-- seshat:start -->\n",
1689            "block1\n",
1690            "<!-- seshat:end -->\n",
1691            "\n",
1692            "middle\n",
1693            "\n",
1694            "<!-- seshat:start -->\n",
1695            "block2\n",
1696            "<!-- seshat:end -->\n",
1697            "\n",
1698            "# Footer\n",
1699        );
1700        fs::write(&path, content).unwrap();
1701
1702        let _ = remove_instructions(&path, false);
1703        let new_content = fs::read_to_string(&path).unwrap();
1704        assert!(!new_content.contains("seshat:start"));
1705        assert!(!new_content.contains("seshat:end"));
1706    }
1707
1708    #[test]
1709    fn run_uninstall_auto_mode_dry_run() {
1710        let result = run_uninstall(None, ScopeRequest::Auto, true);
1711        assert!(result.is_ok());
1712    }
1713
1714    #[test]
1715    fn client_uninstall_plan_holds_correct_data() {
1716        let plan = ClientUninstallPlan {
1717            client: ClientKind::OpenCode,
1718            targets: vec![
1719                UninstallTarget::Instructions {
1720                    path: PathBuf::from("/tmp/AGENTS.md"),
1721                },
1722                UninstallTarget::SkillDir {
1723                    path: PathBuf::from("/tmp/skills/seshat"),
1724                },
1725            ],
1726        };
1727        assert_eq!(plan.client, ClientKind::OpenCode);
1728        assert_eq!(plan.targets.len(), 2);
1729    }
1730
1731    #[test]
1732    fn remove_mcp_entry_nonexistent_file_returns_not_exists() {
1733        let dir = tmp();
1734        let path = dir.path().join("nonexistent.json");
1735        let result =
1736            remove_mcp_entry(&path, ClientKind::ClaudeCode, ConfigFormat::Json, false).unwrap();
1737        assert_eq!(result, UninstallResult::NotExists);
1738    }
1739
1740    #[test]
1741    fn remove_mcp_entry_missing_mcp_key_returns_not_exists() {
1742        let dir = tmp();
1743        let path = dir.path().join("settings.json");
1744        fs::write(&path, r#"{"otherKey": {}}"#).unwrap();
1745        let result =
1746            remove_mcp_entry(&path, ClientKind::ClaudeCode, ConfigFormat::Json, false).unwrap();
1747        assert_eq!(result, UninstallResult::NotExists);
1748    }
1749
1750    #[test]
1751    fn remove_mcp_entry_mcp_key_not_object_returns_removed() {
1752        let dir = tmp();
1753        let path = dir.path().join("settings.json");
1754        fs::write(&path, r#"{"mcpServers": []}"#).unwrap();
1755        let result =
1756            remove_mcp_entry(&path, ClientKind::ClaudeCode, ConfigFormat::Json, false).unwrap();
1757        // When mcpServers key exists but is not an object, the function
1758        // still writes the file back and returns Removed.
1759        assert_eq!(result, UninstallResult::Removed);
1760    }
1761
1762    #[test]
1763    fn remove_hooks_nonexistent_dir_returns_not_exists() {
1764        let dir = tmp();
1765        let hooks_dir = dir.path().join("nonexistent_hooks");
1766        let settings = dir.path().join("settings.json");
1767        let result = remove_hooks(&hooks_dir, &settings, false).unwrap();
1768        assert_eq!(result, UninstallResult::NotExists);
1769    }
1770
1771    #[test]
1772    fn is_seshat_hook_path_exact_match() {
1773        assert!(is_seshat_hook_path("seshat-pre-tool", "seshat-pre-tool"));
1774    }
1775
1776    #[test]
1777    fn is_seshat_hook_path_ends_with_hook_name() {
1778        assert!(is_seshat_hook_path(
1779            "/hooks/seshat-pre-tool",
1780            "seshat-pre-tool"
1781        ));
1782    }
1783
1784    #[test]
1785    fn is_seshat_hook_path_contains_hooks_dir() {
1786        assert!(is_seshat_hook_path(
1787            "/path/hooks/seshat-pre-tool/something",
1788            "seshat-pre-tool"
1789        ));
1790    }
1791
1792    // ── detect_cursor_targets ────────────────────────────────────────
1793
1794    #[test]
1795    fn detect_cursor_targets_project_scope_with_file() {
1796        let dir = tmp();
1797        let cursor_dir = dir.path().join(".cursor");
1798        fs::create_dir_all(&cursor_dir).unwrap();
1799        fs::write(
1800            cursor_dir.join("mcp.json"),
1801            r#"{"mcpServers":{"seshat":{}}}"#,
1802        )
1803        .unwrap();
1804
1805        let targets = detect_cursor_targets(ScopeRequest::Project, dir.path());
1806        assert!(!targets.is_empty());
1807    }
1808
1809    #[test]
1810    fn detect_cursor_targets_project_scope_no_file_returns_empty() {
1811        let dir = tmp();
1812        let targets = detect_cursor_targets(ScopeRequest::Project, dir.path());
1813        assert!(targets.is_empty());
1814    }
1815
1816    #[test]
1817    fn detect_cursor_targets_auto_scope_with_project_file() {
1818        let dir = tmp();
1819        let cursor_dir = dir.path().join(".cursor");
1820        fs::create_dir_all(&cursor_dir).unwrap();
1821        fs::write(cursor_dir.join("mcp.json"), "{}").unwrap();
1822
1823        let targets = detect_cursor_targets(ScopeRequest::Auto, dir.path());
1824        assert!(!targets.is_empty());
1825    }
1826
1827    #[test]
1828    fn detect_client_targets_cursor_dispatches_correctly() {
1829        let dir = tmp();
1830        let cursor_dir = dir.path().join(".cursor");
1831        fs::create_dir_all(&cursor_dir).unwrap();
1832        fs::write(cursor_dir.join("mcp.json"), "{}").unwrap();
1833
1834        let targets = detect_client_targets(ClientKind::Cursor, ScopeRequest::Project, dir.path());
1835        assert!(!targets.is_empty());
1836    }
1837
1838    #[test]
1839    fn detect_client_targets_claude_code_dispatches_without_panic() {
1840        let dir = tmp();
1841        let targets =
1842            detect_client_targets(ClientKind::ClaudeCode, ScopeRequest::Project, dir.path());
1843        // May be empty if ~/.claude doesn't exist, but must not panic.
1844        drop(targets);
1845    }
1846
1847    #[test]
1848    fn detect_client_targets_claude_desktop_dispatches_without_panic() {
1849        let dir = tmp();
1850        let targets =
1851            detect_client_targets(ClientKind::ClaudeDesktop, ScopeRequest::Auto, dir.path());
1852        drop(targets);
1853    }
1854
1855    // ── run_claude_mcp_remove ────────────────────────────────────────
1856
1857    // ── remove_hook_entries_from_settings ───────────────────────────
1858
1859    #[test]
1860    fn remove_hook_entries_from_settings_dry_run_no_modification() {
1861        let dir = tmp();
1862        let path = dir.path().join("settings.json");
1863        let original = r#"{"hooks":{"PreToolUse":[{"hooks":[{"command":"/x/seshat-pre-tool"}]}]}}"#;
1864        fs::write(&path, original).unwrap();
1865
1866        let res = remove_hook_entries_from_settings(&path, true).unwrap();
1867        assert!(matches!(res, UninstallResult::DryRun(_)));
1868        // File must be unchanged.
1869        assert_eq!(fs::read_to_string(&path).unwrap(), original);
1870    }
1871
1872    #[test]
1873    fn remove_hook_entries_from_settings_nonexistent_returns_not_exists() {
1874        let dir = tmp();
1875        let res = remove_hook_entries_from_settings(&dir.path().join("nope.json"), false).unwrap();
1876        assert!(matches!(res, UninstallResult::NotExists));
1877    }
1878
1879    #[test]
1880    fn remove_hook_entries_from_settings_strips_seshat_pre_tool_hook() {
1881        let dir = tmp();
1882        let path = dir.path().join("settings.json");
1883        let original = serde_json::json!({
1884            "hooks": {
1885                "PreToolUse": [
1886                    { "hooks": [{ "command": "/usr/local/bin/seshat-pre-tool" }] },
1887                    { "hooks": [{ "command": "/other/tool" }] }
1888                ]
1889            },
1890            "theme": "dark"
1891        });
1892        fs::write(&path, serde_json::to_string_pretty(&original).unwrap()).unwrap();
1893
1894        let res = remove_hook_entries_from_settings(&path, false).unwrap();
1895        assert!(matches!(res, UninstallResult::Removed));
1896
1897        // Result should keep the non-seshat entry and unrelated keys.
1898        let after: serde_json::Value =
1899            serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
1900        let arr = after["hooks"]["PreToolUse"].as_array().unwrap();
1901        assert_eq!(arr.len(), 1);
1902        assert_eq!(arr[0]["hooks"][0]["command"], "/other/tool");
1903        assert_eq!(after["theme"], "dark");
1904    }
1905
1906    #[test]
1907    fn remove_hook_entries_from_settings_drops_empty_pretooluse_array() {
1908        let dir = tmp();
1909        let path = dir.path().join("settings.json");
1910        let original = serde_json::json!({
1911            "hooks": {
1912                "PreToolUse": [
1913                    { "hooks": [{ "command": "/x/seshat-pre-tool" }] }
1914                ]
1915            }
1916        });
1917        fs::write(&path, original.to_string()).unwrap();
1918
1919        let res = remove_hook_entries_from_settings(&path, false).unwrap();
1920        assert!(matches!(res, UninstallResult::Removed));
1921
1922        let after: serde_json::Value =
1923            serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
1924        // The PreToolUse key itself must be removed when its array becomes empty.
1925        assert!(after["hooks"].get("PreToolUse").is_none());
1926    }
1927
1928    #[test]
1929    fn remove_hook_entries_from_settings_strips_session_start_hook() {
1930        let dir = tmp();
1931        let path = dir.path().join("settings.json");
1932        let original = serde_json::json!({
1933            "hooks": {
1934                "SessionStart": [
1935                    { "hooks": [{ "command": "/x/hooks/seshat-session-start" }] },
1936                    { "hooks": [{ "command": "/other/setup" }] }
1937                ]
1938            }
1939        });
1940        fs::write(&path, original.to_string()).unwrap();
1941
1942        let res = remove_hook_entries_from_settings(&path, false).unwrap();
1943        assert!(matches!(res, UninstallResult::Removed));
1944
1945        let after: serde_json::Value =
1946            serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
1947        let arr = after["hooks"]["SessionStart"].as_array().unwrap();
1948        assert_eq!(arr.len(), 1);
1949        assert_eq!(arr[0]["hooks"][0]["command"], "/other/setup");
1950    }
1951
1952    #[test]
1953    fn remove_hook_entries_from_settings_no_match_returns_not_exists() {
1954        let dir = tmp();
1955        let path = dir.path().join("settings.json");
1956        let original = serde_json::json!({
1957            "hooks": {
1958                "PreToolUse": [
1959                    { "hooks": [{ "command": "/other/tool" }] }
1960                ]
1961            }
1962        });
1963        let original_str = original.to_string();
1964        fs::write(&path, &original_str).unwrap();
1965
1966        let res = remove_hook_entries_from_settings(&path, false).unwrap();
1967        assert!(matches!(res, UninstallResult::NotExists));
1968
1969        // Unchanged content (no rewrite).
1970        assert_eq!(fs::read_to_string(&path).unwrap(), original_str);
1971    }
1972
1973    #[test]
1974    fn remove_hook_entries_from_settings_invalid_json_errors() {
1975        let dir = tmp();
1976        let path = dir.path().join("settings.json");
1977        fs::write(&path, "{not valid").unwrap();
1978        let err = remove_hook_entries_from_settings(&path, false).unwrap_err();
1979        assert!(err.to_string().contains("not valid JSON"));
1980    }
1981
1982    #[test]
1983    fn remove_hook_entries_from_settings_non_object_root_errors() {
1984        let dir = tmp();
1985        let path = dir.path().join("settings.json");
1986        fs::write(&path, "[1, 2, 3]").unwrap();
1987        let err = remove_hook_entries_from_settings(&path, false).unwrap_err();
1988        assert!(err.to_string().contains("not a JSON object"));
1989    }
1990
1991    #[test]
1992    fn remove_hook_entries_from_settings_no_hooks_key_returns_not_exists() {
1993        let dir = tmp();
1994        let path = dir.path().join("settings.json");
1995        fs::write(&path, r#"{"theme": "dark"}"#).unwrap();
1996        let res = remove_hook_entries_from_settings(&path, false).unwrap();
1997        assert!(matches!(res, UninstallResult::NotExists));
1998    }
1999
2000    // ── remove_skill_dir ────────────────────────────────────────────
2001
2002    #[test]
2003    fn remove_skill_dir_dry_run_does_not_modify() {
2004        let dir = tmp();
2005        let skill_dir = dir.path().join("skill");
2006        fs::create_dir_all(&skill_dir).unwrap();
2007        fs::write(skill_dir.join("README.md"), "x").unwrap();
2008
2009        let res = remove_skill_dir(&skill_dir, true).unwrap();
2010        assert!(matches!(res, UninstallResult::DryRun(_)));
2011        assert!(skill_dir.exists());
2012    }
2013
2014    #[test]
2015    fn remove_skill_dir_nonexistent_returns_not_exists() {
2016        let dir = tmp();
2017        let res = remove_skill_dir(&dir.path().join("nope"), false).unwrap();
2018        assert!(matches!(res, UninstallResult::NotExists));
2019    }
2020
2021    #[test]
2022    fn remove_skill_dir_existing_dir_is_removed() {
2023        let dir = tmp();
2024        let skill_dir = dir.path().join("skill");
2025        fs::create_dir_all(skill_dir.join("nested")).unwrap();
2026        fs::write(skill_dir.join("README.md"), "x").unwrap();
2027        fs::write(skill_dir.join("nested/file.txt"), "y").unwrap();
2028
2029        let res = remove_skill_dir(&skill_dir, false).unwrap();
2030        assert!(matches!(res, UninstallResult::Removed));
2031        assert!(!skill_dir.exists());
2032    }
2033
2034    // ── remove_instructions ─────────────────────────────────────────
2035
2036    #[test]
2037    fn remove_instructions_no_markers_returns_not_exists() {
2038        let dir = tmp();
2039        let path = dir.path().join("agents.md");
2040        let content = "# my agents file\n\nno seshat block here.\n";
2041        fs::write(&path, content).unwrap();
2042
2043        let res = remove_instructions(&path, false).unwrap();
2044        assert!(matches!(res, UninstallResult::NotExists));
2045        assert_eq!(fs::read_to_string(&path).unwrap(), content);
2046    }
2047
2048    #[test]
2049    fn remove_instructions_missing_file_returns_not_exists() {
2050        let dir = tmp();
2051        let res = remove_instructions(&dir.path().join("nope.md"), false).unwrap();
2052        assert!(matches!(res, UninstallResult::NotExists));
2053    }
2054
2055    #[test]
2056    fn run_claude_mcp_remove_dry_run_returns_command_string() {
2057        let result = run_claude_mcp_remove(true).unwrap();
2058        assert_eq!(result, "claude mcp remove seshat");
2059    }
2060}