1use 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#[derive(Debug, Clone)]
28pub enum UninstallTarget {
29 McpEntry {
31 path: PathBuf,
32 format: ConfigFormat,
33 is_project: bool,
34 client: ClientKind,
35 },
36 Instructions { path: PathBuf },
38 SkillDir { path: PathBuf },
40 HookScript { path: PathBuf },
42 HookEntries { settings_path: PathBuf },
44}
45
46#[derive(Debug, Clone, PartialEq, Eq)]
48pub enum UninstallResult {
49 Removed,
51 NotExists,
53 DryRun(PathBuf),
55 Skipped(String),
57}
58
59#[derive(Debug)]
61pub struct ClientUninstallPlan {
62 pub client: ClientKind,
63 pub targets: Vec<UninstallTarget>,
64}
65
66pub 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 let cwd = std::env::current_dir().unwrap_or_default();
91 let proj_root = crate::db::sync_root_for(&cwd);
92
93 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 #[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 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 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
144fn 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 let claude_md = claude_dir.join("CLAUDE.md");
180 if claude_md.exists() {
181 targets.push(UninstallTarget::Instructions { path: claude_md });
182 }
183
184 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 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 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 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 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 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 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 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 let proj_agents = project_root.join("AGENTS.md");
317 if proj_agents.exists() {
318 targets.push(UninstallTarget::Instructions { path: proj_agents });
319 }
320
321 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 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
466fn 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
476pub 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 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 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 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 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
551pub 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 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 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_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
630pub 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
648pub 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 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 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 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 if hooks_dir.exists() {
682 if dry_run {
683 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
710fn 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 if let Some(hooks) = root.get_mut("hooks") {
751 if hooks.is_object() {
752 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 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
828fn 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 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 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
873fn 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
897fn 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
910fn is_seshat_hook_path(cmd: &str, hook_name: &str) -> bool {
914 if cmd == hook_name {
916 return true;
917 }
918 if cmd.ends_with(&format!("/{hook_name}")) {
919 return true;
920 }
921 if cmd.contains(&format!("/{hook_name}/")) {
923 return true;
924 }
925 if cmd.contains(&format!("hooks/{hook_name}")) {
927 return true;
928 }
929 false
930}
931
932fn clean_double_newlines(s: &str) -> String {
934 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 result.trim_end().to_string()
952}
953
954fn 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 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 if !ask_yn("Remove Seshat configuration?", false) {
1032 print_info("Skipped.");
1033 eprintln!();
1034 return false;
1035 }
1036
1037 for target in &plan.targets {
1039 match target {
1040 UninstallTarget::McpEntry {
1041 path,
1042 format,
1043 client,
1044 is_project,
1045 ..
1046 } => {
1047 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
1180pub fn run_uninstall(
1186 client: Option<&str>,
1187 scope: ScopeRequest,
1188 dry_run: bool,
1189) -> Result<(), CliError> {
1190 let color = color_enabled();
1191
1192 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 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 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 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 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#[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 #[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 assert!(
1348 !new_content.contains("\n\n\n"),
1349 "should not have triple newlines, got: {:?}",
1350 new_content
1351 );
1352 }
1353
1354 #[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 #[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 #[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 #[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 #[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 #[test]
1616 fn detect_client_targets_opencode_returns_targets() {
1617 let dir = tmp();
1618 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 #[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 #[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 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 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 #[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 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 #[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 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 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 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 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 #[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 #[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}