From ed520e564bf0fb236cbca4d3ec7541a3e046e54c Mon Sep 17 00:00:00 2001 From: AnobleSCM <211227905+AnobleSCM@users.noreply.github.com> Date: Mon, 3 Aug 2026 16:35:03 -0700 Subject: [PATCH 1/2] fix: gate Kimi Code scanner on its own install marker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ~/.agents/skills is not Kimi's own directory — it is the shared install target skills.sh (vercel-labs) uses for several non-Kimi tools (Cline, Warp, Zed, Dexto, Loaf). The Kimi Code scanner attributed anything found there to Kimi Code unconditionally, with no check that Kimi was actually installed, so a user with one of those tools and no Kimi saw a phantom "Kimi Code" section. At user scope this was usually masked by scan-order dedupe (Claude Code's link farm wins); at project scope there was no dedupe to mask it, and the existing test suite already proved the misattribution. detectKimiCode() now runs only when its own install marker exists on disk — ~/.kimi-code at user scope, /.kimi-code at project scope — checked independently per scope with a pure stat, no file reads. Without the marker, Kimi Code contributes nothing: no section in any report, no path in the empty-state "Looked in" list, no entry in --json paths_checked. With the marker present, output is unchanged from 0.2.3. Version 0.2.4. Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 24 +++ README.md | 6 +- package-lock.json | 4 +- package.json | 2 +- src/manifest/kimi.ts | 28 ++- src/version.ts | 2 +- test/integration/kimi-marker-gate.test.ts | 197 ++++++++++++++++++++++ test/unit/manifest/kimi.marker.test.ts | 147 ++++++++++++++++ test/unit/manifest/kimi.skills.test.ts | 31 +++- test/unit/manifest/kimi.test.ts | 8 + 10 files changed, 441 insertions(+), 8 deletions(-) create mode 100644 test/integration/kimi-marker-gate.test.ts create mode 100644 test/unit/manifest/kimi.marker.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 7aa245f..6789e7a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,30 @@ All notable changes to this project are documented in this file. +## [0.2.4] + +Fixes a false attribution introduced in 0.2.3: the Kimi Code scanner ran +unconditionally, so it could claim Kimi Code was installed when it never was. + +- The Kimi Code scanner (MCP servers and skills, both scopes) now runs only + when its own install marker exists on disk — `~/.kimi-code` at user scope, + `.kimi-code` under the current directory at project scope — checked + independently, so a project-only marker still scans that project with the + user pass gated closed, and vice versa +- The bug: `~/.agents/skills` is not Kimi's own directory. It is the shared + install target [skills.sh](https://github.com/vercel-labs/skills.sh) uses + for several non-Kimi tools (Cline, Warp, Zed, Dexto, Loaf), so a user with + one of those installed and no Kimi Code at all saw a phantom "Kimi Code" + section. At user scope this was usually masked by scan-order dedupe + (Claude Code's link farm wins); at project scope there was no dedupe to + mask it, and devcat's own test suite already proved the misattribution +- Without the marker, Kimi Code now contributes nothing — no section in any + report, no path in the empty-state "Looked in" list, no entry in `--json` + `paths_checked`. The skill goes undetected rather than misattributed: + undercount-honest, the same "no path is claimed unless it was actually + checked" rule `paths_checked` already followed for every other client +- With the marker present, output is unchanged from 0.2.3 + ## [0.2.3] Kimi Code joins Claude Code, Codex, and Cursor as a fourth scanned harness — diff --git a/README.md b/README.md index 38292ee..f9f56ef 100644 --- a/README.md +++ b/README.md @@ -145,11 +145,13 @@ 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) · 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. | +| **MCP servers** | Claude Code `~/.claude.json`, `~/.claude/settings.json`, `.mcp.json` (project) · Codex `~/.codex/config.toml`, `.codex/config.toml` (project) · Kimi Code (only when installed — see below) `~/.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/` · 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. | +| **Skills** | Claude Code `~/.claude/skills/`, `.claude/skills/` (project) · Codex `~/.codex/skills/` · Kimi Code (only when installed — see below) `~/.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.** | +**Kimi Code is the one client gated on its own install marker.** `~/.agents/skills` is not Kimi's directory — it is the shared install target [skills.sh](https://github.com/vercel-labs/skills.sh) uses for several non-Kimi tools (Cline, Warp, Zed, Dexto, Loaf), so its mere presence proves nothing about whether Kimi Code itself is installed. The whole Kimi Code scanner — MCP servers and skills, both scopes — runs only when `~/.kimi-code` (user) or `.kimi-code` under the current directory (project) actually exists on disk; each scope is checked independently, so a project-only marker still scans that project with the user pass gated closed, and vice versa. Without its marker, Kimi Code contributes nothing at all: no section in any report, no entry in `paths_checked`, no line in the empty-state "Looked in" list below — the same "only what was actually checked" discipline `paths_checked` follows everywhere else in this doc. + 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. Nothing else inside a config is retained: environment variable values, command-line arguments, install paths, and every other field are dropped at the parser and never appear in any output. Missing and malformed files are skipped silently — a broken `.mcp.json` never fails the scan. diff --git a/package-lock.json b/package-lock.json index b41dfc0..cc69a80 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "devcat-cli", - "version": "0.2.3", + "version": "0.2.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "devcat-cli", - "version": "0.2.3", + "version": "0.2.4", "license": "MIT", "dependencies": { "@napi-rs/keyring": "^1.2.0", diff --git a/package.json b/package.json index 5b2b63b..f4b8227 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "devcat-cli", - "version": "0.2.3", + "version": "0.2.4", "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)", diff --git a/src/manifest/kimi.ts b/src/manifest/kimi.ts index 3cd3cf6..939ecd2 100644 --- a/src/manifest/kimi.ts +++ b/src/manifest/kimi.ts @@ -1,4 +1,4 @@ -import { readFile } from 'node:fs/promises'; +import { readFile, stat } from 'node:fs/promises'; import { homedir } from 'node:os'; import { join } from 'node:path'; import { findUpwardDir, isUserLevelPath } from '../lib/findUpward.js'; @@ -66,12 +66,38 @@ interface SourceScan { * 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. + * + * Install-marker gate: `.agents/skills` is not Kimi's own directory — it is + * the shared global install target skills.sh (vercel-labs) uses for several + * NON-Kimi tools (Cline, Warp, Zed, Dexto, Loaf). Its mere presence proves + * nothing about Kimi, so this whole detector runs only when Kimi's own + * config directory exists: `~/.kimi-code` at user scope, `/.kimi-code` + * at project scope, checked independently (a project-only marker still + * runs the project pass with the user pass gated closed, and vice versa). + * A missing marker means zero contribution — no tools, and no paths added + * to pathsScanned, because nothing was actually checked. */ export async function detectKimiCode(opts: { cwd?: string; scope: 'project' | 'user' }): Promise { + if (!(await kimiInstalled(opts))) return { tools: [], pathsScanned: [] }; const [mcp, skills] = await Promise.all([detectKimiMcp(opts), detectKimiSkills(opts)]); return mergeScans([mcp, skills]); } +/** Pure existence check — no file is read, matching this scanner's names-only philosophy. */ +async function kimiInstalled(opts: { cwd?: string; scope: 'project' | 'user' }): Promise { + if (opts.scope === 'user') return pathExists(join(homedir(), '.kimi-code')); + return opts.cwd != null && (await pathExists(join(opts.cwd, '.kimi-code'))); +} + +async function pathExists(path: string): Promise { + try { + await stat(path); + return true; + } catch { + return false; + } +} + /** * Kimi Code MCP servers. * diff --git a/src/version.ts b/src/version.ts index 6cbf514..3c116bd 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.3'; +export const CLI_VERSION = '0.2.4'; diff --git a/test/integration/kimi-marker-gate.test.ts b/test/integration/kimi-marker-gate.test.ts new file mode 100644 index 0000000..cba0ece --- /dev/null +++ b/test/integration/kimi-marker-gate.test.ts @@ -0,0 +1,197 @@ +import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest'; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +/** + * End-to-end proof that the Kimi Code install-marker gate (src/manifest/ + * kimi.ts) reaches every output surface, not just the detector's return + * value: the terminal report, --markdown, --json, and the empty-state + * "Looked in" listing must all agree Kimi Code contributed nothing when + * its install marker (~/.kimi-code or /.kimi-code) is absent. + * + * ~/.agents/skills is the shared global install target skills.sh + * (vercel-labs, 27k+ stars) uses for several non-Kimi tools (Cline, Warp, + * Zed, Dexto, Loaf) — before this gate, content installed there by one of + * THOSE tools was unconditionally attributed to Kimi Code in every one of + * these surfaces, on a machine that may never have run Kimi at all. + */ +const homedirHolder: { current: string | null } = { current: null }; + +vi.mock('node:os', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, homedir: () => homedirHolder.current ?? actual.homedir() }; +}); + +process.env.NO_COLOR = '1'; + +async function runAndCapture( + cwd: string, + markdown: boolean, + json = false, +): Promise<{ exitCode: number; out: string; err: string }> { + const chunks: string[] = []; + const errChunks: string[] = []; + const writeSpy = vi + .spyOn(process.stdout, 'write') + .mockImplementation((chunk: string | Uint8Array): boolean => { + chunks.push(typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString()); + return true; + }); + const errSpy = vi + .spyOn(process.stderr, 'write') + .mockImplementation((chunk: string | Uint8Array): boolean => { + errChunks.push(typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString()); + return true; + }); + const cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue(cwd); + try { + const { runReport } = await import('../../src/commands/report.js'); + const exitCode = await runReport({ markdown, json }); + return { exitCode, out: chunks.join(''), err: errChunks.join('') }; + } finally { + writeSpy.mockRestore(); + errSpy.mockRestore(); + cwdSpy.mockRestore(); + } +} + +describe('(a) skills.sh scenario — populated ~/.agents/skills, no Kimi marker, nothing else installed', () => { + let tmpHome: string; + let projectDir: string; + + beforeAll(() => { + tmpHome = mkdtempSync(join(tmpdir(), 'devcat-kimi-e2e-empty-')); + projectDir = mkdtempSync(join(tmpdir(), 'devcat-kimi-e2e-empty-proj-')); + // A skills.sh-style install for a non-Kimi tool. No .kimi-code anywhere + // — this is the exact false-positive shape: only Kimi's scanner reads + // this directory directly, and nothing else on this fixture machine + // can see it. + mkdirSync(join(tmpHome, '.agents', 'skills', 'cline-only-skill'), { recursive: true }); + writeFileSync(join(tmpHome, '.agents', 'skills', 'cline-only-skill', 'SKILL.md'), '# x\n'); + homedirHolder.current = tmpHome; + }); + + afterAll(() => { + homedirHolder.current = null; + rmSync(tmpHome, { recursive: true, force: true }); + rmSync(projectDir, { recursive: true, force: true }); + }); + + it('produces the empty-stack report, not a phantom Kimi Code section', async () => { + const { exitCode, out } = await runAndCapture(projectDir, false); + expect(exitCode).toBe(0); + expect(out).toContain('No AI tooling detected on this machine yet.'); + // No populated "Kimi Code · N tools" group heading — the empty state's + // generic closing hint ("Add an MCP server to Claude Code, Codex, Kimi + // Code, or Cursor...") legitimately names all four supported clients + // regardless of what was found, so that line is not asserted against. + expect(out).not.toMatch(/Kimi Code\s*·/); + // Undercount-honest: the skill is truly invisible, not misattributed. + expect(out).not.toContain('cline-only-skill'); + }); + + it('the empty-state "Looked in" list omits every Kimi-only path, including ~/.agents/skills itself', async () => { + const { out } = await runAndCapture(projectDir, false); + expect(out).toContain('Looked in:'); + expect(out).not.toMatch(/\.kimi-code/); + expect(out).not.toMatch(/\.agents[\\/]skills/); + }); + + it('--markdown and --json agree: no Kimi Code anywhere, and paths_checked names nothing it did not check', async () => { + const md = await runAndCapture(projectDir, true); + expect(md.out).toContain('No AI tooling detected on this machine yet.'); + expect(md.out).not.toContain('Kimi Code'); + + const js = await runAndCapture(projectDir, false, true); + const parsed = JSON.parse(js.out); + expect(parsed.total).toBe(0); + expect(parsed.clients).toEqual([]); + expect(parsed.paths_checked.some((p: string) => p.includes('.kimi-code'))).toBe(false); + expect(parsed.paths_checked.some((p: string) => p.includes(join('.agents', 'skills')))).toBe(false); + }); +}); + +describe('(a) mixed stack — a real Claude Code tool alongside the same phantom-Kimi skill', () => { + let tmpHome: string; + let projectDir: string; + + beforeAll(() => { + tmpHome = mkdtempSync(join(tmpdir(), 'devcat-kimi-e2e-mixed-')); + projectDir = mkdtempSync(join(tmpdir(), 'devcat-kimi-e2e-mixed-proj-')); + writeFileSync(join(tmpHome, '.claude.json'), JSON.stringify({ mcpServers: { github: { command: 'npx' } } })); + mkdirSync(join(tmpHome, '.agents', 'skills', 'warp-only-skill'), { recursive: true }); + writeFileSync(join(tmpHome, '.agents', 'skills', 'warp-only-skill', 'SKILL.md'), '# x\n'); + homedirHolder.current = tmpHome; + }); + + afterAll(() => { + homedirHolder.current = null; + rmSync(tmpHome, { recursive: true, force: true }); + rmSync(projectDir, { recursive: true, force: true }); + }); + + it('shows Claude Code but no Kimi Code section, in the terminal report, --markdown, or --json', async () => { + const { out } = await runAndCapture(projectDir, false); + expect(out).toContain('Your AI-coding stack — 1 tool'); + expect(out).toContain('Claude Code'); + expect(out).not.toContain('Kimi Code'); + expect(out).not.toContain('warp-only-skill'); + + const md = await runAndCapture(projectDir, true); + expect(md.out).not.toContain('### Kimi Code'); + + const js = await runAndCapture(projectDir, false, true); + const parsed = JSON.parse(js.out); + expect(parsed.clients.map((c: { client: string }) => c.client)).toEqual(['claude-code']); + }); +}); + +describe('(b)/(c) marker present — Kimi Code appears normally, gated independently per scope', () => { + let tmpHome: string; + let projectDir: string; + + beforeAll(() => { + tmpHome = mkdtempSync(join(tmpdir(), 'devcat-kimi-e2e-marker-')); + projectDir = mkdtempSync(join(tmpdir(), 'devcat-kimi-e2e-marker-proj-')); + // Genuine Kimi install at BOTH scopes — the reference-machine shape. + mkdirSync(join(tmpHome, '.kimi-code'), { recursive: true }); + writeFileSync( + join(tmpHome, '.kimi-code', 'mcp.json'), + JSON.stringify({ mcpServers: { 'global-search': { command: 'npx' } } }), + ); + mkdirSync(join(projectDir, '.kimi-code'), { recursive: true }); + writeFileSync( + join(projectDir, '.kimi-code', 'mcp.json'), + JSON.stringify({ mcpServers: { 'repo-tool': { command: 'npx' } } }), + ); + homedirHolder.current = tmpHome; + }); + + afterAll(() => { + homedirHolder.current = null; + rmSync(tmpHome, { recursive: true, force: true }); + rmSync(projectDir, { recursive: true, force: true }); + }); + + it('(b) reports both the user- and project-scoped Kimi Code MCP servers, unchanged from unconditional scanning', async () => { + const { out } = await runAndCapture(projectDir, false); + expect(out).toContain('Kimi Code'); + expect(out).toMatch(/2 mcp\s+global-search, repo-tool/); + expect(out).toContain('1 project-scoped · 1 user-wide'); + }); + + it('(c) project-only marker still runs project scope in full when the user marker is absent', async () => { + const bareHome = mkdtempSync(join(tmpdir(), 'devcat-kimi-e2e-baremarker-')); + try { + homedirHolder.current = bareHome; + const { out } = await runAndCapture(projectDir, false); + expect(out).toContain('Kimi Code'); + expect(out).toMatch(/1 mcp\s+repo-tool/); + expect(out).not.toContain('global-search'); + } finally { + homedirHolder.current = tmpHome; + rmSync(bareHome, { recursive: true, force: true }); + } + }); +}); diff --git a/test/unit/manifest/kimi.marker.test.ts b/test/unit/manifest/kimi.marker.test.ts new file mode 100644 index 0000000..b91439d --- /dev/null +++ b/test/unit/manifest/kimi.marker.test.ts @@ -0,0 +1,147 @@ +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'; + +/** + * The install-marker gate: detectKimiCode() must contribute nothing — no + * tools, no scanned paths — unless a genuine Kimi Code install marker + * exists for that scope (~/.kimi-code for user, /.kimi-code for + * project, each checked independently). + * + * Why this exists: ~/.agents/skills is the shared global install target + * skills.sh (vercel-labs, 27k+ stars) uses for several non-Kimi tools + * (Cline, Warp, Zed, Dexto, Loaf). Before this gate, content installed + * there by one of those tools was unconditionally attributed to Kimi + * Code, regardless of whether Kimi was ever installed on the machine. + * See kimi.test.ts and kimi.skills.test.ts for coverage of the scanning + * logic this gate sits in front of. + */ +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 — install-marker gate', () => { + let tmpHome: string | null = null; + let tmpProject: string | null = null; + + beforeEach(() => { + homedirHolder.current = null; + }); + + afterEach(() => { + homedirHolder.current = null; + if (tmpHome) { + rmSync(tmpHome, { recursive: true, force: true }); + tmpHome = null; + } + if (tmpProject) { + rmSync(tmpProject, { recursive: true, force: true }); + tmpProject = null; + } + }); + + it('(a) user scope: no ~/.kimi-code marker means zero contribution, even with a populated ~/.agents/skills', async () => { + tmpHome = mkdtempSync(join(tmpdir(), 'devcat-kimi-gate-user-')); + homedirHolder.current = tmpHome; + // skills.sh-style install for a non-Kimi tool — no .kimi-code anywhere. + mkdirSync(join(tmpHome, '.agents', 'skills', 'some-skill'), { recursive: true }); + writeFileSync(join(tmpHome, '.agents', 'skills', 'some-skill', 'SKILL.md'), '# some-skill\n'); + + const result = await detectKimiCode({ scope: 'user' }); + + expect(result.tools).toEqual([]); + // Not scanned means not scanned: no path is reported as looked-at. + expect(result.pathsScanned).toEqual([]); + }); + + it('(a) project scope: no /.kimi-code marker means zero contribution, even with a populated /.agents/skills', async () => { + tmpProject = mkdtempSync(join(tmpdir(), 'devcat-kimi-gate-proj-')); + mkdirSync(join(tmpProject, '.agents', 'skills', 'some-skill'), { recursive: true }); + writeFileSync(join(tmpProject, '.agents', 'skills', 'some-skill', 'SKILL.md'), '# some-skill\n'); + + const result = await detectKimiCode({ cwd: tmpProject, scope: 'project' }); + + expect(result.tools).toEqual([]); + expect(result.pathsScanned).toEqual([]); + }); + + it('user scope: a bare ~/.kimi-code marker (nothing inside it yet) still opens the gate and reports the normal locations', async () => { + tmpHome = mkdtempSync(join(tmpdir(), 'devcat-kimi-gate-user-marker-')); + homedirHolder.current = tmpHome; + mkdirSync(join(tmpHome, '.kimi-code'), { recursive: true }); + + const result = await detectKimiCode({ scope: 'user' }); + + expect(result.tools).toEqual([]); + expect(result.pathsScanned).toEqual([ + join(tmpHome, '.kimi-code', 'mcp.json'), + join(tmpHome, '.kimi-code', 'skills'), + join(tmpHome, '.agents', 'skills'), + ]); + }); + + it('(c) project-only marker: project scope runs normally while user scope, with no marker of its own, stays empty', async () => { + tmpHome = mkdtempSync(join(tmpdir(), 'devcat-kimi-gate-nouserm-')); + homedirHolder.current = tmpHome; + tmpProject = mkdtempSync(join(tmpdir(), 'devcat-kimi-gate-projm-')); + mkdirSync(join(tmpProject, '.kimi-code'), { recursive: true }); + writeFileSync( + join(tmpProject, '.kimi-code', 'mcp.json'), + JSON.stringify({ mcpServers: { 'repo-only': { command: 'npx' } } }), + ); + + const project = await detectKimiCode({ cwd: tmpProject, scope: 'project' }); + expect(project.tools).toHaveLength(1); + expect(project.tools[0]).toMatchObject({ type: 'mcp', name: 'repo-only', scope: 'project', client: 'kimi-code' }); + + const user = await detectKimiCode({ scope: 'user' }); + expect(user.tools).toEqual([]); + expect(user.pathsScanned).toEqual([]); + }); + + it('user-only marker: user scope runs normally while project scope, with no marker of its own, stays empty', async () => { + tmpHome = mkdtempSync(join(tmpdir(), 'devcat-kimi-gate-userm-')); + homedirHolder.current = tmpHome; + mkdirSync(join(tmpHome, '.kimi-code'), { recursive: true }); + writeFileSync( + join(tmpHome, '.kimi-code', 'mcp.json'), + JSON.stringify({ mcpServers: { 'global-only': { command: 'npx' } } }), + ); + tmpProject = mkdtempSync(join(tmpdir(), 'devcat-kimi-gate-noprojm-')); + + const user = await detectKimiCode({ scope: 'user' }); + expect(user.tools).toHaveLength(1); + expect(user.tools[0]).toMatchObject({ type: 'mcp', name: 'global-only', scope: 'user', client: 'kimi-code' }); + + const project = await detectKimiCode({ cwd: tmpProject, scope: 'project' }); + expect(project.tools).toEqual([]); + expect(project.pathsScanned).toEqual([]); + }); + + it('(d) paths_checked reflects exactly what ran: same fixture, marker toggled mid-test, only the gated paths change', async () => { + tmpHome = mkdtempSync(join(tmpdir(), 'devcat-kimi-gate-toggle-')); + homedirHolder.current = tmpHome; + mkdirSync(join(tmpHome, '.agents', 'skills', 'shared-tool-skill'), { recursive: true }); + writeFileSync(join(tmpHome, '.agents', 'skills', 'shared-tool-skill', 'SKILL.md'), '# shared\n'); + + const withoutMarker = await detectKimiCode({ scope: 'user' }); + expect(withoutMarker.tools).toEqual([]); + expect(withoutMarker.pathsScanned).toEqual([]); + + mkdirSync(join(tmpHome, '.kimi-code'), { recursive: true }); + const withMarker = await detectKimiCode({ scope: 'user' }); + expect(withMarker.pathsScanned).toEqual([ + join(tmpHome, '.kimi-code', 'mcp.json'), + join(tmpHome, '.kimi-code', 'skills'), + join(tmpHome, '.agents', 'skills'), + ]); + // The shared skill is real, and on a machine that genuinely has Kimi + // installed it IS Kimi's to report — only the no-marker attribution + // was the bug, not the with-marker one. + expect(withMarker.tools.map((t) => t.name)).toContain('shared-tool-skill'); + }); +}); diff --git a/test/unit/manifest/kimi.skills.test.ts b/test/unit/manifest/kimi.skills.test.ts index 3bea77c..9b8386c 100644 --- a/test/unit/manifest/kimi.skills.test.ts +++ b/test/unit/manifest/kimi.skills.test.ts @@ -49,6 +49,10 @@ describe('detectKimiCode — skills', () => { const skillsRoot = join(tmpHome, '.agents', 'skills'); mkdirSync(skillsRoot, { recursive: true }); makeSkill(skillsRoot, 'demo-generic-skill'); + // Install marker: this machine genuinely has Kimi Code, which is what + // makes it safe for the generic root to be attributed to it — see + // kimi.marker.test.ts for the no-marker case, where it must not be. + mkdirSync(join(tmpHome, '.kimi-code'), { recursive: true }); const { detectKimiCode } = await import('../../../src/manifest/kimi.js'); const result = await detectKimiCode({ scope: 'user' }); @@ -99,6 +103,8 @@ describe('detectKimiCode — skills', () => { symlinkSync(join(tmpHome, 'gone'), join(shelf, 'dangling')); mkdirSync(join(shelf, '.system'), { recursive: true }); writeFileSync(join(shelf, 'AGENTS.md'), 'not a skill'); + // Install marker — see kimi.marker.test.ts for the no-marker case. + mkdirSync(join(tmpHome, '.kimi-code'), { recursive: true }); const { detectKimiCode } = await import('../../../src/manifest/kimi.js'); const result = await detectKimiCode({ scope: 'user' }); @@ -179,11 +185,14 @@ describe('shared shelf — Claude Code, Codex, and Kimi Code all reach one direc 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 () => { + it('a skill only Kimi Code reaches (no Claude/Codex farm entry) still appears, under Kimi Code, when Kimi is genuinely installed', 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. + // Install marker — the machine genuinely has Kimi Code, which is what + // makes attributing this skill to it correct rather than a phantom. + mkdirSync(join(tmpHome, '.kimi-code'), { recursive: true }); const { detect } = await import('../../../src/manifest/index.js'); const result = await detect(join(tmpHome, 'nowhere')); @@ -193,6 +202,26 @@ describe('shared shelf — Claude Code, Codex, and Kimi Code all reach one direc expect(skill!.client).toBe('kimi-code'); }); + it('(a) the same skill, with NO Kimi marker anywhere, is invisible — undercount-honest, not misattributed to Kimi Code', async () => { + // This is the exact phantom-attribution shape the marker gate exists to + // close: a skill only reachable through ~/.agents/skills (no Claude or + // Codex farm symlink names it), on a machine with no ~/.kimi-code — + // e.g. skills.sh installed it for Cline, Warp, Zed, Dexto, or Loaf, none + // of which devcat scans. Before the gate this surfaced as Kimi Code + // regardless of whether Kimi was ever installed. + const canon = join(tmpHome, '.agents', 'skills'); + mkdirSync(canon, { recursive: true }); + makeSkill(canon, 'skillssh-only-skill'); + // Deliberately no .kimi-code marker anywhere in this fixture. + + const { detect } = await import('../../../src/manifest/index.js'); + const result = await detect(join(tmpHome, 'nowhere')); + + expect(result.tools.find((t) => t.name === 'skillssh-only-skill')).toBeUndefined(); + expect(result.tools).toEqual([]); + expect(result.pathsScanned.some((p) => p.includes('.kimi-code'))).toBe(false); + }); + it('is stable across repeated scans', async () => { const canon = join(tmpHome, '.agents', 'skills'); mkdirSync(canon, { recursive: true }); diff --git a/test/unit/manifest/kimi.test.ts b/test/unit/manifest/kimi.test.ts index 327f638..2a131c9 100644 --- a/test/unit/manifest/kimi.test.ts +++ b/test/unit/manifest/kimi.test.ts @@ -54,6 +54,10 @@ describe('detectKimiCode — MCP servers', () => { it('handles missing mcp.json gracefully (returns empty, pathsScanned lists all user locations)', async () => { tmpHome = mkdtempSync(join(tmpdir(), 'devcat-kimi-missing-')); + // The install marker itself is present — this is a machine with Kimi + // Code installed that just hasn't written an mcp.json yet — so the + // gate opens and the normal graceful-miss behavior below is exercised. + mkdirSync(join(tmpHome, '.kimi-code'), { recursive: true }); homedirHolder.current = tmpHome; const result = await detectKimiCode({ scope: 'user' }); expect(result.tools).toEqual([]); @@ -104,6 +108,10 @@ describe('detectKimiCode — MCP servers', () => { ); const childDir = join(parentDir, 'child'); mkdirSync(childDir, { recursive: true }); + // The child's OWN install marker — opens the gate for this cwd + // without giving it the parent's mcp.json, so the assertion below is + // still testing "no upward walk", not "no marker". + mkdirSync(join(childDir, '.kimi-code'), { recursive: true }); const result = await detectKimiCode({ cwd: childDir, scope: 'project' }); // Unlike Codex/Cursor/Claude's project MCP detectors, this is a From d1055d909c8ddbff99aaa08c1875a505ee1704a5 Mon Sep 17 00:00:00 2001 From: AnobleSCM <211227905+AnobleSCM@users.noreply.github.com> Date: Mon, 3 Aug 2026 17:12:34 -0700 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20address=20cross-review=20on=20#18=20?= =?UTF-8?q?=E2=80=94=20dir-check=20hardening,=20stale=20samples,=20doc=20c?= =?UTF-8?q?laim?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four items from the cross-review verdict: 1. README.md's dedupe-order bullet said a Kimi-Code-only skill "still appears under that client" unconditionally — false in the no-marker state, which is this release's entire point. Added the qualifier ("Kimi Code: only when installed"). 2. The README's terminal block, its --json block, and assets/devcat-report.svg were regenerated from one real run of the built 0.2.4 binary against the same 26-tool fixture the 0.2.3 example used (same tool inventory, verified against the fixture reproducing the current README byte-for-byte via the real published 0.2.3 tarball). This also surfaced a pre-existing staleness unrelated to this fix: "locations checked" was already wrong on main (documented as 14; a real 0.2.3 run reports 19). 0.2.4 correctly shows 16 — the same 19 minus the 3 phantom project-scope Kimi paths this fix stops claiming to have checked. Nothing else in the example changed. 3. dirExists() now reuses the existing stat() result's isDirectory(), so a stray file named .kimi-code no longer opens the gate. No extra syscall; a symlink to a real directory still passes (stat follows symlinks, unchanged). 4. Corrected the PR body: 15 new tests total (8 in kimi.marker.test.ts, 6 in kimi-marker-gate.test.ts, 1 in kimi.skills.test.ts), not "13 across the two new files" as originally stated. No version bump — still 0.2.4, unpublished. Co-Authored-By: Claude Sonnet 5 --- README.md | 8 +++---- assets/devcat-report.svg | 4 ++-- src/manifest/kimi.ts | 15 ++++++++---- test/unit/manifest/kimi.marker.test.ts | 33 +++++++++++++++++++++++++- 4 files changed, 48 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index f9f56ef..e7f52f5 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ That's the whole thing — the report above is what it prints. The same output as text ``` -devcat v0.2.3 +devcat v0.2.4 ✓ Your AI-coding stack — 26 tools @@ -40,7 +40,7 @@ Kimi Code · 3 tools Cursor · 2 tools ██ 2 mcp figma, postgres -26 tools in Claude Code, Codex, Kimi Code, and Cursor · 14 locations checked +26 tools in Claude Code, Codex, Kimi Code, and Cursor · 16 locations checked 3 project-scoped · 23 user-wide Share it — npx devcat-cli --markdown @@ -107,7 +107,7 @@ npx devcat-cli --json | jq '.clients[] | {label, total}' ```json { - "cli_version": "0.2.3", + "cli_version": "0.2.4", "total": 26, "project_scoped": 3, "user_scoped": 23, @@ -175,7 +175,7 @@ Project-scoped entries are found by walking up from the current directory, so th - 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 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. +- 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 (Kimi Code: only when installed — see above). Install it globally if you run it often: diff --git a/assets/devcat-report.svg b/assets/devcat-report.svg index 991a8c1..83547ad 100644 --- a/assets/devcat-report.svg +++ b/assets/devcat-report.svg @@ -7,7 +7,7 @@ npx devcat-cli - devcat v0.2.3 + devcat v0.2.4 ✓ Your AI-coding stack — 26 tools Claude Code · 18 tools ██████ 6 mcp context7, github, linear, playwright, sentry, supabase @@ -21,7 +21,7 @@ ███ 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 + 26 tools in Claude Code, Codex, Kimi Code, and Cursor · 16 locations checked 3 project-scoped · 23 user-wide Share it — npx devcat-cli --markdown diff --git a/src/manifest/kimi.ts b/src/manifest/kimi.ts index 939ecd2..e3481f2 100644 --- a/src/manifest/kimi.ts +++ b/src/manifest/kimi.ts @@ -85,14 +85,19 @@ export async function detectKimiCode(opts: { cwd?: string; scope: 'project' | 'u /** Pure existence check — no file is read, matching this scanner's names-only philosophy. */ async function kimiInstalled(opts: { cwd?: string; scope: 'project' | 'user' }): Promise { - if (opts.scope === 'user') return pathExists(join(homedir(), '.kimi-code')); - return opts.cwd != null && (await pathExists(join(opts.cwd, '.kimi-code'))); + if (opts.scope === 'user') return dirExists(join(homedir(), '.kimi-code')); + return opts.cwd != null && (await dirExists(join(opts.cwd, '.kimi-code'))); } -async function pathExists(path: string): Promise { +/** + * True only when `path` is a directory — a stray file named `.kimi-code` + * must not open the gate. `stat` (not `lstat`) follows symlinks, so a + * symlink to a real directory still passes; that's the one stat() call + * this already needed, so the directory check costs nothing extra. + */ +async function dirExists(path: string): Promise { try { - await stat(path); - return true; + return (await stat(path)).isDirectory(); } catch { return false; } diff --git a/test/unit/manifest/kimi.marker.test.ts b/test/unit/manifest/kimi.marker.test.ts index b91439d..61f7284 100644 --- a/test/unit/manifest/kimi.marker.test.ts +++ b/test/unit/manifest/kimi.marker.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'; +import { mkdtempSync, mkdirSync, writeFileSync, symlinkSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { detectKimiCode } from '../../../src/manifest/kimi.js'; @@ -144,4 +144,35 @@ describe('detectKimiCode — install-marker gate', () => { // was the bug, not the with-marker one. expect(withMarker.tools.map((t) => t.name)).toContain('shared-tool-skill'); }); + + it('a FILE named .kimi-code (not a directory) does not open the gate', async () => { + tmpHome = mkdtempSync(join(tmpdir(), 'devcat-kimi-gate-filemarker-')); + homedirHolder.current = tmpHome; + // A stray file, not Kimi's real config directory. + writeFileSync(join(tmpHome, '.kimi-code'), 'not a directory\n'); + + const result = await detectKimiCode({ scope: 'user' }); + + expect(result.tools).toEqual([]); + expect(result.pathsScanned).toEqual([]); + }); + + it('a symlink to a real directory named .kimi-code still opens the gate (stat follows symlinks)', async () => { + tmpHome = mkdtempSync(join(tmpdir(), 'devcat-kimi-gate-symlinkmarker-')); + homedirHolder.current = tmpHome; + const realDir = mkdtempSync(join(tmpdir(), 'devcat-kimi-gate-realdir-')); + try { + symlinkSync(realDir, join(tmpHome, '.kimi-code')); + + const result = await detectKimiCode({ scope: 'user' }); + + expect(result.pathsScanned).toEqual([ + join(tmpHome, '.kimi-code', 'mcp.json'), + join(tmpHome, '.kimi-code', 'skills'), + join(tmpHome, '.agents', 'skills'), + ]); + } finally { + rmSync(realDir, { recursive: true, force: true }); + } + }); });