Skip to main content

seshat_cli/
init.rs

1//! Implementation of the `seshat init` command.
2//!
3//! Detects installed AI coding clients, locates their MCP configuration files,
4//! checks whether Seshat is already configured, and either auto-patches JSON
5//! configs (with backup + confirmation) or displays a copy-paste snippet for
6//! JSONC configs.
7//!
8//! ## Supported clients
9//!
10//! | Client | Detection | Config key |
11//! |--------|-----------|------------|
12//! | Claude Code | `claude` in PATH | `mcpServers` |
13//! | Claude Desktop | app dir exists (macOS) | `mcpServers` |
14//! | OpenCode | `opencode` in PATH | `mcp` |
15//! | Cursor | `cursor` in PATH | `mcpServers` |
16//!
17//! ## Scope selection (default: smart auto-detect)
18//!
19//! Without flags, `seshat init` uses a **smart scope**:
20//! - First checks whether a project-level config exists for each client in the
21//!   current working directory (or nearest git root).
22//! - If a project-level config is found, it targets that.
23//! - If not, falls back to the global user config.
24//!
25//! `--project` forces project-level configs only (no fallback).
26//! `--global`  forces global configs only.
27//!
28//! ## JSONC handling
29//!
30//! OpenCode supports both `.json` and `.jsonc` config files. When a `.jsonc`
31//! file is detected (or a `.json` file that fails JSON parsing), we only show
32//! a snippet — we never auto-patch JSONC to avoid silently destroying comments.
33
34use 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// ══════════════════════════════════════════════════════════════════════
45// Types
46// ══════════════════════════════════════════════════════════════════════
47
48/// A supported AI coding client.
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub enum ClientKind {
51    ClaudeCode,
52    ClaudeDesktop,
53    OpenCode,
54    Cursor,
55}
56
57impl ClientKind {
58    /// Human-readable display name.
59    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    /// CLI name used in `seshat init <client>`.
69    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    /// Parse from a CLI argument string.
79    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    /// The JSON key under which MCP servers are registered for this client.
90    pub fn mcp_key(self) -> &'static str {
91        match self {
92            Self::OpenCode => "mcp",
93            _ => "mcpServers",
94        }
95    }
96
97    /// Generate the JSON entry value for the `"seshat"` key.
98    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    /// Lines to display in the copy block for an existing config.
113    ///
114    /// Returns the `"seshat": { ... }` fragment suitable for pasting into
115    /// an existing `mcpServers` / `mcp` object.
116    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        // First line: `"seshat": {`  — merge key with opening brace.
120        let first = formatted
121            .split_once('\n')
122            .map(|(head, _)| head)
123            .unwrap_or(&formatted);
124        let mut lines = vec![format!("\"seshat\": {first}")];
125        // Remaining lines: body + closing brace.
126        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    /// Lines for a brand-new config file that doesn't exist yet.
135    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/// Config file format.
147#[derive(Debug, Clone, Copy, PartialEq, Eq)]
148pub enum ConfigFormat {
149    /// Standard JSON — can be auto-patched.
150    Json,
151    /// JSON with Comments — show snippet only, never auto-patch.
152    Jsonc,
153}
154
155/// Explicit scope requested by the user via CLI flags.
156#[derive(Debug, Clone, Copy, PartialEq, Eq)]
157pub enum ScopeRequest {
158    /// Default: try project-level first, fall back to global.
159    Auto,
160    /// `--project`: project-level configs only.
161    Project,
162    /// `--global`: global user configs only.
163    Global,
164}
165
166/// A resolved config target for a specific client.
167#[derive(Debug)]
168pub struct ConfigTarget {
169    pub client: ClientKind,
170    pub path: PathBuf,
171    pub format: ConfigFormat,
172    pub exists: bool,
173    /// True when this target was resolved from a project-level location.
174    pub is_project: bool,
175}
176
177// ══════════════════════════════════════════════════════════════════════
178// Detection
179// ══════════════════════════════════════════════════════════════════════
180
181/// Detect all installed AI coding clients and resolve their config targets.
182///
183/// When `scope == Auto`, each client first checks for a project-level config
184/// in `project_root`; if none exists it falls back to the global config.
185/// Detect non-Claude-Code clients that use JSON-patch approach.
186///
187/// Claude Code is intentionally excluded here — it is handled separately
188/// via `handle_claude_code_via_cli` which calls `claude mcp add`.
189pub fn detect_clients(scope: ScopeRequest, project_root: &Path) -> Vec<ConfigTarget> {
190    let mut targets = Vec::new();
191
192    // Claude Code handled separately via CLI — not included here.
193
194    #[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
214/// Resolve config target for a single explicitly-named non-ClaudeCode client.
215///
216/// Claude Code does not use this path — it is handled via `handle_claude_code_via_cli`.
217pub fn resolve_single_client(
218    client: ClientKind,
219    scope: ScopeRequest,
220    project_root: &Path,
221) -> Option<ConfigTarget> {
222    match client {
223        ClientKind::ClaudeCode => None, // handled via `claude mcp add` CLI, not JSON patch
224        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    // Claude Desktop only has a global config; no project-level equivalent.
242    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
254/// Resolve the OpenCode global config directory.
255///
256/// OpenCode follows XDG conventions on all platforms: it reads
257/// `$XDG_CONFIG_HOME/opencode` when the env var is set, and falls back to
258/// `~/.config/opencode` otherwise — including on macOS where
259/// `dirs::config_dir()` would incorrectly return `~/Library/Application Support/`.
260fn opencode_global_config_dir() -> Option<PathBuf> {
261    // Respect $XDG_CONFIG_HOME if set and non-empty.
262    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    // Default XDG fallback: ~/.config/opencode (works on macOS, Linux, Windows).
268    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            // Prefer project-level if either opencode.json or opencode.jsonc exists.
280            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
313/// Find the opencode config in a directory, preferring `.jsonc` over `.json`.
314///
315/// If both exist, `.jsonc` takes precedence (matches opencode's load order).
316/// If neither exists, returns a non-existing target pointing at `opencode.json`.
317pub 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        // A .json file that fails JSON parsing is treated as JSONC (has comments).
331        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        // Neither exists; offer to create opencode.json.
345        ConfigTarget {
346            client: ClientKind::OpenCode,
347            path: json_path,
348            format: ConfigFormat::Json,
349            exists: false,
350            is_project,
351        }
352    }
353}
354
355/// Build a JSON (never JSONC) `ConfigTarget` for non-opencode clients.
356fn 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
366/// Return `true` if the file at `path` parses as valid JSON (no comments).
367fn 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
374// ══════════════════════════════════════════════════════════════════════
375// Already-configured check
376// ══════════════════════════════════════════════════════════════════════
377
378/// Check whether `seshat` is already present in the target's config.
379///
380/// - JSON files: parse and check the appropriate key.
381/// - JSONC files: text search for `"seshat":` (key assignment, not value).
382/// - Non-existent files: `false`.
383pub 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        // Search for `"seshat":` (key assignment) to avoid false-positives from
403        // keys named `"seshat-tools"` or string values that contain `"seshat"`.
404        ConfigFormat::Jsonc => content.contains("\"seshat\":"),
405    }
406}
407
408// ══════════════════════════════════════════════════════════════════════
409// Patching
410// ══════════════════════════════════════════════════════════════════════
411
412/// Write a timestamped backup of `path` next to the original.
413///
414/// Backup name: `{filename}.seshat-backup.{unix_timestamp_ms}`
415/// Using millisecond precision avoids collisions when two patches happen
416/// within the same second.
417pub 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
432/// Merge the `seshat` entry into a parsed JSON `Value`.
433///
434/// Creates the `mcpServers` / `mcp` key if it doesn't exist.
435/// Returns an error if `value` is not a JSON object (guards against corrupt
436/// config files that contain arrays, nulls, or bare scalars at root level).
437pub 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/// Result of patching a JSON config file.
467#[derive(Debug)]
468pub struct PatchResult {
469    /// Path to the backup file, if one was created (only for existing files).
470    pub backup_path: Option<PathBuf>,
471}
472
473/// Patch a JSON config file: (backup if exists) → parse → merge → write.
474///
475/// For new files: parent directories are created as needed, no backup.
476/// For existing files: backup written before any mutation.
477pub fn patch_json_config(target: &ConfigTarget) -> Result<PatchResult, CliError> {
478    // For existing files: backup first, before any mutation.
479    let backup_path = if target.exists {
480        Some(write_backup(&target.path)?)
481    } else {
482        // Create parent directories for new files.
483        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    // Read existing content or start from empty object.
493    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
522// ══════════════════════════════════════════════════════════════════════
523// Output helpers
524// ══════════════════════════════════════════════════════════════════════
525
526fn 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
546/// Ask a yes/no question on stderr, read answer from stdin.
547/// Returns `true` for "y" / "Y". In dry-run mode skips the prompt and
548/// returns `false`.
549fn 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
561// ══════════════════════════════════════════════════════════════════════
562// Claude Code CLI integration
563// ══════════════════════════════════════════════════════════════════════
564
565/// Check whether seshat is already registered via `claude mcp list`.
566///
567/// Runs `claude mcp list` and checks if "seshat" appears in the output.
568/// Returns `None` if the command fails (treat as not configured).
569fn 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
580/// Map our ScopeRequest to the `claude mcp add --scope` argument.
581///
582/// Claude Code scope semantics:
583/// - `user`    → `~/.claude.json` global `mcpServers` — applies to all projects
584/// - `local`   → `~/.claude.json` under `projects["/path"]` — personal, project-specific
585/// - `project` → `.mcp.json` in CWD — committed to repo, shared with team
586///
587/// Our `--project` flag means "personal project-level" → `local`.
588/// Our `--global` / default-global means "all projects" → `user`.
589/// We intentionally don't expose `project` scope (team-shared `.mcp.json`)
590/// as that requires additional team coordination.
591fn claude_scope_arg(scope: ScopeRequest) -> &'static str {
592    match scope {
593        ScopeRequest::Project => "local", // personal, project-specific in ~/.claude.json
594        ScopeRequest::Global | ScopeRequest::Auto => "user", // global for all projects
595    }
596}
597
598/// Register seshat via `claude mcp add`.
599///
600/// Uses the official Claude Code CLI to write the MCP entry to the correct
601/// location in `~/.claude.json`. This avoids manual JSON patching of
602/// internal Claude Code config files.
603///
604/// Returns the command string shown to the user for reference.
605fn 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
631/// Handle Claude Code via its own CLI (`claude mcp add`).
632///
633/// Returns `true` if there was an error.
634fn 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    // Check if already configured.
645    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) => {} // not configured, proceed
656        None => {
657            // `claude mcp list` failed — still try to add
658        }
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
689// ══════════════════════════════════════════════════════════════════════
690// Per-client output
691// ══════════════════════════════════════════════════════════════════════
692
693/// Handle output and optional patching for a single config target.
694///
695/// Returns `true` if a patch was attempted and failed (so the caller can
696/// propagate a non-zero exit).
697fn 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    // Already configured?
714    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    // JSONC — snippet only, no auto-patch.
729    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    // JSON — auto-patch flow.
747    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
799// ══════════════════════════════════════════════════════════════════════
800// Entry point
801// ══════════════════════════════════════════════════════════════════════
802
803/// Run the `seshat init` command.
804///
805/// `scope`: `Auto` = smart project-first + global fallback (default),
806///           `Project` = project only, `Global` = global only.
807pub 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    // Resolve project root: prefer git root, fall back to cwd.
816    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    // Print scope hint when non-default.
823    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    // Explicit client mode.
859    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        // Claude Code uses its own CLI rather than direct JSON patching.
867        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        // Auto-detect mode.
888        let claude_code_present = which::which("claude").is_ok();
889        let targets = detect_clients(scope, &project_root);
890
891        // Claude Code is handled separately via its CLI; filter it from JSON-patch targets.
892        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        // Detection summary header.
906        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        // Handle Claude Code first via CLI.
949        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        // Handle remaining clients via JSON patching.
959        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
979/// Write agent instructions, skill file, and hooks for the given client.
980///
981/// Called after a successful MCP config write. Non-fatal — errors are printed
982/// but do not abort the overall `seshat init` flow.
983fn 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            // AGENTS.md / CLAUDE.md
1000            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            // Skill file
1018            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            // Hooks
1034            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            // AGENTS.md
1062            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            // Skill file
1076            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        // Claude Desktop and Cursor: instruction writing not yet supported.
1087        ClientKind::ClaudeDesktop | ClientKind::Cursor => {}
1088    }
1089}
1090
1091// ══════════════════════════════════════════════════════════════════════
1092// Tests
1093// ══════════════════════════════════════════════════════════════════════
1094
1095#[cfg(test)]
1096mod tests {
1097    use super::*;
1098    use std::fs;
1099    use tempfile::tempdir;
1100
1101    // ── ClientKind ───────────────────────────────────────────────────
1102
1103    #[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    // ── opencode_global_config_dir ───────────────────────────────────
1172
1173    #[test]
1174    fn opencode_global_config_dir_respects_xdg_config_home() {
1175        // When XDG_CONFIG_HOME is set, use it instead of ~/.config.
1176        // We can't safely mutate env in parallel tests, so just verify the
1177        // function returns a path ending in "opencode" in both branches.
1178        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        // Verify the returned path does NOT go through Library/Application Support
1187        // (which dirs::config_dir() would return on macOS).
1188        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    // ── find_opencode_config_in_dir ──────────────────────────────────
1199
1200    #[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    // ── is_already_configured ────────────────────────────────────────
1244
1245    #[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        // Contains "seshat-tools" but NOT `"seshat":` — should be false.
1303        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    // ── merge_seshat_entry ───────────────────────────────────────────
1331
1332    #[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    // ── backup ───────────────────────────────────────────────────────
1373
1374    #[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        // Timestamp part should be numeric (milliseconds).
1384        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    // ── patch_json_config ────────────────────────────────────────────
1393
1394    #[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        // Backup must exist and contain the original.
1411        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        // Config must be updated with seshat entry.
1421        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    // ── is_valid_json ────────────────────────────────────────────────
1468
1469    #[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        // Should succeed cleanly regardless of filesystem state.
1637        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    // ── merge_seshat_entry — error message detail (json_type_name) ───
1655
1656    #[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    // ── is_already_configured ────────────────────────────────────────
1702
1703    #[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        // text search looks for `"seshat":` (key form) — won't match the
1812        // bare word "seshat-tools" in a comment.
1813        assert!(!is_already_configured(&target));
1814    }
1815
1816    // ── find_opencode_config_in_dir ──────────────────────────────────
1817
1818    #[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        // .json file with comments → fails JSON parse → format=Jsonc
1831        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    // ── resolve_cursor_config ────────────────────────────────────────
1848
1849    #[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        // No .cursor/mcp.json in project → falls back to global.
1874        let target = resolve_cursor_config(ScopeRequest::Auto, dir.path()).unwrap();
1875        assert!(!target.is_project);
1876        // Global path is under home dir, not the project tempdir.
1877        assert!(!target.path.starts_with(dir.path()));
1878    }
1879
1880    // ── resolve_single_client (non-claude branches) ─────────────────
1881
1882    #[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    // ── opencode_global_config_dir ───────────────────────────────────
1899
1900    #[test]
1901    fn opencode_global_config_dir_respects_xdg_when_set() {
1902        // SAFETY: env vars are process-global; we restore on drop.
1903        struct EnvGuard {
1904            key: &'static str,
1905            old: Option<std::ffi::OsString>,
1906        }
1907        impl Drop for EnvGuard {
1908            fn drop(&mut self) {
1909                // SAFETY: only mutating process env in single-threaded test.
1910                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        // Use a real, platform-appropriate tempdir for the XDG value so the
1923        // assertion below also holds on Windows (where `/tmp/...` is not a
1924        // well-formed absolute path and `Path::starts_with` parses it
1925        // inconsistently with the joined result).
1926        let xdg = tempdir().expect("xdg tempdir");
1927        // SAFETY: same scope; restored on guard drop.
1928        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        // Home dir should be present in CI/dev; resolves to ~/.config/opencode.
1961        if let Some(d) = dir {
1962            assert!(d.ends_with("opencode"));
1963            assert!(d.to_string_lossy().contains(".config"));
1964        }
1965    }
1966}