From 8866f830c03fdefeae9216d49481ac741baf01d8 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Tue, 4 Aug 2026 12:54:03 +0000 Subject: [PATCH] feat(engines): add kimi (Kimi Code) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `moshcode install kimi`, `/agents kimi`, `/start kimi`, headless `ai()`, and the skill fan-out. Aliases: kimi-cli, kimi-code, moonshot. Verified against the real binary (Kimi Code 0.32.0) rather than the kimi-cli reference docs, which describe the deprecated Python CLI and differ in ways that matter: - install: the kimi-code installer directly. The code.kimi.com/install.sh wrapper those docs name now installs the deprecated kimi-cli, and it prompts — Enter or a 30s timeout redirects to kimi-code anyway. `moshcode install` cannot drive an installer that blocks on a human. - agents: `--yolo` (auto-approve tool calls). No agentsView — Kimi Code has no agent list to land on, so /agents kimi is the autonomous session. - upgrade: `kimi upgrade`, its own native updater. - ai(): `-p `. There is no `--quiet`/`--final-message-only` here. - skills: clones into $KIMI_CODE_HOME/skills (default ~/.kimi-code/skills). - mcp: skipped with a reason. Kimi Code runs MCP servers but dropped the `mcp add` subcommand — they live in ~/.kimi-code/mcp.json or the in-session /mcp-config picker, and moshcode drives engines' CLIs, not their config. Two supporting changes: - engines gain `binDirs`, as tools already have. Kimi's installer writes to ~/.kimi-code/bin and only appends it to your shell rc, so PATH alone reports it missing in the session that just installed it. - the /mcp list column now reads "no mcp add command" instead of "no MCP support". It reports what moshcode can drive; "no MCP support" would be a false claim about kimi and send the reader looking for another engine. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 6 ++++- src/engines.mjs | 38 ++++++++++++++++++++++++++---- src/integrations.mjs | 8 +++++-- src/mcp.mjs | 11 ++++++++- src/skills.mjs | 17 +++++++++++-- test/cli.test.mjs | 1 + test/engines.test.mjs | 2 ++ test/mcp-add-fanout.test.mjs | 24 ++++++++++++++++--- test/skill-install-fanout.test.mjs | 7 ++++-- test/skills.test.mjs | 26 +++++++++++++------- test/support-matrix.test.mjs | 9 +++++-- 11 files changed, 124 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index e1cc1a6..dcd6bd6 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,7 @@ moshcode install opencode # install opencode (curl … | bash) moshcode install privacycode # curl -fsSL https://getprivacycode.com/install | sh moshcode install claude # npm i -g @anthropic-ai/claude-code moshcode install codex # npm i -g @openai/codex +moshcode install kimi # curl -fsSL https://code.kimi.com/kimi-code/install.sh | bash ``` ### Autonomous agents versus raw starts @@ -76,6 +77,7 @@ moshcode agents opencode # opencode agent list (agen moshcode agents privacycode # privacycode agent list (agent view) moshcode agents codex # codex --dangerously-bypass-approvals-and-sandbox (autonomous) moshcode agents gemini # gemini --approval-mode=yolo (autonomous) +moshcode agents kimi # kimi --yolo (autonomous) moshcode agents aider # aider --yes-always (autonomous) ``` @@ -216,7 +218,9 @@ moshcode mcp add porkbun # expands to: npx -y @porkbunllc/mcp-server ``` That registers it across every engine that supports MCP (claude, gemini, codex, -opencode, privacycode) in one go. +opencode, privacycode) in one go. Kimi is skipped with a reason: it runs MCP +servers but has no command to register one from a script — add those in-session +with its own `/mcp-config`, or in `~/.kimi-code/mcp.json`. The catalog is a convenience, never a gate — an explicit command always wins, so `moshcode mcp add porkbun -- node ./my-fork.js` runs your fork. diff --git a/src/engines.mjs b/src/engines.mjs index f8de7d3..aec67a0 100644 --- a/src/engines.mjs +++ b/src/engines.mjs @@ -12,7 +12,7 @@ // approvals bypassed/auto-approved. import { spawn } from "node:child_process"; import { existsSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; +import { homedir, tmpdir } from "node:os"; import path from "node:path"; import { followFile, ptyEnabled, ptySpec, scriptFlavor, stripScriptBanner } from "./pty.mjs"; @@ -70,6 +70,31 @@ export const ENGINES = { agentArgs: ["--approval-mode=yolo"], install: { cmd: "npm", args: ["install", "-g", "@google/gemini-cli"] }, }, + kimi: { + desc: "Kimi Code — Moonshot AI's agentic CLI", + bin: "kimi", + // `--yolo` auto-approves regular tool calls while the agent can still ask a + // question — the same shape as gemini's yolo and aider's --yes-always. Kimi + // also has `--auto`, which additionally suppresses the questions; that is a + // step past what /agents means for every other engine here. + agentArgs: ["--yolo"], + // No agentsView: Kimi Code has no agent list to land on. `--agent ` + // picks a profile for the session it is starting, and there is no `kimi + // agents` subcommand, so agent mode is the autonomous session above. + // + // Install the kimi-code installer directly rather than the code.kimi.com + // /install.sh wrapper the older docs point at. That wrapper now installs the + // deprecated Python kimi-cli, and it *prompts* — Enter, or a 30s timeout, + // silently redirects to this same script. A vendor installer that blocks on + // a human for half a minute is not something `moshcode install` can drive. + install: { cmd: "bash", args: ["-c", "curl -fsSL https://code.kimi.com/kimi-code/install.sh | bash"] }, + upgrade: { cmd: "kimi", args: ["upgrade"] }, + // The installer drops the binary in ~/.kimi-code/bin and only appends that + // to your shell rc, so PATH won't see it until the next shell — including in + // the moshcode session that just installed it. (A custom KIMI_INSTALL_DIR + // lands in the rc the same way; only the default needs bridging here.) + binDirs: [path.join(homedir(), ".kimi-code", "bin")], + }, aider: { desc: "Aider — pair-programming in your terminal", bin: "aider", @@ -92,6 +117,7 @@ export function upgradeSpec(engine) { export const ENGINE_ALIASES = { cc: "claude", "claude-code": "claude", openai: "codex", gpt: "codex", google: "gemini", pc: "privacycode", getprivacycode: "privacycode", privacy: "privacycode", + "kimi-cli": "kimi", "kimi-code": "kimi", moonshot: "kimi", }; /** Resolve a name/alias to `[key, engine]`, or null. */ @@ -172,6 +198,7 @@ const AI_EXEC = { opencode: (p) => ["run", p], // opencode one-shot privacycode: (p) => ["run", p], // privacycode one-shot (opencode-derived) aider: (p) => ["--message", p, "--yes", "--no-auto-commits"], // aider single message + kimi: (p) => ["-p", p], // kimi prompt mode (prints the response, text by default) }; /** argv that runs `prompt` headlessly on `engine` (throws if it has no headless mode). */ @@ -193,16 +220,19 @@ export function aiExecArgs(engine, prompt) { */ export function pickAiEngine(preferred) { const wanted = preferred ? resolveEngine(preferred)?.[0] : null; - const order = preferred ? (wanted ? [wanted] : []) : ["claude", "codex", "opencode", "privacycode", "gemini", "aider"]; + const order = preferred ? (wanted ? [wanted] : []) : ["claude", "codex", "opencode", "privacycode", "gemini", "kimi", "aider"]; for (const key of order) { - if (Object.hasOwn(ENGINES, key) && Object.hasOwn(AI_EXEC, key) && isInstalled(ENGINES[key].bin)) return key; + if (Object.hasOwn(ENGINES, key) && Object.hasOwn(AI_EXEC, key) && isInstalled(ENGINES[key].bin, ENGINES[key].binDirs)) return key; } return null; } /** Engine entries annotated with install status. */ export function engineStatus() { - return Object.entries(ENGINES).map(([key, e]) => ({ key, ...e, installed: isInstalled(e.bin) })); + // Search each engine's own install dir as well as PATH — kimi's installer only + // adds ~/.kimi-code/bin to your shell rc, so PATH alone reports it missing in + // the very session that installed it. (Inert for engines without binDirs.) + return Object.entries(ENGINES).map(([key, e]) => ({ key, ...e, installed: isInstalled(e.bin, e.binDirs) })); } export function engineList() { diff --git a/src/integrations.mjs b/src/integrations.mjs index 3c69011..1ca8e2b 100644 --- a/src/integrations.mjs +++ b/src/integrations.mjs @@ -131,7 +131,7 @@ function integrationTargetStatus(supportedKeys, { installedSet } = {}) { return keys.map((key) => ({ name: key, binary: ENGINES[key].bin, - installed: installedSet ? installedSet.has(key) : isInstalled(ENGINES[key].bin), + installed: installedSet ? installedSet.has(key) : isInstalled(ENGINES[key].bin, ENGINES[key].binDirs), supported: supported.has(key), })); } @@ -159,7 +159,11 @@ export function printMcpTargets(json = false) { console.log(bone(" mcp") + ash(" — register a server everywhere with ") + acid("/mcp install ")); for (const target of targets) { const dot = target.supported && target.installed ? DOT.installed : DOT.missing; - console.log(` ${dot} ${bone(target.name.padEnd(9))} ${ash(target.supported ? "mcp add supported" : "no MCP support")}`); + // "no MCP support" would be a claim about the engine; what this column + // actually knows is whether moshcode can register a server there. Kimi runs + // MCP servers perfectly well and simply has no command to add one from a + // script — the fan-out states each engine's own reason when you run it. + console.log(` ${dot} ${bone(target.name.padEnd(9))} ${ash(target.supported ? "mcp add supported" : "no mcp add command")}`); } } diff --git a/src/mcp.mjs b/src/mcp.mjs index e494cbb..8da2d62 100644 --- a/src/mcp.mjs +++ b/src/mcp.mjs @@ -81,6 +81,15 @@ export function mcpAddArgs(key, spec) { else argv.push(target, ...args); return { argv }; } + case "kimi": + // Kimi Code runs MCP servers, but nothing registers one from a script: it + // reads ~/.kimi-code/mcp.json, edited by hand or through the in-session + // /mcp-config picker. (The deprecated Python kimi-cli did have `kimi mcp + // add`; Kimi Code dropped the subcommand.) MoshCode drives each engine's + // own CLI rather than writing its config file, so this is a stated skip — + // and a more useful one than the blanket "no MCP support", which would + // read as "kimi cannot do MCP at all". + return { skip: "no scriptable `mcp add` — add it in kimi with /mcp-config, or in ~/.kimi-code/mcp.json" }; case "codex": { if (headers.length) { return { skip: "Codex supports only a bearer-token env var, not literal headers" }; @@ -124,7 +133,7 @@ export function planMcpAdd(spec, { installedSet } = {}) { const rest = Object.keys(ENGINES).filter((key) => !MCP_ENGINES.includes(key)); return [...MCP_ENGINES, ...rest].map((key) => { const bin = ENGINES[key].bin; - const installed = installedSet ? installedSet.has(key) : isInstalled(bin); + const installed = installedSet ? installedSet.has(key) : isInstalled(bin, ENGINES[key].binDirs); return { key, bin, installed, ...mcpAddArgs(key, spec) }; }); } diff --git a/src/skills.mjs b/src/skills.mjs index e87616a..b461924 100644 --- a/src/skills.mjs +++ b/src/skills.mjs @@ -6,13 +6,22 @@ import path from "node:path"; import { ENGINES, isInstalled, ranOk, runCmd } from "./engines.mjs"; // Coding engines with a skills primitive. Codex/OpenCode/Aider have none. -export const SKILL_ENGINES = ["claude", "gemini"]; +export const SKILL_ENGINES = ["claude", "gemini", "kimi"]; /** Claude's global personal skills directory (~/.claude/skills). */ export function claudeSkillsDir() { return path.join(os.homedir(), ".claude", "skills"); } +/** + * Kimi Code's global skills directory ($KIMI_CODE_HOME/skills, default + * ~/.kimi-code/skills). Kimi's own user-level skill dir moves with that + * variable, so read it rather than hardcoding the default away. + */ +export function kimiSkillsDir(env = process.env) { + return path.join(env.KIMI_CODE_HOME || path.join(os.homedir(), ".kimi-code"), "skills"); +} + /** Derive a skill name from a git URL or path (basename minus `.git`), or use the override. */ export function skillName(source, override) { const sanitize = (s) => String(s).toLowerCase().replace(/[^a-z0-9._-]/g, "-").replace(/^-+|-+$/g, ""); @@ -41,6 +50,10 @@ export function skillInstallAction(key, spec) { case "claude": // Claude has no `skill install`; clone the source into its skills dir. return { cmd: "git", args: ["clone", "--depth", "1", source, path.join(claudeSkillsDir(), name)] }; + case "kimi": + // Kimi Code discovers skills by scanning directories, with no install + // command of its own — so clone into the one it scans, as Claude does. + return { cmd: "git", args: ["clone", "--depth", "1", source, path.join(kimiSkillsDir(), name)] }; default: return { skip: "no skills primitive" }; } @@ -58,7 +71,7 @@ export function planSkillInstall(spec, { installedSet } = {}) { const rest = Object.keys(ENGINES).filter((key) => !SKILL_ENGINES.includes(key)); return [...SKILL_ENGINES, ...rest].map((key) => { const bin = ENGINES[key].bin; - const installed = installedSet ? installedSet.has(key) : isInstalled(bin); + const installed = installedSet ? installedSet.has(key) : isInstalled(bin, ENGINES[key].binDirs); return { key, bin, installed, ...skillInstallAction(key, spec) }; }); } diff --git a/test/cli.test.mjs b/test/cli.test.mjs index 9905830..635f768 100644 --- a/test/cli.test.mjs +++ b/test/cli.test.mjs @@ -161,6 +161,7 @@ test("aiExecArgs maps each engine to its headless invocation", () => { assert.deepEqual(aiExecArgs("gemini", "hi"), ["-p", "hi"]); assert.deepEqual(aiExecArgs("opencode", "hi"), ["run", "hi"]); assert.deepEqual(aiExecArgs("aider", "hi").slice(0, 2), ["--message", "hi"]); + assert.deepEqual(aiExecArgs("kimi", "hi"), ["-p", "hi"]); assert.throws(() => aiExecArgs("nope", "hi"), /no headless mode/); }); diff --git a/test/engines.test.mjs b/test/engines.test.mjs index f3ffb9d..c875963 100644 --- a/test/engines.test.mjs +++ b/test/engines.test.mjs @@ -24,6 +24,7 @@ const EXPECTED_AGENT_ARGS = { claude: ["--dangerously-skip-permissions"], codex: ["--dangerously-bypass-approvals-and-sandbox"], gemini: ["--approval-mode=yolo"], + kimi: ["--yolo"], aider: ["--yes-always"], }; @@ -35,6 +36,7 @@ const EXPECTED_LAUNCH_ARGS = { claude: ["agents", "--dangerously-skip-permissions"], codex: ["--dangerously-bypass-approvals-and-sandbox"], gemini: ["--approval-mode=yolo"], + kimi: ["--yolo"], // no agents view — kimi has no agent list to land on aider: ["--yes-always"], }; diff --git a/test/mcp-add-fanout.test.mjs b/test/mcp-add-fanout.test.mjs index 3baafb2..5687e74 100644 --- a/test/mcp-add-fanout.test.mjs +++ b/test/mcp-add-fanout.test.mjs @@ -21,11 +21,16 @@ test("the plan covers every engine, not just the ones with MCP support", () => { assert.deepEqual([...keys].sort(), Object.keys(ENGINES).sort()); }); -test("every engine without MCP support carries the skip reason", () => { +test("every engine without MCP support carries a skip reason", () => { + // R6 asks for a *stated reason*, not one shared string: "no MCP support" fits + // aider, which has none, but not kimi, which runs servers and only lacks a way + // to register one from a script. What must hold for all of them is that the + // row says why. const plan = byKey(planMcpAdd(REMOTE, { installedSet: new Set() })); assert.ok(NO_MCP.length, "expected at least one engine with no MCP support"); for (const key of NO_MCP) { - assert.equal(plan[key]?.skip, "no MCP support", `${key} has no skip reason`); + assert.equal(typeof plan[key]?.skip, "string", `${key} has no skip reason`); + assert.ok(plan[key].skip.length, `${key}'s skip reason is empty`); } }); @@ -40,7 +45,7 @@ test("the fan-out reports the no-MCP engines as skipped", async () => { const results = byKey(await runMcpAdd(plan, { run: async () => ({ ok: true, code: 0 }) })); for (const key of NO_MCP) { assert.equal(results[key]?.status, "skipped", `${key} missing from the summary`); - assert.equal(results[key]?.reason, "no MCP support"); + assert.ok(results[key]?.reason, `${key} was skipped without saying why`); } }); @@ -76,6 +81,18 @@ test("MCP_ENGINES is unchanged — no engine gained MCP support", () => { assert.deepEqual(MCP_ENGINES, ["claude", "gemini", "codex", "opencode", "privacycode"]); }); +test("kimi is skipped for the reason that actually applies to it", () => { + // Kimi Code runs MCP servers; it just has no command to register one from a + // script (config file, or the /mcp-config picker inside a session). Reporting + // that as the blanket "no MCP support" would send someone off to look for an + // MCP-capable engine they already have installed. + const { skip, argv } = mcpAddArgs("kimi", REMOTE); + assert.equal(argv, undefined, "a skipped engine must not carry an argv"); + assert.match(skip, /no scriptable `mcp add`/); + assert.match(skip, /mcp-config|mcp\.json/); + assert.notEqual(skip, "no MCP support"); +}); + test("the MCP-capable engines still come first, in their original order", () => { const keys = planMcpAdd(REMOTE, { installedSet: new Set() }).map((p) => p.key); assert.deepEqual(keys.slice(0, MCP_ENGINES.length), MCP_ENGINES); @@ -146,3 +163,4 @@ test("mcpAddArgs itself is untouched for a supported and an unsupported key", () assert.equal(mcpAddArgs("aider", REMOTE).skip, "no MCP support"); assert.deepEqual(mcpAddArgs("codex", REMOTE).argv, ["mcp", "add", "sentry", "--url", "https://mcp.sentry.dev/mcp"]); }); + diff --git a/test/skill-install-fanout.test.mjs b/test/skill-install-fanout.test.mjs index f378649..ef6fa0b 100644 --- a/test/skill-install-fanout.test.mjs +++ b/test/skill-install-fanout.test.mjs @@ -87,8 +87,11 @@ test("the engines with a primitive still come first, in SKILL_ENGINES order", () assert.deepEqual(keys.slice(0, SKILL_ENGINES.length), SKILL_ENGINES); }); -test("SKILL_ENGINES is unchanged: no engine gained a primitive", () => { - assert.deepEqual(SKILL_ENGINES, ["claude", "gemini"]); +// Pinned for the same reason as MCP_ENGINES: a wider fan-out must never widen +// the claimed capability. kimi moved it because kimi really does have a skills +// primitive (it scans ~/.kimi/skills), not because the plan now iterates it. +test("SKILL_ENGINES names exactly the engines with a skills primitive", () => { + assert.deepEqual(SKILL_ENGINES, ["claude", "gemini", "kimi"]); }); test("claude still clones the source into its skills dir, byte for byte", () => { diff --git a/test/skills.test.mjs b/test/skills.test.mjs index 1067879..53d8b6a 100644 --- a/test/skills.test.mjs +++ b/test/skills.test.mjs @@ -4,7 +4,7 @@ import path from "node:path"; import test from "node:test"; import { - SKILL_ENGINES, claudeSkillsDir, planSkillInstall, runSkillInstall, skillInstallAction, skillName, + SKILL_ENGINES, claudeSkillsDir, kimiSkillsDir, planSkillInstall, runSkillInstall, skillInstallAction, skillName, } from "../src/skills.mjs"; test("skillName derives from a git url or path, or takes an override", () => { @@ -29,12 +29,13 @@ test("skillName never yields `.` or `..`, which would escape the skills dir", () assert.equal(skillName("whatever", ".."), "skill"); }); -test("the claude clone destination stays inside the skills dir", () => { - const dir = claudeSkillsDir(); - for (const source of [".", "./", "..", "../", "a/b/."]) { - const { args } = skillInstallAction("claude", { source, name: skillName(source) }); - const dest = args.at(-1); - assert.equal(path.dirname(dest), dir, `${source} escaped to ${dest}`); +test("a clone destination stays inside the engine's skills dir", () => { + for (const [key, dir] of [["claude", claudeSkillsDir()], ["kimi", kimiSkillsDir()]]) { + for (const source of [".", "./", "..", "../", "a/b/."]) { + const { args } = skillInstallAction(key, { source, name: skillName(source) }); + const dest = args.at(-1); + assert.equal(path.dirname(dest), dir, `${key}: ${source} escaped to ${dest}`); + } } }); @@ -44,6 +45,15 @@ test("skillInstallAction: gemini installs natively, claude clones into its skill const claude = skillInstallAction("claude", { source: "https://x/y", name: "y" }); assert.deepEqual(claude, { cmd: "git", args: ["clone", "--depth", "1", "https://x/y", path.join(claudeSkillsDir(), "y")] }); + + // Kimi Code discovers skills by scanning dirs too, so it clones into its own. + const kimi = skillInstallAction("kimi", { source: "https://x/y", name: "y" }); + assert.deepEqual(kimi, { cmd: "git", args: ["clone", "--depth", "1", "https://x/y", path.join(kimiSkillsDir(), "y")] }); +}); + +test("kimiSkillsDir follows KIMI_CODE_HOME, which is what moves kimi's skills", () => { + assert.equal(kimiSkillsDir({}), path.join(os.homedir(), ".kimi-code", "skills")); + assert.equal(kimiSkillsDir({ KIMI_CODE_HOME: "/opt/kimi" }), path.join("/opt/kimi", "skills")); }); test("skillInstallAction: engines without a skills primitive are skipped", () => { @@ -57,7 +67,7 @@ test("claudeSkillsDir points at the personal skills directory", () => { }); test("SKILL_ENGINES is exactly the engines with a skills primitive", () => { - assert.deepEqual(SKILL_ENGINES, ["claude", "gemini"]); + assert.deepEqual(SKILL_ENGINES, ["claude", "gemini", "kimi"]); }); test("runSkillInstall summarizes installed / not-installed", async () => { diff --git a/test/support-matrix.test.mjs b/test/support-matrix.test.mjs index 2ffc53f..c3d5a14 100644 --- a/test/support-matrix.test.mjs +++ b/test/support-matrix.test.mjs @@ -108,13 +108,18 @@ test("/mcp list lists every engine exactly once", () => { }); test("/mcp list splits the rows by MCP_ENGINES", () => { + // The column reports what moshcode can drive, so it says "no mcp add command" + // rather than "no MCP support" — kimi runs MCP servers and only lacks a way to + // register one from a script, and a row claiming otherwise sends the reader + // looking for an engine they already have. const out = capture(printMcpTargets); for (const key of Object.keys(ENGINES)) { const supported = MCP_ENGINES.includes(key); assert.match( out, - new RegExp(`${key}\\s+${supported ? "mcp add supported" : "no MCP support"}`), - `${key} row should say ${supported ? "supported" : "no MCP support"}`, + new RegExp(`${key}\\s+${supported ? "mcp add supported" : "no mcp add command"}`), + `${key} row should say ${supported ? "supported" : "no mcp add command"}`, ); } + assert.doesNotMatch(out, /no MCP support/, "the matrix must not claim an engine cannot do MCP at all"); });