use std::fs;
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use owo_colors::OwoColorize;
use crate::db::sync_root_for;
use crate::error::CliError;
use crate::format::{color_enabled, format_copy_block, format_section_header};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ClientKind {
ClaudeCode,
ClaudeDesktop,
OpenCode,
Cursor,
}
impl ClientKind {
pub fn display_name(self) -> &'static str {
match self {
Self::ClaudeCode => "Claude Code",
Self::ClaudeDesktop => "Claude Desktop",
Self::OpenCode => "OpenCode",
Self::Cursor => "Cursor",
}
}
pub fn cli_name(self) -> &'static str {
match self {
Self::ClaudeCode => "claude-code",
Self::ClaudeDesktop => "claude-desktop",
Self::OpenCode => "opencode",
Self::Cursor => "cursor",
}
}
pub fn from_cli_name(s: &str) -> Option<Self> {
match s {
"claude-code" | "claude" => Some(Self::ClaudeCode),
"claude-desktop" => Some(Self::ClaudeDesktop),
"opencode" => Some(Self::OpenCode),
"cursor" => Some(Self::Cursor),
_ => None,
}
}
pub fn mcp_key(self) -> &'static str {
match self {
Self::OpenCode => "mcp",
_ => "mcpServers",
}
}
pub fn seshat_entry_json(self) -> serde_json::Value {
match self {
Self::OpenCode => serde_json::json!({
"type": "local",
"command": ["seshat", "serve"],
"enabled": true
}),
_ => serde_json::json!({
"command": "seshat",
"args": ["serve"]
}),
}
}
pub fn snippet_lines(self) -> Vec<String> {
let entry = self.seshat_entry_json();
let formatted = serde_json::to_string_pretty(&entry).unwrap_or_else(|_| "{}".to_string());
let first = formatted
.split_once('\n')
.map(|(head, _)| head)
.unwrap_or(&formatted);
let mut lines = vec![format!("\"seshat\": {first}")];
if let Some((_, rest)) = formatted.split_once('\n') {
for line in rest.lines() {
lines.push(line.to_string());
}
}
lines
}
pub fn full_file_lines(self) -> Vec<String> {
let root = serde_json::json!({
self.mcp_key(): {
"seshat": self.seshat_entry_json()
}
});
let formatted = serde_json::to_string_pretty(&root).unwrap_or_else(|_| "{}".to_string());
formatted.lines().map(|l| l.to_string()).collect()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ConfigFormat {
Json,
Jsonc,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ScopeRequest {
Auto,
Project,
Global,
}
#[derive(Debug)]
pub struct ConfigTarget {
pub client: ClientKind,
pub path: PathBuf,
pub format: ConfigFormat,
pub exists: bool,
pub is_project: bool,
}
pub fn detect_clients(scope: ScopeRequest, project_root: &Path) -> Vec<ConfigTarget> {
let mut targets = Vec::new();
#[cfg(target_os = "macos")]
if let Some(t) = resolve_claude_desktop_config() {
targets.push(t);
}
if which::which("opencode").is_ok() {
if let Some(t) = resolve_opencode_config(scope, project_root) {
targets.push(t);
}
}
if which::which("cursor").is_ok() {
if let Some(t) = resolve_cursor_config(scope, project_root) {
targets.push(t);
}
}
targets
}
pub fn resolve_single_client(
client: ClientKind,
scope: ScopeRequest,
project_root: &Path,
) -> Option<ConfigTarget> {
match client {
ClientKind::ClaudeCode => None, ClientKind::ClaudeDesktop => {
#[cfg(target_os = "macos")]
{
resolve_claude_desktop_config()
}
#[cfg(not(target_os = "macos"))]
{
None
}
}
ClientKind::OpenCode => resolve_opencode_config(scope, project_root),
ClientKind::Cursor => resolve_cursor_config(scope, project_root),
}
}
#[cfg(target_os = "macos")]
fn resolve_claude_desktop_config() -> Option<ConfigTarget> {
let home = dirs::home_dir()?;
let app_dir = home
.join("Library")
.join("Application Support")
.join("Claude");
if !app_dir.is_dir() {
return None;
}
let path = app_dir.join("claude_desktop_config.json");
Some(make_target(ClientKind::ClaudeDesktop, path, false))
}
fn opencode_global_config_dir() -> Option<PathBuf> {
if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") {
if !xdg.is_empty() {
return Some(PathBuf::from(xdg).join("opencode"));
}
}
Some(dirs::home_dir()?.join(".config").join("opencode"))
}
fn resolve_opencode_config(scope: ScopeRequest, project_root: &Path) -> Option<ConfigTarget> {
match scope {
ScopeRequest::Global => {
let dir = opencode_global_config_dir()?;
Some(find_opencode_config_in_dir(&dir, false))
}
ScopeRequest::Project => Some(find_opencode_config_in_dir(project_root, true)),
ScopeRequest::Auto => {
let proj_target = find_opencode_config_in_dir(project_root, true);
if proj_target.exists {
Some(proj_target)
} else {
let dir = opencode_global_config_dir()?;
Some(find_opencode_config_in_dir(&dir, false))
}
}
}
}
fn resolve_cursor_config(scope: ScopeRequest, project_root: &Path) -> Option<ConfigTarget> {
match scope {
ScopeRequest::Global => {
let path = dirs::home_dir()?.join(".cursor").join("mcp.json");
Some(make_target(ClientKind::Cursor, path, false))
}
ScopeRequest::Project => {
let path = project_root.join(".cursor").join("mcp.json");
Some(make_target(ClientKind::Cursor, path, true))
}
ScopeRequest::Auto => {
let project_path = project_root.join(".cursor").join("mcp.json");
if project_path.exists() {
Some(make_target(ClientKind::Cursor, project_path, true))
} else {
let global_path = dirs::home_dir()?.join(".cursor").join("mcp.json");
Some(make_target(ClientKind::Cursor, global_path, false))
}
}
}
}
pub fn find_opencode_config_in_dir(dir: &Path, is_project: bool) -> ConfigTarget {
let jsonc_path = dir.join("opencode.jsonc");
let json_path = dir.join("opencode.json");
if jsonc_path.exists() {
ConfigTarget {
client: ClientKind::OpenCode,
path: jsonc_path,
format: ConfigFormat::Jsonc,
exists: true,
is_project,
}
} else if json_path.exists() {
let format = if is_valid_json(&json_path) {
ConfigFormat::Json
} else {
ConfigFormat::Jsonc
};
ConfigTarget {
client: ClientKind::OpenCode,
path: json_path,
format,
exists: true,
is_project,
}
} else {
ConfigTarget {
client: ClientKind::OpenCode,
path: json_path,
format: ConfigFormat::Json,
exists: false,
is_project,
}
}
}
fn make_target(client: ClientKind, path: PathBuf, is_project: bool) -> ConfigTarget {
ConfigTarget {
exists: path.exists(),
client,
path,
format: ConfigFormat::Json,
is_project,
}
}
fn is_valid_json(path: &Path) -> bool {
fs::read_to_string(path)
.ok()
.and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())
.is_some()
}
pub fn is_already_configured(target: &ConfigTarget) -> bool {
if !target.exists {
return false;
}
let content = match fs::read_to_string(&target.path) {
Ok(s) => s,
Err(_) => return false,
};
match target.format {
ConfigFormat::Json => {
let value: serde_json::Value = match serde_json::from_str(&content) {
Ok(v) => v,
Err(_) => return false,
};
value
.get(target.client.mcp_key())
.and_then(|s| s.get("seshat"))
.is_some()
}
ConfigFormat::Jsonc => content.contains("\"seshat\":"),
}
}
pub fn write_backup(path: &Path) -> Result<PathBuf, CliError> {
let ts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis())
.unwrap_or(0);
let filename = path.file_name().unwrap_or_default().to_string_lossy();
let backup_name = format!("{filename}.seshat-backup.{ts}");
let backup_path = path.with_file_name(backup_name);
fs::copy(path, &backup_path).map_err(|e| CliError::IoWithPath {
message: format!("failed to write backup: {e}"),
path: backup_path.clone(),
})?;
Ok(backup_path)
}
pub fn merge_seshat_entry(
value: &mut serde_json::Value,
client: ClientKind,
) -> Result<(), CliError> {
if !value.is_object() {
return Err(CliError::InvalidArgument(format!(
"config file root is not a JSON object (got {})",
json_type_name(value)
)));
}
let mcp_key = client.mcp_key();
if value.get(mcp_key).is_none() {
value[mcp_key] = serde_json::json!({});
}
value[mcp_key]["seshat"] = client.seshat_entry_json();
Ok(())
}
fn json_type_name(v: &serde_json::Value) -> &'static str {
match v {
serde_json::Value::Null => "null",
serde_json::Value::Bool(_) => "bool",
serde_json::Value::Number(_) => "number",
serde_json::Value::String(_) => "string",
serde_json::Value::Array(_) => "array",
serde_json::Value::Object(_) => "object",
}
}
#[derive(Debug)]
pub struct PatchResult {
pub backup_path: Option<PathBuf>,
}
pub fn patch_json_config(target: &ConfigTarget) -> Result<PatchResult, CliError> {
let backup_path = if target.exists {
Some(write_backup(&target.path)?)
} else {
if let Some(parent) = target.path.parent() {
fs::create_dir_all(parent).map_err(|e| CliError::IoWithPath {
message: format!("failed to create directory: {e}"),
path: parent.to_path_buf(),
})?;
}
None
};
let content = if target.exists {
fs::read_to_string(&target.path).map_err(|e| CliError::IoWithPath {
message: format!("failed to read config: {e}"),
path: target.path.clone(),
})?
} else {
"{}".to_string()
};
let mut value: serde_json::Value = serde_json::from_str(&content).map_err(|e| {
CliError::InvalidArgument(format!(
"config file contains invalid JSON at {}: {e}",
target.path.display()
))
})?;
merge_seshat_entry(&mut value, target.client)?;
let updated = serde_json::to_string_pretty(&value)
.map_err(|e| CliError::InvalidArgument(format!("failed to serialize config: {e}")))?;
fs::write(&target.path, updated.as_bytes()).map_err(|e| CliError::IoWithPath {
message: format!("failed to write config: {e}"),
path: target.path.clone(),
})?;
Ok(PatchResult { backup_path })
}
fn print_ok(message: &str, color: bool) {
if color {
eprintln!(" {} {message}", "✓".green().bold());
} else {
eprintln!(" ✓ {message}");
}
}
fn print_info(message: &str) {
eprintln!(" {message}");
}
fn print_error(message: &str, color: bool) {
if color {
eprintln!(" {} {message}", "error:".red().bold());
} else {
eprintln!(" error: {message}");
}
}
fn ask_yn(prompt: &str, dry_run: bool) -> bool {
if dry_run {
eprintln!(" {prompt} [dry-run — no changes]");
return false;
}
eprint!(" {prompt} [y/N] ");
io::stderr().flush().ok();
let mut input = String::new();
io::stdin().read_line(&mut input).ok();
matches!(input.trim(), "y" | "Y")
}
fn claude_mcp_list_has_seshat() -> Option<bool> {
let output = std::process::Command::new("claude")
.args(["mcp", "list"])
.output()
.ok()?;
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
let combined = format!("{stdout}{stderr}");
Some(combined.contains("seshat"))
}
fn claude_scope_arg(scope: ScopeRequest) -> &'static str {
match scope {
ScopeRequest::Project => "local", ScopeRequest::Global | ScopeRequest::Auto => "user", }
}
fn run_claude_mcp_add(scope: ScopeRequest, dry_run: bool) -> Result<String, CliError> {
let scope_arg = claude_scope_arg(scope);
let cmd_display = format!("claude mcp add -s {scope_arg} seshat seshat serve");
if dry_run {
return Ok(cmd_display);
}
let status = std::process::Command::new("claude")
.args(["mcp", "add", "-s", scope_arg, "seshat", "seshat", "serve"])
.status()
.map_err(|e| CliError::CommandFailed {
command: "claude mcp add".to_owned(),
reason: format!("failed to run: {e}"),
})?;
if !status.success() {
return Err(CliError::CommandFailed {
command: "claude mcp add".to_owned(),
reason: format!("exited with status {status}"),
});
}
Ok(cmd_display)
}
fn handle_claude_code_via_cli(scope: ScopeRequest, dry_run: bool, color: bool) -> bool {
eprintln!("{}", format_section_header("Claude Code", color));
eprintln!();
let scope_arg = claude_scope_arg(scope);
let scope_label = match scope_arg {
"local" => "project-local (~/.claude.json, bound to this path)",
_ => "user-global (~/.claude.json, all projects)",
};
match claude_mcp_list_has_seshat() {
Some(true) => {
print_info(&format!("Scope: {scope_label}"));
print_ok(
"Already configured (detected via `claude mcp list`).",
color,
);
eprintln!();
return false;
}
Some(false) => {} None => {
}
}
print_info(&format!("Scope: {scope_label}"));
print_info("Will run:");
eprintln!();
let cmd_str = format!("claude mcp add -s {scope_arg} seshat seshat serve");
let refs: Vec<&str> = vec![cmd_str.as_str()];
eprint!("{}", format_copy_block(&refs, color));
eprintln!();
if ask_yn("Run command?", dry_run) {
match run_claude_mcp_add(scope, dry_run) {
Ok(_) => {
print_ok("Seshat added to Claude Code.", color);
}
Err(e) => {
print_error(&e.to_string(), color);
eprintln!();
return true;
}
}
} else if !dry_run {
print_info("Skipped. Run the command above manually.");
}
eprintln!();
false
}
fn handle_target(target: &ConfigTarget, dry_run: bool, color: bool) -> bool {
let mut had_error = false;
eprintln!(
"{}",
format_section_header(target.client.display_name(), color)
);
eprintln!();
let path_display = target.path.display().to_string();
let scope_label = if target.is_project {
"project"
} else {
"global"
};
if is_already_configured(target) {
print_info(&format!("Config ({scope_label}): {path_display}"));
if target.format == ConfigFormat::Jsonc {
print_ok(
"Already configured (detected in JSONC — verify manually).",
color,
);
} else {
print_ok("Already configured.", color);
}
eprintln!();
return false;
}
if target.format == ConfigFormat::Jsonc {
print_info(&format!("Config ({scope_label}): {path_display}"));
print_info("Format: JSONC (contains comments — auto-patch not supported)");
eprintln!();
print_info(&format!(
"Add to \"{}\" section manually:",
target.client.mcp_key()
));
eprintln!();
let owned = target.client.snippet_lines();
let refs: Vec<&str> = owned.iter().map(|s| s.as_str()).collect();
eprint!("{}", format_copy_block(&refs, color));
print_info("Note: add a comma after the preceding entry if \"seshat\" is not the first.");
eprintln!();
return false;
}
if target.exists {
print_info(&format!("Config ({scope_label}): {path_display}"));
print_info(&format!(
"Seshat is not configured. Add to \"{}\":",
target.client.mcp_key()
));
} else {
print_info(&format!("Config not found ({scope_label}): {path_display}"));
print_info("Will create new file with:");
}
eprintln!();
let owned = if target.exists {
target.client.snippet_lines()
} else {
target.client.full_file_lines()
};
let refs: Vec<&str> = owned.iter().map(|s| s.as_str()).collect();
eprint!("{}", format_copy_block(&refs, color));
if target.exists {
print_info("Note: add a comma after the preceding entry if \"seshat\" is not the first.");
}
eprintln!();
let prompt = if target.exists {
"Auto-add?"
} else {
"Create file?"
};
if ask_yn(prompt, dry_run) {
match patch_json_config(target) {
Ok(result) => {
if let Some(backup) = result.backup_path {
print_ok(&format!("Backup saved: {}", backup.display()), color);
}
print_ok(&format!("Updated {path_display}"), color);
}
Err(e) => {
print_error(&e.to_string(), color);
had_error = true;
}
}
} else if !dry_run {
print_info("Skipped. Add the snippet above manually.");
}
eprintln!();
had_error
}
pub fn run_init(
client: Option<&str>,
scope: ScopeRequest,
dry_run: bool,
skip_instructions: bool,
) -> Result<(), CliError> {
let color = color_enabled();
let cwd = std::env::current_dir().map_err(|e| CliError::IoWithPath {
message: format!("cannot determine current directory: {e}"),
path: PathBuf::from("."),
})?;
let project_root = sync_root_for(&cwd);
match scope {
ScopeRequest::Auto => {}
ScopeRequest::Project => {
if color {
eprintln!(
" {} project ({})\n",
"Scope:".dimmed(),
project_root.display()
);
} else {
eprintln!(" Scope: project ({})\n", project_root.display());
}
}
ScopeRequest::Global => {
if color {
eprintln!(" {} global\n", "Scope:".dimmed());
} else {
eprintln!(" Scope: global\n");
}
}
}
if dry_run {
if color {
eprintln!(
" {} no files will be written\n",
"Dry run:".yellow().bold()
);
} else {
eprintln!(" Dry run: no files will be written\n");
}
}
let mut any_error = false;
if let Some(name) = client {
let kind = ClientKind::from_cli_name(name).ok_or_else(|| {
CliError::InvalidArgument(format!(
"Unknown client: {name}\n\nhint: Supported clients: claude-code, claude-desktop, opencode, cursor\nhint: Run `seshat init --help` for usage."
))
})?;
if kind == ClientKind::ClaudeCode {
if handle_claude_code_via_cli(scope, dry_run, color) {
any_error = true;
} else if !skip_instructions {
write_instructions_for_client(ClientKind::ClaudeCode, dry_run, color);
}
} else {
let target = resolve_single_client(kind, scope, &project_root).ok_or_else(|| {
CliError::InvalidArgument(format!(
"{} is not available on this platform",
kind.display_name(),
))
})?;
if handle_target(&target, dry_run, color) {
any_error = true;
} else if !skip_instructions {
write_instructions_for_client(kind, dry_run, color);
}
}
} else {
let claude_code_present = which::which("claude").is_ok();
let targets = detect_clients(scope, &project_root);
let other_targets: Vec<&ConfigTarget> = targets
.iter()
.filter(|t| t.client != ClientKind::ClaudeCode)
.collect();
if !claude_code_present && other_targets.is_empty() {
eprintln!(" No AI coding clients detected in PATH.");
eprintln!();
eprintln!(" Supported clients: claude-code, claude-desktop, opencode, cursor");
eprintln!(" Run `seshat init <client>` to generate config for a specific client.");
return Ok(());
}
eprintln!(" Detected AI coding clients:");
eprintln!();
if claude_code_present {
let scope_hint = match scope {
ScopeRequest::Project => " (project → .mcp.json)",
_ => " (global → ~/.claude.json)",
};
if color {
eprintln!(
" {} claude — Claude Code{}",
"✓".green().bold(),
scope_hint.dimmed(),
);
} else {
eprintln!(" ✓ claude — Claude Code{scope_hint}");
}
}
for t in &other_targets {
let scope_hint = if t.is_project {
" (project)"
} else {
" (global)"
};
if color {
eprintln!(
" {} {} — {}{}",
"✓".green().bold(),
t.client.cli_name(),
t.client.display_name(),
scope_hint.dimmed(),
);
} else {
eprintln!(
" ✓ {} — {}{}",
t.client.cli_name(),
t.client.display_name(),
scope_hint,
);
}
}
eprintln!();
if claude_code_present {
let mcp_error = handle_claude_code_via_cli(scope, dry_run, color);
if mcp_error {
any_error = true;
} else if !skip_instructions {
write_instructions_for_client(ClientKind::ClaudeCode, dry_run, color);
}
}
for target in &other_targets {
let mcp_error = handle_target(target, dry_run, color);
if mcp_error {
any_error = true;
} else if !skip_instructions {
write_instructions_for_client(target.client, dry_run, color);
}
}
}
if any_error {
Err(CliError::CommandFailed {
command: "init".to_owned(),
reason: "one or more configs could not be updated".to_owned(),
})
} else {
Ok(())
}
}
fn write_instructions_for_client(client: ClientKind, dry_run: bool, color: bool) {
use crate::instructions::{
AGENTS_MD_CONTENT, HooksResult, SKILL_MD_CONTENT, SkillResult, claude_home,
install_hooks_claude_code, install_skill, opencode_config_dir, upsert_instructions,
};
match client {
ClientKind::ClaudeCode => {
let Some(claude_home) = claude_home() else {
print_error(
"Could not determine home directory; skipping instructions for Claude Code.",
color,
);
return;
};
let claude_md = claude_home.join("CLAUDE.md");
match upsert_instructions(&claude_md, AGENTS_MD_CONTENT, dry_run) {
Ok(result) => {
let msg = if dry_run {
format!("Instructions would be written to {}", claude_md.display())
} else {
format!(
"Instructions {} in {}",
result.description(),
claude_md.display()
)
};
print_ok(&msg, color);
}
Err(e) => print_error(&format!("Failed to write instructions: {e}"), color),
}
let skill_dir = claude_home.join("skills").join("seshat");
let skill_path = skill_dir.join("SKILL.md");
match install_skill(&skill_dir, SKILL_MD_CONTENT, dry_run) {
Ok(SkillResult::Installed) => {
print_ok(&format!("Skill installed: {}", skill_path.display()), color);
}
Ok(SkillResult::DryRun(Some(ref p))) => {
print_ok(&format!("Skill would be installed: {}", p.display()), color);
}
Ok(SkillResult::DryRun(None)) => {
print_ok("Skill dry-run (no changes written)", color);
}
Err(e) => print_error(&format!("Failed to install skill: {e}"), color),
}
let hooks_dir = claude_home.join("hooks");
let settings_path = claude_home.join("settings.json");
match install_hooks_claude_code(&hooks_dir, &settings_path, dry_run) {
Ok(HooksResult::Installed(Some(backup))) => print_ok(
&format!("Hooks registered (backup: {})", backup.display()),
color,
),
Ok(HooksResult::Installed(None)) => {
print_ok("Hooks registered in ~/.claude/settings.json", color)
}
Ok(HooksResult::DryRun { settings, .. }) => print_ok(
&format!("Hooks would be registered in {}", settings.display()),
color,
),
Err(e) => print_error(&format!("Failed to install hooks: {e}"), color),
}
}
ClientKind::OpenCode => {
let Some(opencode_dir) = opencode_config_dir() else {
print_error(
"Could not determine config directory; skipping instructions for OpenCode.",
color,
);
return;
};
let agents_md = opencode_dir.join("AGENTS.md");
match upsert_instructions(&agents_md, AGENTS_MD_CONTENT, dry_run) {
Ok(result) => print_ok(
&format!(
"Instructions {} in {}",
result.description(),
agents_md.display()
),
color,
),
Err(e) => print_error(&format!("Failed to write instructions: {e}"), color),
}
let skill_dir = opencode_dir.join("skills").join("seshat");
match install_skill(&skill_dir, SKILL_MD_CONTENT, dry_run) {
Ok(_) => print_ok(
&format!("Skill installed: {}", skill_dir.join("SKILL.md").display()),
color,
),
Err(e) => print_error(&format!("Failed to install skill: {e}"), color),
}
}
ClientKind::ClaudeDesktop | ClientKind::Cursor => {}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::tempdir;
#[test]
fn client_from_cli_name_known() {
assert_eq!(
ClientKind::from_cli_name("claude-code"),
Some(ClientKind::ClaudeCode)
);
assert_eq!(
ClientKind::from_cli_name("claude"),
Some(ClientKind::ClaudeCode)
);
assert_eq!(
ClientKind::from_cli_name("opencode"),
Some(ClientKind::OpenCode)
);
assert_eq!(
ClientKind::from_cli_name("cursor"),
Some(ClientKind::Cursor)
);
assert_eq!(
ClientKind::from_cli_name("claude-desktop"),
Some(ClientKind::ClaudeDesktop)
);
}
#[test]
fn client_from_cli_name_unknown() {
assert!(ClientKind::from_cli_name("vscode").is_none());
assert!(ClientKind::from_cli_name("").is_none());
}
#[test]
fn client_mcp_key_opencode_uses_mcp() {
assert_eq!(ClientKind::OpenCode.mcp_key(), "mcp");
}
#[test]
fn client_mcp_key_others_use_mcp_servers() {
assert_eq!(ClientKind::ClaudeCode.mcp_key(), "mcpServers");
assert_eq!(ClientKind::ClaudeDesktop.mcp_key(), "mcpServers");
assert_eq!(ClientKind::Cursor.mcp_key(), "mcpServers");
}
#[test]
fn snippet_lines_claude_code_structure() {
let lines = ClientKind::ClaudeCode.snippet_lines();
let joined = lines.join("\n");
assert!(joined.contains("\"seshat\":"));
assert!(joined.contains("\"command\""));
assert!(joined.contains("\"args\""));
assert!(joined.contains("\"serve\""));
}
#[test]
fn snippet_lines_opencode_contains_type_and_enabled() {
let lines = ClientKind::OpenCode.snippet_lines();
let joined = lines.join("\n");
assert!(joined.contains("\"type\""));
assert!(joined.contains("\"local\""));
assert!(joined.contains("\"enabled\""));
}
#[test]
fn full_file_lines_valid_json() {
let lines = ClientKind::ClaudeCode.full_file_lines();
let joined = lines.join("\n");
let _: serde_json::Value = serde_json::from_str(&joined).expect("full file is valid JSON");
}
#[test]
fn opencode_global_config_dir_respects_xdg_config_home() {
let result = opencode_global_config_dir();
assert!(result.is_some());
let dir = result.unwrap();
assert_eq!(dir.file_name().unwrap(), "opencode");
}
#[test]
fn opencode_global_config_dir_does_not_use_macos_library() {
let result = opencode_global_config_dir();
if let Some(dir) = result {
let path_str = dir.to_string_lossy();
assert!(
!path_str.contains("Library/Application Support"),
"OpenCode config path must not use macOS Library dir, got: {path_str}"
);
}
}
#[test]
fn detect_opencode_config_prefers_jsonc() {
let dir = tempdir().unwrap();
let json_path = dir.path().join("opencode.json");
let jsonc_path = dir.path().join("opencode.jsonc");
fs::write(&json_path, r#"{"mcp": {}}"#).unwrap();
fs::write(&jsonc_path, "// comment\n{\"mcp\": {}}").unwrap();
let target = find_opencode_config_in_dir(dir.path(), false);
assert_eq!(target.path, jsonc_path);
assert_eq!(target.format, ConfigFormat::Jsonc);
}
#[test]
fn detect_opencode_config_json_when_no_jsonc() {
let dir = tempdir().unwrap();
let json_path = dir.path().join("opencode.json");
fs::write(&json_path, r#"{"mcp": {}}"#).unwrap();
let target = find_opencode_config_in_dir(dir.path(), false);
assert_eq!(target.path, json_path);
assert_eq!(target.format, ConfigFormat::Json);
}
#[test]
fn detect_opencode_config_misnamed_json_with_comments_is_jsonc() {
let dir = tempdir().unwrap();
let json_path = dir.path().join("opencode.json");
fs::write(&json_path, "// comment\n{\"mcp\": {}}").unwrap();
let target = find_opencode_config_in_dir(dir.path(), false);
assert_eq!(target.format, ConfigFormat::Jsonc);
}
#[test]
fn detect_opencode_config_not_found_defaults_to_json() {
let dir = tempdir().unwrap();
let target = find_opencode_config_in_dir(dir.path(), false);
assert!(!target.exists);
assert_eq!(target.format, ConfigFormat::Json);
assert_eq!(target.path.file_name().unwrap(), "opencode.json");
}
#[test]
fn already_configured_json_true() {
let dir = tempdir().unwrap();
let path = dir.path().join("settings.json");
fs::write(
&path,
r#"{"mcpServers": {"seshat": {"command": "seshat"}}}"#,
)
.unwrap();
let target = ConfigTarget {
client: ClientKind::ClaudeCode,
path,
format: ConfigFormat::Json,
exists: true,
is_project: false,
};
assert!(is_already_configured(&target));
}
#[test]
fn already_configured_json_false() {
let dir = tempdir().unwrap();
let path = dir.path().join("settings.json");
fs::write(&path, r#"{"mcpServers": {"other": {}}}"#).unwrap();
let target = ConfigTarget {
client: ClientKind::ClaudeCode,
path,
format: ConfigFormat::Json,
exists: true,
is_project: false,
};
assert!(!is_already_configured(&target));
}
#[test]
fn already_configured_jsonc_text_search_true() {
let dir = tempdir().unwrap();
let path = dir.path().join("opencode.jsonc");
fs::write(
&path,
"// comment\n{\"mcp\": {\"seshat\": {\"type\": \"local\"}}}",
)
.unwrap();
let target = ConfigTarget {
client: ClientKind::OpenCode,
path,
format: ConfigFormat::Jsonc,
exists: true,
is_project: false,
};
assert!(is_already_configured(&target));
}
#[test]
fn already_configured_jsonc_no_false_positive_on_seshat_tools() {
let dir = tempdir().unwrap();
let path = dir.path().join("opencode.jsonc");
fs::write(
&path,
"// comment\n{\"mcp\": {\"seshat-tools\": {\"type\": \"local\"}}}",
)
.unwrap();
let target = ConfigTarget {
client: ClientKind::OpenCode,
path,
format: ConfigFormat::Jsonc,
exists: true,
is_project: false,
};
assert!(!is_already_configured(&target));
}
#[test]
fn already_configured_not_exists() {
let target = ConfigTarget {
client: ClientKind::ClaudeCode,
path: PathBuf::from("/nonexistent/settings.json"),
format: ConfigFormat::Json,
exists: false,
is_project: false,
};
assert!(!is_already_configured(&target));
}
#[test]
fn merge_mcp_servers_entry_adds_seshat() {
let mut value = serde_json::json!({"mcpServers": {"other": {}}});
merge_seshat_entry(&mut value, ClientKind::ClaudeCode).unwrap();
assert!(value["mcpServers"]["seshat"].is_object());
assert_eq!(value["mcpServers"]["seshat"]["command"], "seshat");
assert!(value["mcpServers"]["other"].is_object());
}
#[test]
fn merge_mcp_servers_creates_key_if_missing() {
let mut value = serde_json::json!({"model": "gpt-4"});
merge_seshat_entry(&mut value, ClientKind::ClaudeCode).unwrap();
assert!(value["mcpServers"]["seshat"].is_object());
assert_eq!(value["model"], "gpt-4");
}
#[test]
fn merge_mcp_entry_opencode_uses_mcp_key() {
let mut value = serde_json::json!({});
merge_seshat_entry(&mut value, ClientKind::OpenCode).unwrap();
assert!(value["mcp"]["seshat"].is_object());
assert_eq!(value["mcp"]["seshat"]["type"], "local");
assert!(value["mcp"]["seshat"]["enabled"].as_bool().unwrap_or(false));
}
#[test]
fn merge_seshat_entry_rejects_non_object_root() {
let mut value = serde_json::json!([1, 2, 3]);
let err = merge_seshat_entry(&mut value, ClientKind::ClaudeCode);
assert!(err.is_err());
assert!(err.unwrap_err().to_string().contains("not a JSON object"));
}
#[test]
fn merge_seshat_entry_rejects_null_root() {
let mut value = serde_json::Value::Null;
assert!(merge_seshat_entry(&mut value, ClientKind::ClaudeCode).is_err());
}
#[test]
fn backup_filename_has_timestamp_suffix() {
let dir = tempdir().unwrap();
let path = dir.path().join("settings.json");
fs::write(&path, "{}").unwrap();
let backup = write_backup(&path).expect("backup should succeed");
let name = backup.file_name().unwrap().to_string_lossy();
assert!(name.starts_with("settings.json.seshat-backup."));
let ts_part = name.split('.').next_back().unwrap_or("");
assert!(
ts_part.parse::<u128>().is_ok(),
"timestamp must be numeric: {ts_part}"
);
assert!(backup.exists());
}
#[test]
fn patch_json_config_adds_entry_and_creates_backup() {
let dir = tempdir().unwrap();
let path = dir.path().join("settings.json");
fs::write(&path, r#"{"globalShortcut": ""}"#).unwrap();
let target = ConfigTarget {
client: ClientKind::ClaudeCode,
path: path.clone(),
format: ConfigFormat::Json,
exists: true,
is_project: false,
};
let result = patch_json_config(&target).expect("patch should succeed");
let backup = result
.backup_path
.expect("backup should be Some for existing file");
assert!(backup.exists());
assert_eq!(
fs::read_to_string(&backup).unwrap(),
r#"{"globalShortcut": ""}"#
);
let updated: serde_json::Value =
serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
assert!(updated["mcpServers"]["seshat"].is_object());
assert_eq!(updated["globalShortcut"], "");
}
#[test]
fn patch_json_config_creates_new_file_no_backup() {
let dir = tempdir().unwrap();
let path = dir.path().join("new_settings.json");
let target = ConfigTarget {
client: ClientKind::ClaudeCode,
path: path.clone(),
format: ConfigFormat::Json,
exists: false,
is_project: false,
};
let result = patch_json_config(&target).expect("patch should succeed");
assert!(result.backup_path.is_none(), "no backup for new file");
assert!(path.exists());
let created: serde_json::Value =
serde_json::from_str(&fs::read_to_string(&path).unwrap()).unwrap();
assert!(created["mcpServers"]["seshat"].is_object());
}
#[test]
fn patch_json_config_fails_on_non_object_json() {
let dir = tempdir().unwrap();
let path = dir.path().join("bad.json");
fs::write(&path, "[1, 2, 3]").unwrap();
let target = ConfigTarget {
client: ClientKind::ClaudeCode,
path,
format: ConfigFormat::Json,
exists: true,
is_project: false,
};
let err = patch_json_config(&target);
assert!(err.is_err());
assert!(err.unwrap_err().to_string().contains("not a JSON object"));
}
#[test]
fn is_valid_json_true_for_clean_json() {
let dir = tempdir().unwrap();
let path = dir.path().join("f.json");
fs::write(&path, r#"{"key": "value"}"#).unwrap();
assert!(is_valid_json(&path));
}
#[test]
fn is_valid_json_false_for_jsonc() {
let dir = tempdir().unwrap();
let path = dir.path().join("f.json");
fs::write(&path, "// comment\n{}").unwrap();
assert!(!is_valid_json(&path));
}
#[test]
fn client_kind_display_name_all_variants() {
assert_eq!(ClientKind::ClaudeCode.display_name(), "Claude Code");
assert_eq!(ClientKind::ClaudeDesktop.display_name(), "Claude Desktop");
assert_eq!(ClientKind::OpenCode.display_name(), "OpenCode");
assert_eq!(ClientKind::Cursor.display_name(), "Cursor");
}
#[test]
fn client_kind_cli_name_all_variants() {
assert_eq!(ClientKind::ClaudeCode.cli_name(), "claude-code");
assert_eq!(ClientKind::ClaudeDesktop.cli_name(), "claude-desktop");
assert_eq!(ClientKind::OpenCode.cli_name(), "opencode");
assert_eq!(ClientKind::Cursor.cli_name(), "cursor");
}
#[test]
fn client_kind_mcp_key() {
assert_eq!(ClientKind::OpenCode.mcp_key(), "mcp");
assert_eq!(ClientKind::ClaudeCode.mcp_key(), "mcpServers");
assert_eq!(ClientKind::ClaudeDesktop.mcp_key(), "mcpServers");
assert_eq!(ClientKind::Cursor.mcp_key(), "mcpServers");
}
#[test]
fn from_cli_name_claude_alias() {
assert_eq!(
ClientKind::from_cli_name("claude"),
Some(ClientKind::ClaudeCode)
);
}
#[test]
fn seshat_entry_json_opencode() {
let entry = ClientKind::OpenCode.seshat_entry_json();
assert_eq!(entry["type"], "local");
assert_eq!(entry["enabled"], true);
}
#[test]
fn seshat_entry_json_claude_code() {
let entry = ClientKind::ClaudeCode.seshat_entry_json();
assert_eq!(entry["command"], "seshat");
}
#[test]
fn snippet_lines_opencode_produces_multiple_lines() {
let lines = ClientKind::OpenCode.snippet_lines();
assert!(!lines.is_empty());
assert!(lines[0].contains("\"seshat\":"));
}
#[test]
fn snippet_lines_claude_code() {
let lines = ClientKind::ClaudeCode.snippet_lines();
assert!(lines[0].contains("\"seshat\":"));
}
#[test]
fn full_file_lines_opencode() {
let lines = ClientKind::OpenCode.full_file_lines();
assert!(!lines.is_empty());
let joined = lines.join("");
assert!(joined.contains("mcp"));
assert!(joined.contains("seshat"));
}
#[test]
fn full_file_lines_claude_code() {
let lines = ClientKind::ClaudeCode.full_file_lines();
let joined = lines.join("");
assert!(joined.contains("mcpServers"));
assert!(joined.contains("seshat"));
}
#[test]
fn claude_scope_arg_project_is_local() {
assert_eq!(claude_scope_arg(ScopeRequest::Project), "local");
}
#[test]
fn claude_scope_arg_global_is_user() {
assert_eq!(claude_scope_arg(ScopeRequest::Global), "user");
}
#[test]
fn claude_scope_arg_auto_is_user() {
assert_eq!(claude_scope_arg(ScopeRequest::Auto), "user");
}
#[test]
fn find_opencode_config_prefers_jsonc() {
let dir = tempdir().unwrap();
fs::write(dir.path().join("opencode.jsonc"), "{}").unwrap();
fs::write(dir.path().join("opencode.json"), "{}").unwrap();
let target = find_opencode_config_in_dir(dir.path(), true);
assert_eq!(target.format, ConfigFormat::Jsonc);
}
#[test]
fn detect_opencode_config_falls_back_to_json() {
let dir = tempdir().unwrap();
fs::write(dir.path().join("opencode.json"), "{}").unwrap();
let target = find_opencode_config_in_dir(dir.path(), true);
assert_eq!(target.format, ConfigFormat::Json);
}
#[test]
fn patch_json_config_parent_dirs_created() {
let dir = tempdir().unwrap();
let new_dir = dir.path().join("nested").join("deep");
let path = new_dir.join("settings.json");
let target = ConfigTarget {
client: ClientKind::ClaudeCode,
path,
format: ConfigFormat::Json,
exists: false,
is_project: false,
};
let result = patch_json_config(&target).expect("patch should succeed");
assert!(result.backup_path.is_none());
assert!(new_dir.join("settings.json").exists());
}
#[test]
fn resolve_single_client_claude_code_returns_none() {
let dir = tempdir().unwrap();
let target = resolve_single_client(ClientKind::ClaudeCode, ScopeRequest::Auto, dir.path());
assert!(target.is_none());
}
#[test]
fn run_init_unknown_client_returns_error() {
let result = run_init(Some("unknown-client"), ScopeRequest::Auto, false, false);
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.to_string().contains("Unknown client"));
}
#[test]
fn run_init_empty_scope_with_none_client_auto_detects() {
let result = run_init(None, ScopeRequest::Auto, true, false);
assert!(result.is_ok());
}
#[test]
fn run_init_dry_run_skips_modifications() {
let dir = tempdir().unwrap();
let _guard = set_project_dir(dir.path());
let result = run_init(Some("opencode"), ScopeRequest::Project, true, false);
assert!(result.is_ok());
}
fn set_project_dir(path: &std::path::Path) -> impl Drop {
let old = std::env::current_dir().ok();
std::env::set_current_dir(path).ok();
struct RestoreCwd(Option<std::path::PathBuf>);
impl Drop for RestoreCwd {
fn drop(&mut self) {
if let Some(ref old) = self.0 {
let _ = std::env::set_current_dir(old);
}
}
}
RestoreCwd(old)
}
#[test]
fn merge_seshat_entry_overwrites_existing_seshat() {
let mut value = serde_json::json!({
"mcpServers": {"seshat": {"command": "stale"}}
});
merge_seshat_entry(&mut value, ClientKind::ClaudeCode).unwrap();
assert_eq!(value["mcpServers"]["seshat"]["command"], "seshat");
}
#[test]
fn merge_seshat_entry_rejects_array_root_message_says_array() {
let mut value = serde_json::json!([1, 2, 3]);
let err = merge_seshat_entry(&mut value, ClientKind::ClaudeCode).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("array"));
}
#[test]
fn merge_seshat_entry_rejects_string_root_message_says_string() {
let mut value = serde_json::Value::String("hello".to_owned());
let err = merge_seshat_entry(&mut value, ClientKind::OpenCode).unwrap_err();
assert!(err.to_string().contains("string"));
}
#[test]
fn merge_seshat_entry_rejects_number_root_message_says_number() {
let mut value = serde_json::json!(42);
let err = merge_seshat_entry(&mut value, ClientKind::ClaudeCode).unwrap_err();
assert!(err.to_string().contains("number"));
}
#[test]
fn merge_seshat_entry_rejects_null_root_message_says_null() {
let mut value = serde_json::Value::Null;
let err = merge_seshat_entry(&mut value, ClientKind::ClaudeCode).unwrap_err();
assert!(err.to_string().contains("null"));
}
#[test]
fn merge_seshat_entry_rejects_bool_root_message_says_bool() {
let mut value = serde_json::Value::Bool(true);
let err = merge_seshat_entry(&mut value, ClientKind::ClaudeCode).unwrap_err();
assert!(err.to_string().contains("bool"));
}
#[test]
fn is_already_configured_false_for_nonexistent_path() {
let dir = tempdir().unwrap();
let target = ConfigTarget {
client: ClientKind::ClaudeCode,
path: dir.path().join("missing.json"),
format: ConfigFormat::Json,
exists: false,
is_project: false,
};
assert!(!is_already_configured(&target));
}
#[test]
fn is_already_configured_true_when_seshat_present_in_json() {
let dir = tempdir().unwrap();
let path = dir.path().join("settings.json");
fs::write(
&path,
r#"{"mcpServers": {"seshat": {"command": "seshat"}}}"#,
)
.unwrap();
let target = ConfigTarget {
client: ClientKind::ClaudeCode,
path,
format: ConfigFormat::Json,
exists: true,
is_project: false,
};
assert!(is_already_configured(&target));
}
#[test]
fn is_already_configured_false_when_only_other_servers() {
let dir = tempdir().unwrap();
let path = dir.path().join("settings.json");
fs::write(&path, r#"{"mcpServers": {"other": {}}}"#).unwrap();
let target = ConfigTarget {
client: ClientKind::ClaudeCode,
path,
format: ConfigFormat::Json,
exists: true,
is_project: false,
};
assert!(!is_already_configured(&target));
}
#[test]
fn is_already_configured_false_when_no_mcp_key() {
let dir = tempdir().unwrap();
let path = dir.path().join("settings.json");
fs::write(&path, r#"{"otherStuff": true}"#).unwrap();
let target = ConfigTarget {
client: ClientKind::ClaudeCode,
path,
format: ConfigFormat::Json,
exists: true,
is_project: false,
};
assert!(!is_already_configured(&target));
}
#[test]
fn is_already_configured_false_when_invalid_json() {
let dir = tempdir().unwrap();
let path = dir.path().join("broken.json");
fs::write(&path, "{not valid").unwrap();
let target = ConfigTarget {
client: ClientKind::ClaudeCode,
path,
format: ConfigFormat::Json,
exists: true,
is_project: false,
};
assert!(!is_already_configured(&target));
}
#[test]
fn is_already_configured_jsonc_text_search() {
let dir = tempdir().unwrap();
let path = dir.path().join("opencode.jsonc");
fs::write(
&path,
"// some comment\n{ \"mcp\": { \"seshat\": {\"command\": \"x\"} } }",
)
.unwrap();
let target = ConfigTarget {
client: ClientKind::OpenCode,
path,
format: ConfigFormat::Jsonc,
exists: true,
is_project: false,
};
assert!(is_already_configured(&target));
}
#[test]
fn is_already_configured_jsonc_no_match() {
let dir = tempdir().unwrap();
let path = dir.path().join("opencode.jsonc");
fs::write(&path, "// note about seshat-tools\n{}").unwrap();
let target = ConfigTarget {
client: ClientKind::OpenCode,
path,
format: ConfigFormat::Jsonc,
exists: true,
is_project: false,
};
assert!(!is_already_configured(&target));
}
#[test]
fn find_opencode_config_neither_exists_returns_non_existing_json_target() {
let dir = tempdir().unwrap();
let target = find_opencode_config_in_dir(dir.path(), false);
assert_eq!(target.format, ConfigFormat::Json);
assert!(!target.exists);
assert!(target.path.ends_with("opencode.json"));
}
#[test]
fn find_opencode_config_json_with_comments_treated_as_jsonc() {
let dir = tempdir().unwrap();
fs::write(dir.path().join("opencode.json"), "// comment\n{}").unwrap();
let target = find_opencode_config_in_dir(dir.path(), true);
assert_eq!(target.format, ConfigFormat::Jsonc);
assert!(target.exists);
}
#[test]
fn find_opencode_config_marks_is_project_correctly() {
let dir = tempdir().unwrap();
fs::write(dir.path().join("opencode.json"), "{}").unwrap();
let proj = find_opencode_config_in_dir(dir.path(), true);
let global = find_opencode_config_in_dir(dir.path(), false);
assert!(proj.is_project);
assert!(!global.is_project);
}
#[test]
fn resolve_cursor_config_project_scope_uses_project_dir() {
let dir = tempdir().unwrap();
let target = resolve_cursor_config(ScopeRequest::Project, dir.path()).unwrap();
assert_eq!(target.client, ClientKind::Cursor);
assert!(target.is_project);
assert!(target.path.starts_with(dir.path()));
assert!(target.path.ends_with("mcp.json"));
}
#[test]
fn resolve_cursor_config_auto_picks_project_when_exists() {
let dir = tempdir().unwrap();
let cursor_dir = dir.path().join(".cursor");
fs::create_dir_all(&cursor_dir).unwrap();
fs::write(cursor_dir.join("mcp.json"), "{}").unwrap();
let target = resolve_cursor_config(ScopeRequest::Auto, dir.path()).unwrap();
assert!(target.is_project);
assert!(target.path.starts_with(dir.path()));
}
#[test]
fn resolve_cursor_config_auto_falls_back_to_global() {
let dir = tempdir().unwrap();
let target = resolve_cursor_config(ScopeRequest::Auto, dir.path()).unwrap();
assert!(!target.is_project);
assert!(!target.path.starts_with(dir.path()));
}
#[test]
fn resolve_single_client_opencode_returns_target() {
let dir = tempdir().unwrap();
let target = resolve_single_client(ClientKind::OpenCode, ScopeRequest::Project, dir.path());
assert!(target.is_some());
assert_eq!(target.unwrap().client, ClientKind::OpenCode);
}
#[test]
fn resolve_single_client_cursor_returns_target() {
let dir = tempdir().unwrap();
let target = resolve_single_client(ClientKind::Cursor, ScopeRequest::Project, dir.path());
assert!(target.is_some());
assert_eq!(target.unwrap().client, ClientKind::Cursor);
}
#[test]
fn opencode_global_config_dir_respects_xdg_when_set() {
struct EnvGuard {
key: &'static str,
old: Option<std::ffi::OsString>,
}
impl Drop for EnvGuard {
fn drop(&mut self) {
unsafe {
match &self.old {
Some(v) => std::env::set_var(self.key, v),
None => std::env::remove_var(self.key),
}
}
}
}
let _g = EnvGuard {
key: "XDG_CONFIG_HOME",
old: std::env::var_os("XDG_CONFIG_HOME"),
};
let xdg = tempdir().expect("xdg tempdir");
unsafe {
std::env::set_var("XDG_CONFIG_HOME", xdg.path());
}
let dir = opencode_global_config_dir().expect("should resolve");
assert_eq!(dir.file_name().and_then(|s| s.to_str()), Some("opencode"));
assert_eq!(dir.parent(), Some(xdg.path()));
}
#[test]
fn opencode_global_config_dir_empty_xdg_falls_back_to_home() {
struct EnvGuard {
key: &'static str,
old: Option<std::ffi::OsString>,
}
impl Drop for EnvGuard {
fn drop(&mut self) {
unsafe {
match &self.old {
Some(v) => std::env::set_var(self.key, v),
None => std::env::remove_var(self.key),
}
}
}
}
let _g = EnvGuard {
key: "XDG_CONFIG_HOME",
old: std::env::var_os("XDG_CONFIG_HOME"),
};
unsafe {
std::env::set_var("XDG_CONFIG_HOME", "");
}
let dir = opencode_global_config_dir();
if let Some(d) = dir {
assert!(d.ends_with("opencode"));
assert!(d.to_string_lossy().contains(".config"));
}
}
}