diff --git a/crates/codegen/kigi-bin/src/main.rs b/crates/codegen/kigi-bin/src/main.rs index a860edc..df3ba92 100644 --- a/crates/codegen/kigi-bin/src/main.rs +++ b/crates/codegen/kigi-bin/src/main.rs @@ -1567,6 +1567,10 @@ async fn async_main() -> Result<()> { kigi_shell::inspect::inspect(&cwd, json).await?; return Ok(()); } + Command::ImportKimi { dry_run } => { + init_tracing_simple("cli"); + return kigi_tui::import_kimi_cmd::run(dry_run); + } Command::Setup { json } => { init_tracing_simple("cli"); run_setup_command(json).await; diff --git a/crates/codegen/kigi-shell/src/kimi_import.rs b/crates/codegen/kigi-shell/src/kimi_import.rs new file mode 100644 index 0000000..2c1f6ee --- /dev/null +++ b/crates/codegen/kigi-shell/src/kimi_import.rs @@ -0,0 +1,966 @@ +// kimi_import.rs +// One-time, READ-ONLY import of the official kimi-cli configuration (PRD F7). +// +// Sources (shapes ported from kimi-cli 1.49.0): +// - `~/.kimi/config.toml`: top-level `default_model` plus `[models.]` +// (`src/kimi_cli/config.py::LLMModel { provider, model, max_context_size, +// capabilities }`) and `[providers.]` +// (`src/kimi_cli/config.py::LLMProvider { type, base_url, api_key, env, +// custom_headers }`). +// - `~/.kimi/mcp.json`: `{"mcpServers": {...}}` — the exact shape kigi +// already parses via [`McpConfig`]. +// +// READ-ONLY invariant: nothing under the kimi dir is ever written, modified, +// or deleted — the scanner opens both files with plain `fs::read_to_string` +// (contents and mtimes stay untouched) and `apply_*` receives an owned plan, +// writing exclusively into the kigi home. Keyring credentials are NOT +// imported (kigi has its own login). `KIMI_SHARE_DIR` (or any `KIMI_*` env +// var) is never consulted — the official dir is hardcoded to `~/.kimi`. + +use std::path::{Path, PathBuf}; + +use indexmap::IndexMap; +use serde::Deserialize; +use toml::Value as TomlValue; +use toml::map::Map as TomlMap; +use tracing::{debug, info}; + +use crate::util::config::{McpConfig, McpServerConfig, McpServerTransportConfig}; +use kigi_models::PlatformId; + +// Types + +/// A kimi-cli model entry that maps to a kigi `[model.]` custom entry +/// (non-built-in provider). +#[derive(Debug, Clone)] +pub struct KimiCustomModel { + /// kimi `[models.]` key; becomes the kigi `[model.]` key. + pub alias: String, + /// Provider-side model id (`LLMModel.model`). + pub model: String, + /// `LLMProvider.base_url` of the model's provider. + pub base_url: String, + /// `LLMProvider.api_key`. SECRET: flows only into the user's own + /// config.toml (same trust domain); never logged or shown in summaries. + pub api_key: Option, + /// `LLMModel.max_context_size` → kigi `context_window`. + pub context_window: Option, +} + +/// What a scan of `~/.kimi` found to import. +#[derive(Debug, Clone, Default)] +pub struct KimiImportPlan { + /// MCP servers from `~/.kimi/mcp.json`, in file order. + pub mcp_servers: Vec<(String, McpServerConfig)>, + /// Models on non-built-in providers, imported as `[model.]`. + pub custom_models: Vec, + /// The kigi `[models] default` value the kimi `default_model` maps to: + /// the alias itself for an imported custom model, or the managed catalog + /// key (`{platform_id}/{model_id}`) for a built-in-platform model. + pub default_model: Option, + /// Models skipped because kigi's built-in platform registry already + /// covers their provider: `(alias, managed catalog key)`. + pub skipped_builtin: Vec<(String, String)>, + /// Non-fatal caveats surfaced in the summary. + pub notes: Vec, +} + +impl KimiImportPlan { + /// Whether there is nothing to import. Built-in-platform models alone + /// don't count — kigi already ships those platforms. + pub fn is_empty(&self) -> bool { + self.mcp_servers.is_empty() && self.custom_models.is_empty() && self.default_model.is_none() + } + + /// Human-readable summary. Never includes api_key values. + pub fn summary(&self) -> String { + let mut out = String::from("Found kimi-cli settings to import from ~/.kimi:\n"); + if !self.mcp_servers.is_empty() { + out.push_str(&format!(" - {} MCP server(s)\n", self.mcp_servers.len())); + for (name, config) in &self.mcp_servers { + let target = match &config.transport { + McpServerTransportConfig::Stdio { command, args, .. } => { + if args.is_empty() { + command.clone() + } else { + format!("{} {}", command, args.join(" ")) + } + } + McpServerTransportConfig::StreamableHttp { url, .. } => url.clone(), + }; + out.push_str(&format!(" {name}: {target}\n")); + } + } + if !self.custom_models.is_empty() { + out.push_str(&format!( + " - {} custom model(s)\n", + self.custom_models.len() + )); + for m in &self.custom_models { + // Redact the key: only its presence and length are shown, so + // the secret never reaches terminals, screenshots, or logs. + let key_note = match &m.api_key { + Some(k) => format!(" (api key: )", k.len()), + None => String::new(), + }; + out.push_str(&format!( + " {}: {} @ {}{}\n", + m.alias, m.model, m.base_url, key_note + )); + } + } + if let Some(default) = &self.default_model { + out.push_str(&format!(" - default model: {default}\n")); + } + if !self.skipped_builtin.is_empty() { + out.push_str(" Skipped (kigi already has these platforms built in):\n"); + for (alias, managed_key) in &self.skipped_builtin { + out.push_str(&format!(" {alias} -> {managed_key}\n")); + } + } + for note in &self.notes { + out.push_str(&format!(" Note: {note}\n")); + } + out + } +} + +// kimi-cli config.toml wire shapes (subset we import) + +#[derive(Debug, Deserialize)] +struct KimiConfigToml { + #[serde(default)] + default_model: String, + #[serde(default)] + models: IndexMap, + #[serde(default)] + providers: IndexMap, +} + +/// `src/kimi_cli/config.py::LLMModel` (fields we import). +#[derive(Debug, Deserialize)] +struct KimiModelToml { + provider: String, + model: String, + /// Required in kimi-cli; tolerated as absent here because kigi has its + /// own context-window default. + #[serde(default)] + max_context_size: Option, +} + +/// `src/kimi_cli/config.py::LLMProvider` (fields we import). +#[derive(Debug, Deserialize)] +struct KimiProviderToml { + #[serde(rename = "type")] + provider_type: String, + #[serde(default)] + base_url: String, + #[serde(default)] + api_key: Option, +} + +/// Built-in kigi platform a kimi provider duplicates, if any: provider type +/// `kimi` is the Kimi Code subscription channel; the two Moonshot open +/// platforms are recognized by their fixed production hosts (the same hosts +/// `kigi_models::PlatformId::base_url` compiles in). +fn builtin_platform(provider: &KimiProviderToml) -> Option { + if provider.provider_type == "kimi" { + return Some(PlatformId::KimiCode); + } + match url_host(&provider.base_url) { + Some("api.moonshot.cn") => Some(PlatformId::MoonshotCn), + Some("api.moonshot.ai") => Some(PlatformId::MoonshotAi), + _ => None, + } +} + +/// Host component of an http(s) URL. `None` for other schemes. +fn url_host(url: &str) -> Option<&str> { + let rest = url + .strip_prefix("https://") + .or_else(|| url.strip_prefix("http://"))?; + let end = rest.find(['/', ':', '?']).unwrap_or(rest.len()); + Some(&rest[..end]) +} + +// Scanner + +/// The official kimi-cli share dir. Hardcoded — `KIMI_SHARE_DIR` is +/// deliberately NOT consulted (PRD F7). +pub fn default_kimi_dir() -> Option { + dirs::home_dir().map(|home| home.join(".kimi")) +} + +/// Scan `~/.kimi` for importable settings. +/// +/// Returns `Ok(None)` when the one-time marker is already set, when +/// `~/.kimi/config.toml` is absent, or when the plan would be empty. +/// Malformed files are an error (surfaced by `kigi import-kimi`), not a +/// silent "nothing to import". +pub fn scan() -> anyhow::Result> { + if is_kimi_import_marked() { + return Ok(None); + } + let Some(kimi_dir) = default_kimi_dir() else { + return Ok(None); + }; + scan_kimi_dir(&kimi_dir) +} + +/// Testable variant of [`scan`]: marker checked at an explicit path, kimi dir +/// passed in. Never touches the real home. +pub fn scan_with_marker( + kimi_dir: &Path, + marker_path: &Path, +) -> anyhow::Result> { + if marker_path.exists() { + return Ok(None); + } + scan_kimi_dir(kimi_dir) +} + +/// Read `/config.toml` + `/mcp.json` (read-only) and +/// build the plan. `Ok(None)` when config.toml is absent or the plan is empty. +fn scan_kimi_dir(kimi_dir: &Path) -> anyhow::Result> { + let config_path = kimi_dir.join("config.toml"); + let raw = match std::fs::read_to_string(&config_path) { + Ok(s) => s, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(e) => { + return Err(anyhow::anyhow!( + "cannot read {}: {e}", + config_path.display() + )); + } + }; + let config: KimiConfigToml = toml::from_str(&raw).map_err(|e| { + anyhow::anyhow!( + "{} is not a valid kimi-cli config ({e}). Fix the file and re-run \ + `kigi import-kimi`.", + config_path.display() + ) + })?; + + let mut plan = KimiImportPlan::default(); + + for (alias, model) in &config.models { + let Some(provider) = config.providers.get(&model.provider) else { + return Err(anyhow::anyhow!( + "{}: model '{alias}' references provider '{}' which has no \ + [providers.{}] entry. Fix the file and re-run `kigi import-kimi`.", + config_path.display(), + model.provider, + model.provider + )); + }; + if let Some(platform) = builtin_platform(provider) { + plan.skipped_builtin + .push((alias.clone(), platform.managed_model_key(&model.model))); + continue; + } + if provider.base_url.is_empty() { + return Err(anyhow::anyhow!( + "{}: provider '{}' has an empty base_url. Fix the file and \ + re-run `kigi import-kimi`.", + config_path.display(), + model.provider + )); + } + plan.custom_models.push(KimiCustomModel { + alias: alias.clone(), + model: model.model.clone(), + base_url: provider.base_url.clone(), + api_key: provider.api_key.clone().filter(|k| !k.is_empty()), + context_window: model.max_context_size, + }); + } + + if !config.default_model.is_empty() { + let alias = &config.default_model; + if plan.custom_models.iter().any(|m| &m.alias == alias) { + plan.default_model = Some(alias.clone()); + } else if let Some((_, managed_key)) = plan.skipped_builtin.iter().find(|(a, _)| a == alias) + { + // kigi refers to built-in platform models by their managed + // catalog key ({platform_id}/{model_id}). + plan.default_model = Some(managed_key.clone()); + } else { + // kimi-cli validates default_model against [models], so this only + // happens with a hand-edited file. Not worth failing the import. + plan.notes.push(format!( + "default_model '{alias}' has no [models.{alias}] entry; \ + default model preference not imported" + )); + } + } + + let mcp_path = kimi_dir.join("mcp.json"); + match std::fs::read_to_string(&mcp_path) { + Ok(s) => { + let mcp: McpConfig = serde_json::from_str(&s).map_err(|e| { + anyhow::anyhow!( + "{} is not valid MCP JSON ({e}). Fix the file and re-run \ + `kigi import-kimi`.", + mcp_path.display() + ) + })?; + plan.mcp_servers = mcp.mcp_servers.into_iter().collect(); + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => return Err(anyhow::anyhow!("cannot read {}: {e}", mcp_path.display())), + } + + if plan.is_empty() { + debug!( + "kimi import scan: nothing importable in {}", + kimi_dir.display() + ); + return Ok(None); + } + info!( + mcp = plan.mcp_servers.len(), + custom_models = plan.custom_models.len(), + skipped_builtin = plan.skipped_builtin.len(), + default_model = plan.default_model.is_some(), + "Scanned kimi-cli settings for import" + ); + Ok(Some(plan)) +} + +/// Display-only helper for the welcome-screen hint: true when a scan would +/// offer something. Scan errors (unreadable or malformed `~/.kimi` files) are +/// reported as `false` with a debug log — `kigi import-kimi` is the surface +/// that explains failures; the startup hint must never block launch. +pub fn has_pending_kimi_import() -> bool { + match scan() { + Ok(Some(_)) => true, + Ok(None) => false, + Err(e) => { + debug!(error = %e, "kimi import scan failed; hiding startup hint"); + false + } + } +} + +// Applier + +/// Result of applying a [`KimiImportPlan`]. +#[derive(Debug, Default)] +pub struct KimiApplied { + /// The kigi config file that received the entries. + pub config_path: PathBuf, + /// The one-time marker file written under the kigi home. + pub marker_path: PathBuf, + pub mcp_added: Vec, + /// Names already present in kigi config, left untouched (never clobber). + pub mcp_skipped_existing: Vec, + pub models_added: Vec, + /// Aliases already present as `[model.]`, left untouched. + pub models_skipped_existing: Vec, + /// The `[models] default` value written, if any. + pub default_model_set: Option, + /// True when `[models] default` was already set and left untouched. + pub default_model_kept_existing: bool, +} + +impl KimiApplied { + pub fn total_added(&self) -> usize { + self.mcp_added.len() + + self.models_added.len() + + usize::from(self.default_model_set.is_some()) + } + + /// Human-readable result summary. + pub fn summary(&self) -> String { + let mut out = format!("Imported into {}:\n", self.config_path.display()); + if !self.mcp_added.is_empty() { + out.push_str(&format!( + " - added MCP server(s): {}\n", + self.mcp_added.join(", ") + )); + } + if !self.mcp_skipped_existing.is_empty() { + out.push_str(&format!( + " - kept existing MCP server(s) (not overwritten): {}\n", + self.mcp_skipped_existing.join(", ") + )); + } + if !self.models_added.is_empty() { + out.push_str(&format!( + " - added model(s): {}\n", + self.models_added.join(", ") + )); + } + if !self.models_skipped_existing.is_empty() { + out.push_str(&format!( + " - kept existing model(s) (not overwritten): {}\n", + self.models_skipped_existing.join(", ") + )); + } + if let Some(default) = &self.default_model_set { + out.push_str(&format!(" - set default model: {default}\n")); + } + if self.default_model_kept_existing { + out.push_str(" - kept existing default model (not overwritten)\n"); + } + if self.total_added() == 0 { + out.push_str(" - nothing new to add\n"); + } + out.push_str(&format!( + "One-time import complete; marker written to {}.\n", + self.marker_path.display() + )); + out + } +} + +/// Apply the plan to the user's kigi home and set the one-time marker. +pub fn apply(plan: &KimiImportPlan) -> anyhow::Result { + let applied = apply_at(plan, &crate::util::kigi_home::kigi_home())?; + refresh_kimi_marker_cache(true); + Ok(applied) +} + +/// Testable variant of [`apply`]: writes ONLY under `kigi_home` (config.toml +/// merge + marker file). Existing entries are never overwritten. +pub fn apply_at(plan: &KimiImportPlan, kigi_home: &Path) -> anyhow::Result { + let config_path = kigi_home.join("config.toml"); + // Surface parse errors instead of silently discarding the file — an + // atomic rewrite would otherwise drop unrelated sections (same policy as + // claude_import::apply_items_to_config). + let mut root: TomlValue = match std::fs::read_to_string(&config_path) { + Ok(s) => toml::from_str(&s).map_err(|e| { + anyhow::anyhow!( + "refusing to import: existing config at {} is not valid TOML \ + ({e}). Fix the file (or move it aside) and retry.", + config_path.display() + ) + })?, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => TomlValue::Table(TomlMap::new()), + Err(e) => return Err(e.into()), + }; + let table = root + .as_table_mut() + .ok_or_else(|| anyhow::anyhow!("config root is not a table"))?; + + let mut applied = KimiApplied { + config_path: config_path.clone(), + marker_path: kigi_home.join(KIMI_IMPORT_MARKER_FILE), + ..KimiApplied::default() + }; + + if !plan.mcp_servers.is_empty() { + let servers = table + .entry("mcp_servers") + .or_insert_with(|| TomlValue::Table(TomlMap::new())) + .as_table_mut() + .ok_or_else(|| anyhow::anyhow!("[mcp_servers] is not a table"))?; + for (name, config) in &plan.mcp_servers { + if servers.contains_key(name) { + applied.mcp_skipped_existing.push(name.clone()); + continue; + } + let serialized = TomlValue::try_from(config) + .map_err(|e| anyhow::anyhow!("failed to serialize MCP server {name}: {e}"))?; + servers.insert(name.clone(), serialized); + applied.mcp_added.push(name.clone()); + } + } + + if !plan.custom_models.is_empty() { + let models = table + .entry("model") + .or_insert_with(|| TomlValue::Table(TomlMap::new())) + .as_table_mut() + .ok_or_else(|| anyhow::anyhow!("[model] is not a table"))?; + for m in &plan.custom_models { + if models.contains_key(&m.alias) { + applied.models_skipped_existing.push(m.alias.clone()); + continue; + } + let mut entry = TomlMap::new(); + entry.insert("model".to_string(), TomlValue::String(m.model.clone())); + entry.insert( + "base_url".to_string(), + TomlValue::String(m.base_url.clone()), + ); + if let Some(api_key) = &m.api_key { + entry.insert("api_key".to_string(), TomlValue::String(api_key.clone())); + } + if let Some(cw) = m.context_window { + let cw = i64::try_from(cw).map_err(|_| { + anyhow::anyhow!( + "model '{}': max_context_size {cw} does not fit a TOML integer", + m.alias + ) + })?; + entry.insert("context_window".to_string(), TomlValue::Integer(cw)); + } + models.insert(m.alias.clone(), TomlValue::Table(entry)); + applied.models_added.push(m.alias.clone()); + } + } + + if let Some(default) = &plan.default_model { + let models_section = table + .entry("models") + .or_insert_with(|| TomlValue::Table(TomlMap::new())) + .as_table_mut() + .ok_or_else(|| anyhow::anyhow!("[models] is not a table"))?; + if models_section.contains_key("default") { + applied.default_model_kept_existing = true; + } else { + models_section.insert("default".to_string(), TomlValue::String(default.clone())); + applied.default_model_set = Some(default.clone()); + } + } + + std::fs::create_dir_all(kigi_home)?; + if applied.total_added() > 0 { + // Atomic write: tmp + rename (same pattern as save_mcp_server_config). + let toml_str = toml::to_string_pretty(&root)?; + let tmp = config_path.with_extension("toml.tmp"); + if let Err(e) = std::fs::write(&tmp, &toml_str) { + let _ = std::fs::remove_file(&tmp); + return Err(e.into()); + } + if let Err(e) = std::fs::rename(&tmp, &config_path) { + let _ = std::fs::remove_file(&tmp); + return Err(e.into()); + } + info!( + path = %config_path.display(), + added = applied.total_added(), + "Wrote imported kimi-cli settings to config.toml" + ); + } + + // The marker is written even when nothing new was added: the import is + // the user's one-time choice, and re-offering it every launch after an + // all-collisions apply would be noise. + std::fs::write( + &applied.marker_path, + format!("imported {}\n", chrono::Utc::now().to_rfc3339()), + )?; + + Ok(applied) +} + +// One-Time Marker + +/// Marker file under the kigi home recording that the one-time kimi import +/// ran (successfully applied OR found nothing). Presence = done. +const KIMI_IMPORT_MARKER_FILE: &str = "kimi_import_done"; + +/// Path of the marker under the user's kigi home. +pub fn kimi_import_marker_path() -> PathBuf { + crate::util::kigi_home::kigi_home().join(KIMI_IMPORT_MARKER_FILE) +} + +/// Cached marker state, mirroring `claude_import::MARKER_CACHE`: +/// `RwLock>` so tests could reset it, one stat per process. +static KIMI_MARKER_CACHE: std::sync::RwLock> = std::sync::RwLock::new(None); + +/// Whether the one-time kimi import already ran for this user. Checked by +/// [`scan`] and the TUI startup hint; cached per process (a user who removes +/// the marker mid-session must restart, same trade-off as the claude marker). +pub fn is_kimi_import_marked() -> bool { + if let Some(v) = *KIMI_MARKER_CACHE + .read() + .expect("KIMI_MARKER_CACHE poisoned") + { + return v; + } + let v = kimi_import_marker_path().exists(); + *KIMI_MARKER_CACHE + .write() + .expect("KIMI_MARKER_CACHE poisoned") = Some(v); + v +} + +/// Seed the cache after [`apply`] writes the marker so in-process checks +/// reflect the new state without restart. +pub fn refresh_kimi_marker_cache(value: bool) { + *KIMI_MARKER_CACHE + .write() + .expect("KIMI_MARKER_CACHE poisoned") = Some(value); +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A realistic fake `~/.kimi` with two MCP servers, one built-in (kimi) + /// provider, one custom (openai-compatible) provider, and a default model + /// pointing at the custom alias. Server names are namespaced so loader + /// assertions can't collide with entries from a developer's real config. + fn write_fake_kimi_dir(kimi_dir: &Path) { + std::fs::create_dir_all(kimi_dir).unwrap(); + std::fs::write( + kimi_dir.join("config.toml"), + r#" +default_model = "my-openai" + +[models.k2] +provider = "kimi" +model = "kimi-for-coding" +max_context_size = 262144 + +[models.my-openai] +provider = "openrouter" +model = "gpt-x" +max_context_size = 128000 + +[providers.kimi] +type = "kimi" +base_url = "https://api.kimi.com/coding/v1" +api_key = "sk-kimi-secret" + +[providers.openrouter] +type = "openai_legacy" +base_url = "https://openrouter.ai/api/v1" +api_key = "sk-or-secret" +"#, + ) + .unwrap(); + std::fs::write( + kimi_dir.join("mcp.json"), + r#"{ + "mcpServers": { + "kimi-import-test-fs": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-filesystem"] + }, + "kimi-import-test-linear": { + "url": "https://mcp.linear.app/sse" + } + } +}"#, + ) + .unwrap(); + } + + /// `(content, mtime)` snapshot of every file directly under `dir`. + fn snapshot_dir(dir: &Path) -> Vec<(PathBuf, Vec, std::time::SystemTime)> { + let mut entries: Vec<_> = std::fs::read_dir(dir) + .unwrap() + .map(|e| e.unwrap().path()) + .collect(); + entries.sort(); + entries + .into_iter() + .map(|p| { + let content = std::fs::read(&p).unwrap(); + let mtime = std::fs::metadata(&p).unwrap().modified().unwrap(); + (p, content, mtime) + }) + .collect() + } + + fn scan_fake(kimi_dir: &Path, marker: &Path) -> KimiImportPlan { + scan_with_marker(kimi_dir, marker) + .expect("scan must succeed") + .expect("plan must be non-empty") + } + + #[test] + fn scan_parses_mcp_servers_models_and_default() { + let tmp = tempfile::tempdir().unwrap(); + let kimi_dir = tmp.path().join("kimi"); + write_fake_kimi_dir(&kimi_dir); + + let plan = scan_fake(&kimi_dir, &tmp.path().join("no-marker")); + + assert_eq!(plan.mcp_servers.len(), 2); + let (fs_name, fs_cfg) = plan + .mcp_servers + .iter() + .find(|(n, _)| n == "kimi-import-test-fs") + .expect("fs server in plan"); + assert_eq!(fs_name, "kimi-import-test-fs"); + match &fs_cfg.transport { + McpServerTransportConfig::Stdio { command, args, .. } => { + assert_eq!(command, "npx"); + assert_eq!( + args, + &[ + "-y".to_string(), + "@modelcontextprotocol/server-filesystem".to_string() + ] + ); + } + other => panic!("expected stdio transport, got {other:?}"), + } + let (_, linear_cfg) = plan + .mcp_servers + .iter() + .find(|(n, _)| n == "kimi-import-test-linear") + .expect("linear server in plan"); + match &linear_cfg.transport { + McpServerTransportConfig::StreamableHttp { url, .. } => { + assert_eq!(url, "https://mcp.linear.app/sse"); + } + other => panic!("expected http transport, got {other:?}"), + } + + assert_eq!(plan.custom_models.len(), 1); + let m = &plan.custom_models[0]; + assert_eq!(m.alias, "my-openai"); + assert_eq!(m.model, "gpt-x"); + assert_eq!(m.base_url, "https://openrouter.ai/api/v1"); + assert_eq!(m.api_key.as_deref(), Some("sk-or-secret")); + assert_eq!(m.context_window, Some(128_000)); + + // The kimi-platform model is skipped, mapped to its managed key. + assert_eq!( + plan.skipped_builtin, + vec![("k2".to_string(), "kimi-code/kimi-for-coding".to_string())] + ); + assert_eq!(plan.default_model.as_deref(), Some("my-openai")); + + // Secrets never surface in the human-facing summary. + let summary = plan.summary(); + assert!(!summary.contains("sk-or-secret"), "summary leaked api key"); + assert!(summary.contains("my-openai")); + assert!(summary.contains("kimi-import-test-fs")); + } + + #[test] + fn scan_maps_builtin_default_to_managed_key() { + let tmp = tempfile::tempdir().unwrap(); + let kimi_dir = tmp.path().join("kimi"); + std::fs::create_dir_all(&kimi_dir).unwrap(); + // Default points at a moonshot open-platform model (recognized by + // host, not provider type). + std::fs::write( + kimi_dir.join("config.toml"), + r#" +default_model = "k2t" + +[models.k2t] +provider = "moonshot" +model = "kimi-k2-turbo-preview" +max_context_size = 262144 + +[providers.moonshot] +type = "openai_legacy" +base_url = "https://api.moonshot.cn/v1" +api_key = "sk-ms" +"#, + ) + .unwrap(); + + let plan = scan_fake(&kimi_dir, &tmp.path().join("no-marker")); + assert!(plan.custom_models.is_empty()); + assert_eq!( + plan.default_model.as_deref(), + Some("moonshot-cn/kimi-k2-turbo-preview") + ); + } + + #[test] + fn scan_returns_none_when_config_absent_or_plan_empty() { + let tmp = tempfile::tempdir().unwrap(); + let kimi_dir = tmp.path().join("kimi"); + let marker = tmp.path().join("no-marker"); + + // No ~/.kimi/config.toml at all (even with an mcp.json present: + // detection is keyed on config.toml per PRD F7). + std::fs::create_dir_all(&kimi_dir).unwrap(); + assert!(scan_with_marker(&kimi_dir, &marker).unwrap().is_none()); + + // config.toml exists but has nothing importable. + std::fs::write(kimi_dir.join("config.toml"), "theme = \"dark\"\n").unwrap(); + assert!(scan_with_marker(&kimi_dir, &marker).unwrap().is_none()); + } + + #[test] + fn scan_fails_fast_on_malformed_files() { + let tmp = tempfile::tempdir().unwrap(); + let kimi_dir = tmp.path().join("kimi"); + let marker = tmp.path().join("no-marker"); + std::fs::create_dir_all(&kimi_dir).unwrap(); + + std::fs::write(kimi_dir.join("config.toml"), "default_model = [not toml").unwrap(); + let err = scan_with_marker(&kimi_dir, &marker).expect_err("malformed toml must fail"); + assert!(err.to_string().contains("config.toml"), "got: {err}"); + + // Model referencing an undefined provider is corrupt, not skippable. + std::fs::write( + kimi_dir.join("config.toml"), + "[models.m]\nprovider = \"ghost\"\nmodel = \"x\"\nmax_context_size = 1\n", + ) + .unwrap(); + let err = scan_with_marker(&kimi_dir, &marker).expect_err("dangling provider must fail"); + assert!(err.to_string().contains("ghost"), "got: {err}"); + + // Malformed mcp.json fails even when config.toml is fine. + std::fs::write( + kimi_dir.join("config.toml"), + "[models.m]\nprovider = \"p\"\nmodel = \"x\"\nmax_context_size = 1\n\ + [providers.p]\ntype = \"openai_legacy\"\nbase_url = \"https://x.example/v1\"\n", + ) + .unwrap(); + std::fs::write(kimi_dir.join("mcp.json"), "{ not json").unwrap(); + let err = scan_with_marker(&kimi_dir, &marker).expect_err("malformed json must fail"); + assert!(err.to_string().contains("mcp.json"), "got: {err}"); + } + + #[test] + fn apply_writes_kigi_config_and_loader_sees_both_servers() { + let tmp = tempfile::tempdir().unwrap(); + let kimi_dir = tmp.path().join("kimi"); + write_fake_kimi_dir(&kimi_dir); + // Use `/.kigi` as the kigi home so the standard + // project-config loader can observe the result from disk. + let project = tmp.path().join("project"); + let kigi_home = project.join(".kigi"); + + let plan = scan_fake(&kimi_dir, &tmp.path().join("no-marker")); + let applied = apply_at(&plan, &kigi_home).expect("apply must succeed"); + + assert_eq!( + applied.mcp_added, + vec!["kimi-import-test-fs", "kimi-import-test-linear"] + ); + assert_eq!(applied.models_added, vec!["my-openai"]); + assert_eq!(applied.default_model_set.as_deref(), Some("my-openai")); + assert!(applied.marker_path.is_file(), "marker file must be written"); + + // Raw TOML shape: [mcp_servers.*], [model.*], [models] default. + let written = std::fs::read_to_string(kigi_home.join("config.toml")).unwrap(); + let root: TomlValue = toml::from_str(&written).unwrap(); + assert_eq!( + root["mcp_servers"]["kimi-import-test-fs"]["command"] + .as_str() + .unwrap(), + "npx" + ); + assert_eq!( + root["mcp_servers"]["kimi-import-test-linear"]["url"] + .as_str() + .unwrap(), + "https://mcp.linear.app/sse" + ); + let model = &root["model"]["my-openai"]; + assert_eq!(model["model"].as_str().unwrap(), "gpt-x"); + assert_eq!( + model["base_url"].as_str().unwrap(), + "https://openrouter.ai/api/v1" + ); + assert_eq!(model["api_key"].as_str().unwrap(), "sk-or-secret"); + assert_eq!(model["context_window"].as_integer().unwrap(), 128_000); + assert_eq!(root["models"]["default"].as_str().unwrap(), "my-openai"); + + // `kigi mcp list`-level loader (project overlay path) sees both. + let servers = crate::util::config::load_mcp_server_configs_with_project(&project); + for name in ["kimi-import-test-fs", "kimi-import-test-linear"] { + let (_, scope) = servers + .get(name) + .unwrap_or_else(|| panic!("loader must see imported server {name}")); + assert_eq!(*scope, "project"); + } + } + + #[test] + fn scan_after_apply_returns_none_via_marker() { + let tmp = tempfile::tempdir().unwrap(); + let kimi_dir = tmp.path().join("kimi"); + write_fake_kimi_dir(&kimi_dir); + let kigi_home = tmp.path().join("kigi-home"); + let marker = kigi_home.join(KIMI_IMPORT_MARKER_FILE); + + let plan = scan_fake(&kimi_dir, &marker); + apply_at(&plan, &kigi_home).unwrap(); + + assert!( + scan_with_marker(&kimi_dir, &marker).unwrap().is_none(), + "second scan must be gated by the one-time marker" + ); + } + + #[test] + fn apply_never_touches_the_kimi_dir() { + let tmp = tempfile::tempdir().unwrap(); + let kimi_dir = tmp.path().join("kimi"); + write_fake_kimi_dir(&kimi_dir); + let before = snapshot_dir(&kimi_dir); + + let plan = scan_fake(&kimi_dir, &tmp.path().join("no-marker")); + apply_at(&plan, &tmp.path().join("kigi-home")).unwrap(); + + let after = snapshot_dir(&kimi_dir); + assert_eq!( + before.len(), + after.len(), + "no files may appear or vanish under ~/.kimi" + ); + for ((path_b, content_b, mtime_b), (path_a, content_a, mtime_a)) in + before.iter().zip(after.iter()) + { + assert_eq!(path_b, path_a); + assert_eq!( + content_b, + content_a, + "{}: content changed", + path_b.display() + ); + assert_eq!(mtime_b, mtime_a, "{}: mtime changed", path_b.display()); + } + } + + #[test] + fn apply_never_clobbers_existing_kigi_entries() { + let tmp = tempfile::tempdir().unwrap(); + let kimi_dir = tmp.path().join("kimi"); + write_fake_kimi_dir(&kimi_dir); + let kigi_home = tmp.path().join("kigi-home"); + std::fs::create_dir_all(&kigi_home).unwrap(); + std::fs::write( + kigi_home.join("config.toml"), + r#" +[models] +default = "existing-default" + +[mcp_servers.kimi-import-test-fs] +command = "original-command" + +[model.my-openai] +model = "original-model" +"#, + ) + .unwrap(); + + let plan = scan_fake(&kimi_dir, &tmp.path().join("no-marker")); + let applied = apply_at(&plan, &kigi_home).unwrap(); + + assert_eq!(applied.mcp_added, vec!["kimi-import-test-linear"]); + assert_eq!(applied.mcp_skipped_existing, vec!["kimi-import-test-fs"]); + assert!(applied.models_added.is_empty()); + assert_eq!(applied.models_skipped_existing, vec!["my-openai"]); + assert_eq!(applied.default_model_set, None); + assert!(applied.default_model_kept_existing); + + let written = std::fs::read_to_string(kigi_home.join("config.toml")).unwrap(); + let root: TomlValue = toml::from_str(&written).unwrap(); + assert_eq!( + root["mcp_servers"]["kimi-import-test-fs"]["command"] + .as_str() + .unwrap(), + "original-command", + "existing MCP server must not be overwritten" + ); + assert_eq!( + root["model"]["my-openai"]["model"].as_str().unwrap(), + "original-model", + "existing model entry must not be overwritten" + ); + assert_eq!( + root["models"]["default"].as_str().unwrap(), + "existing-default", + "existing default model must not be overwritten" + ); + // The non-colliding server was still added. + assert!(root["mcp_servers"].get("kimi-import-test-linear").is_some()); + } +} diff --git a/crates/codegen/kigi-shell/src/lib.rs b/crates/codegen/kigi-shell/src/lib.rs index a0a6dd8..45d1ad3 100644 --- a/crates/codegen/kigi-shell/src/lib.rs +++ b/crates/codegen/kigi-shell/src/lib.rs @@ -23,6 +23,7 @@ pub use kigi_http as http; pub use kigi_workspace::foreign_sessions; pub mod inspect; pub mod instrumentation; +pub mod kimi_import; pub mod leader; pub mod managed_config; pub mod mcp_doctor; diff --git a/crates/codegen/kigi-tui/src/app/cli.rs b/crates/codegen/kigi-tui/src/app/cli.rs index 63259de..bd65702 100644 --- a/crates/codegen/kigi-tui/src/app/cli.rs +++ b/crates/codegen/kigi-tui/src/app/cli.rs @@ -17,6 +17,13 @@ pub enum Command { #[arg(long)] json: bool, }, + /// One-time, read-only import of the official kimi-cli configuration + /// (~/.kimi): MCP servers, custom model providers, default model + ImportKimi { + /// Print what would be imported without writing anything. + #[arg(long)] + dry_run: bool, + }, /// Manage running leader processes Leader(LeaderMgmtArgs), /// Sign out and clear cached credentials @@ -1208,6 +1215,23 @@ mod tests { .expect("both effort flag names parse (reverse order)"); assert_eq!(reverse.reasoning_effort.as_deref(), Some("low")); } + /// PRD F7: `import-kimi` parses with and without `--dry-run`. + #[test] + fn import_kimi_parses_with_and_without_dry_run() { + let args = PagerArgs::try_parse_from(["kigi", "import-kimi", "--dry-run"]) + .expect("import-kimi --dry-run parses"); + assert!(matches!( + args.command, + Some(Command::ImportKimi { dry_run: true }) + )); + let args = + PagerArgs::try_parse_from(["kigi", "import-kimi"]).expect("bare import-kimi parses"); + assert!(matches!( + args.command, + Some(Command::ImportKimi { dry_run: false }) + )); + } + #[test] fn agent_args_effort_alias_parses() { let args = PagerArgs::try_parse_from(["grok", "agent", "--effort", "max", "stdio"]) diff --git a/crates/codegen/kigi-tui/src/app/event_loop.rs b/crates/codegen/kigi-tui/src/app/event_loop.rs index abcee71..47d624e 100644 --- a/crates/codegen/kigi-tui/src/app/event_loop.rs +++ b/crates/codegen/kigi-tui/src/app/event_loop.rs @@ -881,6 +881,19 @@ pub(crate) async fn run( ); } + // F7: one-time offer to import the official kimi-cli configuration. + // Parallel to the Claude-import detection (`has_claude_import`), but + // deliberately lightweight: a welcome-screen system line pointing at the + // CLI command instead of a modal. Disappears once `kigi import-kimi` has + // run (marker file under ~/.kigi); the scan is read-only over ~/.kimi. + if kigi_shell::kimi_import::has_pending_kimi_import() { + app.startup_warnings.push(crate::startup::StartupWarning { + severity: crate::startup::WarningSeverity::Info, + message: "Found official kimi-cli settings in ~/.kimi.".to_string(), + action: Some("Run `kigi import-kimi` to import them (one-time).".to_string()), + }); + } + // Apply initial config (may come from existing ~/.kigi/pager.toml). let mut initial_config = config_watcher.current().clone(); // The cache holds the USER compact value; the render value is derived diff --git a/crates/codegen/kigi-tui/src/import_kimi_cmd.rs b/crates/codegen/kigi-tui/src/import_kimi_cmd.rs new file mode 100644 index 0000000..cd53931 --- /dev/null +++ b/crates/codegen/kigi-tui/src/import_kimi_cmd.rs @@ -0,0 +1,33 @@ +//! `kigi import-kimi` — one-time, read-only import of the official kimi-cli +//! configuration (`~/.kimi`), PRD F7. +//! +//! Reads `~/.kimi/config.toml` + `~/.kimi/mcp.json` without modifying them +//! and merges MCP servers, custom model providers, and the default-model +//! preference into `~/.kigi/config.toml`. Existing kigi entries are never +//! overwritten. A marker file under the kigi home makes the import one-time. + +use anyhow::Result; + +pub fn run(dry_run: bool) -> Result<()> { + if kigi_shell::kimi_import::is_kimi_import_marked() { + println!( + "kimi-cli settings were already imported (marker: {}).\n\ + Delete the marker file and re-run to import again.", + kigi_shell::kimi_import::kimi_import_marker_path().display() + ); + return Ok(()); + } + let Some(plan) = kigi_shell::kimi_import::scan()? else { + println!("Nothing to import: no importable settings found in ~/.kimi."); + return Ok(()); + }; + print!("{}", plan.summary()); + if dry_run { + println!("\nDry run: nothing was written."); + return Ok(()); + } + let applied = kigi_shell::kimi_import::apply(&plan)?; + println!(); + print!("{}", applied.summary()); + Ok(()) +} diff --git a/crates/codegen/kigi-tui/src/lib.rs b/crates/codegen/kigi-tui/src/lib.rs index a91701a..d512b9e 100644 --- a/crates/codegen/kigi-tui/src/lib.rs +++ b/crates/codegen/kigi-tui/src/lib.rs @@ -15,6 +15,7 @@ pub mod export_cmd; pub mod git_info; pub mod headless; pub mod hyperlink_route; +pub mod import_kimi_cmd; pub mod inline_media_ffmpeg; pub mod input; pub mod input_log;