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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
```

Expand Down Expand Up @@ -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.
Expand Down
38 changes: 34 additions & 4 deletions src/engines.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 <name>`
// 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",
Expand All @@ -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. */
Expand Down Expand Up @@ -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). */
Expand All @@ -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() {
Expand Down
8 changes: 6 additions & 2 deletions src/integrations.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
}));
}
Expand Down Expand Up @@ -159,7 +159,11 @@ export function printMcpTargets(json = false) {
console.log(bone(" mcp") + ash(" — register a server everywhere with ") + acid("/mcp install <url>"));
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")}`);
}
}

Expand Down
11 changes: 10 additions & 1 deletion src/mcp.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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" };
Expand Down Expand Up @@ -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) };
});
}
Expand Down
17 changes: 15 additions & 2 deletions src/skills.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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, "");
Expand Down Expand Up @@ -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" };
}
Expand All @@ -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) };
});
}
Expand Down
1 change: 1 addition & 0 deletions test/cli.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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/);
});

Expand Down
2 changes: 2 additions & 0 deletions test/engines.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
};

Expand All @@ -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"],
};

Expand Down
24 changes: 21 additions & 3 deletions test/mcp-add-fanout.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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`);
}
});

Expand All @@ -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`);
}
});

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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"]);
});

7 changes: 5 additions & 2 deletions test/skill-install-fanout.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
26 changes: 18 additions & 8 deletions test/skills.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand All @@ -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}`);
}
}
});

Expand All @@ -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", () => {
Expand All @@ -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 () => {
Expand Down
9 changes: 7 additions & 2 deletions test/support-matrix.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
Loading