From 53c4c30ec975c03f51ba648882681ca8db5fcfdb Mon Sep 17 00:00:00 2001 From: AnobleSCM <211227905+AnobleSCM@users.noreply.github.com> Date: Mon, 3 Aug 2026 14:21:31 -0700 Subject: [PATCH] feat: add Kimi Code as a fourth scanned harness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Kimi Code joins Claude Code, Codex, and Cursor as a fourth scanned harness across the terminal report, --markdown, --json, and the empty-state listing — detection parity only, no product expansion. MCP servers are read from mcp.json (Kimi's actual config shape — JSON, not TOML; config.toml holds Kimi's own settings and never MCP declarations, verified against the installed CLI's own bundled skill source and MCP path resolver): ~/.kimi-code/mcp.json at user scope, and a literal /.kimi-code/mcp.json at project scope, read from the exact working directory rather than found by an upward walk, matching Kimi's documented behavior. Skills are read from both roots Kimi auto-discovers — .kimi-code/skills and .agents/skills — at user and project scope. ~/.agents/skills is the same shared shelf Claude Code and Codex already reach through their own link farms, so the existing canonical-path dedupe now collapses a skill across all three harnesses. Not scanned: Kimi's .agents/agents / .kimi-code/agents subagent roots (real and auto-discovered the same way, but out of this change's scope). Version 0.2.3. Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 24 +++ README.md | 38 +++-- assets/devcat-report.svg | 20 ++- package-lock.json | 14 +- package.json | 4 +- src/cli.ts | 2 +- src/manifest/index.ts | 5 +- src/manifest/kimi.ts | 198 ++++++++++++++++++++++ src/ui/report.ts | 7 +- src/version.ts | 2 +- test/unit/manifest/dedupe.test.ts | 14 ++ test/unit/manifest/kimi.skills.test.ts | 220 +++++++++++++++++++++++++ test/unit/manifest/kimi.test.ts | 135 +++++++++++++++ test/unit/ui/report.test.ts | 2 +- 14 files changed, 639 insertions(+), 46 deletions(-) create mode 100644 src/manifest/kimi.ts create mode 100644 test/unit/manifest/kimi.skills.test.ts create mode 100644 test/unit/manifest/kimi.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c0da2f..7aa245f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,30 @@ All notable changes to this project are documented in this file. +## [0.2.3] + +Kimi Code joins Claude Code, Codex, and Cursor as a fourth scanned harness — +detection parity only, no product expansion. + +- New scanner detects Kimi Code MCP servers and skills. MCP servers are read + from `mcp.json` files (Kimi's actual config shape — JSON, not TOML, and + never `config.toml`, which holds no MCP declarations): `~/.kimi-code/mcp.json` + at user scope, and `/.kimi-code/mcp.json` at project scope — read + literally from the working directory, not found by an upward walk, matching + Kimi's own documented behavior +- Skills are read from both roots Kimi auto-discovers: `.kimi-code/skills` + and `.agents/skills`, at user and project scope. `~/.agents/skills` is the + same shared shelf Claude Code and Codex already reach through their own + link farms — the existing canonical-path dedupe now collapses a skill + across all three harnesses, deterministically, under whichever client + `detect()` scans first +- Terminal report, `--markdown`, `--json`, and the empty-state "Looked in" + listing all gain a Kimi Code section using the existing type accents — no + new colors +- Not scanned: Kimi's `.agents/agents` / `.kimi-code/agents` subagent roots. + They are real and auto-discovered the same way the skill roots are, but + subagent detection is outside this release's scope + ## [0.2.2] Presentation-only polish pass on the local stack report. Nothing about what is diff --git a/README.md b/README.md index dd8c9b2..38292ee 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,9 @@ See your whole AI-coding stack in one command. -`npx devcat-cli` scans this machine for the MCP servers, plugins, skills, and subagents you have installed across Claude Code, Codex, and Cursor, and prints them grouped in one report. No account, no sign-in, no network call. +`npx devcat-cli` scans this machine for the MCP servers, plugins, skills, and subagents you have installed across Claude Code, Codex, Kimi Code, and Cursor, and prints them grouped in one report. No account, no sign-in, no network call. -![Terminal window showing devcat-cli output: 23 tools grouped under Claude Code, Codex, and Cursor](https://raw.githubusercontent.com/AnobleSCM/devcat-cli/main/assets/devcat-report.svg) +![Terminal window showing devcat-cli output: 26 tools grouped under Claude Code, Codex, Kimi Code, and Cursor](https://raw.githubusercontent.com/AnobleSCM/devcat-cli/main/assets/devcat-report.svg) Example output — a real run of this CLI against a small demo machine, captured verbatim. Your report lists whatever you actually have installed. @@ -20,9 +20,9 @@ That's the whole thing — the report above is what it prints. The same output as text ``` -devcat v0.2.2 +devcat v0.2.3 -✓ Your AI-coding stack — 23 tools +✓ Your AI-coding stack — 26 tools Claude Code · 18 tools ██████ 6 mcp context7, github, linear, playwright, sentry, supabase @@ -34,11 +34,14 @@ Claude Code · 18 tools Codex · 3 tools ███ 3 mcp exa, node-repl, serena +Kimi Code · 3 tools + ███ 3 mcp browserbase, raycast, vercel + Cursor · 2 tools ██ 2 mcp figma, postgres -23 tools in Claude Code, Codex, and Cursor · 13 locations checked -2 project-scoped · 21 user-wide +26 tools in Claude Code, Codex, Kimi Code, and Cursor · 14 locations checked +3 project-scoped · 23 user-wide Share it — npx devcat-cli --markdown ``` @@ -74,7 +77,7 @@ npx devcat-cli --markdown ```markdown ## My AI stack -23 tools across Claude Code, Codex, and Cursor. +26 tools across Claude Code, Codex, Kimi Code, and Cursor. ### Claude Code - **MCP servers (6):** `context7`, `github`, `linear`, `playwright`, `sentry`, `supabase` @@ -85,6 +88,9 @@ npx devcat-cli --markdown ### Codex - **MCP servers (3):** `exa`, `node-repl`, `serena` +### Kimi Code +- **MCP servers (3):** `browserbase`, `raycast`, `vercel` + ### Cursor - **MCP servers (2):** `figma`, `postgres` @@ -101,10 +107,10 @@ npx devcat-cli --json | jq '.clients[] | {label, total}' ```json { - "cli_version": "0.2.2", - "total": 23, - "project_scoped": 2, - "user_scoped": 21, + "cli_version": "0.2.3", + "total": 26, + "project_scoped": 3, + "user_scoped": 23, "clients": [ { "client": "claude-code", @@ -139,9 +145,9 @@ Two kinds of location, read two different ways: | | Where | How | |---|---|---| -| **MCP servers** | Claude Code `~/.claude.json`, `~/.claude/settings.json`, `.mcp.json` (project) · Codex `~/.codex/config.toml`, `.codex/config.toml` (project) · Cursor `~/.cursor/mcp.json`, `.cursor/mcp.json` (project) | The file is read and parsed. Only the server **names** (the keys) are kept. | +| **MCP servers** | Claude Code `~/.claude.json`, `~/.claude/settings.json`, `.mcp.json` (project) · Codex `~/.codex/config.toml`, `.codex/config.toml` (project) · Kimi Code `~/.kimi-code/mcp.json`, `.kimi-code/mcp.json` (project — read from the exact working directory, not found by an upward walk) · Cursor `~/.cursor/mcp.json`, `.cursor/mcp.json` (project) | The file is read and parsed. Only the server **names** (the keys) are kept. Kimi Code's config is JSON, same `{ "mcpServers": {...} }` shape as Claude Code and Cursor — `config.toml` holds Kimi Code's own settings, never MCP servers. | | **Plugins** | Claude Code `~/.claude/plugins/installed_plugins.json` | Same — parsed, keys kept. | -| **Skills** | Claude Code `~/.claude/skills/`, `.claude/skills/` (project) · Codex `~/.codex/skills/` | The directory is listed. A child counts as a skill if it contains a `SKILL.md`. **No file is opened** — not even the `SKILL.md`, whose presence is all that is checked. The name is the folder's. | +| **Skills** | Claude Code `~/.claude/skills/`, `.claude/skills/` (project) · Codex `~/.codex/skills/` · Kimi Code `~/.kimi-code/skills/` and `~/.agents/skills/` (user), `.kimi-code/skills/` and `.agents/skills/` (project) | The directory is listed. A child counts as a skill if it contains a `SKILL.md`. **No file is opened** — not even the `SKILL.md`, whose presence is all that is checked. The name is the folder's. | | **Subagents** | Claude Code `~/.claude/agents/`, `.claude/agents/` (project) | The directory is listed. Two shapes count: `.md`, where the name is the file's; and `/.md`, where the name is the folder's and the inner filename must match. A folder holding only other markdown — a README, notes — is not a subagent. **No file is opened.** | So: config files are read and parsed, and the only thing taken **out of their contents** is the tool's name. Directory scans open nothing at all. @@ -161,13 +167,13 @@ The directory scans are deliberately shallow and doubly bounded. They read a kno Both sit well above the sizes these directories run to in practice — dozens of entries, not thousands — and below them the result is fully deterministic. If either bites, **the CLI says so** rather than presenting a partial list as complete: a warning naming the root and the counts goes to stderr, the terminal and markdown reports carry a footnote, and `--json` gets a `truncated` flag plus a `truncations` array carrying, per root, `entries_read` (what the ceiling counts, dot-entries included), `entries_seen` (candidates among them), `entries_kept` (examined), `hit_read_ceiling`, and `read_failed`. -Project-scoped entries are found by walking up from the current directory, so the report changes depending on where you run it. `$HOME` is not treated as a project root for any location that also has a user-scope reader — `~/.claude/skills`, `~/.claude/agents`, `~/.codex/config.toml`, `~/.cursor/mcp.json` — so those are never double-counted as project config. `~/.mcp.json` has no user-scope reader, so it is still picked up by the upward walk and reported as project-scoped. +Project-scoped entries are found by walking up from the current directory, so the report changes depending on where you run it — with one exception: Kimi Code's project-local `.kimi-code/mcp.json` is read from the exact working directory only, matching Kimi Code's own behavior, never found by walking upward. `$HOME` is not treated as a project root for any location that also has a user-scope reader — `~/.claude/skills`, `~/.claude/agents`, `~/.codex/config.toml`, `~/.cursor/mcp.json`, `~/.kimi-code/mcp.json`, `~/.kimi-code/skills`, `~/.agents/skills` — so those are never double-counted as project config. `~/.mcp.json` has no user-scope reader, so it is still picked up by the upward walk and reported as project-scoped. **A tool configured in more than one place is listed once.** Identity is deterministic, so an unchanged machine produces the same report every run — as long as no root hit the read ceiling. Above that ceiling, *which* 10,000 entries were read is the directory's enumeration order, which the CLI does not control; sorting happens after. A run in that state says so rather than implying stability it does not have. - Anything found as a folder — skills, subagents — is identified by its **resolved symlink target**. Two links to one directory are one entry however they are named, and two genuinely different skills that happen to share a name both survive. - MCP servers and plugins are keys in a config file with no path of their own, so they are identified by (type, name) — the same identity the server matches on. -- When two locations do hold the same thing, the first wins in a fixed scan order: project before user, and Claude Code before Codex before Cursor. `~/.claude/skills` and `~/.codex/skills` are commonly link farms into one shared directory, so a skill on both shelves is listed once under Claude Code. A skill only Codex has still appears under Codex. +- When two locations do hold the same thing, the first wins in a fixed scan order: project before user, and Claude Code before Codex before Kimi Code before Cursor. `~/.claude/skills` and `~/.codex/skills` are commonly link farms into one shared directory that Kimi Code also reads directly at `~/.agents/skills` — no farm of its own needed — so a skill any of the three can see is listed once under Claude Code. A skill only Codex (or only Kimi Code) has still appears under that client. Install it globally if you run it often: @@ -198,7 +204,7 @@ The four other variables the CLI reads take effect only on `devcat sync`, whose **The report is empty.** It lists the locations each detector resolved to — see [What it reads](#what-it-reads); intermediate parent-directory probes from the upward walk are not recorded. If your config lives somewhere else entirely, that's the gap: open an [issue](https://github.com/AnobleSCM/devcat-cli/issues) with the location and it can be added. -**A tool is missing.** Detected today: MCP servers (Claude Code, Codex, Cursor), Claude Code plugins and subagents, and skills from both the Claude Code and Codex shelves. Skills bundled inside an installed plugin are not counted separately — the plugin itself is listed. +**A tool is missing.** Detected today: MCP servers (Claude Code, Codex, Kimi Code, Cursor), Claude Code plugins and subagents, and skills from the Claude Code, Codex, and Kimi Code shelves. Skills bundled inside an installed plugin are not counted separately — the plugin itself is listed. diff --git a/assets/devcat-report.svg b/assets/devcat-report.svg index 478e76a..991a8c1 100644 --- a/assets/devcat-report.svg +++ b/assets/devcat-report.svg @@ -1,5 +1,5 @@ - - + + @@ -7,8 +7,8 @@ npx devcat-cli - devcat v0.2.2 - ✓ Your AI-coding stack — 23 tools + devcat v0.2.3 + ✓ Your AI-coding stack — 26 tools Claude Code · 18 tools ██████ 6 mcp context7, github, linear, playwright, sentry, supabase ███ 3 plugin pr-review-toolkit, superpowers, typescript-lsp @@ -17,10 +17,12 @@ ███ 3 subagent code-reviewer, debugger, test-engineer Codex · 3 tools ███ 3 mcp exa, node-repl, serena - Cursor · 2 tools - ██ 2 mcp figma, postgres - 23 tools in Claude Code, Codex, and Cursor · 13 locations checked - 2 project-scoped · 21 user-wide - Share it — npx devcat-cli --markdown + Kimi Code · 3 tools + ███ 3 mcp browserbase, raycast, vercel + Cursor · 2 tools + ██ 2 mcp figma, postgres + 26 tools in Claude Code, Codex, Kimi Code, and Cursor · 14 locations checked + 3 project-scoped · 23 user-wide + Share it — npx devcat-cli --markdown diff --git a/package-lock.json b/package-lock.json index f600a8e..b41dfc0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "devcat-cli", - "version": "0.2.2", + "version": "0.2.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "devcat-cli", - "version": "0.2.2", + "version": "0.2.3", "license": "MIT", "dependencies": { "@napi-rs/keyring": "^1.2.0", @@ -43,7 +43,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" @@ -56,7 +55,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "tslib": "^2.4.0" } @@ -1066,7 +1064,6 @@ "integrity": "sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -1123,7 +1120,6 @@ "integrity": "sha512-HDQH9O/47Dxi1ceDhBXdaldtf/WV9yRYMjbjCuNk3qnaTD564qwv61Y7+gTxwxRKzSrgO5uhtw584igXVuuZkA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.59.1", "@typescript-eslint/types": "8.59.1", @@ -1441,7 +1437,6 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1809,7 +1804,6 @@ "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -2770,7 +2764,6 @@ "dev": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { "@inquirer/confirm": "^6.0.11", "@mswjs/interceptors": "^0.41.3", @@ -2992,7 +2985,6 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -3450,7 +3442,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -3505,7 +3496,6 @@ "integrity": "sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", diff --git a/package.json b/package.json index 07ba0e9..5b2b63b 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "devcat-cli", - "version": "0.2.2", - "description": "See your whole AI-coding stack in one command. npx devcat-cli scans this machine for the MCP servers, plugins, skills, and subagents installed across Claude Code, Codex, and Cursor and prints them grouped — locally, with no account and no network call.", + "version": "0.2.3", + "description": "See your whole AI-coding stack in one command. npx devcat-cli scans this machine for the MCP servers, plugins, skills, and subagents installed across Claude Code, Codex, Kimi Code, and Cursor and prints them grouped — locally, with no account and no network call.", "license": "MIT", "author": "Andrew Noble (https://github.com/AnobleSCM)", "repository": { diff --git a/src/cli.ts b/src/cli.ts index 274051d..b6c8f1f 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -30,7 +30,7 @@ export function buildProgram(sink: ExitCodeSink): Command { program .name('devcat') .description( - 'DevCat CLI — see your whole AI-coding stack in one command. Scans this machine for the MCP servers, plugins, skills, and subagents installed across Claude Code, Codex, and Cursor.', + 'DevCat CLI — see your whole AI-coding stack in one command. Scans this machine for the MCP servers, plugins, skills, and subagents installed across Claude Code, Codex, Kimi Code, and Cursor.', ) .version(CLI_VERSION); diff --git a/src/manifest/index.ts b/src/manifest/index.ts index 1f0eed9..674920e 100644 --- a/src/manifest/index.ts +++ b/src/manifest/index.ts @@ -1,6 +1,7 @@ import { detectClaudeCode } from './claude.js'; import { detectCodex } from './codex.js'; import { detectCursor } from './cursor.js'; +import { detectKimiCode } from './kimi.js'; import { dedupe } from './dedupe.js'; import type { RootTruncation } from './dirScan.js'; @@ -8,7 +9,7 @@ export type { RootTruncation } from './dirScan.js'; export { READ_CEILING, MAX_ENTRIES_EXAMINED } from './dirScan.js'; /** Which AI-coding tool a manifest entry was found in. */ -export type ToolClient = 'claude-code' | 'codex' | 'cursor'; +export type ToolClient = 'claude-code' | 'codex' | 'kimi-code' | 'cursor'; /** * A single tool surfaced from any local manifest. @@ -74,10 +75,12 @@ export async function detect(cwd: string): Promise { // PROJECT FIRST — closer to the user's intent. detectClaudeCode({ cwd, scope: 'project' }), detectCodex({ cwd, scope: 'project' }), + detectKimiCode({ cwd, scope: 'project' }), detectCursor({ cwd, scope: 'project' }), // USER SECOND. detectClaudeCode({ scope: 'user' }), detectCodex({ scope: 'user' }), + detectKimiCode({ scope: 'user' }), detectCursor({ scope: 'user' }), ]); const allTools = sources.flatMap((s) => s.tools); diff --git a/src/manifest/kimi.ts b/src/manifest/kimi.ts new file mode 100644 index 0000000..3cd3cf6 --- /dev/null +++ b/src/manifest/kimi.ts @@ -0,0 +1,198 @@ +import { readFile } from 'node:fs/promises'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import { findUpwardDir, isUserLevelPath } from '../lib/findUpward.js'; +import { scanSkills, type RootTruncation } from './dirScan.js'; +import type { ToolEntry } from './index.js'; + +interface McpServersFile { + mcpServers?: Record; +} + +interface SourceScan { + tools: ToolEntry[]; + pathsScanned: string[]; + /** Roots where a scan bound bit. Absent means nothing was left out. */ + truncations?: RootTruncation[]; +} + +/** + * Detect Kimi Code tooling: MCP servers from mcp.json, plus skills on + * Kimi's own discovery roots. + * + * Kimi Code's MCP config is JSON, not TOML, and does not live in + * config.toml at all — verified against the installed CLI's own bundled + * `import-from-cc-codex` skill source and its MCP path resolver (kimi + * 0.31.1, 2026-08-03). Three files, all `{ mcpServers: { : {...} } }`, + * same shape as Claude Code's: + * + * - user-global: ~/.kimi-code/mcp.json (or $KIMI_CODE_HOME/mcp.json) + * - project-root: /.mcp.json — the SAME file + * Claude Code's project detector already reads. Not re-scanned here: + * Claude Code is scanned before Kimi Code in detect(), so a second + * read of this file would only ever dedupe away on the (type, name) + * key, and modeling Kimi's git-root walk with a second algorithm just + * to produce entries that never survive dedupe isn't worth the surface. + * - project-local: /.kimi-code/mcp.json — read from the + * exact working directory, NOT an upward walk. Confirmed in the + * bundled skill source: "Kimi reads the current working directory's + * Kimi-specific MCP file, not every project-root .kimi-code/mcp.json + * from subdirectories." + * + * Skills: Kimi auto-discovers two roots at both scopes (verified via the + * PROJECT_BRAND_DIRS / PROJECT_GENERIC_DIRS / USER_BRAND_DIRS / + * USER_GENERIC_DIRS constants embedded in the installed CLI binary): + * + * - brand: .kimi-code/skills (user: ~/.kimi-code/skills) + * - generic: .agents/skills (user: ~/.agents/skills — the same + * shared shelf Claude Code and Codex already reach through their own + * link farms) + * + * Kimi resolves its project skill roots via its own project-root walk + * (nearest .git ancestor, confirmed in the same binary). Approximated here + * with findUpwardDir — the same upward-search Claude Code's project + * skills/agents roots already use, so this is an existing simplification, + * not a new one. + * + * Not scanned: Kimi's `.agents/agents` / `.kimi-code/agents` subagent + * roots. They are real and auto-discovered (confirmed in the same binary + * strings as the skill roots above), but subagent detection is outside + * this mission's scope (MCP servers + skills only) — left for a follow-up. + * + * Three dedupe layers now apply to the shared shelf, same as Codex: + * + * - Within a root, scanSkills() collapses aliases by resolved path. + * - Across clients, dedupe() keys path-backed entries on (type, resolved + * path). detect() scans Claude Code, then Codex, then Kimi Code, so a + * skill all three shelves link to is listed once under Claude Code — + * deterministically, not by whichever filesystem answered first. + */ +export async function detectKimiCode(opts: { cwd?: string; scope: 'project' | 'user' }): Promise { + const [mcp, skills] = await Promise.all([detectKimiMcp(opts), detectKimiSkills(opts)]); + return mergeScans([mcp, skills]); +} + +/** + * Kimi Code MCP servers. + * + * User scope: reads ~/.kimi-code/mcp.json. + * Project scope: reads `/.kimi-code/mcp.json` literally — no upward + * walk (see module doc). Guarded the same way an upward walk would be: + * run from $HOME, this candidate IS the user-global file, and the user + * pass above already names it, so it is skipped here rather than + * double-reported as project-scoped. + */ +async function detectKimiMcp(opts: { cwd?: string; scope: 'project' | 'user' }): Promise { + if (opts.scope === 'user') { + return readMcpServersJson(join(homedir(), '.kimi-code', 'mcp.json'), 'user'); + } + if (!opts.cwd) return { tools: [], pathsScanned: [] }; + const path = join(opts.cwd, '.kimi-code', 'mcp.json'); + if (await isUserLevelPath(path, '.kimi-code', 'mcp.json')) { + return { tools: [], pathsScanned: [] }; + } + return readMcpServersJson(path, 'project'); +} + +async function readMcpServersJson(path: string, scope: 'project' | 'user'): Promise { + let raw: string; + try { + raw = await readFile(path, 'utf8'); + } catch { + return { tools: [], pathsScanned: [path] }; + } + let parsed: McpServersFile; + try { + parsed = JSON.parse(raw) as McpServersFile; + } catch { + return { tools: [], pathsScanned: [path] }; + } + const tools: ToolEntry[] = Object.keys(parsed.mcpServers ?? {}).map((name) => ({ + type: 'mcp' as const, + name, + source: path, + scope, + client: 'kimi-code' as const, + })); + return { tools, pathsScanned: [path] }; +} + +/** Kimi Code skills: the brand root and the shared generic root, both scopes. */ +async function detectKimiSkills(opts: { cwd?: string; scope: 'project' | 'user' }): Promise { + if (opts.scope === 'user') { + const [brand, generic] = await Promise.all([ + readSkillsDir(join(homedir(), '.kimi-code', 'skills'), 'user'), + readSkillsDir(join(homedir(), '.agents', 'skills'), 'user'), + ]); + return mergeScans([brand, generic]); + } + + if (!opts.cwd) return { tools: [], pathsScanned: [] }; + const [brandDir, genericDir] = await Promise.all([ + projectDirToScan(opts.cwd, '.kimi-code', 'skills'), + projectDirToScan(opts.cwd, '.agents', 'skills'), + ]); + const [brand, generic] = await Promise.all([ + scanProjectDir(brandDir, readSkillsDir), + scanProjectDir(genericDir, readSkillsDir), + ]); + return mergeScans([brand, generic]); +} + +/** + * Decide what the project pass may scan for a directory-shaped location. + * + * Returns the directory to scan, or a report-only path when there is + * nothing legitimate to scan there — the caller still names it among the + * locations checked, unless it is the user root, which the user pass + * already reports. Mirrors claude.ts's projectDirToScan: same guard, same + * reasoning (findUpwardDir approximates Kimi's real git-root project + * resolution, and a walk landing on $HOME must not double-count the user + * shelf as project-scoped). + */ +async function projectDirToScan( + cwd: string, + ...segments: string[] +): Promise<{ scan: string | null; report: string | null }> { + const hit = await findUpwardDir(cwd, ...segments); + const candidate = hit ?? join(cwd, ...segments); + if (await isUserLevelPath(candidate, ...segments)) { + return { scan: null, report: null }; + } + return { scan: hit, report: candidate }; +} + +/** Run a directory reader, or produce a report-only scan when it must be skipped. */ +async function scanProjectDir( + target: { scan: string | null; report: string | null }, + read: (path: string, scope: 'project' | 'user') => Promise, +): Promise { + if (target.scan) return read(target.scan, 'project'); + return { tools: [], pathsScanned: target.report ? [target.report] : [] }; +} + +/** + * Installed skills under a Kimi Code `skills/` root. Report-only, like + * every skill entry — never part of the /api/sync payload (see + * syncableTools). + */ +async function readSkillsDir(path: string, scope: 'project' | 'user'): Promise { + const { hits, truncation } = await scanSkills(path); + const tools: ToolEntry[] = hits.map((hit) => ({ + type: 'skill' as const, + name: hit.name, + source: path, + scope, + client: 'kimi-code' as const, + canonicalPath: hit.realPath, + })); + return { tools, pathsScanned: [path], truncations: truncation ? [truncation] : [] }; +} + +function mergeScans(scans: SourceScan[]): SourceScan { + return { + tools: scans.flatMap((s) => s.tools), + pathsScanned: scans.flatMap((s) => s.pathsScanned), + truncations: scans.flatMap((s) => s.truncations ?? []), + }; +} diff --git a/src/ui/report.ts b/src/ui/report.ts index cf4e419..990ed62 100644 --- a/src/ui/report.ts +++ b/src/ui/report.ts @@ -20,11 +20,12 @@ import { c, SUCCESS_GLYPH } from './colors.js'; */ /** Fixed display order so two runs on the same machine print identically. */ -const CLIENT_ORDER: readonly ToolClient[] = ['claude-code', 'codex', 'cursor']; +const CLIENT_ORDER: readonly ToolClient[] = ['claude-code', 'codex', 'kimi-code', 'cursor']; const CLIENT_LABEL: Record = { 'claude-code': 'Claude Code', codex: 'Codex', + 'kimi-code': 'Kimi Code', cursor: 'Cursor', }; @@ -275,7 +276,7 @@ function masthead(): string { * ██████ 12 mcp alpha, beta, gamma, … * ██ 4 plugin swift-lsp, vercel * - * 21 tools in Claude Code, Codex, and Cursor · 8 locations checked + * 21 tools in Claude Code, Codex, Kimi Code, and Cursor · 8 locations checked * 3 project-scoped · 18 user-wide * * Share it — npx devcat-cli --markdown @@ -507,7 +508,7 @@ function renderEmptyStack(result: DetectResult): string { } lines.push(''); - lines.push(c.dim('Add an MCP server to Claude Code, Codex, or Cursor and run this again.')); + lines.push(c.dim('Add an MCP server to Claude Code, Codex, Kimi Code, or Cursor and run this again.')); return lines.join('\n'); } diff --git a/src/version.ts b/src/version.ts index fc5ce59..6cbf514 100644 --- a/src/version.ts +++ b/src/version.ts @@ -4,4 +4,4 @@ * Sent as cli_version on POST /api/device/token per Phase 40 D-06. * Server validates against semver regex /^\d+\.\d+\.\d+(-[a-zA-Z0-9.-]+)?$/. */ -export const CLI_VERSION = '0.2.2'; +export const CLI_VERSION = '0.2.3'; diff --git a/test/unit/manifest/dedupe.test.ts b/test/unit/manifest/dedupe.test.ts index 6a03d57..23b53be 100644 --- a/test/unit/manifest/dedupe.test.ts +++ b/test/unit/manifest/dedupe.test.ts @@ -88,6 +88,20 @@ describe('dedupe — canonical path identity', () => { expect(result.map((t) => t.type)).toEqual(['skill', 'subagent']); }); + it('collapses THREE aliases of one canonical skill across Claude Code, Codex, and Kimi Code', () => { + // The shared shelf is now reachable from three harnesses: Claude Code + // and Codex through their own link farms, Kimi Code by reading the + // canonical directory directly. Whichever client detect() scans first + // wins — dedupe() itself only sees three entries at one canonical path. + const viaClaude = skill('panel', '/canon/skills/panel', 'claude-code'); + const viaCodex = skill('panel', '/canon/skills/panel', 'codex'); + const viaKimi = skill('panel', '/canon/skills/panel', 'kimi-code'); + + const result = dedupe([viaClaude, viaCodex, viaKimi]); + expect(result).toHaveLength(1); + expect(result[0]!.client).toBe('claude-code'); + }); + it('still collapses same-path entries of the SAME type', () => { const shared = '/canon/panel'; const viaClaude = skill('panel', shared, 'claude-code'); diff --git a/test/unit/manifest/kimi.skills.test.ts b/test/unit/manifest/kimi.skills.test.ts new file mode 100644 index 0000000..3bea77c --- /dev/null +++ b/test/unit/manifest/kimi.skills.test.ts @@ -0,0 +1,220 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, mkdirSync, writeFileSync, symlinkSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +const homedirHolder: { current: string | null } = { current: null }; + +vi.mock('node:os', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, homedir: () => homedirHolder.current ?? actual.homedir() }; +}); + +let tmpHome: string; + +function makeSkill(root: string, name: string): string { + const dir = join(root, name); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, 'SKILL.md'), '# skill\n'); + return dir; +} + +beforeEach(() => { + tmpHome = mkdtempSync(join(tmpdir(), 'devcat-kimi-skills-')); + homedirHolder.current = tmpHome; +}); + +afterEach(() => { + homedirHolder.current = null; + rmSync(tmpHome, { recursive: true, force: true }); +}); + +describe('detectKimiCode — skills', () => { + it('reads the brand root ~/.kimi-code/skills and tags entries as Kimi Code skills', async () => { + const skillsRoot = join(tmpHome, '.kimi-code', 'skills'); + mkdirSync(skillsRoot, { recursive: true }); + makeSkill(skillsRoot, 'demo-brand-skill'); + + const { detectKimiCode } = await import('../../../src/manifest/kimi.js'); + const result = await detectKimiCode({ scope: 'user' }); + + const skills = result.tools.filter((t) => t.type === 'skill'); + expect(skills.map((t) => t.name)).toEqual(['demo-brand-skill']); + expect(skills[0]!.client).toBe('kimi-code'); + expect(skills[0]!.scope).toBe('user'); + expect(result.pathsScanned).toContain(skillsRoot); + }); + + it('reads the generic root ~/.agents/skills directly (the shared shelf)', async () => { + const skillsRoot = join(tmpHome, '.agents', 'skills'); + mkdirSync(skillsRoot, { recursive: true }); + makeSkill(skillsRoot, 'demo-generic-skill'); + + const { detectKimiCode } = await import('../../../src/manifest/kimi.js'); + const result = await detectKimiCode({ scope: 'user' }); + + const skills = result.tools.filter((t) => t.type === 'skill'); + expect(skills.map((t) => t.name)).toEqual(['demo-generic-skill']); + expect(skills[0]!.client).toBe('kimi-code'); + expect(result.pathsScanned).toContain(skillsRoot); + }); + + it('merges brand and generic roots when both exist at user scope', async () => { + makeSkill(join(tmpHome, '.kimi-code', 'skills'), 'brand-only'); + makeSkill(join(tmpHome, '.agents', 'skills'), 'generic-only'); + + const { detectKimiCode } = await import('../../../src/manifest/kimi.js'); + const result = await detectKimiCode({ scope: 'user' }); + + const skills = result.tools.filter((t) => t.type === 'skill'); + expect(skills.map((t) => t.name).sort()).toEqual(['brand-only', 'generic-only']); + }); + + it('detects project-scope skills via upward walk on both roots', async () => { + const projectDir = mkdtempSync(join(tmpdir(), 'devcat-kimi-projskills-')); + try { + makeSkill(join(projectDir, '.kimi-code', 'skills'), 'repo-brand-skill'); + makeSkill(join(projectDir, '.agents', 'skills'), 'repo-generic-skill'); + + const { detectKimiCode } = await import('../../../src/manifest/kimi.js'); + const result = await detectKimiCode({ cwd: projectDir, scope: 'project' }); + + const skills = result.tools.filter((t) => t.type === 'skill'); + expect(skills.map((t) => t.name).sort()).toEqual(['repo-brand-skill', 'repo-generic-skill']); + expect(skills.every((t) => t.scope === 'project')).toBe(true); + } finally { + rmSync(projectDir, { recursive: true, force: true }); + } + }); + + it('applies the same bounded scan: symlinks resolved, aliases and broken links collapsed', async () => { + const canon = join(tmpHome, '.agents', 'canon-skills'); + mkdirSync(canon, { recursive: true }); + const target = makeSkill(canon, 'panel'); + + const shelf = join(tmpHome, '.agents', 'skills'); + mkdirSync(shelf, { recursive: true }); + symlinkSync(target, join(shelf, 'panel')); + symlinkSync(target, join(shelf, 'panel-alias')); + symlinkSync(join(tmpHome, 'gone'), join(shelf, 'dangling')); + mkdirSync(join(shelf, '.system'), { recursive: true }); + writeFileSync(join(shelf, 'AGENTS.md'), 'not a skill'); + + const { detectKimiCode } = await import('../../../src/manifest/kimi.js'); + const result = await detectKimiCode({ scope: 'user' }); + + // Two aliases resolve to one target, so one entry survives. + expect(result.tools.filter((t) => t.type === 'skill')).toHaveLength(1); + }); + + it('is silent when neither skills root exists', async () => { + mkdirSync(join(tmpHome, '.kimi-code'), { recursive: true }); + writeFileSync(join(tmpHome, '.kimi-code', 'mcp.json'), JSON.stringify({ mcpServers: { 'demo-search': {} } })); + + const { detectKimiCode } = await import('../../../src/manifest/kimi.js'); + const result = await detectKimiCode({ scope: 'user' }); + + expect(result.tools.filter((t) => t.type === 'skill')).toEqual([]); + expect(result.tools.map((t) => t.name)).toEqual(['demo-search']); + }); +}); + +describe('$HOME is not a project root — Kimi Code skills', () => { + it('does not report ~/.agents/skills or ~/.kimi-code/skills as project-scoped', async () => { + makeSkill(join(tmpHome, '.agents', 'skills'), 'panel'); + makeSkill(join(tmpHome, '.kimi-code', 'skills'), 'brand-skill'); + const cwd = join(tmpHome, 'Developer', 'some-repo'); + mkdirSync(cwd, { recursive: true }); + + const { detectKimiCode } = await import('../../../src/manifest/kimi.js'); + const project = await detectKimiCode({ cwd, scope: 'project' }); + expect(project.tools.filter((t) => t.type === 'skill')).toEqual([]); + + const user = await detectKimiCode({ scope: 'user' }); + expect(user.tools.filter((t) => t.type === 'skill').map((t) => t.name).sort()).toEqual([ + 'brand-skill', + 'panel', + ]); + }); + + it('does not claim the user shelf as project-scoped when cwd IS $HOME', async () => { + makeSkill(join(tmpHome, '.agents', 'skills'), 'panel'); + + const { detectKimiCode } = await import('../../../src/manifest/kimi.js'); + const result = await detectKimiCode({ cwd: tmpHome, scope: 'project' }); + expect(result.tools.filter((t) => t.type === 'skill')).toEqual([]); + }); +}); + +describe('shared shelf — Claude Code, Codex, and Kimi Code all reach one directory', () => { + it('lists a skill visible to all three exactly once, deterministically under Claude Code', async () => { + // Real-world layout: ~/.claude/skills and ~/.codex/skills are link + // farms into ~/.agents/skills. Kimi Code reads ~/.agents/skills + // directly — no farm of its own needed to reach the same directory. + const canon = join(tmpHome, '.agents', 'skills'); + mkdirSync(canon, { recursive: true }); + const shared = makeSkill(canon, 'panel'); + const claudeOnly = makeSkill(canon, 'handoff'); + + const claudeFarm = join(tmpHome, '.claude', 'skills'); + mkdirSync(claudeFarm, { recursive: true }); + symlinkSync(shared, join(claudeFarm, 'panel')); + symlinkSync(claudeOnly, join(claudeFarm, 'handoff')); + + const codexFarm = join(tmpHome, '.codex', 'skills'); + mkdirSync(codexFarm, { recursive: true }); + symlinkSync(shared, join(codexFarm, 'panel')); + + const { detect } = await import('../../../src/manifest/index.js'); + const result = await detect(join(tmpHome, 'nowhere')); + + const skills = result.tools.filter((t) => t.type === 'skill' && t.name === 'panel'); + expect(skills).toHaveLength(1); + // detect() scans Claude Code before Codex before Kimi Code, so the + // shelf entry is attributed to Claude Code regardless of how many + // harnesses can also see it. + expect(skills[0]!.client).toBe('claude-code'); + + // A skill only Claude's farm names still shows under Claude Code. + expect(result.tools.find((t) => t.type === 'skill' && t.name === 'handoff')!.client).toBe('claude-code'); + }); + + it('a skill only Kimi Code reaches (no Claude/Codex farm entry) still appears, under Kimi Code', async () => { + const canon = join(tmpHome, '.agents', 'skills'); + mkdirSync(canon, { recursive: true }); + makeSkill(canon, 'kimi-only-skill'); + // Deliberately no .claude/skills or .codex/skills farm entries at all. + + const { detect } = await import('../../../src/manifest/index.js'); + const result = await detect(join(tmpHome, 'nowhere')); + + const skill = result.tools.find((t) => t.type === 'skill' && t.name === 'kimi-only-skill'); + expect(skill).toBeDefined(); + expect(skill!.client).toBe('kimi-code'); + }); + + it('is stable across repeated scans', async () => { + const canon = join(tmpHome, '.agents', 'skills'); + mkdirSync(canon, { recursive: true }); + const shared = makeSkill(canon, 'panel'); + + const claudeFarm = join(tmpHome, '.claude', 'skills'); + mkdirSync(claudeFarm, { recursive: true }); + symlinkSync(shared, join(claudeFarm, 'panel')); + const codexFarm = join(tmpHome, '.codex', 'skills'); + mkdirSync(codexFarm, { recursive: true }); + symlinkSync(shared, join(codexFarm, 'panel')); + + const { detect } = await import('../../../src/manifest/index.js'); + const runs = await Promise.all([ + detect(join(tmpHome, 'nowhere')), + detect(join(tmpHome, 'nowhere')), + detect(join(tmpHome, 'nowhere')), + ]); + for (const run of runs) { + const skills = run.tools.filter((t) => t.type === 'skill' && t.name === 'panel'); + expect(skills).toHaveLength(1); + expect(skills[0]!.client).toBe('claude-code'); + } + }); +}); diff --git a/test/unit/manifest/kimi.test.ts b/test/unit/manifest/kimi.test.ts new file mode 100644 index 0000000..327f638 --- /dev/null +++ b/test/unit/manifest/kimi.test.ts @@ -0,0 +1,135 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { detectKimiCode } from '../../../src/manifest/kimi.js'; + +// Cross-platform homedir override (Pitfall 7): process.env.HOME doesn't +// affect os.homedir() on Windows. Mock node:os instead. +const homedirHolder: { current: string | null } = { current: null }; +vi.mock('node:os', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + homedir: () => homedirHolder.current ?? actual.homedir(), + }; +}); + +describe('detectKimiCode — MCP servers', () => { + let tmpHome: string | null = null; + + beforeEach(() => { + homedirHolder.current = null; + }); + + afterEach(() => { + homedirHolder.current = null; + if (tmpHome) { + rmSync(tmpHome, { recursive: true, force: true }); + tmpHome = null; + } + }); + + it('parses mcpServers from ~/.kimi-code/mcp.json, emits ToolEntry with type=mcp, client=kimi-code', async () => { + tmpHome = mkdtempSync(join(tmpdir(), 'devcat-kimi-userscope-')); + mkdirSync(join(tmpHome, '.kimi-code'), { recursive: true }); + writeFileSync( + join(tmpHome, '.kimi-code', 'mcp.json'), + JSON.stringify({ + mcpServers: { + 'demo-search': { command: 'npx', args: ['-y', '@example/demo-search-mcp'] }, + 'demo-fetch': { command: 'npx', args: ['-y', '@example/demo-fetch-mcp'] }, + }, + }), + ); + homedirHolder.current = tmpHome; + const result = await detectKimiCode({ scope: 'user' }); + const mcp = result.tools.filter((t) => t.type === 'mcp'); + expect(mcp).toHaveLength(2); + expect(mcp.find((t) => t.name === 'demo-search')).toBeDefined(); + expect(mcp.find((t) => t.name === 'demo-fetch')).toBeDefined(); + expect(mcp.every((t) => t.client === 'kimi-code')).toBe(true); + expect(mcp.every((t) => t.scope === 'user')).toBe(true); + }); + + it('handles missing mcp.json gracefully (returns empty, pathsScanned lists all user locations)', async () => { + tmpHome = mkdtempSync(join(tmpdir(), 'devcat-kimi-missing-')); + homedirHolder.current = tmpHome; + const result = await detectKimiCode({ scope: 'user' }); + expect(result.tools).toEqual([]); + // User scope checks four locations: mcp.json, the brand skills root, + // and the generic skills root (shared shelf). + expect(result.pathsScanned).toEqual([ + join(tmpHome, '.kimi-code', 'mcp.json'), + join(tmpHome, '.kimi-code', 'skills'), + join(tmpHome, '.agents', 'skills'), + ]); + }); + + it('handles malformed JSON gracefully (returns empty)', async () => { + tmpHome = mkdtempSync(join(tmpdir(), 'devcat-kimi-bad-')); + mkdirSync(join(tmpHome, '.kimi-code'), { recursive: true }); + writeFileSync(join(tmpHome, '.kimi-code', 'mcp.json'), '{ not: valid json'); + homedirHolder.current = tmpHome; + const result = await detectKimiCode({ scope: 'user' }); + expect(result.tools).toEqual([]); + expect(result.pathsScanned).toContain(join(tmpHome, '.kimi-code', 'mcp.json')); + }); + + it('detects project-scope MCP config via a literal /.kimi-code/mcp.json', async () => { + const projectDir = mkdtempSync(join(tmpdir(), 'devcat-kimi-proj-')); + try { + mkdirSync(join(projectDir, '.kimi-code'), { recursive: true }); + writeFileSync( + join(projectDir, '.kimi-code', 'mcp.json'), + JSON.stringify({ mcpServers: { 'repo-local': { command: 'npx' } } }), + ); + const result = await detectKimiCode({ cwd: projectDir, scope: 'project' }); + const mcp = result.tools.filter((t) => t.type === 'mcp'); + expect(mcp).toHaveLength(1); + expect(mcp[0]).toMatchObject({ type: 'mcp', name: 'repo-local', scope: 'project', client: 'kimi-code' }); + expect(mcp[0]!.source).toBe(join(projectDir, '.kimi-code', 'mcp.json')); + } finally { + rmSync(projectDir, { recursive: true, force: true }); + } + }); + + it('does NOT find .kimi-code/mcp.json in a parent directory — Kimi does not walk upward for this file', async () => { + const parentDir = mkdtempSync(join(tmpdir(), 'devcat-kimi-noupward-')); + try { + mkdirSync(join(parentDir, '.kimi-code'), { recursive: true }); + writeFileSync( + join(parentDir, '.kimi-code', 'mcp.json'), + JSON.stringify({ mcpServers: { 'parent-only': { command: 'npx' } } }), + ); + const childDir = join(parentDir, 'child'); + mkdirSync(childDir, { recursive: true }); + + const result = await detectKimiCode({ cwd: childDir, scope: 'project' }); + // Unlike Codex/Cursor/Claude's project MCP detectors, this is a + // literal cwd check — the parent's file is invisible from here. + expect(result.tools.filter((t) => t.type === 'mcp')).toEqual([]); + expect(result.pathsScanned).toContain(join(childDir, '.kimi-code', 'mcp.json')); + } finally { + rmSync(parentDir, { recursive: true, force: true }); + } + }); + + it('$HOME guard: does not double-report ~/.kimi-code/mcp.json as project-scoped when cwd is $HOME', async () => { + tmpHome = mkdtempSync(join(tmpdir(), 'devcat-kimi-homeguard-')); + mkdirSync(join(tmpHome, '.kimi-code'), { recursive: true }); + writeFileSync( + join(tmpHome, '.kimi-code', 'mcp.json'), + JSON.stringify({ mcpServers: { 'demo-search': { command: 'npx' } } }), + ); + homedirHolder.current = tmpHome; + + const project = await detectKimiCode({ cwd: tmpHome, scope: 'project' }); + expect(project.tools).toEqual([]); + expect(project.pathsScanned.filter((p) => p.endsWith(join('.kimi-code', 'mcp.json')))).toEqual([]); + + const user = await detectKimiCode({ scope: 'user' }); + expect(user.tools.map((t) => t.name)).toContain('demo-search'); + expect(user.tools.every((t) => t.scope === 'user')).toBe(true); + }); +}); diff --git a/test/unit/ui/report.test.ts b/test/unit/ui/report.test.ts index 8526b37..21760a8 100644 --- a/test/unit/ui/report.test.ts +++ b/test/unit/ui/report.test.ts @@ -293,7 +293,7 @@ describe('renderEmptyStack — copy and home-relative paths (terminal report onl truncations: [], }).split('\n'); expect(lines[lines.length - 1]).toBe( - 'Add an MCP server to Claude Code, Codex, or Cursor and run this again.', + 'Add an MCP server to Claude Code, Codex, Kimi Code, or Cursor and run this again.', ); expect(lines.join('\n')).not.toContain('Share it'); });