1use std::fs;
35use std::io::{self, Write};
36use std::path::{Path, PathBuf};
37
38use owo_colors::OwoColorize;
39
40use crate::db::sync_root_for;
41use crate::error::CliError;
42use crate::format::{color_enabled, format_copy_block, format_section_header};
43
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub enum ClientKind {
51 ClaudeCode,
52 ClaudeDesktop,
53 OpenCode,
54 Cursor,
55}
56
57impl ClientKind {
58 pub fn display_name(self) -> &'static str {
60 match self {
61 Self::ClaudeCode => "Claude Code",
62 Self::ClaudeDesktop => "Claude Desktop",
63 Self::OpenCode => "OpenCode",
64 Self::Cursor => "Cursor",
65 }
66 }
67
68 pub fn cli_name(self) -> &'static str {
70 match self {
71 Self::ClaudeCode => "claude-code",
72 Self::ClaudeDesktop => "claude-desktop",
73 Self::OpenCode => "opencode",
74 Self::Cursor => "cursor",
75 }
76 }
77
78 pub fn from_cli_name(s: &str) -> Option<Self> {
80 match s {
81 "claude-code" | "claude" => Some(Self::ClaudeCode),
82 "claude-desktop" => Some(Self::ClaudeDesktop),
83 "opencode" => Some(Self::OpenCode),
84 "cursor" => Some(Self::Cursor),
85 _ => None,
86 }
87 }
88
89 pub fn mcp_key(self) -> &'static str {
91 match self {
92 Self::OpenCode => "mcp",
93 _ => "mcpServers",
94 }
95 }
96
97 pub fn seshat_entry_json(self) -> serde_json::Value {
99 match self {
100 Self::OpenCode => serde_json::json!({
101 "type": "local",
102 "command": ["seshat", "serve"],
103 "enabled": true
104 }),
105 _ => serde_json::json!({
106 "command": "seshat",
107 "args": ["serve"]
108 }),
109 }
110 }
111
112 pub fn snippet_lines(self) -> Vec<String> {
117 let entry = self.seshat_entry_json();
118 let formatted = serde_json::to_string_pretty(&entry).unwrap_or_else(|_| "{}".to_string());
119 let first = formatted
121 .split_once('\n')
122 .map(|(head, _)| head)
123 .unwrap_or(&formatted);
124 let mut lines = vec![format!("\"seshat\": {first}")];
125 if let Some((_, rest)) = formatted.split_once('\n') {
127 for line in rest.lines() {
128 lines.push(line.to_string());
129 }
130 }
131 lines
132 }
133
134 pub fn full_file_lines(self) -> Vec<String> {
136 let root = serde_json::json!({
137 self.mcp_key(): {
138 "seshat": self.seshat_entry_json()
139 }
140 });
141 let formatted = serde_json::to_string_pretty(&root).unwrap_or_else(|_| "{}".to_string());
142 formatted.lines().map(|l| l.to_string()).collect()
143 }
144}
145
146#[derive(Debug, Clone, Copy, PartialEq, Eq)]
148pub enum ConfigFormat {
149 Json,
151 Jsonc,
153}
154
155#[derive(Debug, Clone, Copy, PartialEq, Eq)]
157pub enum ScopeRequest {
158 Auto,
160 Project,
162 Global,
164}
165
166#[derive(Debug)]
168pub struct ConfigTarget {
169 pub client: ClientKind,
170 pub path: PathBuf,
171 pub format: ConfigFormat,
172 pub exists: bool,
173 pub is_project: bool,
175}
176
177pub fn detect_clients(scope: ScopeRequest, project_root: &Path) -> Vec<ConfigTarget> {
190 let mut targets = Vec::new();
191
192 #[cfg(target_os = "macos")]
195 if let Some(t) = resolve_claude_desktop_config() {
196 targets.push(t);
197 }
198
199 if which::which("opencode").is_ok() {
200 if let Some(t) = resolve_opencode_config(scope, project_root) {
201 targets.push(t);
202 }
203 }
204
205 if which::which("cursor").is_ok() {
206 if let Some(t) = resolve_cursor_config(scope, project_root) {
207 targets.push(t);
208 }
209 }
210
211 targets
212}
213
214pub fn resolve_single_client(
218 client: ClientKind,
219 scope: ScopeRequest,
220 project_root: &Path,
221) -> Option<ConfigTarget> {
222 match client {
223 ClientKind::ClaudeCode => None, ClientKind::ClaudeDesktop => {
225 #[cfg(target_os = "macos")]
226 {
227 resolve_claude_desktop_config()
228 }
229 #[cfg(not(target_os = "macos"))]
230 {
231 None
232 }
233 }
234 ClientKind::OpenCode => resolve_opencode_config(scope, project_root),
235 ClientKind::Cursor => resolve_cursor_config(scope, project_root),
236 }
237}
238
239#[cfg(target_os = "macos")]
240fn resolve_claude_desktop_config() -> Option<ConfigTarget> {
241 let home = dirs::home_dir()?;
243 let app_dir = home
244 .join("Library")
245 .join("Application Support")
246 .join("Claude");
247 if !app_dir.is_dir() {
248 return None;
249 }
250 let path = app_dir.join("claude_desktop_config.json");
251 Some(make_target(ClientKind::ClaudeDesktop, path, false))
252}
253
254fn opencode_global_config_dir() -> Option<PathBuf> {
261 if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") {
263 if !xdg.is_empty() {
264 return Some(PathBuf::from(xdg).join("opencode"));
265 }
266 }
267 Some(dirs::home_dir()?.join(".config").join("opencode"))
269}
270
271fn resolve_opencode_config(scope: ScopeRequest, project_root: &Path) -> Option<ConfigTarget> {
272 match scope {
273 ScopeRequest::Global => {
274 let dir = opencode_global_config_dir()?;
275 Some(find_opencode_config_in_dir(&dir, false))
276 }
277 ScopeRequest::Project => Some(find_opencode_config_in_dir(project_root, true)),
278 ScopeRequest::Auto => {
279 let proj_target = find_opencode_config_in_dir(project_root, true);
281 if proj_target.exists {
282 Some(proj_target)
283 } else {
284 let dir = opencode_global_config_dir()?;
285 Some(find_opencode_config_in_dir(&dir, false))
286 }
287 }
288 }
289}
290
291fn resolve_cursor_config(scope: ScopeRequest, project_root: &Path) -> Option<ConfigTarget> {
292 match scope {
293 ScopeRequest::Global => {
294 let path = dirs::home_dir()?.join(".cursor").join("mcp.json");
295 Some(make_target(ClientKind::Cursor, path, false))
296 }
297 ScopeRequest::Project => {
298 let path = project_root.join(".cursor").join("mcp.json");
299 Some(make_target(ClientKind::Cursor, path, true))
300 }
301 ScopeRequest::Auto => {
302 let project_path = project_root.join(".cursor").join("mcp.json");
303 if project_path.exists() {
304 Some(make_target(ClientKind::Cursor, project_path, true))
305 } else {
306 let global_path = dirs::home_dir()?.join(".cursor").join("mcp.json");
307 Some(make_target(ClientKind::Cursor, global_path, false))
308 }
309 }
310 }
311}
312
313pub fn find_opencode_config_in_dir(dir: &Path, is_project: bool) -> ConfigTarget {
318 let jsonc_path = dir.join("opencode.jsonc");
319 let json_path = dir.join("opencode.json");
320
321 if jsonc_path.exists() {
322 ConfigTarget {
323 client: ClientKind::OpenCode,
324 path: jsonc_path,
325 format: ConfigFormat::Jsonc,
326 exists: true,
327 is_project,
328 }
329 } else if json_path.exists() {
330 let format = if is_valid_json(&json_path) {
332 ConfigFormat::Json
333 } else {
334 ConfigFormat::Jsonc
335 };
336 ConfigTarget {
337 client: ClientKind::OpenCode,
338 path: json_path,
339 format,
340 exists: true,
341 is_project,
342 }
343 } else {
344 ConfigTarget {
346 client: ClientKind::OpenCode,
347 path: json_path,
348 format: ConfigFormat::Json,
349 exists: false,
350 is_project,
351 }
352 }
353}
354
355fn make_target(client: ClientKind, path: PathBuf, is_project: bool) -> ConfigTarget {
357 ConfigTarget {
358 exists: path.exists(),
359 client,
360 path,
361 format: ConfigFormat::Json,
362 is_project,
363 }
364}
365
366fn is_valid_json(path: &Path) -> bool {
368 fs::read_to_string(path)
369 .ok()
370 .and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())
371 .is_some()
372}
373
374pub fn is_already_configured(target: &ConfigTarget) -> bool {
384 if !target.exists {
385 return false;
386 }
387 let content = match fs::read_to_string(&target.path) {
388 Ok(s) => s,
389 Err(_) => return false,
390 };
391 match target.format {
392 ConfigFormat::Json => {
393 let value: serde_json::Value = match serde_json::from_str(&content) {
394 Ok(v) => v,
395 Err(_) => return false,
396 };
397 value
398 .get(target.client.mcp_key())
399 .and_then(|s| s.get("seshat"))
400 .is_some()
401 }
402 ConfigFormat::Jsonc => content.contains("\"seshat\":"),
405 }
406}
407
408pub fn write_backup(path: &Path) -> Result<PathBuf, CliError> {
418 let ts = std::time::SystemTime::now()
419 .duration_since(std::time::UNIX_EPOCH)
420 .map(|d| d.as_millis())
421 .unwrap_or(0);
422 let filename = path.file_name().unwrap_or_default().to_string_lossy();
423 let backup_name = format!("{filename}.seshat-backup.{ts}");
424 let backup_path = path.with_file_name(backup_name);
425 fs::copy(path, &backup_path).map_err(|e| CliError::IoWithPath {
426 message: format!("failed to write backup: {e}"),
427 path: backup_path.clone(),
428 })?;
429 Ok(backup_path)
430}
431
432pub fn merge_seshat_entry(
438 value: &mut serde_json::Value,
439 client: ClientKind,
440) -> Result<(), CliError> {
441 if !value.is_object() {
442 return Err(CliError::InvalidArgument(format!(
443 "config file root is not a JSON object (got {})",
444 json_type_name(value)
445 )));
446 }
447 let mcp_key = client.mcp_key();
448 if value.get(mcp_key).is_none() {
449 value[mcp_key] = serde_json::json!({});
450 }
451 value[mcp_key]["seshat"] = client.seshat_entry_json();
452 Ok(())
453}
454
455fn json_type_name(v: &serde_json::Value) -> &'static str {
456 match v {
457 serde_json::Value::Null => "null",
458 serde_json::Value::Bool(_) => "bool",
459 serde_json::Value::Number(_) => "number",
460 serde_json::Value::String(_) => "string",
461 serde_json::Value::Array(_) => "array",
462 serde_json::Value::Object(_) => "object",
463 }
464}
465
466#[derive(Debug)]
468pub struct PatchResult {
469 pub backup_path: Option<PathBuf>,
471}
472
473pub fn patch_json_config(target: &ConfigTarget) -> Result<PatchResult, CliError> {
478 let backup_path = if target.exists {
480 Some(write_backup(&target.path)?)
481 } else {
482 if let Some(parent) = target.path.parent() {
484 fs::create_dir_all(parent).map_err(|e| CliError::IoWithPath {
485 message: format!("failed to create directory: {e}"),
486 path: parent.to_path_buf(),
487 })?;
488 }
489 None
490 };
491
492 let content = if target.exists {
494 fs::read_to_string(&target.path).map_err(|e| CliError::IoWithPath {
495 message: format!("failed to read config: {e}"),
496 path: target.path.clone(),
497 })?
498 } else {
499 "{}".to_string()
500 };
501
502 let mut value: serde_json::Value = serde_json::from_str(&content).map_err(|e| {
503 CliError::InvalidArgument(format!(
504 "config file contains invalid JSON at {}: {e}",
505 target.path.display()
506 ))
507 })?;
508
509 merge_seshat_entry(&mut value, target.client)?;
510
511 let updated = serde_json::to_string_pretty(&value)
512 .map_err(|e| CliError::InvalidArgument(format!("failed to serialize config: {e}")))?;
513
514 fs::write(&target.path, updated.as_bytes()).map_err(|e| CliError::IoWithPath {
515 message: format!("failed to write config: {e}"),
516 path: target.path.clone(),
517 })?;
518
519 Ok(PatchResult { backup_path })
520}
521
522fn print_ok(message: &str, color: bool) {
527 if color {
528 eprintln!(" {} {message}", "✓".green().bold());
529 } else {
530 eprintln!(" ✓ {message}");
531 }
532}
533
534fn print_info(message: &str) {
535 eprintln!(" {message}");
536}
537
538fn print_error(message: &str, color: bool) {
539 if color {
540 eprintln!(" {} {message}", "error:".red().bold());
541 } else {
542 eprintln!(" error: {message}");
543 }
544}
545
546fn ask_yn(prompt: &str, dry_run: bool) -> bool {
550 if dry_run {
551 eprintln!(" {prompt} [dry-run — no changes]");
552 return false;
553 }
554 eprint!(" {prompt} [y/N] ");
555 io::stderr().flush().ok();
556 let mut input = String::new();
557 io::stdin().read_line(&mut input).ok();
558 matches!(input.trim(), "y" | "Y")
559}
560
561fn claude_mcp_list_has_seshat() -> Option<bool> {
570 let output = std::process::Command::new("claude")
571 .args(["mcp", "list"])
572 .output()
573 .ok()?;
574 let stdout = String::from_utf8_lossy(&output.stdout);
575 let stderr = String::from_utf8_lossy(&output.stderr);
576 let combined = format!("{stdout}{stderr}");
577 Some(combined.contains("seshat"))
578}
579
580fn claude_scope_arg(scope: ScopeRequest) -> &'static str {
592 match scope {
593 ScopeRequest::Project => "local", ScopeRequest::Global | ScopeRequest::Auto => "user", }
596}
597
598fn run_claude_mcp_add(scope: ScopeRequest, dry_run: bool) -> Result<String, CliError> {
606 let scope_arg = claude_scope_arg(scope);
607 let cmd_display = format!("claude mcp add -s {scope_arg} seshat seshat serve");
608
609 if dry_run {
610 return Ok(cmd_display);
611 }
612
613 let status = std::process::Command::new("claude")
614 .args(["mcp", "add", "-s", scope_arg, "seshat", "seshat", "serve"])
615 .status()
616 .map_err(|e| CliError::CommandFailed {
617 command: "claude mcp add".to_owned(),
618 reason: format!("failed to run: {e}"),
619 })?;
620
621 if !status.success() {
622 return Err(CliError::CommandFailed {
623 command: "claude mcp add".to_owned(),
624 reason: format!("exited with status {status}"),
625 });
626 }
627
628 Ok(cmd_display)
629}
630
631fn handle_claude_code_via_cli(scope: ScopeRequest, dry_run: bool, color: bool) -> bool {
635 eprintln!("{}", format_section_header("Claude Code", color));
636 eprintln!();
637
638 let scope_arg = claude_scope_arg(scope);
639 let scope_label = match scope_arg {
640 "local" => "project-local (~/.claude.json, bound to this path)",
641 _ => "user-global (~/.claude.json, all projects)",
642 };
643
644 match claude_mcp_list_has_seshat() {
646 Some(true) => {
647 print_info(&format!("Scope: {scope_label}"));
648 print_ok(
649 "Already configured (detected via `claude mcp list`).",
650 color,
651 );
652 eprintln!();
653 return false;
654 }
655 Some(false) => {} None => {
657 }
659 }
660
661 print_info(&format!("Scope: {scope_label}"));
662 print_info("Will run:");
663 eprintln!();
664
665 let cmd_str = format!("claude mcp add -s {scope_arg} seshat seshat serve");
666 let refs: Vec<&str> = vec![cmd_str.as_str()];
667 eprint!("{}", format_copy_block(&refs, color));
668 eprintln!();
669
670 if ask_yn("Run command?", dry_run) {
671 match run_claude_mcp_add(scope, dry_run) {
672 Ok(_) => {
673 print_ok("Seshat added to Claude Code.", color);
674 }
675 Err(e) => {
676 print_error(&e.to_string(), color);
677 eprintln!();
678 return true;
679 }
680 }
681 } else if !dry_run {
682 print_info("Skipped. Run the command above manually.");
683 }
684
685 eprintln!();
686 false
687}
688
689fn handle_target(target: &ConfigTarget, dry_run: bool, color: bool) -> bool {
698 let mut had_error = false;
699
700 eprintln!(
701 "{}",
702 format_section_header(target.client.display_name(), color)
703 );
704 eprintln!();
705
706 let path_display = target.path.display().to_string();
707 let scope_label = if target.is_project {
708 "project"
709 } else {
710 "global"
711 };
712
713 if is_already_configured(target) {
715 print_info(&format!("Config ({scope_label}): {path_display}"));
716 if target.format == ConfigFormat::Jsonc {
717 print_ok(
718 "Already configured (detected in JSONC — verify manually).",
719 color,
720 );
721 } else {
722 print_ok("Already configured.", color);
723 }
724 eprintln!();
725 return false;
726 }
727
728 if target.format == ConfigFormat::Jsonc {
730 print_info(&format!("Config ({scope_label}): {path_display}"));
731 print_info("Format: JSONC (contains comments — auto-patch not supported)");
732 eprintln!();
733 print_info(&format!(
734 "Add to \"{}\" section manually:",
735 target.client.mcp_key()
736 ));
737 eprintln!();
738 let owned = target.client.snippet_lines();
739 let refs: Vec<&str> = owned.iter().map(|s| s.as_str()).collect();
740 eprint!("{}", format_copy_block(&refs, color));
741 print_info("Note: add a comma after the preceding entry if \"seshat\" is not the first.");
742 eprintln!();
743 return false;
744 }
745
746 if target.exists {
748 print_info(&format!("Config ({scope_label}): {path_display}"));
749 print_info(&format!(
750 "Seshat is not configured. Add to \"{}\":",
751 target.client.mcp_key()
752 ));
753 } else {
754 print_info(&format!("Config not found ({scope_label}): {path_display}"));
755 print_info("Will create new file with:");
756 }
757 eprintln!();
758
759 let owned = if target.exists {
760 target.client.snippet_lines()
761 } else {
762 target.client.full_file_lines()
763 };
764 let refs: Vec<&str> = owned.iter().map(|s| s.as_str()).collect();
765 eprint!("{}", format_copy_block(&refs, color));
766
767 if target.exists {
768 print_info("Note: add a comma after the preceding entry if \"seshat\" is not the first.");
769 }
770 eprintln!();
771
772 let prompt = if target.exists {
773 "Auto-add?"
774 } else {
775 "Create file?"
776 };
777
778 if ask_yn(prompt, dry_run) {
779 match patch_json_config(target) {
780 Ok(result) => {
781 if let Some(backup) = result.backup_path {
782 print_ok(&format!("Backup saved: {}", backup.display()), color);
783 }
784 print_ok(&format!("Updated {path_display}"), color);
785 }
786 Err(e) => {
787 print_error(&e.to_string(), color);
788 had_error = true;
789 }
790 }
791 } else if !dry_run {
792 print_info("Skipped. Add the snippet above manually.");
793 }
794
795 eprintln!();
796 had_error
797}
798
799pub fn run_init(
808 client: Option<&str>,
809 scope: ScopeRequest,
810 dry_run: bool,
811 skip_instructions: bool,
812) -> Result<(), CliError> {
813 let color = color_enabled();
814
815 let cwd = std::env::current_dir().map_err(|e| CliError::IoWithPath {
817 message: format!("cannot determine current directory: {e}"),
818 path: PathBuf::from("."),
819 })?;
820 let project_root = sync_root_for(&cwd);
821
822 match scope {
824 ScopeRequest::Auto => {}
825 ScopeRequest::Project => {
826 if color {
827 eprintln!(
828 " {} project ({})\n",
829 "Scope:".dimmed(),
830 project_root.display()
831 );
832 } else {
833 eprintln!(" Scope: project ({})\n", project_root.display());
834 }
835 }
836 ScopeRequest::Global => {
837 if color {
838 eprintln!(" {} global\n", "Scope:".dimmed());
839 } else {
840 eprintln!(" Scope: global\n");
841 }
842 }
843 }
844
845 if dry_run {
846 if color {
847 eprintln!(
848 " {} no files will be written\n",
849 "Dry run:".yellow().bold()
850 );
851 } else {
852 eprintln!(" Dry run: no files will be written\n");
853 }
854 }
855
856 let mut any_error = false;
857
858 if let Some(name) = client {
860 let kind = ClientKind::from_cli_name(name).ok_or_else(|| {
861 CliError::InvalidArgument(format!(
862 "Unknown client: {name}\n\nhint: Supported clients: claude-code, claude-desktop, opencode, cursor\nhint: Run `seshat init --help` for usage."
863 ))
864 })?;
865
866 if kind == ClientKind::ClaudeCode {
868 if handle_claude_code_via_cli(scope, dry_run, color) {
869 any_error = true;
870 } else if !skip_instructions {
871 write_instructions_for_client(ClientKind::ClaudeCode, dry_run, color);
872 }
873 } else {
874 let target = resolve_single_client(kind, scope, &project_root).ok_or_else(|| {
875 CliError::InvalidArgument(format!(
876 "{} is not available on this platform",
877 kind.display_name(),
878 ))
879 })?;
880 if handle_target(&target, dry_run, color) {
881 any_error = true;
882 } else if !skip_instructions {
883 write_instructions_for_client(kind, dry_run, color);
884 }
885 }
886 } else {
887 let claude_code_present = which::which("claude").is_ok();
889 let targets = detect_clients(scope, &project_root);
890
891 let other_targets: Vec<&ConfigTarget> = targets
893 .iter()
894 .filter(|t| t.client != ClientKind::ClaudeCode)
895 .collect();
896
897 if !claude_code_present && other_targets.is_empty() {
898 eprintln!(" No AI coding clients detected in PATH.");
899 eprintln!();
900 eprintln!(" Supported clients: claude-code, claude-desktop, opencode, cursor");
901 eprintln!(" Run `seshat init <client>` to generate config for a specific client.");
902 return Ok(());
903 }
904
905 eprintln!(" Detected AI coding clients:");
907 eprintln!();
908 if claude_code_present {
909 let scope_hint = match scope {
910 ScopeRequest::Project => " (project → .mcp.json)",
911 _ => " (global → ~/.claude.json)",
912 };
913 if color {
914 eprintln!(
915 " {} claude — Claude Code{}",
916 "✓".green().bold(),
917 scope_hint.dimmed(),
918 );
919 } else {
920 eprintln!(" ✓ claude — Claude Code{scope_hint}");
921 }
922 }
923 for t in &other_targets {
924 let scope_hint = if t.is_project {
925 " (project)"
926 } else {
927 " (global)"
928 };
929 if color {
930 eprintln!(
931 " {} {} — {}{}",
932 "✓".green().bold(),
933 t.client.cli_name(),
934 t.client.display_name(),
935 scope_hint.dimmed(),
936 );
937 } else {
938 eprintln!(
939 " ✓ {} — {}{}",
940 t.client.cli_name(),
941 t.client.display_name(),
942 scope_hint,
943 );
944 }
945 }
946 eprintln!();
947
948 if claude_code_present {
950 let mcp_error = handle_claude_code_via_cli(scope, dry_run, color);
951 if mcp_error {
952 any_error = true;
953 } else if !skip_instructions {
954 write_instructions_for_client(ClientKind::ClaudeCode, dry_run, color);
955 }
956 }
957
958 for target in &other_targets {
960 let mcp_error = handle_target(target, dry_run, color);
961 if mcp_error {
962 any_error = true;
963 } else if !skip_instructions {
964 write_instructions_for_client(target.client, dry_run, color);
965 }
966 }
967 }
968
969 if any_error {
970 Err(CliError::CommandFailed {
971 command: "init".to_owned(),
972 reason: "one or more configs could not be updated".to_owned(),
973 })
974 } else {
975 Ok(())
976 }
977}
978
979fn write_instructions_for_client(client: ClientKind, dry_run: bool, color: bool) {
984 use crate::instructions::{
985 AGENTS_MD_CONTENT, HooksResult, SKILL_MD_CONTENT, SkillResult, claude_home,
986 install_hooks_claude_code, install_skill, opencode_config_dir, upsert_instructions,
987 };
988
989 match client {
990 ClientKind::ClaudeCode => {
991 let Some(claude_home) = claude_home() else {
992 print_error(
993 "Could not determine home directory; skipping instructions for Claude Code.",
994 color,
995 );
996 return;
997 };
998
999 let claude_md = claude_home.join("CLAUDE.md");
1001 match upsert_instructions(&claude_md, AGENTS_MD_CONTENT, dry_run) {
1002 Ok(result) => {
1003 let msg = if dry_run {
1004 format!("Instructions would be written to {}", claude_md.display())
1005 } else {
1006 format!(
1007 "Instructions {} in {}",
1008 result.description(),
1009 claude_md.display()
1010 )
1011 };
1012 print_ok(&msg, color);
1013 }
1014 Err(e) => print_error(&format!("Failed to write instructions: {e}"), color),
1015 }
1016
1017 let skill_dir = claude_home.join("skills").join("seshat");
1019 let skill_path = skill_dir.join("SKILL.md");
1020 match install_skill(&skill_dir, SKILL_MD_CONTENT, dry_run) {
1021 Ok(SkillResult::Installed) => {
1022 print_ok(&format!("Skill installed: {}", skill_path.display()), color);
1023 }
1024 Ok(SkillResult::DryRun(Some(ref p))) => {
1025 print_ok(&format!("Skill would be installed: {}", p.display()), color);
1026 }
1027 Ok(SkillResult::DryRun(None)) => {
1028 print_ok("Skill dry-run (no changes written)", color);
1029 }
1030 Err(e) => print_error(&format!("Failed to install skill: {e}"), color),
1031 }
1032
1033 let hooks_dir = claude_home.join("hooks");
1035 let settings_path = claude_home.join("settings.json");
1036 match install_hooks_claude_code(&hooks_dir, &settings_path, dry_run) {
1037 Ok(HooksResult::Installed(Some(backup))) => print_ok(
1038 &format!("Hooks registered (backup: {})", backup.display()),
1039 color,
1040 ),
1041 Ok(HooksResult::Installed(None)) => {
1042 print_ok("Hooks registered in ~/.claude/settings.json", color)
1043 }
1044 Ok(HooksResult::DryRun { settings, .. }) => print_ok(
1045 &format!("Hooks would be registered in {}", settings.display()),
1046 color,
1047 ),
1048 Err(e) => print_error(&format!("Failed to install hooks: {e}"), color),
1049 }
1050 }
1051
1052 ClientKind::OpenCode => {
1053 let Some(opencode_dir) = opencode_config_dir() else {
1054 print_error(
1055 "Could not determine config directory; skipping instructions for OpenCode.",
1056 color,
1057 );
1058 return;
1059 };
1060
1061 let agents_md = opencode_dir.join("AGENTS.md");
1063 match upsert_instructions(&agents_md, AGENTS_MD_CONTENT, dry_run) {
1064 Ok(result) => print_ok(
1065 &format!(
1066 "Instructions {} in {}",
1067 result.description(),
1068 agents_md.display()
1069 ),
1070 color,
1071 ),
1072 Err(e) => print_error(&format!("Failed to write instructions: {e}"), color),
1073 }
1074
1075 let skill_dir = opencode_dir.join("skills").join("seshat");
1077 match install_skill(&skill_dir, SKILL_MD_CONTENT, dry_run) {
1078 Ok(_) => print_ok(
1079 &format!("Skill installed: {}", skill_dir.join("SKILL.md").display()),
1080 color,
1081 ),
1082 Err(e) => print_error(&format!("Failed to install skill: {e}"), color),
1083 }
1084 }
1085
1086 ClientKind::ClaudeDesktop | ClientKind::Cursor => {}
1088 }
1089}
1090
1091#[cfg(test)]
1096mod tests {
1097 use super::*;
1098 use std::fs;
1099 use tempfile::tempdir;
1100
1101 #[test]
1104 fn client_from_cli_name_known() {
1105 assert_eq!(
1106 ClientKind::from_cli_name("claude-code"),
1107 Some(ClientKind::ClaudeCode)
1108 );
1109 assert_eq!(
1110 ClientKind::from_cli_name("claude"),
1111 Some(ClientKind::ClaudeCode)
1112 );
1113 assert_eq!(
1114 ClientKind::from_cli_name("opencode"),
1115 Some(ClientKind::OpenCode)
1116 );
1117 assert_eq!(
1118 ClientKind::from_cli_name("cursor"),
1119 Some(ClientKind::Cursor)
1120 );
1121 assert_eq!(
1122 ClientKind::from_cli_name("claude-desktop"),
1123 Some(ClientKind::ClaudeDesktop)
1124 );
1125 }
1126
1127 #[test]
1128 fn client_from_cli_name_unknown() {
1129 assert!(ClientKind::from_cli_name("vscode").is_none());
1130 assert!(ClientKind::from_cli_name("").is_none());
1131 }
1132
1133 #[test]
1134 fn client_mcp_key_opencode_uses_mcp() {
1135 assert_eq!(ClientKind::OpenCode.mcp_key(), "mcp");
1136 }
1137
1138 #[test]
1139 fn client_mcp_key_others_use_mcp_servers() {
1140 assert_eq!(ClientKind::ClaudeCode.mcp_key(), "mcpServers");
1141 assert_eq!(ClientKind::ClaudeDesktop.mcp_key(), "mcpServers");
1142 assert_eq!(ClientKind::Cursor.mcp_key(), "mcpServers");
1143 }
1144
1145 #[test]
1146 fn snippet_lines_claude_code_structure() {
1147 let lines = ClientKind::ClaudeCode.snippet_lines();
1148 let joined = lines.join("\n");
1149 assert!(joined.contains("\"seshat\":"));
1150 assert!(joined.contains("\"command\""));
1151 assert!(joined.contains("\"args\""));
1152 assert!(joined.contains("\"serve\""));
1153 }
1154
1155 #[test]
1156 fn snippet_lines_opencode_contains_type_and_enabled() {
1157 let lines = ClientKind::OpenCode.snippet_lines();
1158 let joined = lines.join("\n");
1159 assert!(joined.contains("\"type\""));
1160 assert!(joined.contains("\"local\""));
1161 assert!(joined.contains("\"enabled\""));
1162 }
1163
1164 #[test]
1165 fn full_file_lines_valid_json() {
1166 let lines = ClientKind::ClaudeCode.full_file_lines();
1167 let joined = lines.join("\n");
1168 let _: serde_json::Value = serde_json::from_str(&joined).expect("full file is valid JSON");
1169 }
1170
1171 #[test]
1174 fn opencode_global_config_dir_respects_xdg_config_home() {
1175 let result = opencode_global_config_dir();
1179 assert!(result.is_some());
1180 let dir = result.unwrap();
1181 assert_eq!(dir.file_name().unwrap(), "opencode");
1182 }
1183
1184 #[test]
1185 fn opencode_global_config_dir_does_not_use_macos_library() {
1186 let result = opencode_global_config_dir();
1189 if let Some(dir) = result {
1190 let path_str = dir.to_string_lossy();
1191 assert!(
1192 !path_str.contains("Library/Application Support"),
1193 "OpenCode config path must not use macOS Library dir, got: {path_str}"
1194 );
1195 }
1196 }
1197
1198 #[test]
1201 fn detect_opencode_config_prefers_jsonc() {
1202 let dir = tempdir().unwrap();
1203 let json_path = dir.path().join("opencode.json");
1204 let jsonc_path = dir.path().join("opencode.jsonc");
1205 fs::write(&json_path, r#"{"mcp": {}}"#).unwrap();
1206 fs::write(&jsonc_path, "// comment\n{\"mcp\": {}}").unwrap();
1207
1208 let target = find_opencode_config_in_dir(dir.path(), false);
1209 assert_eq!(target.path, jsonc_path);
1210 assert_eq!(target.format, ConfigFormat::Jsonc);
1211 }
1212
1213 #[test]
1214 fn detect_opencode_config_json_when_no_jsonc() {
1215 let dir = tempdir().unwrap();
1216 let json_path = dir.path().join("opencode.json");
1217 fs::write(&json_path, r#"{"mcp": {}}"#).unwrap();
1218
1219 let target = find_opencode_config_in_dir(dir.path(), false);
1220 assert_eq!(target.path, json_path);
1221 assert_eq!(target.format, ConfigFormat::Json);
1222 }
1223
1224 #[test]
1225 fn detect_opencode_config_misnamed_json_with_comments_is_jsonc() {
1226 let dir = tempdir().unwrap();
1227 let json_path = dir.path().join("opencode.json");
1228 fs::write(&json_path, "// comment\n{\"mcp\": {}}").unwrap();
1229
1230 let target = find_opencode_config_in_dir(dir.path(), false);
1231 assert_eq!(target.format, ConfigFormat::Jsonc);
1232 }
1233
1234 #[test]
1235 fn detect_opencode_config_not_found_defaults_to_json() {
1236 let dir = tempdir().unwrap();
1237 let target = find_opencode_config_in_dir(dir.path(), false);
1238 assert!(!target.exists);
1239 assert_eq!(target.format, ConfigFormat::Json);
1240 assert_eq!(target.path.file_name().unwrap(), "opencode.json");
1241 }
1242
1243 #[test]
1246 fn already_configured_json_true() {
1247 let dir = tempdir().unwrap();
1248 let path = dir.path().join("settings.json");
1249 fs::write(
1250 &path,
1251 r#"{"mcpServers": {"seshat": {"command": "seshat"}}}"#,
1252 )
1253 .unwrap();
1254 let target = ConfigTarget {
1255 client: ClientKind::ClaudeCode,
1256 path,
1257 format: ConfigFormat::Json,
1258 exists: true,
1259 is_project: false,
1260 };
1261 assert!(is_already_configured(&target));
1262 }
1263
1264 #[test]
1265 fn already_configured_json_false() {
1266 let dir = tempdir().unwrap();
1267 let path = dir.path().join("settings.json");
1268 fs::write(&path, r#"{"mcpServers": {"other": {}}}"#).unwrap();
1269 let target = ConfigTarget {
1270 client: ClientKind::ClaudeCode,
1271 path,
1272 format: ConfigFormat::Json,
1273 exists: true,
1274 is_project: false,
1275 };
1276 assert!(!is_already_configured(&target));
1277 }
1278
1279 #[test]
1280 fn already_configured_jsonc_text_search_true() {
1281 let dir = tempdir().unwrap();
1282 let path = dir.path().join("opencode.jsonc");
1283 fs::write(
1284 &path,
1285 "// comment\n{\"mcp\": {\"seshat\": {\"type\": \"local\"}}}",
1286 )
1287 .unwrap();
1288 let target = ConfigTarget {
1289 client: ClientKind::OpenCode,
1290 path,
1291 format: ConfigFormat::Jsonc,
1292 exists: true,
1293 is_project: false,
1294 };
1295 assert!(is_already_configured(&target));
1296 }
1297
1298 #[test]
1299 fn already_configured_jsonc_no_false_positive_on_seshat_tools() {
1300 let dir = tempdir().unwrap();
1301 let path = dir.path().join("opencode.jsonc");
1302 fs::write(
1304 &path,
1305 "// comment\n{\"mcp\": {\"seshat-tools\": {\"type\": \"local\"}}}",
1306 )
1307 .unwrap();
1308 let target = ConfigTarget {
1309 client: ClientKind::OpenCode,
1310 path,
1311 format: ConfigFormat::Jsonc,
1312 exists: true,
1313 is_project: false,
1314 };
1315 assert!(!is_already_configured(&target));
1316 }
1317
1318 #[test]
1319 fn already_configured_not_exists() {
1320 let target = ConfigTarget {
1321 client: ClientKind::ClaudeCode,
1322 path: PathBuf::from("/nonexistent/settings.json"),
1323 format: ConfigFormat::Json,
1324 exists: false,
1325 is_project: false,
1326 };
1327 assert!(!is_already_configured(&target));
1328 }
1329
1330 #[test]
1333 fn merge_mcp_servers_entry_adds_seshat() {
1334 let mut value = serde_json::json!({"mcpServers": {"other": {}}});
1335 merge_seshat_entry(&mut value, ClientKind::ClaudeCode).unwrap();
1336 assert!(value["mcpServers"]["seshat"].is_object());
1337 assert_eq!(value["mcpServers"]["seshat"]["command"], "seshat");
1338 assert!(value["mcpServers"]["other"].is_object());
1339 }
1340
1341 #[test]
1342 fn merge_mcp_servers_creates_key_if_missing() {
1343 let mut value = serde_json::json!({"model": "gpt-4"});
1344 merge_seshat_entry(&mut value, ClientKind::ClaudeCode).unwrap();
1345 assert!(value["mcpServers"]["seshat"].is_object());
1346 assert_eq!(value["model"], "gpt-4");
1347 }
1348
1349 #[test]
1350 fn merge_mcp_entry_opencode_uses_mcp_key() {
1351 let mut value = serde_json::json!({});
1352 merge_seshat_entry(&mut value, ClientKind::OpenCode).unwrap();
1353 assert!(value["mcp"]["seshat"].is_object());
1354 assert_eq!(value["mcp"]["seshat"]["type"], "local");
1355 assert!(value["mcp"]["seshat"]["enabled"].as_bool().unwrap_or(false));
1356 }
1357
1358 #[test]
1359 fn merge_seshat_entry_rejects_non_object_root() {
1360 let mut value = serde_json::json!([1, 2, 3]);
1361 let err = merge_seshat_entry(&mut value, ClientKind::ClaudeCode);
1362 assert!(err.is_err());
1363 assert!(err.unwrap_err().to_string().contains("not a JSON object"));
1364 }
1365
1366 #[test]
1367 fn merge_seshat_entry_rejects_null_root() {
1368 let mut value = serde_json::Value::Null;
1369 assert!(merge_seshat_entry(&mut value, ClientKind::ClaudeCode).is_err());
1370 }
1371
1372 #[test]
1375 fn backup_filename_has_timestamp_suffix() {
1376 let dir = tempdir().unwrap();
1377 let path = dir.path().join("settings.json");
1378 fs::write(&path, "{}").unwrap();
1379
1380 let backup = write_backup(&path).expect("backup should succeed");
1381 let name = backup.file_name().unwrap().to_string_lossy();
1382 assert!(name.starts_with("settings.json.seshat-backup."));
1383 let ts_part = name.split('.').next_back().unwrap_or("");
1385 assert!(
1386 ts_part.parse::<u128>().is_ok(),
1387 "timestamp must be numeric: {ts_part}"
1388 );
1389 assert!(backup.exists());
1390 }
1391
1392 #[test]
1395 fn patch_json_config_adds_entry_and_creates_backup() {
1396 let dir = tempdir().unwrap();
1397 let path = dir.path().join("settings.json");
1398 fs::write(&path, r#"{"globalShortcut": ""}"#).unwrap();
1399
1400 let target = ConfigTarget {
1401 client: ClientKind::ClaudeCode,
1402 path: path.clone(),
1403 format: ConfigFormat::Json,
1404 exists: true,
1405 is_project: false,
1406 };
1407
1408 let result = patch_json_config(&target).expect("patch should succeed");
1409
1410 let backup = result
1412 .backup_path
1413 .expect("backup should be Some for existing file");
1414 assert!(backup.exists());
1415 assert_eq!(
1416 fs::read_to_string(&backup).unwrap(),
1417 r#"{"globalShortcut": ""}"#
1418 );
1419
1420 let updated: serde_json::Value =
1422 serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
1423 assert!(updated["mcpServers"]["seshat"].is_object());
1424 assert_eq!(updated["globalShortcut"], "");
1425 }
1426
1427 #[test]
1428 fn patch_json_config_creates_new_file_no_backup() {
1429 let dir = tempdir().unwrap();
1430 let path = dir.path().join("new_settings.json");
1431
1432 let target = ConfigTarget {
1433 client: ClientKind::ClaudeCode,
1434 path: path.clone(),
1435 format: ConfigFormat::Json,
1436 exists: false,
1437 is_project: false,
1438 };
1439
1440 let result = patch_json_config(&target).expect("patch should succeed");
1441 assert!(result.backup_path.is_none(), "no backup for new file");
1442 assert!(path.exists());
1443 let created: serde_json::Value =
1444 serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
1445 assert!(created["mcpServers"]["seshat"].is_object());
1446 }
1447
1448 #[test]
1449 fn patch_json_config_fails_on_non_object_json() {
1450 let dir = tempdir().unwrap();
1451 let path = dir.path().join("bad.json");
1452 fs::write(&path, "[1, 2, 3]").unwrap();
1453
1454 let target = ConfigTarget {
1455 client: ClientKind::ClaudeCode,
1456 path,
1457 format: ConfigFormat::Json,
1458 exists: true,
1459 is_project: false,
1460 };
1461
1462 let err = patch_json_config(&target);
1463 assert!(err.is_err());
1464 assert!(err.unwrap_err().to_string().contains("not a JSON object"));
1465 }
1466
1467 #[test]
1470 fn is_valid_json_true_for_clean_json() {
1471 let dir = tempdir().unwrap();
1472 let path = dir.path().join("f.json");
1473 fs::write(&path, r#"{"key": "value"}"#).unwrap();
1474 assert!(is_valid_json(&path));
1475 }
1476
1477 #[test]
1478 fn is_valid_json_false_for_jsonc() {
1479 let dir = tempdir().unwrap();
1480 let path = dir.path().join("f.json");
1481 fs::write(&path, "// comment\n{}").unwrap();
1482 assert!(!is_valid_json(&path));
1483 }
1484
1485 #[test]
1486 fn client_kind_display_name_all_variants() {
1487 assert_eq!(ClientKind::ClaudeCode.display_name(), "Claude Code");
1488 assert_eq!(ClientKind::ClaudeDesktop.display_name(), "Claude Desktop");
1489 assert_eq!(ClientKind::OpenCode.display_name(), "OpenCode");
1490 assert_eq!(ClientKind::Cursor.display_name(), "Cursor");
1491 }
1492
1493 #[test]
1494 fn client_kind_cli_name_all_variants() {
1495 assert_eq!(ClientKind::ClaudeCode.cli_name(), "claude-code");
1496 assert_eq!(ClientKind::ClaudeDesktop.cli_name(), "claude-desktop");
1497 assert_eq!(ClientKind::OpenCode.cli_name(), "opencode");
1498 assert_eq!(ClientKind::Cursor.cli_name(), "cursor");
1499 }
1500
1501 #[test]
1502 fn client_kind_mcp_key() {
1503 assert_eq!(ClientKind::OpenCode.mcp_key(), "mcp");
1504 assert_eq!(ClientKind::ClaudeCode.mcp_key(), "mcpServers");
1505 assert_eq!(ClientKind::ClaudeDesktop.mcp_key(), "mcpServers");
1506 assert_eq!(ClientKind::Cursor.mcp_key(), "mcpServers");
1507 }
1508
1509 #[test]
1510 fn from_cli_name_claude_alias() {
1511 assert_eq!(
1512 ClientKind::from_cli_name("claude"),
1513 Some(ClientKind::ClaudeCode)
1514 );
1515 }
1516
1517 #[test]
1518 fn seshat_entry_json_opencode() {
1519 let entry = ClientKind::OpenCode.seshat_entry_json();
1520 assert_eq!(entry["type"], "local");
1521 assert_eq!(entry["enabled"], true);
1522 }
1523
1524 #[test]
1525 fn seshat_entry_json_claude_code() {
1526 let entry = ClientKind::ClaudeCode.seshat_entry_json();
1527 assert_eq!(entry["command"], "seshat");
1528 }
1529
1530 #[test]
1531 fn snippet_lines_opencode_produces_multiple_lines() {
1532 let lines = ClientKind::OpenCode.snippet_lines();
1533 assert!(!lines.is_empty());
1534 assert!(lines[0].contains("\"seshat\":"));
1535 }
1536
1537 #[test]
1538 fn snippet_lines_claude_code() {
1539 let lines = ClientKind::ClaudeCode.snippet_lines();
1540 assert!(lines[0].contains("\"seshat\":"));
1541 }
1542
1543 #[test]
1544 fn full_file_lines_opencode() {
1545 let lines = ClientKind::OpenCode.full_file_lines();
1546 assert!(!lines.is_empty());
1547 let joined = lines.join("");
1548 assert!(joined.contains("mcp"));
1549 assert!(joined.contains("seshat"));
1550 }
1551
1552 #[test]
1553 fn full_file_lines_claude_code() {
1554 let lines = ClientKind::ClaudeCode.full_file_lines();
1555 let joined = lines.join("");
1556 assert!(joined.contains("mcpServers"));
1557 assert!(joined.contains("seshat"));
1558 }
1559
1560 #[test]
1561 fn claude_scope_arg_project_is_local() {
1562 assert_eq!(claude_scope_arg(ScopeRequest::Project), "local");
1563 }
1564
1565 #[test]
1566 fn claude_scope_arg_global_is_user() {
1567 assert_eq!(claude_scope_arg(ScopeRequest::Global), "user");
1568 }
1569
1570 #[test]
1571 fn claude_scope_arg_auto_is_user() {
1572 assert_eq!(claude_scope_arg(ScopeRequest::Auto), "user");
1573 }
1574
1575 #[test]
1576 fn find_opencode_config_prefers_jsonc() {
1577 let dir = tempdir().unwrap();
1578 fs::write(dir.path().join("opencode.jsonc"), "{}").unwrap();
1579 fs::write(dir.path().join("opencode.json"), "{}").unwrap();
1580 let target = find_opencode_config_in_dir(dir.path(), true);
1581 assert_eq!(target.format, ConfigFormat::Jsonc);
1582 }
1583
1584 #[test]
1585 fn detect_opencode_config_falls_back_to_json() {
1586 let dir = tempdir().unwrap();
1587 fs::write(dir.path().join("opencode.json"), "{}").unwrap();
1588 let target = find_opencode_config_in_dir(dir.path(), true);
1589 assert_eq!(target.format, ConfigFormat::Json);
1590 }
1591
1592 #[test]
1593 fn patch_json_config_parent_dirs_created() {
1594 let dir = tempdir().unwrap();
1595 let new_dir = dir.path().join("nested").join("deep");
1596 let path = new_dir.join("settings.json");
1597
1598 let target = ConfigTarget {
1599 client: ClientKind::ClaudeCode,
1600 path,
1601 format: ConfigFormat::Json,
1602 exists: false,
1603 is_project: false,
1604 };
1605 let result = patch_json_config(&target).expect("patch should succeed");
1606 assert!(result.backup_path.is_none());
1607 assert!(new_dir.join("settings.json").exists());
1608 }
1609
1610 #[test]
1611 fn resolve_single_client_claude_code_returns_none() {
1612 let dir = tempdir().unwrap();
1613 let target = resolve_single_client(ClientKind::ClaudeCode, ScopeRequest::Auto, dir.path());
1614 assert!(target.is_none());
1615 }
1616
1617 #[test]
1618 fn run_init_unknown_client_returns_error() {
1619 let result = run_init(Some("unknown-client"), ScopeRequest::Auto, false, false);
1620 assert!(result.is_err());
1621 let err = result.unwrap_err();
1622 assert!(err.to_string().contains("Unknown client"));
1623 }
1624
1625 #[test]
1626 fn run_init_empty_scope_with_none_client_auto_detects() {
1627 let result = run_init(None, ScopeRequest::Auto, true, false);
1628 assert!(result.is_ok());
1629 }
1630
1631 #[test]
1632 fn run_init_dry_run_skips_modifications() {
1633 let dir = tempdir().unwrap();
1634 let _guard = set_project_dir(dir.path());
1635 let result = run_init(Some("opencode"), ScopeRequest::Project, true, false);
1636 assert!(result.is_ok());
1638 }
1639
1640 fn set_project_dir(path: &std::path::Path) -> impl Drop {
1641 let old = std::env::current_dir().ok();
1642 std::env::set_current_dir(path).ok();
1643 struct RestoreCwd(Option<std::path::PathBuf>);
1644 impl Drop for RestoreCwd {
1645 fn drop(&mut self) {
1646 if let Some(ref old) = self.0 {
1647 let _ = std::env::set_current_dir(old);
1648 }
1649 }
1650 }
1651 RestoreCwd(old)
1652 }
1653
1654 #[test]
1657 fn merge_seshat_entry_overwrites_existing_seshat() {
1658 let mut value = serde_json::json!({
1659 "mcpServers": {"seshat": {"command": "stale"}}
1660 });
1661 merge_seshat_entry(&mut value, ClientKind::ClaudeCode).unwrap();
1662 assert_eq!(value["mcpServers"]["seshat"]["command"], "seshat");
1663 }
1664
1665 #[test]
1666 fn merge_seshat_entry_rejects_array_root_message_says_array() {
1667 let mut value = serde_json::json!([1, 2, 3]);
1668 let err = merge_seshat_entry(&mut value, ClientKind::ClaudeCode).unwrap_err();
1669 let msg = err.to_string();
1670 assert!(msg.contains("array"));
1671 }
1672
1673 #[test]
1674 fn merge_seshat_entry_rejects_string_root_message_says_string() {
1675 let mut value = serde_json::Value::String("hello".to_owned());
1676 let err = merge_seshat_entry(&mut value, ClientKind::OpenCode).unwrap_err();
1677 assert!(err.to_string().contains("string"));
1678 }
1679
1680 #[test]
1681 fn merge_seshat_entry_rejects_number_root_message_says_number() {
1682 let mut value = serde_json::json!(42);
1683 let err = merge_seshat_entry(&mut value, ClientKind::ClaudeCode).unwrap_err();
1684 assert!(err.to_string().contains("number"));
1685 }
1686
1687 #[test]
1688 fn merge_seshat_entry_rejects_null_root_message_says_null() {
1689 let mut value = serde_json::Value::Null;
1690 let err = merge_seshat_entry(&mut value, ClientKind::ClaudeCode).unwrap_err();
1691 assert!(err.to_string().contains("null"));
1692 }
1693
1694 #[test]
1695 fn merge_seshat_entry_rejects_bool_root_message_says_bool() {
1696 let mut value = serde_json::Value::Bool(true);
1697 let err = merge_seshat_entry(&mut value, ClientKind::ClaudeCode).unwrap_err();
1698 assert!(err.to_string().contains("bool"));
1699 }
1700
1701 #[test]
1704 fn is_already_configured_false_for_nonexistent_path() {
1705 let dir = tempdir().unwrap();
1706 let target = ConfigTarget {
1707 client: ClientKind::ClaudeCode,
1708 path: dir.path().join("missing.json"),
1709 format: ConfigFormat::Json,
1710 exists: false,
1711 is_project: false,
1712 };
1713 assert!(!is_already_configured(&target));
1714 }
1715
1716 #[test]
1717 fn is_already_configured_true_when_seshat_present_in_json() {
1718 let dir = tempdir().unwrap();
1719 let path = dir.path().join("settings.json");
1720 fs::write(
1721 &path,
1722 r#"{"mcpServers": {"seshat": {"command": "seshat"}}}"#,
1723 )
1724 .unwrap();
1725 let target = ConfigTarget {
1726 client: ClientKind::ClaudeCode,
1727 path,
1728 format: ConfigFormat::Json,
1729 exists: true,
1730 is_project: false,
1731 };
1732 assert!(is_already_configured(&target));
1733 }
1734
1735 #[test]
1736 fn is_already_configured_false_when_only_other_servers() {
1737 let dir = tempdir().unwrap();
1738 let path = dir.path().join("settings.json");
1739 fs::write(&path, r#"{"mcpServers": {"other": {}}}"#).unwrap();
1740 let target = ConfigTarget {
1741 client: ClientKind::ClaudeCode,
1742 path,
1743 format: ConfigFormat::Json,
1744 exists: true,
1745 is_project: false,
1746 };
1747 assert!(!is_already_configured(&target));
1748 }
1749
1750 #[test]
1751 fn is_already_configured_false_when_no_mcp_key() {
1752 let dir = tempdir().unwrap();
1753 let path = dir.path().join("settings.json");
1754 fs::write(&path, r#"{"otherStuff": true}"#).unwrap();
1755 let target = ConfigTarget {
1756 client: ClientKind::ClaudeCode,
1757 path,
1758 format: ConfigFormat::Json,
1759 exists: true,
1760 is_project: false,
1761 };
1762 assert!(!is_already_configured(&target));
1763 }
1764
1765 #[test]
1766 fn is_already_configured_false_when_invalid_json() {
1767 let dir = tempdir().unwrap();
1768 let path = dir.path().join("broken.json");
1769 fs::write(&path, "{not valid").unwrap();
1770 let target = ConfigTarget {
1771 client: ClientKind::ClaudeCode,
1772 path,
1773 format: ConfigFormat::Json,
1774 exists: true,
1775 is_project: false,
1776 };
1777 assert!(!is_already_configured(&target));
1778 }
1779
1780 #[test]
1781 fn is_already_configured_jsonc_text_search() {
1782 let dir = tempdir().unwrap();
1783 let path = dir.path().join("opencode.jsonc");
1784 fs::write(
1785 &path,
1786 "// some comment\n{ \"mcp\": { \"seshat\": {\"command\": \"x\"} } }",
1787 )
1788 .unwrap();
1789 let target = ConfigTarget {
1790 client: ClientKind::OpenCode,
1791 path,
1792 format: ConfigFormat::Jsonc,
1793 exists: true,
1794 is_project: false,
1795 };
1796 assert!(is_already_configured(&target));
1797 }
1798
1799 #[test]
1800 fn is_already_configured_jsonc_no_match() {
1801 let dir = tempdir().unwrap();
1802 let path = dir.path().join("opencode.jsonc");
1803 fs::write(&path, "// note about seshat-tools\n{}").unwrap();
1804 let target = ConfigTarget {
1805 client: ClientKind::OpenCode,
1806 path,
1807 format: ConfigFormat::Jsonc,
1808 exists: true,
1809 is_project: false,
1810 };
1811 assert!(!is_already_configured(&target));
1814 }
1815
1816 #[test]
1819 fn find_opencode_config_neither_exists_returns_non_existing_json_target() {
1820 let dir = tempdir().unwrap();
1821 let target = find_opencode_config_in_dir(dir.path(), false);
1822 assert_eq!(target.format, ConfigFormat::Json);
1823 assert!(!target.exists);
1824 assert!(target.path.ends_with("opencode.json"));
1825 }
1826
1827 #[test]
1828 fn find_opencode_config_json_with_comments_treated_as_jsonc() {
1829 let dir = tempdir().unwrap();
1830 fs::write(dir.path().join("opencode.json"), "// comment\n{}").unwrap();
1832 let target = find_opencode_config_in_dir(dir.path(), true);
1833 assert_eq!(target.format, ConfigFormat::Jsonc);
1834 assert!(target.exists);
1835 }
1836
1837 #[test]
1838 fn find_opencode_config_marks_is_project_correctly() {
1839 let dir = tempdir().unwrap();
1840 fs::write(dir.path().join("opencode.json"), "{}").unwrap();
1841 let proj = find_opencode_config_in_dir(dir.path(), true);
1842 let global = find_opencode_config_in_dir(dir.path(), false);
1843 assert!(proj.is_project);
1844 assert!(!global.is_project);
1845 }
1846
1847 #[test]
1850 fn resolve_cursor_config_project_scope_uses_project_dir() {
1851 let dir = tempdir().unwrap();
1852 let target = resolve_cursor_config(ScopeRequest::Project, dir.path()).unwrap();
1853 assert_eq!(target.client, ClientKind::Cursor);
1854 assert!(target.is_project);
1855 assert!(target.path.starts_with(dir.path()));
1856 assert!(target.path.ends_with("mcp.json"));
1857 }
1858
1859 #[test]
1860 fn resolve_cursor_config_auto_picks_project_when_exists() {
1861 let dir = tempdir().unwrap();
1862 let cursor_dir = dir.path().join(".cursor");
1863 fs::create_dir_all(&cursor_dir).unwrap();
1864 fs::write(cursor_dir.join("mcp.json"), "{}").unwrap();
1865 let target = resolve_cursor_config(ScopeRequest::Auto, dir.path()).unwrap();
1866 assert!(target.is_project);
1867 assert!(target.path.starts_with(dir.path()));
1868 }
1869
1870 #[test]
1871 fn resolve_cursor_config_auto_falls_back_to_global() {
1872 let dir = tempdir().unwrap();
1873 let target = resolve_cursor_config(ScopeRequest::Auto, dir.path()).unwrap();
1875 assert!(!target.is_project);
1876 assert!(!target.path.starts_with(dir.path()));
1878 }
1879
1880 #[test]
1883 fn resolve_single_client_opencode_returns_target() {
1884 let dir = tempdir().unwrap();
1885 let target = resolve_single_client(ClientKind::OpenCode, ScopeRequest::Project, dir.path());
1886 assert!(target.is_some());
1887 assert_eq!(target.unwrap().client, ClientKind::OpenCode);
1888 }
1889
1890 #[test]
1891 fn resolve_single_client_cursor_returns_target() {
1892 let dir = tempdir().unwrap();
1893 let target = resolve_single_client(ClientKind::Cursor, ScopeRequest::Project, dir.path());
1894 assert!(target.is_some());
1895 assert_eq!(target.unwrap().client, ClientKind::Cursor);
1896 }
1897
1898 #[test]
1901 fn opencode_global_config_dir_respects_xdg_when_set() {
1902 struct EnvGuard {
1904 key: &'static str,
1905 old: Option<std::ffi::OsString>,
1906 }
1907 impl Drop for EnvGuard {
1908 fn drop(&mut self) {
1909 unsafe {
1911 match &self.old {
1912 Some(v) => std::env::set_var(self.key, v),
1913 None => std::env::remove_var(self.key),
1914 }
1915 }
1916 }
1917 }
1918 let _g = EnvGuard {
1919 key: "XDG_CONFIG_HOME",
1920 old: std::env::var_os("XDG_CONFIG_HOME"),
1921 };
1922 let xdg = tempdir().expect("xdg tempdir");
1927 unsafe {
1929 std::env::set_var("XDG_CONFIG_HOME", xdg.path());
1930 }
1931 let dir = opencode_global_config_dir().expect("should resolve");
1932 assert_eq!(dir.file_name().and_then(|s| s.to_str()), Some("opencode"));
1933 assert_eq!(dir.parent(), Some(xdg.path()));
1934 }
1935
1936 #[test]
1937 fn opencode_global_config_dir_empty_xdg_falls_back_to_home() {
1938 struct EnvGuard {
1939 key: &'static str,
1940 old: Option<std::ffi::OsString>,
1941 }
1942 impl Drop for EnvGuard {
1943 fn drop(&mut self) {
1944 unsafe {
1945 match &self.old {
1946 Some(v) => std::env::set_var(self.key, v),
1947 None => std::env::remove_var(self.key),
1948 }
1949 }
1950 }
1951 }
1952 let _g = EnvGuard {
1953 key: "XDG_CONFIG_HOME",
1954 old: std::env::var_os("XDG_CONFIG_HOME"),
1955 };
1956 unsafe {
1957 std::env::set_var("XDG_CONFIG_HOME", "");
1958 }
1959 let dir = opencode_global_config_dir();
1960 if let Some(d) = dir {
1962 assert!(d.ends_with("opencode"));
1963 assert!(d.to_string_lossy().contains(".config"));
1964 }
1965 }
1966}