Skip to content

Commit 4f0026a

Browse files
ralyodioclaude
andauthored
feat(engines): add kimi (Kimi Code) (#286)
`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 <prompt>`. 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) <noreply@anthropic.com>
1 parent b6ab86f commit 4f0026a

11 files changed

Lines changed: 124 additions & 25 deletions

README.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ moshcode install opencode # install opencode (curl … | bash)
6161
moshcode install privacycode # curl -fsSL https://getprivacycode.com/install | sh
6262
moshcode install claude # npm i -g @anthropic-ai/claude-code
6363
moshcode install codex # npm i -g @openai/codex
64+
moshcode install kimi # curl -fsSL https://code.kimi.com/kimi-code/install.sh | bash
6465
```
6566

6667
### Autonomous agents versus raw starts
@@ -76,6 +77,7 @@ moshcode agents opencode # opencode agent list (agen
7677
moshcode agents privacycode # privacycode agent list (agent view)
7778
moshcode agents codex # codex --dangerously-bypass-approvals-and-sandbox (autonomous)
7879
moshcode agents gemini # gemini --approval-mode=yolo (autonomous)
80+
moshcode agents kimi # kimi --yolo (autonomous)
7981
moshcode agents aider # aider --yes-always (autonomous)
8082
```
8183

@@ -216,7 +218,9 @@ moshcode mcp add porkbun # expands to: npx -y @porkbunllc/mcp-server
216218
```
217219

218220
That registers it across every engine that supports MCP (claude, gemini, codex,
219-
opencode, privacycode) in one go.
221+
opencode, privacycode) in one go. Kimi is skipped with a reason: it runs MCP
222+
servers but has no command to register one from a script — add those in-session
223+
with its own `/mcp-config`, or in `~/.kimi-code/mcp.json`.
220224

221225
The catalog is a convenience, never a gate — an explicit command always wins, so
222226
`moshcode mcp add porkbun -- node ./my-fork.js` runs your fork.

src/engines.mjs

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
// approvals bypassed/auto-approved.
1313
import { spawn } from "node:child_process";
1414
import { existsSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
15-
import { tmpdir } from "node:os";
15+
import { homedir, tmpdir } from "node:os";
1616
import path from "node:path";
1717

1818
import { followFile, ptyEnabled, ptySpec, scriptFlavor, stripScriptBanner } from "./pty.mjs";
@@ -70,6 +70,31 @@ export const ENGINES = {
7070
agentArgs: ["--approval-mode=yolo"],
7171
install: { cmd: "npm", args: ["install", "-g", "@google/gemini-cli"] },
7272
},
73+
kimi: {
74+
desc: "Kimi Code — Moonshot AI's agentic CLI",
75+
bin: "kimi",
76+
// `--yolo` auto-approves regular tool calls while the agent can still ask a
77+
// question — the same shape as gemini's yolo and aider's --yes-always. Kimi
78+
// also has `--auto`, which additionally suppresses the questions; that is a
79+
// step past what /agents means for every other engine here.
80+
agentArgs: ["--yolo"],
81+
// No agentsView: Kimi Code has no agent list to land on. `--agent <name>`
82+
// picks a profile for the session it is starting, and there is no `kimi
83+
// agents` subcommand, so agent mode is the autonomous session above.
84+
//
85+
// Install the kimi-code installer directly rather than the code.kimi.com
86+
// /install.sh wrapper the older docs point at. That wrapper now installs the
87+
// deprecated Python kimi-cli, and it *prompts* — Enter, or a 30s timeout,
88+
// silently redirects to this same script. A vendor installer that blocks on
89+
// a human for half a minute is not something `moshcode install` can drive.
90+
install: { cmd: "bash", args: ["-c", "curl -fsSL https://code.kimi.com/kimi-code/install.sh | bash"] },
91+
upgrade: { cmd: "kimi", args: ["upgrade"] },
92+
// The installer drops the binary in ~/.kimi-code/bin and only appends that
93+
// to your shell rc, so PATH won't see it until the next shell — including in
94+
// the moshcode session that just installed it. (A custom KIMI_INSTALL_DIR
95+
// lands in the rc the same way; only the default needs bridging here.)
96+
binDirs: [path.join(homedir(), ".kimi-code", "bin")],
97+
},
7398
aider: {
7499
desc: "Aider — pair-programming in your terminal",
75100
bin: "aider",
@@ -92,6 +117,7 @@ export function upgradeSpec(engine) {
92117
export const ENGINE_ALIASES = {
93118
cc: "claude", "claude-code": "claude", openai: "codex", gpt: "codex", google: "gemini",
94119
pc: "privacycode", getprivacycode: "privacycode", privacy: "privacycode",
120+
"kimi-cli": "kimi", "kimi-code": "kimi", moonshot: "kimi",
95121
};
96122

97123
/** Resolve a name/alias to `[key, engine]`, or null. */
@@ -172,6 +198,7 @@ const AI_EXEC = {
172198
opencode: (p) => ["run", p], // opencode one-shot
173199
privacycode: (p) => ["run", p], // privacycode one-shot (opencode-derived)
174200
aider: (p) => ["--message", p, "--yes", "--no-auto-commits"], // aider single message
201+
kimi: (p) => ["-p", p], // kimi prompt mode (prints the response, text by default)
175202
};
176203

177204
/** argv that runs `prompt` headlessly on `engine` (throws if it has no headless mode). */
@@ -193,16 +220,19 @@ export function aiExecArgs(engine, prompt) {
193220
*/
194221
export function pickAiEngine(preferred) {
195222
const wanted = preferred ? resolveEngine(preferred)?.[0] : null;
196-
const order = preferred ? (wanted ? [wanted] : []) : ["claude", "codex", "opencode", "privacycode", "gemini", "aider"];
223+
const order = preferred ? (wanted ? [wanted] : []) : ["claude", "codex", "opencode", "privacycode", "gemini", "kimi", "aider"];
197224
for (const key of order) {
198-
if (Object.hasOwn(ENGINES, key) && Object.hasOwn(AI_EXEC, key) && isInstalled(ENGINES[key].bin)) return key;
225+
if (Object.hasOwn(ENGINES, key) && Object.hasOwn(AI_EXEC, key) && isInstalled(ENGINES[key].bin, ENGINES[key].binDirs)) return key;
199226
}
200227
return null;
201228
}
202229

203230
/** Engine entries annotated with install status. */
204231
export function engineStatus() {
205-
return Object.entries(ENGINES).map(([key, e]) => ({ key, ...e, installed: isInstalled(e.bin) }));
232+
// Search each engine's own install dir as well as PATH — kimi's installer only
233+
// adds ~/.kimi-code/bin to your shell rc, so PATH alone reports it missing in
234+
// the very session that installed it. (Inert for engines without binDirs.)
235+
return Object.entries(ENGINES).map(([key, e]) => ({ key, ...e, installed: isInstalled(e.bin, e.binDirs) }));
206236
}
207237

208238
export function engineList() {

src/integrations.mjs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,7 @@ function integrationTargetStatus(supportedKeys, { installedSet } = {}) {
131131
return keys.map((key) => ({
132132
name: key,
133133
binary: ENGINES[key].bin,
134-
installed: installedSet ? installedSet.has(key) : isInstalled(ENGINES[key].bin),
134+
installed: installedSet ? installedSet.has(key) : isInstalled(ENGINES[key].bin, ENGINES[key].binDirs),
135135
supported: supported.has(key),
136136
}));
137137
}
@@ -159,7 +159,11 @@ export function printMcpTargets(json = false) {
159159
console.log(bone(" mcp") + ash(" — register a server everywhere with ") + acid("/mcp install <url>"));
160160
for (const target of targets) {
161161
const dot = target.supported && target.installed ? DOT.installed : DOT.missing;
162-
console.log(` ${dot} ${bone(target.name.padEnd(9))} ${ash(target.supported ? "mcp add supported" : "no MCP support")}`);
162+
// "no MCP support" would be a claim about the engine; what this column
163+
// actually knows is whether moshcode can register a server there. Kimi runs
164+
// MCP servers perfectly well and simply has no command to add one from a
165+
// script — the fan-out states each engine's own reason when you run it.
166+
console.log(` ${dot} ${bone(target.name.padEnd(9))} ${ash(target.supported ? "mcp add supported" : "no mcp add command")}`);
163167
}
164168
}
165169

src/mcp.mjs

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,15 @@ export function mcpAddArgs(key, spec) {
8181
else argv.push(target, ...args);
8282
return { argv };
8383
}
84+
case "kimi":
85+
// Kimi Code runs MCP servers, but nothing registers one from a script: it
86+
// reads ~/.kimi-code/mcp.json, edited by hand or through the in-session
87+
// /mcp-config picker. (The deprecated Python kimi-cli did have `kimi mcp
88+
// add`; Kimi Code dropped the subcommand.) MoshCode drives each engine's
89+
// own CLI rather than writing its config file, so this is a stated skip —
90+
// and a more useful one than the blanket "no MCP support", which would
91+
// read as "kimi cannot do MCP at all".
92+
return { skip: "no scriptable `mcp add` — add it in kimi with /mcp-config, or in ~/.kimi-code/mcp.json" };
8493
case "codex": {
8594
if (headers.length) {
8695
return { skip: "Codex supports only a bearer-token env var, not literal headers" };
@@ -124,7 +133,7 @@ export function planMcpAdd(spec, { installedSet } = {}) {
124133
const rest = Object.keys(ENGINES).filter((key) => !MCP_ENGINES.includes(key));
125134
return [...MCP_ENGINES, ...rest].map((key) => {
126135
const bin = ENGINES[key].bin;
127-
const installed = installedSet ? installedSet.has(key) : isInstalled(bin);
136+
const installed = installedSet ? installedSet.has(key) : isInstalled(bin, ENGINES[key].binDirs);
128137
return { key, bin, installed, ...mcpAddArgs(key, spec) };
129138
});
130139
}

src/skills.mjs

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,22 @@ import path from "node:path";
66
import { ENGINES, isInstalled, ranOk, runCmd } from "./engines.mjs";
77

88
// Coding engines with a skills primitive. Codex/OpenCode/Aider have none.
9-
export const SKILL_ENGINES = ["claude", "gemini"];
9+
export const SKILL_ENGINES = ["claude", "gemini", "kimi"];
1010

1111
/** Claude's global personal skills directory (~/.claude/skills). */
1212
export function claudeSkillsDir() {
1313
return path.join(os.homedir(), ".claude", "skills");
1414
}
1515

16+
/**
17+
* Kimi Code's global skills directory ($KIMI_CODE_HOME/skills, default
18+
* ~/.kimi-code/skills). Kimi's own user-level skill dir moves with that
19+
* variable, so read it rather than hardcoding the default away.
20+
*/
21+
export function kimiSkillsDir(env = process.env) {
22+
return path.join(env.KIMI_CODE_HOME || path.join(os.homedir(), ".kimi-code"), "skills");
23+
}
24+
1625
/** Derive a skill name from a git URL or path (basename minus `.git`), or use the override. */
1726
export function skillName(source, override) {
1827
const sanitize = (s) => String(s).toLowerCase().replace(/[^a-z0-9._-]/g, "-").replace(/^-+|-+$/g, "");
@@ -41,6 +50,10 @@ export function skillInstallAction(key, spec) {
4150
case "claude":
4251
// Claude has no `skill install`; clone the source into its skills dir.
4352
return { cmd: "git", args: ["clone", "--depth", "1", source, path.join(claudeSkillsDir(), name)] };
53+
case "kimi":
54+
// Kimi Code discovers skills by scanning directories, with no install
55+
// command of its own — so clone into the one it scans, as Claude does.
56+
return { cmd: "git", args: ["clone", "--depth", "1", source, path.join(kimiSkillsDir(), name)] };
4457
default:
4558
return { skip: "no skills primitive" };
4659
}
@@ -58,7 +71,7 @@ export function planSkillInstall(spec, { installedSet } = {}) {
5871
const rest = Object.keys(ENGINES).filter((key) => !SKILL_ENGINES.includes(key));
5972
return [...SKILL_ENGINES, ...rest].map((key) => {
6073
const bin = ENGINES[key].bin;
61-
const installed = installedSet ? installedSet.has(key) : isInstalled(bin);
74+
const installed = installedSet ? installedSet.has(key) : isInstalled(bin, ENGINES[key].binDirs);
6275
return { key, bin, installed, ...skillInstallAction(key, spec) };
6376
});
6477
}

test/cli.test.mjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,7 @@ test("aiExecArgs maps each engine to its headless invocation", () => {
161161
assert.deepEqual(aiExecArgs("gemini", "hi"), ["-p", "hi"]);
162162
assert.deepEqual(aiExecArgs("opencode", "hi"), ["run", "hi"]);
163163
assert.deepEqual(aiExecArgs("aider", "hi").slice(0, 2), ["--message", "hi"]);
164+
assert.deepEqual(aiExecArgs("kimi", "hi"), ["-p", "hi"]);
164165
assert.throws(() => aiExecArgs("nope", "hi"), /no headless mode/);
165166
});
166167

test/engines.test.mjs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ const EXPECTED_AGENT_ARGS = {
2424
claude: ["--dangerously-skip-permissions"],
2525
codex: ["--dangerously-bypass-approvals-and-sandbox"],
2626
gemini: ["--approval-mode=yolo"],
27+
kimi: ["--yolo"],
2728
aider: ["--yes-always"],
2829
};
2930

@@ -35,6 +36,7 @@ const EXPECTED_LAUNCH_ARGS = {
3536
claude: ["agents", "--dangerously-skip-permissions"],
3637
codex: ["--dangerously-bypass-approvals-and-sandbox"],
3738
gemini: ["--approval-mode=yolo"],
39+
kimi: ["--yolo"], // no agents view — kimi has no agent list to land on
3840
aider: ["--yes-always"],
3941
};
4042

test/mcp-add-fanout.test.mjs

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,16 @@ test("the plan covers every engine, not just the ones with MCP support", () => {
2121
assert.deepEqual([...keys].sort(), Object.keys(ENGINES).sort());
2222
});
2323

24-
test("every engine without MCP support carries the skip reason", () => {
24+
test("every engine without MCP support carries a skip reason", () => {
25+
// R6 asks for a *stated reason*, not one shared string: "no MCP support" fits
26+
// aider, which has none, but not kimi, which runs servers and only lacks a way
27+
// to register one from a script. What must hold for all of them is that the
28+
// row says why.
2529
const plan = byKey(planMcpAdd(REMOTE, { installedSet: new Set() }));
2630
assert.ok(NO_MCP.length, "expected at least one engine with no MCP support");
2731
for (const key of NO_MCP) {
28-
assert.equal(plan[key]?.skip, "no MCP support", `${key} has no skip reason`);
32+
assert.equal(typeof plan[key]?.skip, "string", `${key} has no skip reason`);
33+
assert.ok(plan[key].skip.length, `${key}'s skip reason is empty`);
2934
}
3035
});
3136

@@ -40,7 +45,7 @@ test("the fan-out reports the no-MCP engines as skipped", async () => {
4045
const results = byKey(await runMcpAdd(plan, { run: async () => ({ ok: true, code: 0 }) }));
4146
for (const key of NO_MCP) {
4247
assert.equal(results[key]?.status, "skipped", `${key} missing from the summary`);
43-
assert.equal(results[key]?.reason, "no MCP support");
48+
assert.ok(results[key]?.reason, `${key} was skipped without saying why`);
4449
}
4550
});
4651

@@ -76,6 +81,18 @@ test("MCP_ENGINES is unchanged — no engine gained MCP support", () => {
7681
assert.deepEqual(MCP_ENGINES, ["claude", "gemini", "codex", "opencode", "privacycode"]);
7782
});
7883

84+
test("kimi is skipped for the reason that actually applies to it", () => {
85+
// Kimi Code runs MCP servers; it just has no command to register one from a
86+
// script (config file, or the /mcp-config picker inside a session). Reporting
87+
// that as the blanket "no MCP support" would send someone off to look for an
88+
// MCP-capable engine they already have installed.
89+
const { skip, argv } = mcpAddArgs("kimi", REMOTE);
90+
assert.equal(argv, undefined, "a skipped engine must not carry an argv");
91+
assert.match(skip, /no scriptable `mcp add`/);
92+
assert.match(skip, /mcp-config|mcp\.json/);
93+
assert.notEqual(skip, "no MCP support");
94+
});
95+
7996
test("the MCP-capable engines still come first, in their original order", () => {
8097
const keys = planMcpAdd(REMOTE, { installedSet: new Set() }).map((p) => p.key);
8198
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", ()
146163
assert.equal(mcpAddArgs("aider", REMOTE).skip, "no MCP support");
147164
assert.deepEqual(mcpAddArgs("codex", REMOTE).argv, ["mcp", "add", "sentry", "--url", "https://mcp.sentry.dev/mcp"]);
148165
});
166+

test/skill-install-fanout.test.mjs

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -87,8 +87,11 @@ test("the engines with a primitive still come first, in SKILL_ENGINES order", ()
8787
assert.deepEqual(keys.slice(0, SKILL_ENGINES.length), SKILL_ENGINES);
8888
});
8989

90-
test("SKILL_ENGINES is unchanged: no engine gained a primitive", () => {
91-
assert.deepEqual(SKILL_ENGINES, ["claude", "gemini"]);
90+
// Pinned for the same reason as MCP_ENGINES: a wider fan-out must never widen
91+
// the claimed capability. kimi moved it because kimi really does have a skills
92+
// primitive (it scans ~/.kimi/skills), not because the plan now iterates it.
93+
test("SKILL_ENGINES names exactly the engines with a skills primitive", () => {
94+
assert.deepEqual(SKILL_ENGINES, ["claude", "gemini", "kimi"]);
9295
});
9396

9497
test("claude still clones the source into its skills dir, byte for byte", () => {

test/skills.test.mjs

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import path from "node:path";
44
import test from "node:test";
55

66
import {
7-
SKILL_ENGINES, claudeSkillsDir, planSkillInstall, runSkillInstall, skillInstallAction, skillName,
7+
SKILL_ENGINES, claudeSkillsDir, kimiSkillsDir, planSkillInstall, runSkillInstall, skillInstallAction, skillName,
88
} from "../src/skills.mjs";
99

1010
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", ()
2929
assert.equal(skillName("whatever", ".."), "skill");
3030
});
3131

32-
test("the claude clone destination stays inside the skills dir", () => {
33-
const dir = claudeSkillsDir();
34-
for (const source of [".", "./", "..", "../", "a/b/."]) {
35-
const { args } = skillInstallAction("claude", { source, name: skillName(source) });
36-
const dest = args.at(-1);
37-
assert.equal(path.dirname(dest), dir, `${source} escaped to ${dest}`);
32+
test("a clone destination stays inside the engine's skills dir", () => {
33+
for (const [key, dir] of [["claude", claudeSkillsDir()], ["kimi", kimiSkillsDir()]]) {
34+
for (const source of [".", "./", "..", "../", "a/b/."]) {
35+
const { args } = skillInstallAction(key, { source, name: skillName(source) });
36+
const dest = args.at(-1);
37+
assert.equal(path.dirname(dest), dir, `${key}: ${source} escaped to ${dest}`);
38+
}
3839
}
3940
});
4041

@@ -44,6 +45,15 @@ test("skillInstallAction: gemini installs natively, claude clones into its skill
4445

4546
const claude = skillInstallAction("claude", { source: "https://x/y", name: "y" });
4647
assert.deepEqual(claude, { cmd: "git", args: ["clone", "--depth", "1", "https://x/y", path.join(claudeSkillsDir(), "y")] });
48+
49+
// Kimi Code discovers skills by scanning dirs too, so it clones into its own.
50+
const kimi = skillInstallAction("kimi", { source: "https://x/y", name: "y" });
51+
assert.deepEqual(kimi, { cmd: "git", args: ["clone", "--depth", "1", "https://x/y", path.join(kimiSkillsDir(), "y")] });
52+
});
53+
54+
test("kimiSkillsDir follows KIMI_CODE_HOME, which is what moves kimi's skills", () => {
55+
assert.equal(kimiSkillsDir({}), path.join(os.homedir(), ".kimi-code", "skills"));
56+
assert.equal(kimiSkillsDir({ KIMI_CODE_HOME: "/opt/kimi" }), path.join("/opt/kimi", "skills"));
4757
});
4858

4959
test("skillInstallAction: engines without a skills primitive are skipped", () => {
@@ -57,7 +67,7 @@ test("claudeSkillsDir points at the personal skills directory", () => {
5767
});
5868

5969
test("SKILL_ENGINES is exactly the engines with a skills primitive", () => {
60-
assert.deepEqual(SKILL_ENGINES, ["claude", "gemini"]);
70+
assert.deepEqual(SKILL_ENGINES, ["claude", "gemini", "kimi"]);
6171
});
6272

6373
test("runSkillInstall summarizes installed / not-installed", async () => {

0 commit comments

Comments
 (0)