From 06aa8e681bf1924d10c13ef95690f5be18429295 Mon Sep 17 00:00:00 2001 From: Grafton <143531040+satsdisco@users.noreply.github.com> Date: Wed, 22 Jul 2026 13:39:23 +0200 Subject: [PATCH] Add Kimi Code runtime support Co-authored-by: Grafton <143531040+satsdisco@users.noreply.github.com> Signed-off-by: Grafton <143531040+satsdisco@users.noreply.github.com> --- .../src/managed_agents/config_bridge/kimi.rs | 216 ++++++++++++++++++ .../src/managed_agents/config_bridge/mod.rs | 1 + .../managed_agents/config_bridge/reader.rs | 4 + .../src-tauri/src/managed_agents/discovery.rs | 35 ++- .../src/managed_agents/discovery/tests.rs | 2 +- .../managed_agents/discovery/tests/kimi.rs | 21 ++ .../agents/ui/agentConfigOptions.test.mjs | 3 +- .../features/agents/ui/agentConfigOptions.tsx | 3 +- .../agents/ui/personaRuntimeModel.test.mjs | 1 + .../features/agents/ui/personaRuntimeModel.ts | 4 +- 10 files changed, 284 insertions(+), 6 deletions(-) create mode 100644 desktop/src-tauri/src/managed_agents/config_bridge/kimi.rs create mode 100644 desktop/src-tauri/src/managed_agents/discovery/tests/kimi.rs diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/kimi.rs b/desktop/src-tauri/src/managed_agents/config_bridge/kimi.rs new file mode 100644 index 0000000000..37c56116be --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/config_bridge/kimi.rs @@ -0,0 +1,216 @@ +use std::path::PathBuf; + +use super::types::{ExtensionEntry, RuntimeFileConfig}; + +/// Read Kimi Code config from `~/.kimi-code/config.toml` (or `$KIMI_CODE_HOME/config.toml`). +pub(super) fn read_config_file() -> Option { + let path = kimi_config_path()?; + let raw = std::fs::read_to_string(path).ok()?; + parse_kimi_config(&raw).map(|mut cfg| { + cfg.extensions = read_mcp_config(); + cfg + }) +} + +fn parse_kimi_config(toml_str: &str) -> Option { + let table: toml::Table = toml_str.parse().ok()?; + + let model = toml_string(&table, "default_model"); + let mut provider = None; + let mut context_limit = None; + + if let Some(model_id) = model.as_deref() { + if let Some(models) = table.get("models").and_then(|v| v.as_table()) { + if let Some(model_table) = models.get(model_id).and_then(|v| v.as_table()) { + provider = toml_table_string(model_table, "provider"); + context_limit = toml_table_scalar_string(model_table, "max_context_size"); + } + } + } + + let config_json = toml_to_json(&toml::Value::Table(table)); + let skip = &[ + "default_model", + "models", + "providers", + "permission", + "permissions", + "mcp", + ]; + let mut extra = super::schema_walker::extract_config_fields(&config_json, skip); + + if let Some(serde_json::Value::Object(providers)) = config_json.get("providers") { + for (name, provider_config) in providers { + extra.insert(format!("providers.{name}"), "configured".to_string()); + if provider.is_none() { + if let Some(kind) = provider_config.get("type").and_then(|v| v.as_str()) { + provider = Some(kind.to_string()); + } + } + } + } + + if let Some(serde_json::Value::Object(models)) = config_json.get("models") { + for (name, _) in models { + extra.insert(format!("models.{name}"), "configured".to_string()); + } + } + + Some(RuntimeFileConfig { + model, + provider, + mode: None, + thinking_effort: None, + max_output_tokens: None, + context_limit, + system_prompt: None, + extensions: Vec::new(), + extra, + }) +} + +fn read_mcp_config() -> Vec { + let Some(path) = kimi_mcp_config_path() else { + return Vec::new(); + }; + let Some(raw) = std::fs::read_to_string(path).ok() else { + return Vec::new(); + }; + let Some(json) = serde_json::from_str::(&raw).ok() else { + return Vec::new(); + }; + + let servers = json + .get("mcpServers") + .or_else(|| json.get("mcp_servers")) + .and_then(|v| v.as_object()); + let Some(servers) = servers else { + return Vec::new(); + }; + + servers + .keys() + .map(|name| ExtensionEntry { + name: name.clone(), + kind: "mcp".to_string(), + enabled: true, + }) + .collect() +} + +fn toml_string(table: &toml::Table, key: &str) -> Option { + table + .get(key)? + .as_str() + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string) +} + +fn toml_table_string(table: &toml::value::Table, key: &str) -> Option { + table + .get(key)? + .as_str() + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string) +} + +fn toml_table_scalar_string(table: &toml::value::Table, key: &str) -> Option { + match table.get(key)? { + toml::Value::String(s) => { + let trimmed = s.trim(); + (!trimmed.is_empty()).then(|| trimmed.to_string()) + } + toml::Value::Integer(i) => Some(i.to_string()), + toml::Value::Float(f) => Some(f.to_string()), + _ => None, + } +} + +fn toml_to_json(val: &toml::Value) -> serde_json::Value { + match val { + toml::Value::String(s) => serde_json::Value::String(s.clone()), + toml::Value::Integer(i) => serde_json::Value::Number((*i).into()), + toml::Value::Float(f) => serde_json::Number::from_f64(*f) + .map(serde_json::Value::Number) + .unwrap_or(serde_json::Value::Null), + toml::Value::Boolean(b) => serde_json::Value::Bool(*b), + toml::Value::Datetime(dt) => serde_json::Value::String(dt.to_string()), + toml::Value::Array(arr) => serde_json::Value::Array(arr.iter().map(toml_to_json).collect()), + toml::Value::Table(tbl) => { + let map = tbl + .iter() + .map(|(k, v)| (k.clone(), toml_to_json(v))) + .collect(); + serde_json::Value::Object(map) + } + } +} + +pub(crate) fn kimi_config_path() -> Option { + Some(kimi_home_dir()?.join("config.toml")) +} + +pub(crate) fn kimi_mcp_config_path() -> Option { + Some(kimi_home_dir()?.join("mcp.json")) +} + +fn kimi_home_dir() -> Option { + if let Ok(home) = std::env::var("KIMI_CODE_HOME") { + return Some(PathBuf::from(home)); + } + let home = dirs::home_dir()?; + Some(home.join(".kimi-code")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_default_model_provider_and_context() { + let toml = r#" +default_model = "kimi-code/kimi-for-coding" + +[models."kimi-code/kimi-for-coding"] +provider = "kimi-code" +model = "kimi-for-coding" +max_context_size = 200000 + +[providers.kimi-code] +type = "kimi" +base_url = "https://api.kimi.com/coding/v1" +"#; + let cfg = parse_kimi_config(toml).unwrap(); + assert_eq!(cfg.model.as_deref(), Some("kimi-code/kimi-for-coding")); + assert_eq!(cfg.provider.as_deref(), Some("kimi-code")); + assert_eq!(cfg.context_limit.as_deref(), Some("200000")); + assert_eq!( + cfg.extra.get("providers.kimi-code").map(String::as_str), + Some("configured") + ); + assert_eq!( + cfg.extra + .get("models.kimi-code/kimi-for-coding") + .map(String::as_str), + Some("configured") + ); + } + + #[test] + fn parse_provider_type_fallback_when_default_model_missing() { + let toml = r#" +[providers.kimi-code] +type = "kimi" +"#; + let cfg = parse_kimi_config(toml).unwrap(); + assert_eq!(cfg.provider.as_deref(), Some("kimi")); + assert!(cfg.model.is_none()); + } + + #[test] + fn invalid_toml_returns_none() { + assert!(parse_kimi_config("{{{{not valid").is_none()); + } +} diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/mod.rs b/desktop/src-tauri/src/managed_agents/config_bridge/mod.rs index f8b045fc72..7707439dc4 100644 --- a/desktop/src-tauri/src/managed_agents/config_bridge/mod.rs +++ b/desktop/src-tauri/src/managed_agents/config_bridge/mod.rs @@ -2,6 +2,7 @@ mod buzz_agent; mod claude; mod codex; mod goose; +mod kimi; pub(crate) mod reader; mod schema_walker; pub(crate) mod types; diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/reader.rs b/desktop/src-tauri/src/managed_agents/config_bridge/reader.rs index 372d2cfde1..82f8b1feb3 100644 --- a/desktop/src-tauri/src/managed_agents/config_bridge/reader.rs +++ b/desktop/src-tauri/src/managed_agents/config_bridge/reader.rs @@ -22,6 +22,7 @@ pub(crate) fn read_config_surface( "goose" => super::goose::read_config_file().map(|c| (c, true)), "claude" => super::claude::read_config_file().map(|c| (c, true)), "codex" => super::codex::read_config_file().map(|c| (c, true)), + "kimi" => super::kimi::read_config_file().map(|c| (c, true)), "buzz-agent" => super::buzz_agent::read_config_file().map(|c| (c, true)), _ => None, }) @@ -222,6 +223,9 @@ fn mcp_config_file_path_for_runtime(runtime: &KnownAcpRuntime) -> Option "codex" => { super::codex::codex_config_path().map(|path| path.to_string_lossy().into_owned()) } + "kimi" => { + super::kimi::kimi_mcp_config_path().map(|path| path.to_string_lossy().into_owned()) + } _ => None, } } diff --git a/desktop/src-tauri/src/managed_agents/discovery.rs b/desktop/src-tauri/src/managed_agents/discovery.rs index 50206567ad..5f88ae88b3 100644 --- a/desktop/src-tauri/src/managed_agents/discovery.rs +++ b/desktop/src-tauri/src/managed_agents/discovery.rs @@ -16,6 +16,7 @@ pub(crate) use runtime_metadata::KnownAcpRuntime; const GOOSE_AVATAR_URL: &str = "https://goose-docs.ai/img/logo_dark.png"; const CLAUDE_CODE_AVATAR_URL: &str = "https://anthropic.gallerycdn.vsassets.io/extensions/anthropic/claude-code/2.1.77/1773707456892/Microsoft.VisualStudio.Services.Icons.Default"; const CODEX_AVATAR_URL: &str = "https://openai.gallerycdn.vsassets.io/extensions/openai/chatgpt/26.5313.41514/1773706730621/Microsoft.VisualStudio.Services.Icons.Default"; +const KIMI_CODE_AVATAR_URL: &str = "https://github.com/MoonshotAI.png"; const BUZZ_AGENT_AVATAR_URL: &str = "https://raw.githubusercontent.com/block/buzz/refs/heads/main/crates/buzz-agent/buzz-agent.png"; @@ -36,6 +37,7 @@ fn common_binary_paths() -> &'static [PathBuf] { } if let Some(home) = dirs::home_dir() { paths.extend([ + home.join(".kimi-code").join("bin"), home.join(".local/share/mise/shims"), home.join(".local/bin"), home.join(".volta/bin"), @@ -157,6 +159,37 @@ const KNOWN_ACP_RUNTIMES: &[KnownAcpRuntime] = &[ // Verified: `codex login status` exits 0 when logged in, non-zero otherwise. auth_probe_args: Some(&["codex", "login", "status"]), }, + KnownAcpRuntime { + id: "kimi", + label: "Kimi Code", + commands: &["kimi"], + aliases: &["kimi-code", "kimicode"], + avatar_url: KIMI_CODE_AVATAR_URL, + mcp_command: None, + mcp_hooks: false, + underlying_cli: Some("kimi"), + cli_install_commands: &["curl -fsSL https://code.kimi.com/kimi-code/install.sh | bash"], + cli_install_commands_windows: &["powershell.exe -NoProfile -ExecutionPolicy Bypass -Command \"irm https://code.kimi.com/kimi-code/install.ps1 | iex\""], + adapter_install_commands: &[], + install_instructions_url: "https://www.kimi.com/code/docs/en/kimi-code-cli/reference/kimi-acp", + cli_install_hint: "Install the Kimi Code CLI via the official install script.", + adapter_install_hint: "", + skill_dir: None, + supports_acp_model_switching: true, + model_env_var: None, + provider_env_var: None, + provider_locked: true, + default_env: &[], + config_file_path: Some("~/.kimi-code/config.toml"), + config_file_format: Some("toml"), + supports_acp_native_config: false, + thinking_env_var: None, + max_tokens_env_var: None, + context_limit_env_var: None, + required_normalized_fields: &[], + login_hint: Some("Run `kimi login` to authenticate."), + auth_probe_args: None, + }, KnownAcpRuntime { id: "buzz-agent", label: "Buzz Agent", @@ -342,7 +375,7 @@ pub use overrides::{apply_agent_command_update, create_time_agent_command_overri fn default_agent_args(command: &str) -> Option> { match normalize_command_identity(command).as_str() { - "goose" => Some(vec!["acp".to_string()]), + "goose" | "kimi" | "kimi-code" | "kimicode" => Some(vec!["acp".to_string()]), "codex" | "codex-acp" | "claude-agent-acp" | "claude-code-acp" | "claude-code" | "claudecode" | "buzz-agent" => Some(Vec::new()), _ => None, diff --git a/desktop/src-tauri/src/managed_agents/discovery/tests.rs b/desktop/src-tauri/src/managed_agents/discovery/tests.rs index 0ed4fe0f6a..e125a52567 100644 --- a/desktop/src-tauri/src/managed_agents/discovery/tests.rs +++ b/desktop/src-tauri/src/managed_agents/discovery/tests.rs @@ -11,7 +11,7 @@ use super::{ GOOSE_AVATAR_URL, }; use crate::managed_agents::AcpAvailabilityStatus; - +mod kimi; #[test] fn resolves_known_avatar_for_bare_command() { let avatar_url = managed_agent_avatar_url("goose").expect("goose avatar should resolve"); diff --git a/desktop/src-tauri/src/managed_agents/discovery/tests/kimi.rs b/desktop/src-tauri/src/managed_agents/discovery/tests/kimi.rs new file mode 100644 index 0000000000..9755cffc4e --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/discovery/tests/kimi.rs @@ -0,0 +1,21 @@ +use super::super::{managed_agent_avatar_url, normalize_agent_args, KIMI_CODE_AVATAR_URL}; + +#[test] +fn resolves_kimi_avatar() { + assert_eq!( + managed_agent_avatar_url("/usr/local/bin/kimi"), + Some(KIMI_CODE_AVATAR_URL.to_string()) + ); +} + +#[test] +fn normalizes_kimi_args_to_acp() { + assert_eq!( + normalize_agent_args("kimi", Vec::new()), + vec!["acp".to_string()] + ); + assert_eq!( + normalize_agent_args("kimi-code", Vec::new()), + vec!["acp".to_string()] + ); +} diff --git a/desktop/src/features/agents/ui/agentConfigOptions.test.mjs b/desktop/src/features/agents/ui/agentConfigOptions.test.mjs index d0690cefc3..428dfff09e 100644 --- a/desktop/src/features/agents/ui/agentConfigOptions.test.mjs +++ b/desktop/src/features/agents/ui/agentConfigOptions.test.mjs @@ -141,9 +141,10 @@ test("runtimeSupportsLlmProviderSelection is true for buzz-agent and goose", () assert.equal(runtimeSupportsLlmProviderSelection("goose"), true); }); -test("runtimeSupportsLlmProviderSelection is false for codex and claude", () => { +test("runtimeSupportsLlmProviderSelection is false for codex, claude, and kimi", () => { assert.equal(runtimeSupportsLlmProviderSelection("codex"), false); assert.equal(runtimeSupportsLlmProviderSelection("claude"), false); + assert.equal(runtimeSupportsLlmProviderSelection("kimi"), false); }); test("resetConfigForHarnessChange clears harness-specific values", () => { diff --git a/desktop/src/features/agents/ui/agentConfigOptions.tsx b/desktop/src/features/agents/ui/agentConfigOptions.tsx index 6ae81ff6cb..f6220a609f 100644 --- a/desktop/src/features/agents/ui/agentConfigOptions.tsx +++ b/desktop/src/features/agents/ui/agentConfigOptions.tsx @@ -133,6 +133,7 @@ const PERSONA_MODEL_OPTIONS_BY_RUNTIME: Record< "buzz-agent": [DEFAULT_MODEL_OPTION], claude: [DEFAULT_MODEL_OPTION], codex: [DEFAULT_MODEL_OPTION], + kimi: [DEFAULT_MODEL_OPTION], }; export function getRuntimePersonaModelOptions( @@ -151,7 +152,7 @@ function isKnownLlmProvider( * Required credential env keys for the given runtime + provider combination. * Derived from PROVIDER_CREDENTIAL_CONFIG — single source of truth. * - * buzz-agent and goose use provider-specific credentials; claude and codex + * buzz-agent and goose use provider-specific credentials; claude, codex, and kimi * handle auth via CLI login (surfaced separately via the CliLogin surface). */ export function requiredCredentialEnvKeys( diff --git a/desktop/src/features/agents/ui/personaRuntimeModel.test.mjs b/desktop/src/features/agents/ui/personaRuntimeModel.test.mjs index 967bd3e129..efcf6af020 100644 --- a/desktop/src/features/agents/ui/personaRuntimeModel.test.mjs +++ b/desktop/src/features/agents/ui/personaRuntimeModel.test.mjs @@ -195,6 +195,7 @@ test("resolveRuntimeProviderCapability classifies known CLI-login runtimes as lo // The core fix: a not-yet-loaded catalog must not force these to "unknown". assert.equal(resolveRuntimeProviderCapability("claude", false), "locked"); assert.equal(resolveRuntimeProviderCapability("codex", false), "locked"); + assert.equal(resolveRuntimeProviderCapability("kimi", false), "locked"); assert.equal(resolveRuntimeProviderCapability(" claude ", false), "locked"); }); diff --git a/desktop/src/features/agents/ui/personaRuntimeModel.ts b/desktop/src/features/agents/ui/personaRuntimeModel.ts index d8da4108c9..8d39ac8c01 100644 --- a/desktop/src/features/agents/ui/personaRuntimeModel.ts +++ b/desktop/src/features/agents/ui/personaRuntimeModel.ts @@ -18,7 +18,7 @@ export type ProviderRuntimeCapability = "capable" | "locked" | "unknown"; * provider. To avoid that, we resolve capability STATICALLY for known ids: * * - buzz-agent / goose → "capable" (`isProviderCapable`, id-based). - * - claude / codex → "locked" (CLI-login runtimes; no LLM provider selection). + * - claude / codex / kimi → "locked" (CLI-login runtimes; no LLM provider selection). * - anything else (custom, empty, genuinely unknown) → "unknown". * * `isProviderCapable` is the caller-supplied {@link @@ -33,7 +33,7 @@ export function resolveRuntimeProviderCapability( return "capable"; } const id = runtimeId.trim(); - if (id === "claude" || id === "codex") { + if (id === "claude" || id === "codex" || id === "kimi") { return "locked"; } return "unknown";