diff --git a/Cargo.lock b/Cargo.lock index 7abbe7d..9daa523 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1080,8 +1080,7 @@ dependencies = [ [[package]] name = "kitup" version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46d990ed3fd5d645bfc97a2acc03e221eb9e7611da0eea452b9dfdf98c0856a7" +source = "git+https://github.com/lathe-cli/kitup.git?branch=main#3cdf8974e49d8b15ce6709779778dd5da73cc4d4" dependencies = [ "serde", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index 28aefcb..16d6878 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -54,3 +54,7 @@ pre-release-commit-message = "chore(release): bump to v{{version}}" tag-message = "v{{version}}" tag-name = "v{{version}}" pre-release-hook = ["scripts/regenerate-changelog", "{{version}}"] + +# Remove after the next kitup crates.io release (lathe-cli/kitup#41). +[patch.crates-io] +kitup = { git = "https://github.com/lathe-cli/kitup.git", branch = "main" } diff --git a/README.md b/README.md index 878757a..0d46920 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Confer -Confer exposes local coding-agent execution through MCP. Coding agents and other applications can create rooms, send work to Claude Code, Codex, Cursor Agent, Grok Build, Antigravity CLI, and GitHub Copilot CLI, and collect their results. +Confer exposes local coding-agent execution through MCP. Coding agents and other applications can create rooms, send work to Claude Code, Codex, Cursor Agent, Grok Build, Antigravity CLI, GitHub Copilot CLI, and Kimi Code, and collect their results. The host manages tasks and relays; it is not an execution seat. Supply `target_size` for automatic seat selection, explicit `seats`, or both. There is no default count or fixed seat limit, and identical agent configurations can occupy independent seats. diff --git a/docs/SPEC.md b/docs/SPEC.md index eb81709..e225d54 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -18,8 +18,9 @@ The supported products can act as external room participants and as MCP hosts: | `grok` | Grok Build | `grok` | native `grok mcp` command | | `agy` | Antigravity CLI | `agy` | native `agy mcp` command | | `copilot` | GitHub Copilot CLI | `copilot` | native `copilot mcp` command | +| `kimi` | Kimi Code | `kimi` | `$KIMI_CODE_HOME/mcp.json` (default `~/.kimi-code/mcp.json`) | -MCP is the public protocol. Every seat uses an ACP v1 lifecycle internally. Cursor, Grok, and Copilot use native ACP over stdio; Codex uses an in-process ACP bridge to its app-server; Claude and Antigravity use in-process ACP bridges to their native headless commands. Confer ships one Rust binary and requires no separate bridge runtime. +MCP is the public protocol. Every seat uses an ACP v1 lifecycle internally. Cursor, Grok, Copilot, and Kimi Code use native ACP over stdio; Codex uses an in-process ACP bridge to its app-server; Claude and Antigravity use in-process ACP bridges to their native headless commands. Confer ships one Rust binary and requires no separate bridge runtime. ## Room model @@ -81,7 +82,7 @@ Room metadata writes use a short advisory lock and atomic replacement. Current w Readiness checks are local and run when a room is created, when a seat is added, and before each delivery starts. They inspect the executable and local authentication or configuration state without calling a model or checking quota. A positive result means `locally_ready`; it does not guarantee provider availability, model access, or remaining quota. -Creation and seat addition validate the final selected agent's deterministic configuration before saving any room change. Delivery uses the same validation. Malformed Cursor model options, conflicting option sources, and unsupported local effort values fail immediately. Model availability and provider-specific capabilities remain native runtime checks. Cursor accepts `reasoning_effort` without `model` and applies it to its configured default model; a default that does not support that effort returns a native error. Copilot applies `model` and then `reasoning_effort` through ACP session configuration options; a model or effort its session does not offer returns a native error. +Creation and seat addition validate the final selected agent's deterministic configuration before saving any room change. Delivery uses the same validation. Malformed Cursor model options, conflicting option sources, and unsupported local effort values fail immediately. Model availability and provider-specific capabilities remain native runtime checks. Cursor accepts `reasoning_effort` without `model` and applies it to its configured default model; a default that does not support that effort returns a native error. Copilot applies `model` and then `reasoning_effort` through ACP session configuration options; a model or effort its session does not offer returns a native error. Kimi Code applies `model` and then `reasoning_effort` (`thinking`) through ACP session configuration options; a model or effort its session does not offer returns a native error. The host may specify all, some, or none of the seats. For unspecified agents and remaining target positions, Confer cycles through locally ready supported agents, placing agent types other than the known host first. Agent types may repeat; the host's agent type remains eligible. Explicit agent choices are honored. @@ -164,13 +165,13 @@ confer skill install [--scope user|project] [--agent ]... [--dry-run] [--yes `confer mcp` serves stdio MCP. Room operations are not exposed as ordinary CLI commands. -MCP and Skill installation are deliberately independent. `confer mcp install` never installs the Skill, and `confer skill install` never changes MCP configuration. Both installation commands support Claude Code, Codex, Cursor, Grok, Antigravity CLI, and GitHub Copilot CLI. +MCP and Skill installation are deliberately independent. `confer mcp install` never installs the Skill, and `confer skill install` never changes MCP configuration. Both installation commands support Claude Code, Codex, Cursor, Grok, Antigravity CLI, GitHub Copilot CLI, and Kimi Code. `confer skill install` embeds the [canonical Skill](../skills/confer/SKILL.md) and delegates target paths, conflict protection, updates, scope, and dry-run reporting to Kitup. User scope is the default. `confer mcp install` follows each host’s supported registration mechanism. Repeated installation updates the Confer-owned registration without deleting unrelated MCP entries. Uninstall removes only the `confer` entry. -Explicit `--agent cursor` registration and removal edit Cursor's MCP configuration without requiring the participant CLI on `PATH`. Default selection and `--agent '*'` still discover installed host executables; they do not create Cursor configuration on machines without its CLI. Other hosts require their native registration command. Registration does not establish participant readiness or authentication. +Explicit `--agent cursor` and `--agent kimi` registration and removal edit that host's MCP configuration (Cursor's `~/.cursor/mcp.json`, Kimi Code's `$KIMI_CODE_HOME/mcp.json`, default `~/.kimi-code/mcp.json`) without requiring the participant CLI on `PATH`. Default selection and `--agent '*'` still discover installed host executables; they do not create MCP configuration on machines without its CLI. Other hosts require their native registration command. Registration does not establish participant readiness or authentication. ## Adapter contract @@ -187,7 +188,7 @@ Every adapter must: Confer owns the FIFO Queue above every adapter. Each queued delivery opens one ACP connection, runs one native agent process, and resumes the seat's recorded native session when one exists. Session history replay is excluded from the current answer. After a terminal response, Confer closes the connection and reaps its child; a child that remains alive after three seconds is terminated. There is no idle process pool. -Cursor seats use its ACP session store. Old headless Cursor session IDs are not migrated and require new seats. Copilot seats use its native session store through ACP `session/load`; replayed history is excluded from the current answer. Other native session stores are not rewritten. An observed session ID remains available when the prompt fails; a missing or stale ID never triggers silent replacement. A native ACP session whose model or effort configuration fails before its first prompt is not recorded, because no native work has started; the next message to that seat starts a new native session. +Cursor seats use its ACP session store. Old headless Cursor session IDs are not migrated and require new seats. Copilot seats use its native session store through ACP `session/load`; replayed history is excluded from the current answer. Kimi Code seats use native ACP `session/resume`. Other native session stores are not rewritten. An observed session ID remains available when the prompt fails; a missing or stale ID never triggers silent replacement. A native ACP session whose model or effort configuration fails before its first prompt is not recorded, because no native work has started; the next message to that seat starts a new native session. Model and reasoning fields are requests to the native CLI. An unsupported value must produce a clear adapter error rather than silently selecting another model. @@ -195,7 +196,7 @@ Model and reasoning fields are requests to the native CLI. An unsupported value Confer uses the room workspace as each child process working directory. It does not create filesystem isolation. Independent seats may therefore read or modify the same files even when their messages are isolated. -Confer launches every seat with that agent's full-permission setting so a non-interactive process is never blocked on an approval prompt it cannot answer: Claude and Antigravity receive `--dangerously-skip-permissions`; Codex receives app-server `approvalPolicy: never` and the full-access sandbox policy; Cursor receives `--trust --force`; Grok receives `--always-approve` and ACP `yoloMode`; and Copilot receives `--allow-all`. Seats therefore run with the authority of the local Confer process and without sandbox isolation. Explicit task instructions remain the only limit on what a seat is asked to do. +Confer launches every seat with that agent's full-permission setting so a non-interactive process is never blocked on an approval prompt it cannot answer: Claude and Antigravity receive `--dangerously-skip-permissions`; Codex receives app-server `approvalPolicy: never` and the full-access sandbox policy; Cursor receives `--trust --force`; Grok receives `--always-approve` and ACP `yoloMode`; Copilot receives `--allow-all`; and Kimi Code receives ACP `mode=auto`. Seats therefore run with the authority of the local Confer process and without sandbox isolation. Explicit task instructions remain the only limit on what a seat is asked to do. ## Errors diff --git a/docs/confer-architecture.svg b/docs/confer-architecture.svg index 2c2e97d..803e880 100644 --- a/docs/confer-architecture.svg +++ b/docs/confer-architecture.svg @@ -60,7 +60,7 @@ Seat adapters - Claude · Codex · Cursor · Grok · Antigravity · Copilot · agent types may repeat + Claude · Codex · Cursor · Grok · Antigravity · Copilot · Kimi · agent types may repeat Host diff --git a/skills/confer/SKILL.md b/skills/confer/SKILL.md index fa8e8ce..154f9b2 100644 --- a/skills/confer/SKILL.md +++ b/skills/confer/SKILL.md @@ -1,6 +1,6 @@ --- name: confer -description: Coordinate Claude Code, Codex, Cursor Agent, Grok, Antigravity CLI, and GitHub Copilot CLI through private local MCP rooms. Trigger when the user asks to consult another agent, have multiple agents investigate independently, divide planning and implementation, compare answers, or request an independent review without copying between terminals. +description: Coordinate Claude Code, Codex, Cursor Agent, Grok, Antigravity CLI, GitHub Copilot CLI, and Kimi Code through private local MCP rooms. Trigger when the user asks to consult another agent, have multiple agents investigate independently, divide planning and implementation, compare answers, or request an independent review without copying between terminals. --- # Confer @@ -21,6 +21,10 @@ Honor explicit choices for agent, model, reasoning effort, participant count, ro Choose the number of execution seats from the task; there is no default count. Supply a positive `target_size`, explicit `seats`, or both. `target_size` excludes the host. Confer preserves explicit seats and automatically fills remaining positions, preferring agent types other than the host. The same agent, model, and reasoning effort may be used by multiple independent seats. Give seats short unique names and private instructions. Treat one room as one coordination context: add a new seat when a later phase needs another role, and retire a seat only after its delivery finishes and its role is complete. +When the host is Kimi Code, pass `"host_agent": "kimi"` on create_room: confer's automatic host detection recognizes Claude, Codex, Cursor, Grok, and Copilot session markers, while Kimi Code and Antigravity expose no equivalent marker, so auto-detection would mislabel those hosts. Pass it explicitly whenever auto-detection may not identify the host, not only for Kimi. + +Kimi Code seats' `reasoning_effort` is thinking (`on` / `low` / `high` / `max`). + ## Keep Seats Private For independent work, send the same prompt separately or by multicast and wait for every required answer before sharing any of them. diff --git a/src/adapters.rs b/src/adapters.rs index 579aa77..f9230d3 100644 --- a/src/adapters.rs +++ b/src/adapters.rs @@ -11,7 +11,7 @@ use std::path::{Path, PathBuf}; use std::process::Stdio; use std::sync::{Arc, Mutex}; -use anyhow::{Result, bail}; +use anyhow::{Context, Result, bail}; use serde_json::Value; use tokio::io::{AsyncBufReadExt, AsyncReadExt, BufReader}; use tokio::process::Command; @@ -82,7 +82,9 @@ pub(crate) fn check_readiness(agent: AgentKind) -> Readiness { pub(crate) fn reserve_session(agent: AgentKind) -> Option { match agent { AgentKind::Claude | AgentKind::Grok => Some(uuid::Uuid::new_v4().to_string()), - AgentKind::Codex | AgentKind::Cursor | AgentKind::Agy | AgentKind::Copilot => None, + AgentKind::Codex | AgentKind::Cursor | AgentKind::Agy | AgentKind::Copilot | AgentKind::Kimi => { + None + } } } @@ -91,7 +93,9 @@ pub(crate) async fn run(invocation: Invocation) -> AdapterOutput { return AdapterOutput::failed(error.to_string()); } match invocation.agent { - AgentKind::Grok | AgentKind::Cursor | AgentKind::Copilot => native::run(invocation).await, + AgentKind::Grok | AgentKind::Cursor | AgentKind::Copilot | AgentKind::Kimi => { + native::run(invocation).await + } _ => bridge::run(invocation).await, } } @@ -325,7 +329,16 @@ pub(crate) fn validate_seat_config( model: Option<&str>, effort: Option<&str>, ) -> Result<()> { - validate_effort(effort)?; + if agent == AgentKind::Kimi { + // Kimi thinking is `on`/`low`/`high`/`max`; `none`/`off` fail here. + if let Some(effort) = effort + && !["on", "low", "high", "max"].contains(&effort) + { + bail!("unsupported Kimi thinking '{effort}'"); + } + } else { + validate_effort(effort)?; + } if agent == AgentKind::Agy && let Some(effort) = effort && !["low", "medium", "high"].contains(&effort) @@ -429,7 +442,7 @@ fn build_command(invocation: &Invocation, prompt: &str) -> Result { command.args(["--effort", effort]); } } - AgentKind::Codex | AgentKind::Grok | AgentKind::Cursor | AgentKind::Copilot => { + AgentKind::Codex | AgentKind::Grok | AgentKind::Cursor | AgentKind::Copilot | AgentKind::Kimi => { bail!("agent requires its ACP transport") } } @@ -482,12 +495,15 @@ fn executable_file(path: &Path) -> bool { } fn has_local_auth_marker(agent: AgentKind) -> bool { + // Kimi Code reads credentials only from its data root (OAuth files and + // provider `api_key` entries in config.toml); it does not consume + // KIMI_API_KEY/MOONSHOT_API_KEY from the shell environment. let env_ready = match agent { AgentKind::Claude => std::env::var_os("ANTHROPIC_API_KEY").is_some(), AgentKind::Codex => std::env::var_os("OPENAI_API_KEY").is_some(), AgentKind::Cursor => std::env::var_os("CURSOR_API_KEY").is_some(), AgentKind::Grok => std::env::var_os("XAI_API_KEY").is_some(), - AgentKind::Agy => false, + AgentKind::Agy | AgentKind::Kimi => false, AgentKind::Copilot => [ "COPILOT_GITHUB_TOKEN", "GH_TOKEN", @@ -526,10 +542,46 @@ fn has_local_auth_marker(agent: AgentKind) -> bool { // Copilot keeps its login token in the OS credential store, so the // managed config file written on first launch is the local marker. AgentKind::Copilot => vec![copilot_home(&home).join("config.json")], + AgentKind::Kimi => { + let Ok(kimi_home) = resolve_kimi_home(std::env::var_os("KIMI_CODE_HOME"), Some(home)) + else { + return false; + }; + return kimi_home_has_auth(&kimi_home); + } }; markers.iter().any(|marker| marker.is_file()) } +fn kimi_home_has_auth(kimi_home: &Path) -> bool { + // OAuth tokens live in credentials/.json; API-key auth appears + // as a non-empty `api_key` entry in config.toml. Kimi Code writes + // config.toml (with empty api_key values) on first launch, before any + // login, so file existence alone is not an auth marker. Files under + // credentials/mcp/ are MCP server metadata, not credentials. + let credentials = kimi_home.join("credentials"); + if let Ok(entries) = std::fs::read_dir(&credentials) { + for entry in entries.flatten() { + let path = entry.path(); + if path.is_file() && path.extension().is_some_and(|ext| ext == "json") { + return true; + } + } + } + let Ok(config) = std::fs::read_to_string(kimi_home.join("config.toml")) else { + return false; + }; + config.lines().any(|line| { + let Some((key, value)) = line.trim().split_once('=') else { + return false; + }; + if key.trim() != "api_key" { + return false; + } + !value.trim().trim_matches('"').trim().is_empty() + }) +} + fn copilot_home(home: &Path) -> PathBuf { std::env::var_os("COPILOT_HOME") .filter(|value| !value.is_empty()) @@ -537,6 +589,30 @@ fn copilot_home(home: &Path) -> PathBuf { .unwrap_or_else(|| home.join(".copilot")) } +pub(crate) fn resolve_kimi_home( + kimi_code_home: Option, + home: Option, +) -> Result { + match kimi_code_home.filter(|value| !value.is_empty()) { + Some(dir) => { + let dir = PathBuf::from(dir); + if dir.is_absolute() { + Ok(dir) + } else { + // Kimi Code resolves a relative KIMI_CODE_HOME against the + // child's working directory; anchor it to confer's cwd so + // both sides mean the same data root. + let cwd = + std::env::current_dir().context("cannot determine current directory")?; + Ok(cwd.join(dir)) + } + } + None => home + .map(|home| home.join(".kimi-code")) + .context("cannot determine home directory"), + } +} + fn extract_session_id(value: &Value) -> Option { let object = value.as_object()?; for key in [ @@ -922,4 +998,99 @@ mod tests { }; assert!(build_command(&invalid_effort, &super::prompt_text(&invalid_effort)).is_err()); } + + #[test] + fn kimi_requires_native_acp_transport() { + let first = Invocation { + agent: AgentKind::Kimi, + executable: PathBuf::from("kimi"), + workspace: PathBuf::from("/workspace"), + native_session_id: None, + model: Some("kimi-code/k3".into()), + reasoning_effort: None, + instructions: None, + message: "Analyze this".into(), + first_message: true, + }; + let error = build_command(&first, &super::prompt_text(&first)) + .unwrap_err() + .to_string(); + assert!( + error.contains("ACP transport"), + "{error}" + ); + } + + #[test] + fn kimi_accepts_thinking_values_as_reasoning_effort() { + assert!(super::validate_seat_config(AgentKind::Copilot, None, Some("none")).is_ok()); + for effort in [None, Some("on"), Some("low"), Some("high"), Some("max")] { + assert!( + super::validate_seat_config(AgentKind::Kimi, None, effort).is_ok(), + "{effort:?}" + ); + } + for effort in ["none", "off", "medium", "minimal", "xhigh", "ultra", "not-a-level"] { + assert!( + super::validate_seat_config(AgentKind::Kimi, None, Some(effort)).is_err(), + "{effort}" + ); + } + } + + #[test] + fn kimi_home_respects_env_override_and_default() { + use std::ffi::OsString; + let home = PathBuf::from("/home/test"); + assert_eq!( + super::resolve_kimi_home(Some(OsString::from("/custom/kimi")), Some(home.clone())) + .unwrap(), + PathBuf::from("/custom/kimi") + ); + assert_eq!( + super::resolve_kimi_home(None, Some(home.clone())).unwrap(), + home.join(".kimi-code") + ); + assert_eq!( + super::resolve_kimi_home(Some(OsString::new()), Some(home.clone())).unwrap(), + home.join(".kimi-code") + ); + assert!(super::resolve_kimi_home(None, None).is_err()); + // Relative paths anchor to the current directory so confer and the + // kimi child process resolve the same data root. + let cwd = std::env::current_dir().unwrap(); + assert_eq!( + super::resolve_kimi_home(Some(OsString::from("relative/kimi")), Some(home.clone())) + .unwrap(), + cwd.join("relative/kimi") + ); + } + + #[test] + fn kimi_auth_marker_requires_credentials_or_a_key() { + let dir = tempfile::tempdir().unwrap(); + let home = dir.path(); + assert!(!super::kimi_home_has_auth(home)); + // First-launch config.toml carries empty api_key values; not auth. + std::fs::write( + home.join("config.toml"), + "[providers.\"managed:kimi-code\"]\napi_key = \"\"\n", + ) + .unwrap(); + assert!(!super::kimi_home_has_auth(home)); + // A non-empty api_key entry counts. + std::fs::write( + home.join("config.toml"), + "[providers.custom]\ntype = \"openai\"\napi_key = \"sk-test\"\n", + ) + .unwrap(); + assert!(super::kimi_home_has_auth(home)); + // OAuth credential files count; MCP metadata under credentials/mcp/ does not. + std::fs::remove_file(home.join("config.toml")).unwrap(); + std::fs::create_dir_all(home.join("credentials/mcp")).unwrap(); + std::fs::write(home.join("credentials/mcp/tool.json"), "{}").unwrap(); + assert!(!super::kimi_home_has_auth(home)); + std::fs::write(home.join("credentials/kimi-code.json"), "{}").unwrap(); + assert!(super::kimi_home_has_auth(home)); + } } diff --git a/src/adapters/acp.rs b/src/adapters/acp.rs index 845e42f..6669c8f 100644 --- a/src/adapters/acp.rs +++ b/src/adapters/acp.rs @@ -164,6 +164,19 @@ pub(super) async fn run_connection( cx.send_request(SetSessionConfigOptionRequest::new(session.clone(), id.to_owned(), value)).block_task().await?; } } + if invocation.agent == AgentKind::Kimi { + // Unattended seats use ACP mode=auto. Model and thinking use + // the same config-option channel. + let mode = Some(("mode", "auto")); + let model = invocation.model.as_deref().map(|model| ("model", model)); + let thinking = invocation + .reasoning_effort + .as_deref() + .map(|effort| ("thinking", effort)); + for (id, value) in mode.into_iter().chain(model).chain(thinking) { + cx.send_request(SetSessionConfigOptionRequest::new(session.clone(), id.to_owned(), value)).block_task().await?; + } + } // No native work can happen before the prompt, so a session whose // configuration failed is not recorded; agents such as Copilot never // persist a session that received no prompt. diff --git a/src/adapters/acp_tests.rs b/src/adapters/acp_tests.rs index b7e2e8d..38bbdd2 100644 --- a/src/adapters/acp_tests.rs +++ b/src/adapters/acp_tests.rs @@ -555,6 +555,190 @@ async fn copilot_applies_model_and_effort_through_config_options() { } } +#[tokio::test] +async fn kimi_sends_auto_mode_then_model_then_thinking() { + // Wire contract: mode=auto, then model, then thinking. + for (first_message, model, effort, thinking) in [ + (true, Some("kimi-code/k3"), None, None), + (true, Some("kimi-code/k3"), Some("low"), Some("low")), + (true, Some("kimi-code/k3"), Some("high"), Some("high")), + (true, Some("kimi-code/k3"), Some("max"), Some("max")), + (true, Some("kimi-code/k3"), Some("on"), Some("on")), + (false, Some("kimi-code/kimi-for-coding"), Some("on"), Some("on")), + ] { + let mut invocation = invocation(first_message); + invocation.agent = AgentKind::Kimi; + invocation.model = model.map(str::to_owned); + invocation.reasoning_effort = effort.map(str::to_owned); + super::validate_invocation(&invocation).unwrap(); + let expected = std::iter::once((json!("mode"), json!("auto"))) + .chain(model.map(|model| (json!("model"), json!(model)))) + .chain(thinking.map(|thinking| (json!("thinking"), json!(thinking)))) + .collect::>(); + let (client, server) = Channel::duplex(); + let configured = Arc::new(Mutex::new(Vec::new())); + let methods = Arc::new(Mutex::new(Vec::new())); + let seen_methods = methods.clone(); + let server = tokio::spawn(async move { + Agent + .builder() + .on_receive_request( + async move |request: UntypedMessage, responder, cx| { + let params = request.params(); + methods.lock().unwrap().push(request.method().to_owned()); + match request.method() { + "initialize" => responder.respond(json!({ + "protocolVersion":1, + "agentCapabilities":{ + "loadSession":true, + "sessionCapabilities":{"resume":{},"close":{}} + } + })), + "session/new" => { + assert!(first_message); + responder.respond(json!({ + "sessionId":"native-session", + "configOptions":[ + {"type":"select","id":"thinking","currentValue":"on","options":[{"value":"on"}]}, + {"type":"select","id":"mode","currentValue":"default"} + ] + })) + } + "session/resume" => { + assert!(!first_message); + assert_eq!(params["sessionId"], "native-session"); + responder.respond(json!({})) + } + "session/load" => responder.respond_with_error(Error::method_not_found()), + "session/set_config_option" => { + assert_eq!(params["sessionId"], "native-session"); + assert_ne!(params["configId"], json!("yolo")); + assert_ne!(params["value"], json!("yolo")); + configured + .lock() + .unwrap() + .push((params["configId"].clone(), params["value"].clone())); + responder.respond(json!({"configOptions":[]})) + } + "session/prompt" => { + assert_eq!(*configured.lock().unwrap(), expected); + assert_eq!(expected[0], (json!("mode"), json!("auto"))); + cx.send_notification(message("configured answer"))?; + responder.respond(json!({"stopReason":"end_turn"})) + } + "session/close" => responder.respond(json!({})), + _ => responder.respond_with_error(Error::method_not_found()), + } + }, + agent_client_protocol::on_receive_request!(), + ) + .connect_to(server) + .await + }); + let output = tokio::time::timeout( + Duration::from_secs(5), + acp::run_connection(client, invocation, true), + ) + .await + .expect("Kimi setup and configuration must terminate"); + tokio::time::timeout(Duration::from_secs(5), server) + .await + .unwrap() + .unwrap() + .unwrap(); + + assert!(output.error.is_none(), "{output:?}"); + assert_eq!(output.answer.as_deref(), Some("configured answer")); + assert_eq!( + output.observed_session_id.as_deref(), + Some("native-session") + ); + let seen = seen_methods.lock().unwrap().clone(); + if first_message { + assert!(seen.iter().any(|m| m == "session/new"), "{seen:?}"); + assert!(!seen.iter().any(|m| m == "session/resume"), "{seen:?}"); + } else { + assert!(seen.iter().any(|m| m == "session/resume"), "{seen:?}"); + assert!(!seen.iter().any(|m| m == "session/new"), "{seen:?}"); + assert!(!seen.iter().any(|m| m == "session/load"), "{seen:?}"); + } + } +} + +#[tokio::test] +async fn kimi_unknown_thinking_fails_before_the_prompt() { + // A locally accepted level the session does not offer fails here. + let mut invocation = invocation(true); + invocation.agent = AgentKind::Kimi; + invocation.model = Some("kimi-code/kimi-for-coding".into()); + invocation.reasoning_effort = Some("high".into()); + super::validate_invocation(&invocation).unwrap(); + let (client, server) = Channel::duplex(); + let prompted = Arc::new(Mutex::new(false)); + let server_prompted = prompted.clone(); + let server = tokio::spawn(async move { + Agent + .builder() + .on_receive_request( + async move |request: UntypedMessage, responder, _cx| match request.method() { + "initialize" => responder.respond(json!({ + "protocolVersion":1, + "agentCapabilities":{ + "loadSession":true, + "sessionCapabilities":{"resume":{},"close":{}} + } + })), + "session/new" => responder.respond(json!({ + "sessionId":"unprompted-session", + "configOptions":[ + {"type":"select","id":"thinking","currentValue":"on","options":[{"value":"on"}]} + ] + })), + "session/set_config_option" => { + if request.params()["configId"] == "thinking" { + responder.respond_with_error(Error::new( + -32602, + "Invalid params: Unknown thinking value: high", + )) + } else { + responder.respond(json!({"configOptions":[]})) + } + } + "session/prompt" => { + *server_prompted.lock().unwrap() = true; + responder.respond(json!({"stopReason":"end_turn"})) + } + _ => responder.respond_with_error(Error::method_not_found()), + }, + agent_client_protocol::on_receive_request!(), + ) + .connect_to(server) + .await + }); + let output = tokio::time::timeout( + Duration::from_secs(5), + acp::run_connection(client, invocation, true), + ) + .await + .expect("rejected thinking must terminate the connection"); + tokio::time::timeout(Duration::from_secs(5), server) + .await + .unwrap() + .unwrap() + .unwrap(); + + assert!(!*prompted.lock().unwrap()); + assert!(output.observed_session_id.is_none(), "{output:?}"); + assert!(output.answer.is_none()); + assert!( + output + .error + .as_deref() + .is_some_and(|error| error.contains("Unknown thinking value")), + "{output:?}" + ); +} + #[tokio::test] async fn configuration_failure_before_the_prompt_records_no_native_session() { let mut invocation = invocation(true); diff --git a/src/adapters/native.rs b/src/adapters/native.rs index 6b7706d..f10cf26 100644 --- a/src/adapters/native.rs +++ b/src/adapters/native.rs @@ -5,30 +5,12 @@ use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt}; use super::{AdapterOutput, Invocation, acp, error_text}; use crate::types::AgentKind; +use tokio::process::Command; pub(super) async fn run(invocation: Invocation) -> AdapterOutput { let mut command = invocation.command(); - match invocation.agent { - AgentKind::Grok => { - command.args(["agent", "--no-leader", "--always-approve"]); - if let Some(model) = &invocation.model { - command.args(["--model", model]); - } - if let Some(effort) = &invocation.reasoning_effort { - command.args(["--reasoning-effort", effort]); - } - command.arg("stdio"); - } - AgentKind::Cursor => { - command - .args(["--trust", "--force", "--workspace"]) - .arg(&invocation.workspace); - command.arg("acp"); - } - AgentKind::Copilot => { - command.args(["--acp", "--allow-all"]); - } - _ => return AdapterOutput::failed("agent has no native ACP transport".into()), + if let Err(error) = apply_native_args(&invocation, &mut command) { + return AdapterOutput::failed(error); } command .stdin(Stdio::piped()) @@ -65,3 +47,67 @@ pub(super) async fn run(invocation: Invocation) -> AdapterOutput { } result } + +fn apply_native_args(invocation: &Invocation, command: &mut Command) -> Result<(), String> { + match invocation.agent { + AgentKind::Grok => { + command.args(["agent", "--no-leader", "--always-approve"]); + if let Some(model) = &invocation.model { + command.args(["--model", model]); + } + if let Some(effort) = &invocation.reasoning_effort { + command.args(["--reasoning-effort", effort]); + } + command.arg("stdio"); + } + AgentKind::Cursor => { + command + .args(["--trust", "--force", "--workspace"]) + .arg(&invocation.workspace); + command.arg("acp"); + } + AgentKind::Copilot => { + command.args(["--acp", "--allow-all"]); + } + AgentKind::Kimi => { + // Pin the data root so readiness, MCP registration, and the + // ACP child agree even when KIMI_CODE_HOME is unset or relative. + let kimi_home = super::resolve_kimi_home( + std::env::var_os("KIMI_CODE_HOME"), + dirs::home_dir(), + ) + .map_err(|error| error.to_string())?; + command.env("KIMI_CODE_HOME", &kimi_home); + command.arg("acp"); + } + _ => return Err("agent has no native ACP transport".into()), + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + #[test] + fn kimi_native_command_pins_home_and_starts_acp() { + let invocation = Invocation { + agent: AgentKind::Kimi, + executable: PathBuf::from("kimi"), + workspace: PathBuf::from("/workspace"), + native_session_id: None, + model: Some("kimi-code/k3".into()), + reasoning_effort: None, + instructions: None, + message: "Analyze this".into(), + first_message: true, + }; + let mut command = invocation.command(); + apply_native_args(&invocation, &mut command).unwrap(); + let debug = format!("{command:?}"); + assert!(debug.contains("acp"), "{debug}"); + assert!(!debug.contains("-p"), "{debug}"); + assert!(debug.contains("KIMI_CODE_HOME"), "{debug}"); + } +} diff --git a/src/cli.rs b/src/cli.rs index 89b7463..7f152a7 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -38,7 +38,7 @@ enum McpCommands { Install { #[arg( long = "agent", - help = "Target host: claude, codex, cursor, grok, agy, or copilot. Repeat for multiple hosts. Use '*' for all." + help = "Target host: claude, codex, cursor, grok, agy, copilot, or kimi. Repeat for multiple hosts. Use '*' for all." )] agents: Vec, #[arg(long, help = "Print host changes without applying them")] @@ -50,7 +50,7 @@ enum McpCommands { Uninstall { #[arg( long = "agent", - help = "Target host: claude, codex, cursor, grok, agy, or copilot. Repeat for multiple hosts. Use '*' for all." + help = "Target host: claude, codex, cursor, grok, agy, copilot, or kimi. Repeat for multiple hosts. Use '*' for all." )] agents: Vec, #[arg(long, help = "Print host changes without applying them")] @@ -66,7 +66,7 @@ enum SkillCommands { scope: Option, #[arg( long = "agent", - help = "Target agent: claude, codex, cursor, grok, agy, or copilot. Repeat for multiple agents. Use '*' for all." + help = "Target agent: claude, codex, cursor, grok, agy, copilot, or kimi. Repeat for multiple agents. Use '*' for all." )] agents: Vec, #[arg(long, help = "Show the Kitup install plan without writing")] @@ -154,12 +154,22 @@ fn supported_skill_agents( .collect::>(); let selected = match selector { kitup::AgentSelector::Auto => { - kitup::detect_hosts(&kitup::BaseOptions::default(), Some(scope)) + let mut detected = kitup::detect_hosts(&kitup::BaseOptions::default(), Some(scope)) .context("failed to detect local Skill hosts")? .into_iter() .map(|host| host.id) .filter(|id| supported.contains(&id.as_str())) - .collect::>() + .collect::>(); + // kitup's kimi-cli detection only probes the default data root; + // a custom KIMI_CODE_HOME is invisible to it. Fall back to + // confer's own readiness check so auto selection still lists + // Kimi Code. + if !detected.iter().any(|id| id == "kimi-cli") + && crate::adapters::check_readiness(AgentKind::Kimi).locally_ready + { + detected.push("kimi-cli".into()); + } + detected } kitup::AgentSelector::All => supported.iter().map(ToString::to_string).collect(), kitup::AgentSelector::Explicit(values) => { @@ -270,6 +280,16 @@ mod tests { selected, kitup::AgentSelector::Explicit(vec!["github-copilot".into()]) ); + + let selected = supported_skill_agents( + kitup::AgentSelector::Explicit(vec!["kimi".into(), "kimi-cli".into()]), + kitup::Scope::User, + ) + .unwrap(); + assert_eq!( + selected, + kitup::AgentSelector::Explicit(vec!["kimi-cli".into()]) + ); } #[test] diff --git a/src/mcp/rooms.rs b/src/mcp/rooms.rs index 0949ef5..9abd461 100644 --- a/src/mcp/rooms.rs +++ b/src/mcp/rooms.rs @@ -426,6 +426,7 @@ mod tests { (AgentKind::Cursor, Some("model[effort"), None), (AgentKind::Claude, None, Some("invalid")), (AgentKind::Agy, None, Some("xhigh")), + (AgentKind::Kimi, None, Some("none")), ] { let request = SeatSpecInput { agent: Some(agent.id().into()), diff --git a/src/mcp_host.rs b/src/mcp_host.rs index 24d230f..64d3d32 100644 --- a/src/mcp_host.rs +++ b/src/mcp_host.rs @@ -137,6 +137,7 @@ fn add_args(host: AgentKind, bin: &str) -> Option> { bin.into(), SERVER_ARG.into(), ]), + AgentKind::Kimi => None, } } @@ -160,6 +161,7 @@ fn remove_args(host: AgentKind) -> Option> { ]), AgentKind::Agy => Some(vec!["mcp".into(), "remove".into(), SERVER_NAME.into()]), AgentKind::Copilot => Some(vec!["mcp".into(), "remove".into(), SERVER_NAME.into()]), + AgentKind::Kimi => None, } } @@ -174,7 +176,7 @@ fn run_hosts( let mut errors = Vec::new(); for host in hosts { let program = host_program(host); - if program.is_none() && (discover || host != AgentKind::Cursor) { + if program.is_none() && (discover || !uses_config_file(host)) { eprintln!("skipped {}: executable is not on PATH", host.id()); continue; } @@ -235,11 +237,11 @@ fn apply_host( Ok(()) } None => { - let path = cursor_config_path()?; + let path = mcp_config_path(host)?; if dry_run { println!("write {} ({SERVER_NAME})", path.display()); } else { - write_cursor_config(&path, bin)?; + write_mcp_config(host, &path, bin)?; println!("installed {}", host.id()); } Ok(()) @@ -252,11 +254,11 @@ fn apply_host( Ok(()) } None => { - let path = cursor_config_path()?; + let path = mcp_config_path(host)?; if dry_run { println!("remove {} ({SERVER_NAME})", path.display()); } else { - remove_cursor_config(&path)?; + remove_mcp_config(host, &path)?; println!("uninstalled {}", host.id()); } Ok(()) @@ -305,14 +307,25 @@ fn run_host_command( bail!("{}: {}", display_command(program, args), detail) } -fn cursor_config_path() -> Result { - Ok(dirs::home_dir() - .context("cannot determine home directory")? - .join(".cursor/mcp.json")) +fn uses_config_file(host: AgentKind) -> bool { + matches!(host, AgentKind::Cursor | AgentKind::Kimi) } -fn write_cursor_config(path: &Path, bin: &str) -> Result<()> { - update_cursor_config(path, |config| { +fn mcp_config_path(host: AgentKind) -> Result { + match host { + AgentKind::Cursor => Ok(dirs::home_dir() + .context("cannot determine home directory")? + .join(".cursor/mcp.json")), + AgentKind::Kimi => Ok( + crate::adapters::resolve_kimi_home(std::env::var_os("KIMI_CODE_HOME"), dirs::home_dir())? + .join("mcp.json"), + ), + _ => bail!("{} does not use an MCP config file", host.id()), + } +} + +fn write_mcp_config(host: AgentKind, path: &Path, bin: &str) -> Result<()> { + update_mcp_config(path, |config| { let servers = config .as_object_mut() .and_then(|root| { @@ -320,14 +333,19 @@ fn write_cursor_config(path: &Path, bin: &str) -> Result<()> { .or_insert_with(|| Value::Object(Map::new())) .as_object_mut() }) - .context("invalid Cursor MCP config: mcpServers must be an object")?; + .with_context(|| format!("invalid {} MCP config: mcpServers must be an object", host.id()))?; let entry = servers .entry(SERVER_NAME) .or_insert_with(|| Value::Object(Map::new())) .as_object_mut() - .context("invalid Cursor MCP config: mcpServers.confer must be an object")?; + .with_context(|| { + format!("invalid {} MCP config: mcpServers.confer must be an object", host.id()) + })?; if uses_non_stdio_transport(entry) { - bail!("cannot install Cursor MCP: existing confer entry uses a non-stdio transport"); + bail!( + "cannot install {} MCP config: existing confer entry uses a non-stdio transport", + host.id() + ); } entry.insert("type".into(), Value::String("stdio".into())); entry.insert("command".into(), Value::String(bin.into())); @@ -336,11 +354,11 @@ fn write_cursor_config(path: &Path, bin: &str) -> Result<()> { }) } -fn remove_cursor_config(path: &Path) -> Result<()> { +fn remove_mcp_config(host: AgentKind, path: &Path) -> Result<()> { if !path.is_file() { return Ok(()); } - update_cursor_config(path, |config| { + update_mcp_config(path, |config| { if let Some(servers) = config.get_mut("mcpServers").and_then(Value::as_object_mut) { if servers .get(SERVER_NAME) @@ -348,7 +366,8 @@ fn remove_cursor_config(path: &Path) -> Result<()> { .is_some_and(uses_non_stdio_transport) { bail!( - "cannot uninstall Cursor MCP: existing confer entry uses a non-stdio transport" + "cannot uninstall {} MCP config: existing confer entry uses a non-stdio transport", + host.id() ); } servers.remove(SERVER_NAME); @@ -357,13 +376,13 @@ fn remove_cursor_config(path: &Path) -> Result<()> { }) } -fn update_cursor_config(path: &Path, change: impl FnOnce(&mut Value) -> Result<()>) -> Result<()> { - let mut config = read_cursor_config(path)?; +fn update_mcp_config(path: &Path, change: impl FnOnce(&mut Value) -> Result<()>) -> Result<()> { + let mut config = read_mcp_config(path)?; change(&mut config)?; - write_cursor_config_file(path, &config) + write_mcp_config_file(path, &config) } -fn read_cursor_config(path: &Path) -> Result { +fn read_mcp_config(path: &Path) -> Result { match fs::read_to_string(path) { Ok(body) if body.trim().is_empty() => Ok(serde_json::json!({ "mcpServers": {} })), Ok(body) => serde_json::from_str(&body) @@ -375,7 +394,7 @@ fn read_cursor_config(path: &Path) -> Result { } } -fn write_cursor_config_file(path: &Path, config: &Value) -> Result<()> { +fn write_mcp_config_file(path: &Path, config: &Value) -> Result<()> { let target = match fs::symlink_metadata(path) { Ok(metadata) if metadata.file_type().is_symlink() => Some( fs::canonicalize(path) @@ -390,16 +409,16 @@ fn write_cursor_config_file(path: &Path, config: &Value) -> Result<()> { let path = target.as_deref().unwrap_or(path); let parent = path .parent() - .context("Cursor MCP config has no parent directory")?; + .context("MCP config has no parent directory")?; fs::create_dir_all(parent).with_context(|| format!("failed to create {}", parent.display()))?; let mut temp = tempfile::NamedTempFile::new_in(parent) .with_context(|| format!("failed to create temporary file in {}", parent.display()))?; let body = format!("{}\n", serde_json::to_string_pretty(config)?); temp.write_all(body.as_bytes()) - .context("failed to write Cursor MCP config")?; + .context("failed to write MCP config")?; temp.as_file() .sync_all() - .context("failed to sync Cursor MCP config")?; + .context("failed to sync MCP config")?; temp.persist(path) .map_err(|error| error.error) .with_context(|| format!("failed to replace {}", path.display()))?; @@ -485,7 +504,8 @@ fn looks_like_already_exists(value: &str) -> bool { #[cfg(test)] mod tests { use super::{ - add_args, read_cursor_config, remove_args, remove_cursor_config, write_cursor_config, + add_args, read_mcp_config, remove_args, remove_mcp_config, uses_config_file, + write_mcp_config, }; use crate::types::AgentKind; @@ -519,10 +539,17 @@ mod tests { remove_args(AgentKind::Copilot).unwrap(), ["mcp", "remove", "confer"] ); + assert!(add_args(AgentKind::Cursor, "confer").is_none()); + assert!(remove_args(AgentKind::Cursor).is_none()); + assert!(add_args(AgentKind::Kimi, "confer").is_none()); + assert!(remove_args(AgentKind::Kimi).is_none()); + assert!(uses_config_file(AgentKind::Cursor)); + assert!(uses_config_file(AgentKind::Kimi)); + assert!(!uses_config_file(AgentKind::Claude)); } #[test] - fn cursor_update_preserves_unrelated_entries() { + fn mcp_config_update_preserves_unrelated_entries() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("mcp.json"); std::fs::write( @@ -530,8 +557,8 @@ mod tests { r#"{"mcpServers":{"other":{"command":"other"},"confer":{"env":{"A":"B"}}}}"#, ) .unwrap(); - write_cursor_config(&path, "/tmp/confer").unwrap(); - let config = read_cursor_config(&path).unwrap(); + write_mcp_config(AgentKind::Cursor, &path, "/tmp/confer").unwrap(); + let config = read_mcp_config(&path).unwrap(); assert_eq!(config["mcpServers"]["other"]["command"], "other"); assert_eq!(config["mcpServers"]["confer"]["env"]["A"], "B"); assert_eq!(config["mcpServers"]["confer"]["type"], "stdio"); @@ -540,13 +567,13 @@ mod tests { config["mcpServers"]["confer"]["args"], serde_json::json!(["mcp"]) ); - remove_cursor_config(&path).unwrap(); + remove_mcp_config(AgentKind::Cursor, &path).unwrap(); assert_eq!( - read_cursor_config(&path).unwrap()["mcpServers"]["other"]["command"], + read_mcp_config(&path).unwrap()["mcpServers"]["other"]["command"], "other" ); assert!( - read_cursor_config(&path).unwrap()["mcpServers"] + read_mcp_config(&path).unwrap()["mcpServers"] .get("confer") .is_none() ); @@ -605,22 +632,54 @@ mod tests { std::env::current_dir().unwrap().canonicalize().unwrap(), isolated ); - let path = super::cursor_config_path().unwrap(); + let path = super::mcp_config_path(AgentKind::Cursor).unwrap(); + let kimi_path = super::mcp_config_path(AgentKind::Kimi).unwrap(); + assert_eq!( + kimi_path, + dirs::home_dir() + .unwrap() + .join(".kimi-code") + .join("mcp.json") + ); + // A custom KIMI_CODE_HOME redirects the config; a relative value + // anchors to the current directory. Safe: this test runs alone in + // the isolated re-invoked process, so no other thread reads env. + let custom = isolated.join("custom-kimi-home"); + unsafe { + std::env::set_var("KIMI_CODE_HOME", &custom); + } + assert_eq!( + super::mcp_config_path(AgentKind::Kimi).unwrap(), + custom.join("mcp.json") + ); + unsafe { + std::env::set_var("KIMI_CODE_HOME", "relative-kimi-home"); + } + assert_eq!( + super::mcp_config_path(AgentKind::Kimi).unwrap(), + isolated.join("relative-kimi-home").join("mcp.json") + ); + unsafe { + std::env::remove_var("KIMI_CODE_HOME"); + } let agents = ["cursor".into()]; + let kimi_agents = ["kimi".into()]; for dry_run in [true, false] { for selection in [vec![], vec!["*".into()], vec!["cursor".into(), "*".into()]] { assert!(super::install(&selection, dry_run, None).is_err()); assert!(super::uninstall(&selection, dry_run).is_err()); assert!(!path.exists()); + assert!(!kimi_path.exists()); } for host in AgentKind::ALL { - if host != AgentKind::Cursor { + if !uses_config_file(host) { assert!(super::install(&[host.id().into()], dry_run, None).is_err()); assert!(super::uninstall(&[host.id().into()], dry_run).is_err()); } } assert!(super::install(&agents, dry_run, Some(path.clone())).is_err()); assert!(!path.exists()); + assert!(!kimi_path.exists()); } super::install(&agents, true, None).unwrap(); @@ -629,7 +688,7 @@ mod tests { assert!(!path.exists()); super::install(&agents, false, None).unwrap(); assert_eq!( - read_cursor_config(&path).unwrap()["mcpServers"]["confer"]["command"], + read_mcp_config(&path).unwrap()["mcpServers"]["confer"]["command"], "confer" ); let installed = std::fs::read(&path).unwrap(); @@ -638,22 +697,37 @@ mod tests { assert_eq!(std::fs::read(&path).unwrap(), installed); super::uninstall(&agents, false).unwrap(); assert!( - read_cursor_config(&path).unwrap()["mcpServers"] + read_mcp_config(&path).unwrap()["mcpServers"] .get("confer") .is_none() ); assert!(!crate::adapters::check_readiness(AgentKind::Cursor).locally_ready); + + super::install(&kimi_agents, true, None).unwrap(); + assert!(!kimi_path.exists()); + super::install(&kimi_agents, false, None).unwrap(); + assert_eq!( + read_mcp_config(&kimi_path).unwrap()["mcpServers"]["confer"]["command"], + "confer" + ); + super::uninstall(&kimi_agents, false).unwrap(); + assert!( + read_mcp_config(&kimi_path).unwrap()["mcpServers"] + .get("confer") + .is_none() + ); + assert!(!crate::adapters::check_readiness(AgentKind::Kimi).locally_ready); } #[test] - fn cursor_update_accepts_empty_config_file() { + fn mcp_config_update_accepts_empty_config_file() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("mcp.json"); std::fs::write(&path, " \n").unwrap(); - write_cursor_config(&path, "confer").unwrap(); + write_mcp_config(AgentKind::Kimi, &path, "confer").unwrap(); - let config = read_cursor_config(&path).unwrap(); + let config = read_mcp_config(&path).unwrap(); assert_eq!(config["mcpServers"]["confer"]["command"], "confer"); } } diff --git a/src/types.rs b/src/types.rs index 5b7b749..d27d510 100644 --- a/src/types.rs +++ b/src/types.rs @@ -11,16 +11,18 @@ pub(crate) enum AgentKind { Grok, Agy, Copilot, + Kimi, } impl AgentKind { - pub(crate) const ALL: [Self; 6] = [ + pub(crate) const ALL: [Self; 7] = [ Self::Claude, Self::Codex, Self::Cursor, Self::Grok, Self::Agy, Self::Copilot, + Self::Kimi, ]; pub(crate) fn id(self) -> &'static str { @@ -31,6 +33,7 @@ impl AgentKind { Self::Grok => "grok", Self::Agy => "agy", Self::Copilot => "copilot", + Self::Kimi => "kimi", } } @@ -42,6 +45,7 @@ impl AgentKind { "grok" | "grok-build" => Some(Self::Grok), "agy" | "antigravity" | "antigravity-cli" => Some(Self::Agy), "copilot" | "copilot-cli" | "github-copilot" => Some(Self::Copilot), + "kimi" | "kimi-code" | "kimi-code-cli" | "kimi-cli" => Some(Self::Kimi), _ => None, } } @@ -54,6 +58,7 @@ impl AgentKind { Self::Grok => &["grok"], Self::Agy => &["agy"], Self::Copilot => &["copilot"], + Self::Kimi => &["kimi"], } } @@ -65,6 +70,7 @@ impl AgentKind { Self::Grok => Some("grok"), Self::Agy => Some("antigravity-cli"), Self::Copilot => Some("github-copilot"), + Self::Kimi => Some("kimi-cli"), } } }