Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
216 changes: 216 additions & 0 deletions desktop/src-tauri/src/managed_agents/config_bridge/kimi.rs
Original file line number Diff line number Diff line change
@@ -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<RuntimeFileConfig> {
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<RuntimeFileConfig> {
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<ExtensionEntry> {
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::<serde_json::Value>(&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<String> {
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<String> {
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<String> {
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<PathBuf> {
Some(kimi_home_dir()?.join("config.toml"))
}

pub(crate) fn kimi_mcp_config_path() -> Option<PathBuf> {
Some(kimi_home_dir()?.join("mcp.json"))
}

fn kimi_home_dir() -> Option<PathBuf> {
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());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
})
Expand Down Expand Up @@ -222,6 +223,9 @@ fn mcp_config_file_path_for_runtime(runtime: &KnownAcpRuntime) -> Option<String>
"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,
}
}
Expand Down
35 changes: 34 additions & 1 deletion desktop/src-tauri/src/managed_agents/discovery.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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"),
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -342,7 +375,7 @@ pub use overrides::{apply_agent_command_update, create_time_agent_command_overri

fn default_agent_args(command: &str) -> Option<Vec<String>> {
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,
Expand Down
2 changes: 1 addition & 1 deletion desktop/src-tauri/src/managed_agents/discovery/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
21 changes: 21 additions & 0 deletions desktop/src-tauri/src/managed_agents/discovery/tests/kimi.rs
Original file line number Diff line number Diff line change
@@ -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()]
);
}
Loading