From 0a445928369204cc4bfaf8a31f115459320f2f8e Mon Sep 17 00:00:00 2001 From: AnobleSCM <211227905+AnobleSCM@users.noreply.github.com> Date: Sun, 2 Aug 2026 15:14:54 -0700 Subject: [PATCH 01/27] feat(manifest): record which client each detected tool came from MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The scanner knew where a tool was configured (source path) but not which AI-coding tool it belonged to, so nothing downstream could group by client. Adds a `client` field alongside the existing local-only `source`/`scope` metadata, set by each detector to a fixed literal. `client` is never read out of a config file, so it cannot carry user data, and it never reaches /api/sync — the payload is still {type, name} only. The manifest-only-sync security test's key allowlist is updated to match. Co-authored-by: omnigent --- src/manifest/claude.ts | 2 ++ src/manifest/codex.ts | 1 + src/manifest/cursor.ts | 1 + src/manifest/index.ts | 10 ++++++++-- test/unit/manifest/dedupe.test.ts | 14 +++++++------- test/unit/manifest/security.test.ts | 7 +++++-- 6 files changed, 24 insertions(+), 11 deletions(-) diff --git a/src/manifest/claude.ts b/src/manifest/claude.ts index e6c7a94..232dde1 100644 --- a/src/manifest/claude.ts +++ b/src/manifest/claude.ts @@ -84,6 +84,7 @@ async function readMcpServersJson(path: string, scope: 'project' | 'user'): Prom name, source: path, scope, + client: 'claude-code' as const, })); return { tools, pathsScanned: [path] }; } @@ -108,6 +109,7 @@ async function readInstalledPluginsJson(path: string): Promise { name: key.split('@')[0]!, source: path, scope: 'user' as const, + client: 'claude-code' as const, })); return { tools, pathsScanned: [path] }; } diff --git a/src/manifest/codex.ts b/src/manifest/codex.ts index e8b4185..da5adfe 100644 --- a/src/manifest/codex.ts +++ b/src/manifest/codex.ts @@ -57,6 +57,7 @@ export async function detectCodex(opts: { cwd?: string; scope: 'project' | 'user name, source: path!, scope: opts.scope, + client: 'codex' as const, })); return { tools, pathsScanned: [scannedPath] }; } diff --git a/src/manifest/cursor.ts b/src/manifest/cursor.ts index 602c74c..d1ade08 100644 --- a/src/manifest/cursor.ts +++ b/src/manifest/cursor.ts @@ -56,6 +56,7 @@ export async function detectCursor(opts: { cwd?: string; scope: 'project' | 'use name, source: path!, scope: opts.scope, + client: 'cursor' as const, })); return { tools, pathsScanned: [scannedPath] }; } diff --git a/src/manifest/index.ts b/src/manifest/index.ts index ab98487..ef87dd4 100644 --- a/src/manifest/index.ts +++ b/src/manifest/index.ts @@ -3,18 +3,24 @@ import { detectCodex } from './codex.js'; import { detectCursor } from './cursor.js'; import { dedupe } from './dedupe.js'; +/** Which AI-coding tool a manifest entry was found in. */ +export type ToolClient = 'claude-code' | 'codex' | 'cursor'; + /** * A single tool surfaced from any local manifest. * * Only `type` and `name` are sent to /api/sync per CLI-05. - * `source` and `scope` are local-only metadata used for --json terminal output - * and for ordering during the project-first dedupe pass (D-08). + * `source`, `scope`, and `client` are local-only metadata used for --json + * terminal output, for the local stack report, and for ordering during the + * project-first dedupe pass (D-08). `client` is always one of the fixed + * literals above — never a value read out of a config file. */ export interface ToolEntry { type: 'mcp' | 'skill' | 'plugin'; name: string; source: string; scope: 'project' | 'user'; + client: ToolClient; } export interface DetectResult { diff --git a/test/unit/manifest/dedupe.test.ts b/test/unit/manifest/dedupe.test.ts index 77853df..f601c67 100644 --- a/test/unit/manifest/dedupe.test.ts +++ b/test/unit/manifest/dedupe.test.ts @@ -8,28 +8,28 @@ describe('dedupe', () => { }); it('returns single entry untouched', () => { - const e: ToolEntry = { type: 'mcp', name: 'context7', source: 'a', scope: 'project' }; + const e: ToolEntry = { type: 'mcp', name: 'context7', source: 'a', scope: 'project', client: 'claude-code' }; expect(dedupe([e])).toEqual([e]); }); it('keeps first occurrence on (type, name) collision (project-first wins)', () => { - const project: ToolEntry = { type: 'mcp', name: 'context7', source: 'a', scope: 'project' }; - const user: ToolEntry = { type: 'mcp', name: 'context7', source: 'b', scope: 'user' }; + const project: ToolEntry = { type: 'mcp', name: 'context7', source: 'a', scope: 'project', client: 'claude-code' }; + const user: ToolEntry = { type: 'mcp', name: 'context7', source: 'b', scope: 'user', client: 'claude-code' }; const result = dedupe([project, user]); expect(result).toHaveLength(1); expect(result[0]).toEqual(project); }); it('keeps both entries when same name has different types (key is tuple)', () => { - const mcp: ToolEntry = { type: 'mcp', name: 'shared', source: 'a', scope: 'project' }; - const plugin: ToolEntry = { type: 'plugin', name: 'shared', source: 'b', scope: 'user' }; + const mcp: ToolEntry = { type: 'mcp', name: 'shared', source: 'a', scope: 'project', client: 'claude-code' }; + const plugin: ToolEntry = { type: 'plugin', name: 'shared', source: 'b', scope: 'user', client: 'claude-code' }; const result = dedupe([mcp, plugin]); expect(result).toHaveLength(2); }); it('is case-sensitive on name (server-side exact match runs first)', () => { - const lower: ToolEntry = { type: 'mcp', name: 'context7', source: 'a', scope: 'project' }; - const upper: ToolEntry = { type: 'mcp', name: 'Context7', source: 'b', scope: 'user' }; + const lower: ToolEntry = { type: 'mcp', name: 'context7', source: 'a', scope: 'project', client: 'claude-code' }; + const upper: ToolEntry = { type: 'mcp', name: 'Context7', source: 'b', scope: 'user', client: 'claude-code' }; const result = dedupe([lower, upper]); expect(result).toHaveLength(2); }); diff --git a/test/unit/manifest/security.test.ts b/test/unit/manifest/security.test.ts index 3557bb7..737684e 100644 --- a/test/unit/manifest/security.test.ts +++ b/test/unit/manifest/security.test.ts @@ -199,10 +199,13 @@ describe('manifest-only-sync (CLI-05)', () => { } }); - it('detect().tools entries have ONLY the expected keys (type, name, source, scope)', async () => { + it('detect().tools entries have ONLY the expected keys (type, name, source, scope, client)', async () => { + // `client` is a fixed literal set by the detector ('claude-code' | 'codex' + // | 'cursor'), never a value read out of a config file — so it cannot + // carry user data even though it never reaches the server either. const { detect } = await import('../../../src/manifest/index.js'); const result = await detect(tmpHome); - const ALLOWED_KEYS = new Set(['type', 'name', 'source', 'scope']); + const ALLOWED_KEYS = new Set(['type', 'name', 'source', 'scope', 'client']); for (const t of result.tools) { for (const k of Object.keys(t)) { expect(ALLOWED_KEYS.has(k), `unexpected key on ToolEntry: ${k}`).toBe(true); From 618688da003d58b2d7a5bdf8bc4a71851ddc70b7 Mon Sep 17 00:00:00 2001 From: AnobleSCM <211227905+AnobleSCM@users.noreply.github.com> Date: Sun, 2 Aug 2026 15:15:04 -0700 Subject: [PATCH 02/27] feat(cli): make the local stack report the default command `npx devcat-cli` with no arguments now scans this machine and prints the MCP servers and plugins installed across Claude Code, Codex, and Cursor, grouped by client with per-type counts. Local only: no network, no auth, no credentials touched, and an empty result exits 0 rather than erroring. `--markdown` emits the same scan as a "My AI stack" snippet for a README or gist. Both renderers are pure string transforms over DetectResult so the layout is asserted directly. Sync keeps its own subcommand and every flag it had; it just no longer holds the default slot. Co-authored-by: omnigent --- src/bin/devcat.ts | 16 ++- src/commands/report.ts | 22 ++++ src/ui/report.ts | 212 ++++++++++++++++++++++++++++++++ test/integration/report.test.ts | 119 ++++++++++++++++++ test/unit/ui/report.test.ts | 145 ++++++++++++++++++++++ 5 files changed, 511 insertions(+), 3 deletions(-) create mode 100644 src/commands/report.ts create mode 100644 src/ui/report.ts create mode 100644 test/integration/report.test.ts create mode 100644 test/unit/ui/report.test.ts diff --git a/src/bin/devcat.ts b/src/bin/devcat.ts index 317e52a..1c28a75 100644 --- a/src/bin/devcat.ts +++ b/src/bin/devcat.ts @@ -1,6 +1,7 @@ #!/usr/bin/env node import { Command } from 'commander'; import { CLI_VERSION } from '../version.js'; +import { runReport } from '../commands/report.js'; import { runSync } from '../commands/sync.js'; import { runLogout } from '../commands/logout.js'; import { EXIT_GENERIC_ERROR } from '../lib/exitCodes.js'; @@ -10,7 +11,7 @@ async function main(): Promise { program .name('devcat') .description( - 'DevCat CLI — push your AI tool manifest to devcat.dev (manifest-only sync via RFC 8628 device authorization)', + 'DevCat CLI — see your whole AI-coding stack in one command. Scans this machine for the MCP servers and plugins installed across Claude Code, Codex, and Cursor.', ) .version(CLI_VERSION); @@ -21,8 +22,17 @@ async function main(): Promise { .option('-v, --verbose', 'emit redacted HTTP trace to stderr'); program - .command('sync', { isDefault: true }) - .description('Push your AI tool manifest to devcat.dev') + .command('report', { isDefault: true }) + .description('Scan this machine and print your AI-coding stack (default)') + .option('--markdown', 'emit a shareable "My AI stack" markdown snippet') + .action(async (options: { markdown?: boolean }) => { + const exitCode = await runReport({ markdown: options.markdown === true }); + process.exit(exitCode); + }); + + program + .command('sync') + .description('Push your AI tool manifest to devcat.dev (paused while the site is rebuilt)') .option('--no-open', 'do not auto-open the browser at the verification URL') .option('--json', 'emit machine-readable JSON event stream (for CI)') .option('-v, --verbose', 'emit redacted HTTP trace to stderr') diff --git a/src/commands/report.ts b/src/commands/report.ts new file mode 100644 index 0000000..0ec6270 --- /dev/null +++ b/src/commands/report.ts @@ -0,0 +1,22 @@ +import { detect } from '../manifest/index.js'; +import { renderStackReport, renderStackMarkdown } from '../ui/report.js'; +import { EXIT_OK, type ExitCode } from '../lib/exitCodes.js'; + +export interface ReportOptions { + /** Emit the shareable "My AI stack" markdown snippet instead of the terminal report. */ + markdown: boolean; +} + +/** + * `devcat report` — also the default command when devcat is run with no args. + * + * Local-only: scans this machine's AI tool config files and prints what it + * found. No network, no auth, no credentials touched. An empty result is a + * valid outcome, not an error, so this always exits 0. + */ +export async function runReport(opts: ReportOptions): Promise { + const manifest = await detect(process.cwd()); + const out = opts.markdown ? renderStackMarkdown(manifest) : renderStackReport(manifest); + process.stdout.write(`${out}\n`); + return EXIT_OK; +} diff --git a/src/ui/report.ts b/src/ui/report.ts new file mode 100644 index 0000000..51ef930 --- /dev/null +++ b/src/ui/report.ts @@ -0,0 +1,212 @@ +import type { DetectResult, ToolEntry, ToolClient } from '../manifest/index.js'; +import { c, SUCCESS_GLYPH } from './colors.js'; + +/** + * Local stack report — the default `npx devcat-cli` output. + * + * Pure renderers over a DetectResult. No network, no auth, no side effects: + * everything here is a string transform so the shape can be byte-asserted + * in tests. + * + * Two output modes: + * renderStackReport() — grouped terminal report (colorized when the + * terminal allows it) + * renderStackMarkdown() — a "My AI stack" snippet for a README or gist + * (never colorized — it is meant to be pasted) + */ + +/** Fixed display order so two runs on the same machine print identically. */ +const CLIENT_ORDER: readonly ToolClient[] = ['claude-code', 'codex', 'cursor']; + +const CLIENT_LABEL: Record = { + 'claude-code': 'Claude Code', + codex: 'Codex', + cursor: 'Cursor', +}; + +type ToolType = ToolEntry['type']; + +const TYPE_ORDER: readonly ToolType[] = ['mcp', 'plugin', 'skill']; + +const TYPE_LABEL_MARKDOWN: Record = { + mcp: 'MCP servers', + plugin: 'Plugins', + skill: 'Skills', +}; + +/** Total line width the terminal report wraps to. */ +const WRAP_WIDTH = 78; +/** Column where the comma-separated names start (and continuation lines indent to). */ +const NAME_COLUMN = 14; + +export interface StackTypeGroup { + type: ToolType; + names: string[]; +} + +export interface StackGroup { + client: ToolClient; + label: string; + total: number; + byType: StackTypeGroup[]; +} + +/** + * Group detected tools by client, then by type, with names sorted + * alphabetically. Clients and types with nothing in them are omitted. + */ +export function groupStack(tools: readonly ToolEntry[]): StackGroup[] { + const groups: StackGroup[] = []; + for (const client of CLIENT_ORDER) { + const owned = tools.filter((t) => t.client === client); + if (owned.length === 0) continue; + const byType: StackTypeGroup[] = []; + for (const type of TYPE_ORDER) { + const names = owned + .filter((t) => t.type === type) + .map((t) => t.name) + .sort((a, b) => a.localeCompare(b)); + if (names.length > 0) byType.push({ type, names }); + } + groups.push({ client, label: CLIENT_LABEL[client], total: owned.length, byType }); + } + return groups; +} + +/** + * Grouped terminal report: + * + * ✓ Your AI-coding stack — 21 tools + * + * Claude Code · 16 tools + * 12 mcp alpha, beta, gamma, … + * 4 plugin swift-lsp, vercel + * + * 21 tools in Claude Code, Codex, and Cursor · 8 config files scanned + * 3 project-scoped (this directory), 18 user-wide + */ +export function renderStackReport(result: DetectResult): string { + if (result.tools.length === 0) return renderEmptyStack(result.pathsScanned); + + const groups = groupStack(result.tools); + const total = result.tools.length; + const lines: string[] = []; + + lines.push(`${SUCCESS_GLYPH} ${c.bold(`Your AI-coding stack — ${plural(total, 'tool')}`)}`); + + for (const group of groups) { + lines.push(''); + lines.push(`${c.bold(group.label)} ${c.dim(`· ${plural(group.total, 'tool')}`)}`); + for (const { type, names } of group.byType) { + const prefix = ` ${String(names.length).padStart(3)} ${type.padEnd(8)}`; + const wrapped = wrap(names.join(', '), WRAP_WIDTH - NAME_COLUMN); + lines.push(`${prefix}${wrapped[0]}`); + for (const cont of wrapped.slice(1)) { + lines.push(`${' '.repeat(NAME_COLUMN)}${cont}`); + } + } + } + + const clientLabels = groups.map((g) => g.label); + const files = result.pathsScanned.length; + lines.push(''); + lines.push( + c.dim( + `${plural(total, 'tool')} in ${joinWithAnd(clientLabels)} · ${plural(files, 'config file')} checked`, + ), + ); + + // "project-scoped" is the detector's own term: found by walking up from the + // current directory. Deliberately not phrased as "in this directory" — the + // match can come from any ancestor. + const projectScoped = result.tools.filter((t) => t.scope === 'project').length; + if (projectScoped > 0) { + lines.push(c.dim(`${projectScoped} project-scoped · ${total - projectScoped} user-wide`)); + } + + return lines.join('\n'); +} + +/** + * Shareable snippet for a README or gist. Plain markdown, no ANSI, stable + * ordering so re-running it produces a clean diff rather than a reshuffle. + */ +export function renderStackMarkdown(result: DetectResult): string { + const lines: string[] = ['## My AI stack', '']; + + if (result.tools.length === 0) { + lines.push('No AI tooling detected on this machine yet.'); + lines.push(''); + lines.push(MARKDOWN_FOOTER); + return lines.join('\n'); + } + + const groups = groupStack(result.tools); + const total = result.tools.length; + lines.push(`${plural(total, 'tool')} across ${joinWithAnd(groups.map((g) => g.label))}.`); + + for (const group of groups) { + lines.push(''); + lines.push(`### ${group.label}`); + for (const { type, names } of group.byType) { + const rendered = names.map((n) => `\`${n}\``).join(', '); + lines.push(`- **${TYPE_LABEL_MARKDOWN[type]} (${names.length}):** ${rendered}`); + } + } + + lines.push(''); + lines.push(MARKDOWN_FOOTER); + return lines.join('\n'); +} + +const MARKDOWN_FOOTER = + 'Generated by [devcat-cli](https://www.npmjs.com/package/devcat-cli) — `npx devcat-cli --markdown`'; + +/** + * Nothing found. Print the paths that were checked so the user can spot the + * config location the CLI does not know about yet. + */ +function renderEmptyStack(pathsScanned: string[]): string { + const list = pathsScanned.map((p) => ` - ${p}`).join('\n'); + return [ + `${SUCCESS_GLYPH} ${c.bold('No AI tooling detected.')}`, + '', + 'Looked in:', + list, + '', + c.dim('Add an MCP server to Claude Code, Codex, or Cursor and run this again.'), + ].join('\n'); +} + +/** + * Greedy word wrap on ", " boundaries. Always returns at least one line. + * A wrapped line keeps its trailing comma, so the budget check reserves one + * character for it. A single name longer than `width` still gets its own line + * rather than being cut. + */ +function wrap(text: string, width: number): string[] { + const parts = text.split(', '); + const lines: string[] = []; + let current = ''; + for (const part of parts) { + const candidate = current === '' ? part : `${current}, ${part}`; + if (current !== '' && candidate.length + 1 > width) { + lines.push(`${current},`); + current = part; + } else { + current = candidate; + } + } + lines.push(current); + return lines; +} + +function plural(n: number, noun: string): string { + return `${n} ${noun}${n === 1 ? '' : 's'}`; +} + +function joinWithAnd(items: string[]): string { + if (items.length <= 1) return items[0] ?? ''; + if (items.length === 2) return `${items[0]} and ${items[1]}`; + return `${items.slice(0, -1).join(', ')}, and ${items[items.length - 1]}`; +} diff --git a/test/integration/report.test.ts b/test/integration/report.test.ts new file mode 100644 index 0000000..2a5ba8e --- /dev/null +++ b/test/integration/report.test.ts @@ -0,0 +1,119 @@ +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 cover for the default (no-args) command: scan this machine, + * print the stack. Both scopes are redirected at a fixture tree — homedir() + * for user scope, process.cwd() for project scope — so the assertions do not + * depend on whatever the test runner's real machine happens to have installed. + * + * node:os exports are non-configurable, so vi.mock + a holder is the supported + * way to move homedir() (same pattern as unit/manifest/security.test.ts). + */ +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'; + +let tmpHome: string; +let projectDir: string; + +beforeAll(() => { + tmpHome = mkdtempSync(join(tmpdir(), 'devcat-report-home-')); + projectDir = mkdtempSync(join(tmpdir(), 'devcat-report-proj-')); + + // User scope: two Claude MCP servers + one installed plugin, one Codex MCP. + writeFileSync( + join(tmpHome, '.claude.json'), + JSON.stringify({ mcpServers: { github: { command: 'npx' }, exa: { command: 'npx' } } }), + ); + mkdirSync(join(tmpHome, '.claude', 'plugins'), { recursive: true }); + writeFileSync( + join(tmpHome, '.claude', 'plugins', 'installed_plugins.json'), + JSON.stringify({ version: 2, plugins: { 'swift-lsp@claude-plugins-official': [{}] } }), + ); + mkdirSync(join(tmpHome, '.codex'), { recursive: true }); + writeFileSync(join(tmpHome, '.codex', 'config.toml'), '[mcp_servers.serena]\ncommand = "uv"\n'); + + // Project scope: one Cursor MCP server in the "current" directory. + mkdirSync(join(projectDir, '.cursor'), { recursive: true }); + writeFileSync( + join(projectDir, '.cursor', 'mcp.json'), + JSON.stringify({ mcpServers: { supabase: {} } }), + ); + + homedirHolder.current = tmpHome; +}); + +afterAll(() => { + homedirHolder.current = null; + rmSync(tmpHome, { recursive: true, force: true }); + rmSync(projectDir, { recursive: true, force: true }); +}); + +async function runAndCapture(markdown: boolean): Promise<{ exitCode: number; out: string }> { + const chunks: 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 cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue(projectDir); + try { + const { runReport } = await import('../../src/commands/report.js'); + const exitCode = await runReport({ markdown }); + return { exitCode, out: chunks.join('') }; + } finally { + writeSpy.mockRestore(); + cwdSpy.mockRestore(); + } +} + +describe('report — default command', () => { + it('scans the machine and prints every client it found, grouped', async () => { + const { exitCode, out } = await runAndCapture(false); + + expect(exitCode).toBe(0); + expect(out).toContain('Your AI-coding stack — 5 tools'); + // Claude Code: 2 MCP + 1 plugin + expect(out).toContain('Claude Code'); + expect(out).toMatch(/2 mcp\s+exa, github/); + expect(out).toMatch(/1 plugin\s+swift-lsp/); + // Codex + Cursor each contribute one + expect(out).toMatch(/1 mcp\s+serena/); + expect(out).toMatch(/1 mcp\s+supabase/); + expect(out).toContain('5 tools in Claude Code, Codex, and Cursor'); + // The Cursor entry came from the project directory. + expect(out).toContain('1 project-scoped · 4 user-wide'); + }); + + it('touches no network and no credentials', async () => { + const fetchSpy = vi.spyOn(globalThis, 'fetch'); + try { + const { exitCode } = await runAndCapture(false); + expect(exitCode).toBe(0); + expect(fetchSpy).not.toHaveBeenCalled(); + } finally { + fetchSpy.mockRestore(); + } + }); + + it('--markdown emits the shareable snippet from the same scan', async () => { + const { exitCode, out } = await runAndCapture(true); + + expect(exitCode).toBe(0); + expect(out.startsWith('## My AI stack')).toBe(true); + expect(out).toContain('5 tools across Claude Code, Codex, and Cursor.'); + expect(out).toContain('- **MCP servers (2):** `exa`, `github`'); + expect(out).toContain('- **Plugins (1):** `swift-lsp`'); + expect(out).toContain('### Cursor'); + expect(out.endsWith('\n')).toBe(true); + }); +}); diff --git a/test/unit/ui/report.test.ts b/test/unit/ui/report.test.ts new file mode 100644 index 0000000..cdd4560 --- /dev/null +++ b/test/unit/ui/report.test.ts @@ -0,0 +1,145 @@ +import { describe, it, expect } from 'vitest'; +import { groupStack, renderStackReport, renderStackMarkdown } from '../../../src/ui/report.js'; +import type { DetectResult, ToolEntry } from '../../../src/manifest/index.js'; + +// Vitest fork pools leave process.stdout.isTTY undefined so colors auto-strip; +// NO_COLOR is belt-and-suspenders for any CI shape where isTTY is truthy. +process.env.NO_COLOR = '1'; + +function tool(partial: Partial & Pick): ToolEntry { + return { + type: 'mcp', + source: '/fake/path.json', + scope: 'user', + client: 'claude-code', + ...partial, + }; +} + +const MIXED: DetectResult = { + tools: [ + tool({ name: 'context7' }), + tool({ name: 'atelier-board' }), + tool({ name: 'swift-lsp', type: 'plugin' }), + tool({ name: 'supabase', client: 'cursor', scope: 'project' }), + tool({ name: 'serena', client: 'codex' }), + ], + pathsScanned: ['/p/.mcp.json', '~/.claude.json', '~/.codex/config.toml', '~/.cursor/mcp.json'], +}; + +describe('groupStack', () => { + it('groups by client in fixed order, then by type, with names sorted', () => { + const groups = groupStack(MIXED.tools); + expect(groups.map((g) => g.client)).toEqual(['claude-code', 'codex', 'cursor']); + expect(groups[0]!.label).toBe('Claude Code'); + expect(groups[0]!.total).toBe(3); + expect(groups[0]!.byType).toEqual([ + { type: 'mcp', names: ['atelier-board', 'context7'] }, + { type: 'plugin', names: ['swift-lsp'] }, + ]); + }); + + it('omits clients and types with nothing in them', () => { + const groups = groupStack([tool({ name: 'only-one', client: 'cursor' })]); + expect(groups).toHaveLength(1); + expect(groups[0]!.client).toBe('cursor'); + expect(groups[0]!.byType).toHaveLength(1); + }); + + it('returns no groups for an empty scan', () => { + expect(groupStack([])).toEqual([]); + }); +}); + +describe('renderStackReport (default `npx devcat-cli` output)', () => { + it('prints a header, a section per client, and a totals footer', () => { + const out = renderStackReport(MIXED); + expect(out).toContain('Your AI-coding stack — 5 tools'); + expect(out).toContain('Claude Code'); + expect(out).toContain('Codex'); + expect(out).toContain('Cursor'); + expect(out).toContain('atelier-board, context7'); + expect(out).toContain('swift-lsp'); + expect(out).toContain('5 tools in Claude Code, Codex, and Cursor · 4 config files checked'); + }); + + it('shows a per-type count next to each type label', () => { + const out = renderStackReport(MIXED); + expect(out).toMatch(/2 mcp\s+atelier-board, context7/); + expect(out).toMatch(/1 plugin\s+swift-lsp/); + }); + + it('reports the project-scoped split only when something is project-scoped', () => { + expect(renderStackReport(MIXED)).toContain('1 project-scoped · 4 user-wide'); + + const userOnly: DetectResult = { + tools: [tool({ name: 'a' })], + pathsScanned: ['~/.claude.json'], + }; + expect(renderStackReport(userOnly)).not.toContain('project-scoped'); + }); + + it('wraps long name lists and indents the continuation to the name column', () => { + const many: DetectResult = { + tools: Array.from({ length: 20 }, (_, i) => tool({ name: `mcp-server-number-${i}` })), + pathsScanned: ['~/.claude.json'], + }; + const lines = renderStackReport(many).split('\n'); + const nameLines = lines.filter((l) => l.includes('mcp-server-number-')); + expect(nameLines.length).toBeGreaterThan(1); + for (const line of nameLines) expect(line.length).toBeLessThanOrEqual(78); + // Every wrapped line after the first starts at the name column. + for (const line of nameLines.slice(1)) expect(line.startsWith(' '.repeat(14))).toBe(true); + // Wrapping must not drop or duplicate entries. + expect(nameLines.join(' ').match(/mcp-server-number-/g)).toHaveLength(20); + }); + + it('singularizes a one-tool stack', () => { + const one: DetectResult = { tools: [tool({ name: 'solo' })], pathsScanned: ['~/.claude.json'] }; + const out = renderStackReport(one); + expect(out).toContain('Your AI-coding stack — 1 tool'); + expect(out).toContain('1 tool in Claude Code · 1 config file checked'); + }); + + it('empty scan: names the paths it checked instead of printing an empty stack', () => { + const out = renderStackReport({ tools: [], pathsScanned: ['/p/.mcp.json', '~/.cursor/mcp.json'] }); + expect(out).toContain('No AI tooling detected.'); + expect(out).toContain('/p/.mcp.json'); + expect(out).toContain('~/.cursor/mcp.json'); + expect(out).not.toContain('Your AI-coding stack'); + }); +}); + +describe('renderStackMarkdown (--markdown)', () => { + it('emits a "My AI stack" snippet with a section per client', () => { + const out = renderStackMarkdown(MIXED); + expect(out.startsWith('## My AI stack\n')).toBe(true); + expect(out).toContain('5 tools across Claude Code, Codex, and Cursor.'); + expect(out).toContain('### Claude Code'); + expect(out).toContain('- **MCP servers (2):** `atelier-board`, `context7`'); + expect(out).toContain('- **Plugins (1):** `swift-lsp`'); + expect(out).toContain('### Cursor'); + expect(out).toContain('devcat-cli'); + }); + + it('never emits ANSI escapes — the snippet is meant to be pasted', () => { + // eslint-disable-next-line no-control-regex + expect(renderStackMarkdown(MIXED)).not.toMatch(/\u001b\[/); + }); + + it('does not wrap: each type is a single markdown bullet however long', () => { + const many: DetectResult = { + tools: Array.from({ length: 20 }, (_, i) => tool({ name: `mcp-server-number-${i}` })), + pathsScanned: ['~/.claude.json'], + }; + const bullets = renderStackMarkdown(many).split('\n').filter((l) => l.startsWith('- **')); + expect(bullets).toHaveLength(1); + expect(bullets[0]).toContain('MCP servers (20)'); + }); + + it('empty scan still produces a valid snippet', () => { + const out = renderStackMarkdown({ tools: [], pathsScanned: ['~/.claude.json'] }); + expect(out).toContain('## My AI stack'); + expect(out).toContain('No AI tooling detected on this machine yet.'); + }); +}); From d8f68b2d3014532e9c32854a23ec58b4fafa2c44 Mon Sep 17 00:00:00 2001 From: AnobleSCM <211227905+AnobleSCM@users.noreply.github.com> Date: Sun, 2 Aug 2026 15:15:12 -0700 Subject: [PATCH 03/27] feat(sync): stop on one line while devcat.dev is down MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit devcat.dev is being rebuilt, so sync cannot complete. Rather than open a browser, run a device flow, and fail deep inside an HTTP call, runSync now returns immediately with a single human-readable line naming the working alternative. No stack trace, no retries, exit 1 (and a sync.error event under --json). Nothing is deleted: the full device-flow and sync path below is untouched and still compiles. DEVCAT_SYNC_ENABLED=1 runs it — which is how the five existing sync integration suites keep covering the live path, and how a self-hosted DEVCAT_API_URL still works. Removing the gate restores sync. Co-authored-by: omnigent --- src/commands/sync.ts | 22 +++++++ test/integration/sync.401-refresh.test.ts | 3 + test/integration/sync.empty-manifest.test.ts | 3 + .../sync.idempotency-replay.test.ts | 3 + test/integration/sync.json-events.test.ts | 3 + test/integration/sync.paused.test.ts | 59 +++++++++++++++++++ test/integration/sync.success.test.ts | 3 + 7 files changed, 96 insertions(+) create mode 100644 test/integration/sync.paused.test.ts diff --git a/src/commands/sync.ts b/src/commands/sync.ts index 8072cb0..a782875 100644 --- a/src/commands/sync.ts +++ b/src/commands/sync.ts @@ -46,6 +46,23 @@ export interface SyncOptions { noOpen: boolean; } +/** + * devcat.dev is being rebuilt, so the hosted sync endpoint is not answering. + * Sync stops on the one line below instead of running the device flow and + * failing deep inside an HTTP call — no browser, no polling, no retries. + * + * The whole sync path underneath is untouched and still compiles. Set + * DEVCAT_SYNC_ENABLED=1 to run it anyway (integration tests do this, as does + * anyone pointing DEVCAT_API_URL at their own host). Delete this gate when + * devcat.dev is back. + */ +const SYNC_PAUSED_MESSAGE = + 'Profile sync is paused while devcat.dev is rebuilt. Your local stack report still works — run `npx devcat-cli`.'; + +function isSyncPaused(): boolean { + return process.env.DEVCAT_SYNC_ENABLED !== '1'; +} + /** * Top-level `devcat sync` orchestrator. * @@ -60,6 +77,11 @@ export interface SyncOptions { * 5. Render success summary (Phase 40 D-19). */ export async function runSync(opts: SyncOptions): Promise { + if (isSyncPaused()) { + writeErrorOutput({ message: SYNC_PAUSED_MESSAGE, exitCode: EXIT_GENERIC_ERROR }); + return EXIT_GENERIC_ERROR; + } + // 1. Detect manifest const manifest = await detect(process.cwd()); if (manifest.tools.length === 0) { diff --git a/test/integration/sync.401-refresh.test.ts b/test/integration/sync.401-refresh.test.ts index 17fdae3..9b12a27 100644 --- a/test/integration/sync.401-refresh.test.ts +++ b/test/integration/sync.401-refresh.test.ts @@ -31,6 +31,9 @@ vi.mock('@napi-rs/keyring', () => { vi.mock('open', () => ({ default: vi.fn().mockResolvedValue(undefined) })); process.env.NO_COLOR = '1'; +// This suite exercises the live sync path against msw, so it opts past the +// devcat.dev-is-down pause gate in commands/sync.ts. +process.env.DEVCAT_SYNC_ENABLED = '1'; beforeAll(() => server.listen({ onUnhandledRequest: 'error' })); afterEach(() => { diff --git a/test/integration/sync.empty-manifest.test.ts b/test/integration/sync.empty-manifest.test.ts index 801083d..61b21f6 100644 --- a/test/integration/sync.empty-manifest.test.ts +++ b/test/integration/sync.empty-manifest.test.ts @@ -43,6 +43,9 @@ vi.mock('node:os', async (importOriginal) => { }); process.env.NO_COLOR = '1'; +// This suite exercises the live sync path against msw, so it opts past the +// devcat.dev-is-down pause gate in commands/sync.ts. +process.env.DEVCAT_SYNC_ENABLED = '1'; beforeAll(() => server.listen({ onUnhandledRequest: 'error' })); afterEach(() => { diff --git a/test/integration/sync.idempotency-replay.test.ts b/test/integration/sync.idempotency-replay.test.ts index f22692f..186997f 100644 --- a/test/integration/sync.idempotency-replay.test.ts +++ b/test/integration/sync.idempotency-replay.test.ts @@ -29,6 +29,9 @@ vi.mock('@napi-rs/keyring', () => { }); process.env.NO_COLOR = '1'; +// This suite exercises the live sync path against msw, so it opts past the +// devcat.dev-is-down pause gate in commands/sync.ts. +process.env.DEVCAT_SYNC_ENABLED = '1'; beforeAll(() => server.listen({ onUnhandledRequest: 'error' })); afterEach(() => { diff --git a/test/integration/sync.json-events.test.ts b/test/integration/sync.json-events.test.ts index 02288b5..786ceb6 100644 --- a/test/integration/sync.json-events.test.ts +++ b/test/integration/sync.json-events.test.ts @@ -46,6 +46,9 @@ const FIXTURES_CWD = join(__dirname, '..', 'fixtures', 'claude'); const originalArgv = process.argv; process.env.NO_COLOR = '1'; +// This suite exercises the live sync path against msw, so it opts past the +// devcat.dev-is-down pause gate in commands/sync.ts. +process.env.DEVCAT_SYNC_ENABLED = '1'; beforeAll(() => server.listen({ onUnhandledRequest: 'error' })); beforeEach(async () => { diff --git a/test/integration/sync.paused.test.ts b/test/integration/sync.paused.test.ts new file mode 100644 index 0000000..c0b0c31 --- /dev/null +++ b/test/integration/sync.paused.test.ts @@ -0,0 +1,59 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +/** + * devcat.dev is being rebuilt, so `devcat sync` stops on one line instead of + * starting a device flow that cannot finish. This suite deliberately does NOT + * set DEVCAT_SYNC_ENABLED — it covers the paused path the other sync + * integration suites opt out of. + */ +process.env.NO_COLOR = '1'; + +const originalFlag = process.env.DEVCAT_SYNC_ENABLED; + +beforeEach(() => { + delete process.env.DEVCAT_SYNC_ENABLED; +}); + +afterEach(() => { + if (originalFlag === undefined) delete process.env.DEVCAT_SYNC_ENABLED; + else process.env.DEVCAT_SYNC_ENABLED = originalFlag; +}); + +describe('sync — paused while devcat.dev is down', () => { + it('prints exactly one line, opens no browser, makes no request, exits 1', async () => { + const stderr: string[] = []; + const stdout: string[] = []; + const errSpy = vi + .spyOn(process.stderr, 'write') + .mockImplementation((chunk: string | Uint8Array): boolean => { + stderr.push(typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString()); + return true; + }); + const outSpy = vi + .spyOn(process.stdout, 'write') + .mockImplementation((chunk: string | Uint8Array): boolean => { + stdout.push(typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString()); + return true; + }); + const fetchSpy = vi.spyOn(globalThis, 'fetch'); + + const { runSync } = await import('../../src/commands/sync.js'); + const exitCode = await runSync({ noOpen: false }); + + errSpy.mockRestore(); + outSpy.mockRestore(); + fetchSpy.mockRestore(); + + expect(exitCode).toBe(1); + expect(fetchSpy).not.toHaveBeenCalled(); + expect(stdout.join('')).toBe(''); + + const err = stderr.join(''); + expect(err.trimEnd().split('\n')).toHaveLength(1); + expect(err).toContain('Profile sync is paused while devcat.dev is rebuilt.'); + expect(err).toContain('npx devcat-cli'); + // No stack trace, no retry noise. + expect(err).not.toContain('at '); + expect(err).not.toContain('Error:'); + }); +}); diff --git a/test/integration/sync.success.test.ts b/test/integration/sync.success.test.ts index 224b405..0384315 100644 --- a/test/integration/sync.success.test.ts +++ b/test/integration/sync.success.test.ts @@ -34,6 +34,9 @@ vi.mock('@napi-rs/keyring', () => { vi.mock('open', () => ({ default: vi.fn().mockResolvedValue(undefined) })); process.env.NO_COLOR = '1'; +// This suite exercises the live sync path against msw, so it opts past the +// devcat.dev-is-down pause gate in commands/sync.ts. +process.env.DEVCAT_SYNC_ENABLED = '1'; beforeAll(() => server.listen({ onUnhandledRequest: 'error' })); afterEach(() => { From 03ba2a118aa22ab3b024545a9071ba97af2caa7a Mon Sep 17 00:00:00 2001 From: AnobleSCM <211227905+AnobleSCM@users.noreply.github.com> Date: Sun, 2 Aug 2026 15:15:20 -0700 Subject: [PATCH 04/27] docs: re-aim positioning on the standalone stack report Rewrites the README around what the tool does without a backend: see your whole AI-coding stack in one command, with a one-line npx quickstart. The sample outputs are copied from real runs against a fixture home, so they match the code byte for byte. Says plainly that profile sync to devcat.dev is paused while the site is rebuilt and that it is coming back, rather than leading with a command that cannot currently work. Also documents two behaviours that were undocumented and surprise people: the report changes with the directory you run it in, and a tool configured twice is listed once. npm description and keywords follow the same positioning. Co-authored-by: omnigent --- README.md | 169 ++++++++++++++++++++++----------------------------- package.json | 6 +- 2 files changed, 78 insertions(+), 97 deletions(-) diff --git a/README.md b/README.md index 1c1a085..b0fb658 100644 --- a/README.md +++ b/README.md @@ -1,150 +1,127 @@ # DevCat CLI -`npx devcat-cli sync` — push your AI tool manifest to [devcat.dev](https://devcat.dev). +See your whole AI-coding stack in one command. -Manifest-only sync via RFC 8628 device authorization. Tokens stored in your OS keychain. Never sends env vars, configs, secrets, or file contents. +`npx devcat-cli` scans this machine for the MCP servers and plugins you have installed across Claude Code, Codex, and Cursor, and prints them grouped in one report. No account, no sign-in, no network call. -## Install +## Quickstart ```bash -# Zero-install via npx (recommended) -npx devcat-cli sync +npx devcat-cli +``` + +That's the whole thing. Output: -# Or install globally -npm install -g devcat-cli -devcat sync ``` +✓ Your AI-coding stack — 14 tools -Requires Node.js 20 or later. Works on macOS, Linux, and Windows. +Claude Code · 9 tools + 6 mcp context7, github, linear, playwright, sentry, supabase + 3 plugin pr-review-toolkit, superpowers, typescript-lsp -## Quickstart +Codex · 3 tools + 3 mcp exa, node-repl, serena -From any directory: +Cursor · 2 tools + 2 mcp figma, postgres -```bash -npx devcat-cli sync +14 tools in Claude Code, Codex, and Cursor · 8 config files checked +2 project-scoped · 12 user-wide ``` -On the first run you'll see something like: +Requires Node.js 20 or later. Works on macOS, Linux, and Windows. -``` -Sign in to DevCat +## Share it - Visit: https://devcat.dev/device - Enter code: ABCD-EFGH +`--markdown` prints the same scan as a snippet you can paste into a README, a gist, or an issue comment: -Code expires in 10 minutes. +```bash +npx devcat-cli --markdown ``` -The CLI opens your browser automatically. Sign in, paste the code, click Approve. The CLI continues automatically and prints a sync summary: +```markdown +## My AI stack -``` -✓ Pushed 10 tools to devcat.dev +14 tools across Claude Code, Codex, and Cursor. - 7 matched ready in My Tools - 2 matched (fuzzy) review at https://devcat.dev/my-tools - 1 unmatched linear-mcp-custom (no catalog match — still synced) +### Claude Code +- **MCP servers (6):** `context7`, `github`, `linear`, `playwright`, `sentry`, `supabase` +- **Plugins (3):** `pr-review-toolkit`, `superpowers`, `typescript-lsp` -Sync complete. +### Codex +- **MCP servers (3):** `exa`, `node-repl`, `serena` + +### Cursor +- **MCP servers (2):** `figma`, `postgres` + +Generated by [devcat-cli](https://www.npmjs.com/package/devcat-cli) — `npx devcat-cli --markdown` ``` ## Commands | Command | What it does | |---|---| -| `devcat sync` | Push your tool manifest. Auto-triggers sign-in on first run. | +| `devcat` | Scan and print your stack. Same as `devcat report`. | +| `devcat report --markdown` | Print the shareable "My AI stack" snippet instead. | +| `devcat sync` | Push your manifest to devcat.dev. **Paused** — see below. | | `devcat logout` | Clear local DevCat credentials. | -| `devcat --version` | Print the CLI version. | -| `devcat --help` | Print top-level help. `devcat sync --help` shows sync-specific flags. | - -### `devcat sync` flags - -| Flag | Default | What it does | -|---|---|---| -| `--no-open` | off | Do not auto-open the browser at the verification URL. Useful for headless SSH or when you prefer manual paste. | -| `--json` | off | Emit a newline-delimited JSON event stream for CI inspection. | -| `--verbose`, `-v` | off | Emit a redacted HTTP trace to stderr. Authorization headers, tokens, and secrets are stripped. | - -## How it works - -DevCat scans your local AI tool config files for tool **names and types only**: - -- **Claude Code**: `.mcp.json` (project), `~/.claude.json` (user MCP servers), `~/.claude/settings.json`, `~/.claude/plugins/installed_plugins.json` -- **Codex CLI**: `~/.codex/config.toml`, `.codex/config.toml` (project) -- **Cursor**: `~/.cursor/mcp.json`, `.cursor/mcp.json` (project) - -The CLI extracts **only** the `(type, name)` tuples — it never reads or transmits: - -- environment variable values -- command-line arguments -- file paths -- file contents -- any field other than the tool name and type - -The full payload sent to `/api/sync` is a JSON object with two fields: `manifest_hash` (SHA-256 hex of the canonicalized tool list) and `tools` (array of `{ type, name }`). That's it. +| `devcat --version` / `--help` | Version, help. | -Project-local manifests take precedence on dedup — if `context7` appears in both `.mcp.json` and `~/.claude.json`, the project-local copy wins. +## What it reads -## Authentication +Config files only, and only the tool **names and types** inside them: -DevCat uses [RFC 8628 OAuth 2.0 Device Authorization Grant](https://datatracker.ietf.org/doc/html/rfc8628). Same flow as `gh auth login`, `vercel login`, `stripe login`. +- **Claude Code** — `.mcp.json` (project), `~/.claude.json`, `~/.claude/settings.json`, `~/.claude/plugins/installed_plugins.json` +- **Codex CLI** — `.codex/config.toml` (project), `~/.codex/config.toml` +- **Cursor** — `.cursor/mcp.json` (project), `~/.cursor/mcp.json` -Access tokens are valid for 1 hour and refresh tokens for 24 hours. After 24 hours of inactivity you'll be prompted to sign in again. Tokens are stored in your OS keychain via [`@napi-rs/keyring`](https://www.npmjs.com/package/@napi-rs/keyring) — never on disk in plaintext. +It never reads or reports environment variable values, command-line arguments, file paths, file contents, or any field other than the tool name and type. Missing and malformed config files are skipped silently — a broken `.mcp.json` never fails the scan. -To see active CLI sessions or revoke a session, sign in to [devcat.dev](https://devcat.dev) and open the user menu (top-right) → "Active devices". Revoking a session takes effect on the next sync attempt. +Project-scoped entries are found by walking up from the current directory, so the report changes depending on where you run it. A tool configured in more than one place is listed once, under the first place it was found (project configs before user configs). -## Troubleshooting - -### `OS keychain unavailable` on Linux - -On Linux servers without a graphical environment, the system keychain may not be running. Install `libsecret`: +Install it globally if you run it often: ```bash -# Debian / Ubuntu -sudo apt install libsecret-1-0 - -# Arch / Manjaro -sudo pacman -S libsecret - -# Fedora / RHEL -sudo dnf install libsecret +npm install -g devcat-cli +devcat ``` -For headless CI/CD pipelines, set the `DEVCAT_TOKEN` environment variable instead. The CLI reads this directly and skips the keychain. +## Profile sync -### Headless SSH / WSL +`devcat sync` pushes your manifest to a devcat.dev profile. **That site is being rebuilt, so sync is paused** — it stops immediately with one line rather than starting a sign-in it can't finish: -If running over SSH without `$DISPLAY` or in WSL without a browser, the CLI detects this and skips browser auto-open. Copy the verification URL manually. +``` +✗ Profile sync is paused while devcat.dev is rebuilt. Your local stack report still works — run `npx devcat-cli`. +``` -On WSL, if polling repeatedly fails with throttle responses, run `wsl --update` and try again — WSL's monotonic clock can drift relative to wall-clock time. The CLI mirrors gh CLI's safety multiplier, which handles most cases but not extreme drift. +Nothing else is affected: the scan, `--markdown`, and `logout` all work offline as normal. Sync returns with the site. -### `Sync failed` errors +The sync path itself is intact — [RFC 8628 device authorization](https://datatracker.ietf.org/doc/html/rfc8628), tokens in your OS keychain via [`@napi-rs/keyring`](https://www.npmjs.com/package/@napi-rs/keyring), no plaintext token on disk. If you run your own instance, point `DEVCAT_API_URL` at it and set `DEVCAT_SYNC_ENABLED=1` to run the full flow. -The error message includes the exact next step. Most common: +## Environment variables -- `Rate-limited. Try \`npx devcat-cli sync\` again in a moment.` — wait 60 seconds. -- `Approval timed out after 10 minutes.` — run `npx devcat-cli sync` again to mint a new code. -- `Approval canceled.` — you clicked Cancel at /device. Run `npx devcat-cli sync` again if you change your mind. -- `Session expired. Let's get you signed in again.` — refresh token is older than 24 hours. The CLI auto-runs the device flow inline; just type when prompted. +| Variable | Default | Purpose | +|---|---|---| +| `NO_COLOR` | unset | Disable color output ([no-color.org](https://no-color.org/) standard). | +| `DEVCAT_SYNC_ENABLED` | unset | Set to `1` to run `devcat sync` against a live API instead of stopping at the paused message. | +| `DEVCAT_API_URL` | `https://devcat.dev` | Override the API base URL (staging / self-hosted). HTTPS required except `http://localhost:*`. | +| `DEVCAT_TOKEN` | unset | CI escape hatch — bypass keychain and use this access token directly. | +| `DEVCAT_DEBUG` | unset | Verbose logging without the `--verbose` flag. | ## Security -DevCat takes manifest-only sync seriously: - -- **No env vars, no command args, no paths, no file contents leave your machine.** Source code, including a unit test, proves this for every release. -- **Tokens stored in OS keychain only.** No plaintext fallback. If the keychain is unavailable, the CLI errors clearly with install instructions. -- **Verification URL host is validated.** The CLI refuses to open URLs that don't match `devcat.dev` (or your `DEVCAT_API_URL` override) — defends against compromised servers redirecting users to phishing pages. -- **Bearer tokens redacted in `--verbose` output.** Authorization headers, `*_token`, `*_secret`, `device_code`, `user_code`, and `password` body fields all stripped from any HTTP trace. +- **Nothing leaves your machine on the default command.** The scan is local; `npx devcat-cli` makes no network request at all. +- **No env vars, no command args, no paths, no file contents** are read out of your configs — only tool names and types. A unit test proves this for every release. +- **Tokens live in the OS keychain**, no plaintext fallback, and bearer tokens are redacted from `--verbose` output. - **Source is public** — read every line at [github.com/AnobleSCM/devcat-cli](https://github.com/AnobleSCM/devcat-cli). -## Environment variables +## Troubleshooting -| Variable | Default | Purpose | -|---|---|---| -| `DEVCAT_API_URL` | `https://devcat.dev` | Override the API base URL (staging / self-hosted). HTTPS required except `http://localhost:*`. | -| `DEVCAT_TOKEN` | unset | CI escape hatch — bypass keychain and use this access token directly. Token expires when CLI exits. | -| `DEVCAT_DEBUG` | unset | Enable verbose logging without `--verbose` flag. | -| `NO_COLOR` | unset | Disable color output ([no-color.org](https://no-color.org/) standard). | +**The report is empty.** It lists every path it checked — if your config lives somewhere else, 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.** Only MCP servers and Claude Code plugins are detected today. Skills and subagents are not scanned yet. + +**`OS keychain unavailable` on Linux.** Only affects `sync`. Install libsecret: `sudo apt install libsecret-1-0` (Debian/Ubuntu), `sudo pacman -S libsecret` (Arch), or `sudo dnf install libsecret` (Fedora). ## License diff --git a/package.json b/package.json index f2a50ac..8bb44ec 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "devcat-cli", "version": "0.1.1", - "description": "DevCat CLI — npx devcat-cli sync. Pushes your AI tool manifest to devcat.dev. Manifest-only sync via RFC 8628 device authorization with OS keychain credentials.", + "description": "See your whole AI-coding stack in one command. npx devcat-cli scans this machine for the MCP servers and plugins installed across Claude Code, Codex, and Cursor and prints them grouped — locally, with no account and no network call.", "license": "MIT", "author": "Andrew Noble (https://github.com/AnobleSCM)", "repository": { @@ -55,7 +55,11 @@ "cli", "devcat", "claude-code", + "codex", + "cursor", "mcp", + "mcp-servers", + "developer-tools", "device-flow", "rfc-8628" ] From 0bcca9e386fc053ea03fba237bce71dd21348910 Mon Sep 17 00:00:00 2001 From: AnobleSCM <211227905+AnobleSCM@users.noreply.github.com> Date: Sun, 2 Aug 2026 15:38:47 -0700 Subject: [PATCH 05/27] feat(manifest): detect installed skills and subagents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The report listed MCP servers and plugins but not the two things people actually accumulate most: Claude Code skills and subagent personas. Both are folders rather than config keys, so they need a directory scan. dirScan.ts keeps that scan narrow. It reads a known config root and its immediate children and never recurses, so it cannot wander out of the root by following a link. Symlinks are resolved to one canonical path — the ~/.claude/skills link farm is entirely symlinks — and that resolved path dedupes aliases pointing at the same skill. Broken links, unreadable directories, and entries that vanish mid-scan are skipped, and entry count per root is capped. Names come from the folder; no file is opened. Both Claude Code subagent shapes are handled: .md and /.md. These are report-only. syncableTools() narrows detections to the mcp and plugin types the catalog matches, so the /api/sync payload is unchanged — and because the narrowed type is what postSync accepts, handing it an unfiltered list is now a compile error rather than a runtime surprise. Also fixes a mislabel this exposed: $HOME is an ancestor of most working directories, so the upward project walk was "finding" ~/.claude/skills, ~/.codex/config.toml and ~/.cursor/mcp.json and reporting the entire user shelf as project-scoped. Those paths already have a user-scope reader, so the project pass now skips them — nothing is detected less, it is just attributed correctly. ~/.mcp.json has no user-scope reader and is left as it was. Co-authored-by: omnigent --- src/commands/sync.ts | 18 +-- src/lib/findUpward.ts | 41 ++++++ src/manifest/claude.ts | 76 +++++++++-- src/manifest/dirScan.ts | 151 ++++++++++++++++++++++ src/manifest/index.ts | 18 ++- src/ui/report.ts | 42 ++++++- test/unit/manifest/claude.skills.test.ts | 119 ++++++++++++++++++ test/unit/manifest/claude.test.ts | 9 +- test/unit/manifest/dirScan.test.ts | 153 +++++++++++++++++++++++ test/unit/manifest/security.test.ts | 42 ++++++- 10 files changed, 646 insertions(+), 23 deletions(-) create mode 100644 src/manifest/dirScan.ts create mode 100644 test/unit/manifest/claude.skills.test.ts create mode 100644 test/unit/manifest/dirScan.test.ts diff --git a/src/commands/sync.ts b/src/commands/sync.ts index a782875..75e6c60 100644 --- a/src/commands/sync.ts +++ b/src/commands/sync.ts @@ -1,4 +1,4 @@ -import { detect, type DetectResult } from '../manifest/index.js'; +import { detect, syncableTools, type SyncableToolEntry } from '../manifest/index.js'; import { loadToken, saveToken, @@ -82,9 +82,11 @@ export async function runSync(opts: SyncOptions): Promise { return EXIT_GENERIC_ERROR; } - // 1. Detect manifest + // 1. Detect manifest. Skills and subagents are report-only detections, so + // only the syncable subset is ever considered here or sent. const manifest = await detect(process.cwd()); - if (manifest.tools.length === 0) { + const tools = syncableTools(manifest.tools); + if (tools.length === 0) { if (isJsonMode()) { emitEvent({ type: 'sync.start', tool_count: 0 }); emitEvent({ @@ -98,7 +100,7 @@ export async function runSync(opts: SyncOptions): Promise { return EXIT_OK; } const idempotencyKey = createSyncIdempotencyKey(); - emitEvent({ type: 'sync.start', tool_count: manifest.tools.length, idempotency_key: idempotencyKey }); + emitEvent({ type: 'sync.start', tool_count: tools.length, idempotency_key: idempotencyKey }); // 2. Ensure token let tokens: TokenPair; @@ -117,7 +119,7 @@ export async function runSync(opts: SyncOptions): Promise { try { const body = await postSync({ accessToken: tokens.access_token, - tools: manifest.tools, + tools, idempotencyKey, emitStartEvent: false, }); @@ -130,7 +132,7 @@ export async function runSync(opts: SyncOptions): Promise { } catch (err) { if (err instanceof TokenInvalidError) { // D-16 recovery - return await recoverFromTokenInvalid(tokens, manifest, opts, idempotencyKey); + return await recoverFromTokenInvalid(tokens, tools, opts, idempotencyKey); } return handleSyncError(err); } @@ -196,7 +198,7 @@ async function runDeviceFlowInline(opts: SyncOptions): Promise { async function recoverFromTokenInvalid( tokens: TokenPair, - manifest: DetectResult, + tools: SyncableToolEntry[], opts: SyncOptions, idempotencyKey: string, ): Promise { @@ -246,7 +248,7 @@ async function recoverFromTokenInvalid( try { const body = await postSync({ accessToken: nextAccessToken, - tools: manifest.tools, + tools, idempotencyKey, emitStartEvent: false, }); diff --git a/src/lib/findUpward.ts b/src/lib/findUpward.ts index 851b0dc..3afdde9 100644 --- a/src/lib/findUpward.ts +++ b/src/lib/findUpward.ts @@ -1,4 +1,5 @@ import { stat } from 'node:fs/promises'; +import { homedir } from 'node:os'; import { dirname, join, parse } from 'node:path'; /** @@ -29,3 +30,43 @@ export async function findUpward(start: string, ...relativePathSegments: string[ } return null; } + +/** + * True when an upward walk landed on the user-level config path itself. + * + * $HOME is an ancestor of most working directories, so a walk started in + * ~/Developer/some-repo happily "finds" ~/.codex/config.toml and would label + * it project-scoped. Callers whose user-scope pass already reads that exact + * path use this to skip the hit rather than mislabel it — nothing is lost, + * the same tools are still detected, with the right scope. + * + * Only for paths that HAVE a user-scope reader. `.mcp.json` has none, so + * ~/.mcp.json is still legitimately picked up by the project walk. + */ +export function isUserLevelPath(found: string, ...relativePathSegments: string[]): boolean { + return found === join(homedir(), ...relativePathSegments); +} + +/** + * Directory-matching twin of findUpward, for config that is a folder rather + * than a file (`.claude/skills/`, `.claude/agents/`). Same bounds and same + * stop-at-root behaviour; kept separate so findUpward's callers are untouched. + */ +export async function findUpwardDir(start: string, ...relativePathSegments: string[]): Promise { + const root = parse(start).root; + let current = start; + for (let i = 0; i < 64; i++) { + const candidate = join(current, ...relativePathSegments); + try { + const s = await stat(candidate); + if (s.isDirectory()) return candidate; + } catch { + // not found at this level; keep walking upward + } + if (current === root) return null; + const parent = dirname(current); + if (parent === current) return null; + current = parent; + } + return null; +} diff --git a/src/manifest/claude.ts b/src/manifest/claude.ts index 232dde1..eb51338 100644 --- a/src/manifest/claude.ts +++ b/src/manifest/claude.ts @@ -1,7 +1,8 @@ import { readFile } from 'node:fs/promises'; import { homedir } from 'node:os'; import { join } from 'node:path'; -import { findUpward } from '../lib/findUpward.js'; +import { findUpward, findUpwardDir, isUserLevelPath } from '../lib/findUpward.js'; +import { scanSkills, scanSubagents } from './dirScan.js'; import type { ToolEntry } from './index.js'; interface McpServersFile { @@ -40,32 +41,93 @@ export async function detectClaudeCode(opts: { cwd?: string; scope: 'project' | } async function detectClaudeProjectScope(cwd: string): Promise { - const path = await findUpward(cwd, '.mcp.json'); - if (!path) return { tools: [], pathsScanned: [join(cwd, '.mcp.json')] }; - return readMcpServersJson(path, 'project'); + const [mcpPath, skillsHit, agentsHit] = await Promise.all([ + findUpward(cwd, '.mcp.json'), + findUpwardDir(cwd, '.claude', 'skills'), + findUpwardDir(cwd, '.claude', 'agents'), + ]); + + // The user-scope pass reads ~/.claude/skills and ~/.claude/agents directly, + // so a walk that climbed all the way to $HOME is dropped here — otherwise + // the whole user shelf would be reported as project-scoped. + const skillsDir = skillsHit && !isUserLevelPath(skillsHit, '.claude', 'skills') ? skillsHit : null; + const agentsDir = agentsHit && !isUserLevelPath(agentsHit, '.claude', 'agents') ? agentsHit : null; + + const [mcp, skills, subagents] = await Promise.all([ + mcpPath + ? readMcpServersJson(mcpPath, 'project') + : Promise.resolve({ tools: [], pathsScanned: [join(cwd, '.mcp.json')] }), + readSkillsDir(skillsDir ?? join(cwd, '.claude', 'skills'), 'project'), + readSubagentsDir(agentsDir ?? join(cwd, '.claude', 'agents'), 'project'), + ]); + + return mergeScans([mcp, skills, subagents]); } async function detectClaudeUserScope(): Promise { const claudeJsonPath = join(homedir(), '.claude.json'); const settingsPath = join(homedir(), '.claude', 'settings.json'); const pluginsPath = join(homedir(), '.claude', 'plugins', 'installed_plugins.json'); + const skillsPath = join(homedir(), '.claude', 'skills'); + const agentsPath = join(homedir(), '.claude', 'agents'); - const [claudeJson, settings, plugins] = await Promise.all([ + const [claudeJson, settings, plugins, skills, subagents] = await Promise.all([ readMcpServersJson(claudeJsonPath, 'user'), readMcpServersJson(settingsPath, 'user'), readInstalledPluginsJson(pluginsPath), + readSkillsDir(skillsPath, 'user'), + readSubagentsDir(agentsPath, 'user'), ]); // ~/.claude.json wins over ~/.claude/settings.json on name collision (Q4). const seen = new Set(claudeJson.tools.map((t) => t.name)); const settingsFiltered = settings.tools.filter((t) => !seen.has(t.name)); + return mergeScans([ + { tools: claudeJson.tools, pathsScanned: claudeJson.pathsScanned }, + { tools: settingsFiltered, pathsScanned: settings.pathsScanned }, + plugins, + skills, + subagents, + ]); +} + +function mergeScans(scans: SourceScan[]): SourceScan { return { - tools: [...claudeJson.tools, ...settingsFiltered, ...plugins.tools], - pathsScanned: [...claudeJson.pathsScanned, ...settings.pathsScanned, ...plugins.pathsScanned], + tools: scans.flatMap((s) => s.tools), + pathsScanned: scans.flatMap((s) => s.pathsScanned), }; } +/** + * Installed skills under a `skills/` root. These are report-only detections — + * they never enter the /api/sync payload (see syncableTools in index.ts). + */ +async function readSkillsDir(path: string, scope: 'project' | 'user'): Promise { + const hits = await scanSkills(path); + const tools: ToolEntry[] = hits.map((hit) => ({ + type: 'skill' as const, + name: hit.name, + source: path, + scope, + client: 'claude-code' as const, + })); + return { tools, pathsScanned: [path] }; +} + +/** Subagent personas under an `agents/` root. Report-only, as above. */ +async function readSubagentsDir(path: string, scope: 'project' | 'user'): Promise { + const hits = await scanSubagents(path); + const tools: ToolEntry[] = hits.map((hit) => ({ + type: 'subagent' as const, + name: hit.name, + source: path, + scope, + client: 'claude-code' as const, + })); + return { tools, pathsScanned: [path] }; +} + async function readMcpServersJson(path: string, scope: 'project' | 'user'): Promise { let raw: string; try { diff --git a/src/manifest/dirScan.ts b/src/manifest/dirScan.ts new file mode 100644 index 0000000..3d81884 --- /dev/null +++ b/src/manifest/dirScan.ts @@ -0,0 +1,151 @@ +import { readdir, realpath, stat } from 'node:fs/promises'; +import { join } from 'node:path'; + +/** + * Directory-shaped detections: skills and subagents are folders on disk, not + * keys in a config file, so they need a bounded directory scan rather than a + * JSON/TOML parse. + * + * Every scan here is deliberately shallow and deliberately incurious: + * + * - It only ever reads a root it was handed (a known config location) and + * that root's immediate children. It never recurses, so it cannot wander + * out of the config root by following a symlink into a large tree. + * - Symlinks are resolved with realpath() to a single canonical path — the + * `~/.claude/skills` link farm is entirely symlinks — and the resolved + * path is what deduplicates aliases pointing at the same skill. + * - A broken symlink, an unreadable directory, or a vanished entry is + * skipped. A machine with a stale link never fails a scan. + * - Entry count per root is capped so a pathological directory cannot make + * the CLI hang. + * + * Names come from the directory (or file) name, which is how skills and + * subagents are actually addressed — no file contents are read. + */ + +/** Defensive bound, in the spirit of findUpward's 64-level cap. */ +const MAX_ENTRIES_PER_ROOT = 500; + +export interface DirHit { + name: string; + /** Symlink-resolved absolute path. Dedupes link-farm aliases. */ + realPath: string; +} + +/** + * Skills: an immediate child directory of `root` containing SKILL.md. + * + * Depth 1. Non-skill clutter that shares the directory (`.git`, `AGENTS.md`, + * a README) is ignored by the SKILL.md requirement. + */ +export async function scanSkills(root: string): Promise { + return scanRoot(root, async (entryPath, name) => { + const resolved = await resolveDir(entryPath); + if (!resolved) return null; + if (!(await isFile(join(resolved, 'SKILL.md')))) return null; + return { name, realPath: resolved }; + }); +} + +/** + * Subagents: both shapes Claude Code accepts — + * /.md (a bare persona file) + * //.md (a persona folder) + * + * Depth 2 at the most, and the second level is read only far enough to + * confirm the folder holds at least one .md. + */ +export async function scanSubagents(root: string): Promise { + return scanRoot(root, async (entryPath, name) => { + if (name.toLowerCase().endsWith('.md')) { + if (!(await isFile(entryPath))) return null; + const resolved = await realpathOrNull(entryPath); + if (!resolved) return null; + return { name: name.slice(0, -'.md'.length), realPath: resolved }; + } + const resolved = await resolveDir(entryPath); + if (!resolved) return null; + if (!(await containsMarkdown(resolved))) return null; + return { name, realPath: resolved }; + }); +} + +/** + * Shared shape: list a root's immediate children, classify each with + * `classify`, drop the misses, and dedupe on resolved path. + */ +async function scanRoot( + root: string, + classify: (entryPath: string, name: string) => Promise, +): Promise { + let names: string[]; + try { + names = await readdir(root); + } catch { + // Missing root, or no permission to read it. Either way: nothing here. + return []; + } + + const candidates = names + .filter((name) => !name.startsWith('.')) + .slice(0, MAX_ENTRIES_PER_ROOT) + .sort((a, b) => a.localeCompare(b)); + + const settled = await Promise.all( + candidates.map(async (name) => { + try { + return await classify(join(root, name), name); + } catch { + return null; + } + }), + ); + + const seen = new Set(); + const hits: DirHit[] = []; + for (const hit of settled) { + if (!hit) continue; + if (seen.has(hit.realPath)) continue; + seen.add(hit.realPath); + hits.push(hit); + } + return hits; +} + +/** Resolve `path` to a real directory, or null if it is not one / is broken. */ +async function resolveDir(path: string): Promise { + const resolved = await realpathOrNull(path); + if (!resolved) return null; + try { + const s = await stat(resolved); + return s.isDirectory() ? resolved : null; + } catch { + return null; + } +} + +async function realpathOrNull(path: string): Promise { + try { + return await realpath(path); + } catch { + // Broken symlink, or removed between readdir and here. + return null; + } +} + +async function isFile(path: string): Promise { + try { + return (await stat(path)).isFile(); + } catch { + return false; + } +} + +async function containsMarkdown(dir: string): Promise { + try { + const names = await readdir(dir); + return names.some((n) => !n.startsWith('.') && n.toLowerCase().endsWith('.md')); + } catch { + return false; + } +} diff --git a/src/manifest/index.ts b/src/manifest/index.ts index ef87dd4..53301fa 100644 --- a/src/manifest/index.ts +++ b/src/manifest/index.ts @@ -16,13 +16,29 @@ export type ToolClient = 'claude-code' | 'codex' | 'cursor'; * literals above — never a value read out of a config file. */ export interface ToolEntry { - type: 'mcp' | 'skill' | 'plugin'; + type: 'mcp' | 'skill' | 'plugin' | 'subagent'; name: string; source: string; scope: 'project' | 'user'; client: ToolClient; } +/** + * The subset of detections that may enter the /api/sync payload: MCP servers + * and plugins, the two things devcat.dev's catalog matches against. + * + * Skills and subagents are local report-only detections. They are folders on + * this machine with no catalog entry behind them, and 'subagent' is not even + * a type the server accepts — so they stay out of the payload entirely. + * Narrowing here rather than filtering at the call site means handing an + * unfiltered ToolEntry[] to postSync is a compile error, not a runtime bug. + */ +export type SyncableToolEntry = ToolEntry & { type: 'mcp' | 'plugin' }; + +export function syncableTools(tools: readonly ToolEntry[]): SyncableToolEntry[] { + return tools.filter((t): t is SyncableToolEntry => t.type === 'mcp' || t.type === 'plugin'); +} + export interface DetectResult { tools: ToolEntry[]; pathsScanned: string[]; diff --git a/src/ui/report.ts b/src/ui/report.ts index 51ef930..e869618 100644 --- a/src/ui/report.ts +++ b/src/ui/report.ts @@ -1,4 +1,5 @@ import type { DetectResult, ToolEntry, ToolClient } from '../manifest/index.js'; +import { CLI_VERSION } from '../version.js'; import { c, SUCCESS_GLYPH } from './colors.js'; /** @@ -26,18 +27,21 @@ const CLIENT_LABEL: Record = { type ToolType = ToolEntry['type']; -const TYPE_ORDER: readonly ToolType[] = ['mcp', 'plugin', 'skill']; +const TYPE_ORDER: readonly ToolType[] = ['mcp', 'plugin', 'skill', 'subagent']; const TYPE_LABEL_MARKDOWN: Record = { mcp: 'MCP servers', plugin: 'Plugins', skill: 'Skills', + subagent: 'Subagents', }; /** Total line width the terminal report wraps to. */ const WRAP_WIDTH = 78; +/** Width of the type label column — one wider than the longest type name. */ +const TYPE_WIDTH = 9; /** Column where the comma-separated names start (and continuation lines indent to). */ -const NAME_COLUMN = 14; +const NAME_COLUMN = 15; export interface StackTypeGroup { type: ToolType; @@ -98,7 +102,7 @@ export function renderStackReport(result: DetectResult): string { lines.push(''); lines.push(`${c.bold(group.label)} ${c.dim(`· ${plural(group.total, 'tool')}`)}`); for (const { type, names } of group.byType) { - const prefix = ` ${String(names.length).padStart(3)} ${type.padEnd(8)}`; + const prefix = ` ${String(names.length).padStart(3)} ${type.padEnd(TYPE_WIDTH)}`; const wrapped = wrap(names.join(', '), WRAP_WIDTH - NAME_COLUMN); lines.push(`${prefix}${wrapped[0]}`); for (const cont of wrapped.slice(1)) { @@ -159,6 +163,38 @@ export function renderStackMarkdown(result: DetectResult): string { return lines.join('\n'); } +/** + * Machine-readable mirror of the terminal report, for `devcat --json`. + * + * One JSON object rather than the NDJSON event stream sync emits — this is a + * result, not a sequence of events. Same grouping, same ordering, same counts + * as the human report, so a script and a reader see the same scan. + */ +export function renderStackJson(result: DetectResult): string { + const groups = groupStack(result.tools); + const total = result.tools.length; + const projectScoped = result.tools.filter((t) => t.scope === 'project').length; + + const payload = { + cli_version: CLI_VERSION, + total, + project_scoped: projectScoped, + user_scoped: total - projectScoped, + clients: groups.map((group) => ({ + client: group.client, + label: group.label, + total: group.total, + types: group.byType.map(({ type, names }) => ({ + type, + count: names.length, + names, + })), + })), + paths_checked: result.pathsScanned, + }; + return JSON.stringify(payload, null, 2); +} + const MARKDOWN_FOOTER = 'Generated by [devcat-cli](https://www.npmjs.com/package/devcat-cli) — `npx devcat-cli --markdown`'; diff --git a/test/unit/manifest/claude.skills.test.ts b/test/unit/manifest/claude.skills.test.ts new file mode 100644 index 0000000..6fc470d --- /dev/null +++ b/test/unit/manifest/claude.skills.test.ts @@ -0,0 +1,119 @@ +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-claude-skills-')); + homedirHolder.current = tmpHome; +}); + +afterEach(() => { + homedirHolder.current = null; + rmSync(tmpHome, { recursive: true, force: true }); +}); + +describe('detectClaudeCode — skills and subagents', () => { + it('reads ~/.claude/skills and ~/.claude/agents, tagged like every other entry', async () => { + const skillsRoot = join(tmpHome, '.claude', 'skills'); + mkdirSync(skillsRoot, { recursive: true }); + makeSkill(skillsRoot, 'deep-research'); + makeSkill(skillsRoot, 'panel'); + + const agentsRoot = join(tmpHome, '.claude', 'agents'); + mkdirSync(agentsRoot, { recursive: true }); + writeFileSync(join(agentsRoot, 'debugger.md'), 'x'); + + const { detectClaudeCode } = await import('../../../src/manifest/claude.js'); + const result = await detectClaudeCode({ scope: 'user' }); + + const skills = result.tools.filter((t) => t.type === 'skill'); + const subagents = result.tools.filter((t) => t.type === 'subagent'); + expect(skills.map((t) => t.name).sort()).toEqual(['deep-research', 'panel']); + expect(subagents.map((t) => t.name)).toEqual(['debugger']); + for (const t of [...skills, ...subagents]) { + expect(t.client).toBe('claude-code'); + expect(t.scope).toBe('user'); + } + expect(result.pathsScanned).toContain(skillsRoot); + expect(result.pathsScanned).toContain(agentsRoot); + }); + + it('resolves the symlinked link-farm layout the real shelf uses', async () => { + const canon = join(tmpHome, '.agents', 'skills'); + mkdirSync(canon, { recursive: true }); + const target = makeSkill(canon, 'handoff'); + + const farm = join(tmpHome, '.claude', 'skills'); + mkdirSync(farm, { recursive: true }); + symlinkSync(target, join(farm, 'handoff')); + + const { detectClaudeCode } = await import('../../../src/manifest/claude.js'); + const result = await detectClaudeCode({ scope: 'user' }); + expect(result.tools.filter((t) => t.type === 'skill').map((t) => t.name)).toEqual(['handoff']); + }); + + it('finds a project-scoped .claude/skills by walking up from cwd', async () => { + const projectRoot = mkdtempSync(join(tmpdir(), 'devcat-claude-proj-')); + try { + const skillsRoot = join(projectRoot, '.claude', 'skills'); + mkdirSync(skillsRoot, { recursive: true }); + makeSkill(skillsRoot, 'repo-local-skill'); + const nested = join(projectRoot, 'src', 'deep'); + mkdirSync(nested, { recursive: true }); + + const { detectClaudeCode } = await import('../../../src/manifest/claude.js'); + const result = await detectClaudeCode({ cwd: nested, scope: 'project' }); + + const skills = result.tools.filter((t) => t.type === 'skill'); + expect(skills).toHaveLength(1); + expect(skills[0]!.name).toBe('repo-local-skill'); + expect(skills[0]!.scope).toBe('project'); + } finally { + rmSync(projectRoot, { recursive: true, force: true }); + } + }); + + it('does NOT claim the user shelf as project-scoped when cwd sits under $HOME', async () => { + // $HOME is an ancestor of most working directories, so an unguarded + // upward walk would find ~/.claude/skills and label the whole shelf + // project-scoped. The user pass already reads it. + const skillsRoot = join(tmpHome, '.claude', 'skills'); + mkdirSync(skillsRoot, { recursive: true }); + makeSkill(skillsRoot, 'user-shelf-skill'); + const agentsRoot = join(tmpHome, '.claude', 'agents'); + mkdirSync(agentsRoot, { recursive: true }); + writeFileSync(join(agentsRoot, 'persona.md'), 'x'); + + const cwd = join(tmpHome, 'Developer', 'some-repo'); + mkdirSync(cwd, { recursive: true }); + + const { detectClaudeCode } = await import('../../../src/manifest/claude.js'); + const result = await detectClaudeCode({ cwd, scope: 'project' }); + + expect(result.tools.filter((t) => t.type === 'skill')).toEqual([]); + expect(result.tools.filter((t) => t.type === 'subagent')).toEqual([]); + }); + + it('is silent when neither directory exists', async () => { + const { detectClaudeCode } = await import('../../../src/manifest/claude.js'); + const result = await detectClaudeCode({ scope: 'user' }); + expect(result.tools.filter((t) => t.type === 'skill' || t.type === 'subagent')).toEqual([]); + }); +}); diff --git a/test/unit/manifest/claude.test.ts b/test/unit/manifest/claude.test.ts index 5476f8c..3b9ed81 100644 --- a/test/unit/manifest/claude.test.ts +++ b/test/unit/manifest/claude.test.ts @@ -50,7 +50,14 @@ describe('detectClaudeCode', () => { const cwd = '/tmp/devcat-nonexistent-' + Date.now(); const result = await detectClaudeCode({ cwd, scope: 'project' }); expect(result.tools).toEqual([]); - expect(result.pathsScanned.length).toBe(1); + // Project scope checks three locations: the MCP file, the skills + // directory, and the agents directory. All three are reported as checked + // so the empty-state message can name them. + expect(result.pathsScanned).toEqual([ + join(cwd, '.mcp.json'), + join(cwd, '.claude', 'skills'), + join(cwd, '.claude', 'agents'), + ]); }); it('reads user-scope ~/.claude.json with HOME pointing to fixture dir, returns scope=user', async () => { diff --git a/test/unit/manifest/dirScan.test.ts b/test/unit/manifest/dirScan.test.ts new file mode 100644 index 0000000..b0be984 --- /dev/null +++ b/test/unit/manifest/dirScan.test.ts @@ -0,0 +1,153 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, mkdirSync, writeFileSync, symlinkSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { scanSkills, scanSubagents } from '../../../src/manifest/dirScan.js'; + +/** + * These cover the filesystem-walk risk directly: the link-farm layout + * (~/.claude/skills is entirely symlinks), broken links, aliases pointing at + * one target, unreadable roots, and the promise that neither scan recurses. + */ +let tmp: string; + +beforeEach(() => { + tmp = mkdtempSync(join(tmpdir(), 'devcat-dirscan-')); +}); + +afterEach(() => { + rmSync(tmp, { recursive: true, force: true }); +}); + +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; +} + +describe('scanSkills', () => { + it('finds directories containing SKILL.md', async () => { + const root = join(tmp, 'skills'); + mkdirSync(root); + makeSkill(root, 'deep-research'); + makeSkill(root, 'panel'); + + const hits = await scanSkills(root); + expect(hits.map((h) => h.name).sort()).toEqual(['deep-research', 'panel']); + }); + + it('ignores directories without SKILL.md, loose files, and dotfiles', async () => { + const root = join(tmp, 'skills'); + mkdirSync(root); + makeSkill(root, 'real-skill'); + mkdirSync(join(root, 'not-a-skill')); + writeFileSync(join(root, 'AGENTS.md'), 'x'); + writeFileSync(join(root, 'CLAUDE.md'), 'x'); + mkdirSync(join(root, '.git')); + + const hits = await scanSkills(root); + expect(hits.map((h) => h.name)).toEqual(['real-skill']); + }); + + it('follows symlinked skill directories (the link-farm layout)', async () => { + const canon = join(tmp, 'canon'); + mkdirSync(canon); + const target = makeSkill(canon, 'handoff'); + + const farm = join(tmp, 'farm'); + mkdirSync(farm); + symlinkSync(target, join(farm, 'handoff')); + + const hits = await scanSkills(farm); + expect(hits).toHaveLength(1); + expect(hits[0]!.name).toBe('handoff'); + // realPath resolves through the link to the canonical location. + expect(hits[0]!.realPath).toContain('canon'); + }); + + it('dedupes two aliases pointing at the same resolved path', async () => { + const canon = join(tmp, 'canon'); + mkdirSync(canon); + const target = makeSkill(canon, 'panel'); + + const farm = join(tmp, 'farm'); + mkdirSync(farm); + symlinkSync(target, join(farm, 'panel')); + symlinkSync(target, join(farm, 'panel-alias')); + + const hits = await scanSkills(farm); + expect(hits).toHaveLength(1); + }); + + it('skips broken symlinks instead of throwing', async () => { + const farm = join(tmp, 'farm'); + mkdirSync(farm); + makeSkill(farm, 'good'); + symlinkSync(join(tmp, 'does-not-exist'), join(farm, 'dangling')); + + const hits = await scanSkills(farm); + expect(hits.map((h) => h.name)).toEqual(['good']); + }); + + it('does not recurse into a skill directory', async () => { + const root = join(tmp, 'skills'); + mkdirSync(root); + const outer = makeSkill(root, 'outer'); + makeSkill(outer, 'nested-should-be-invisible'); + + const hits = await scanSkills(root); + expect(hits.map((h) => h.name)).toEqual(['outer']); + }); + + it('returns empty for a missing root', async () => { + await expect(scanSkills(join(tmp, 'nope'))).resolves.toEqual([]); + }); + + it('returns empty when the root is unreadable rather than a directory', async () => { + const file = join(tmp, 'a-file'); + writeFileSync(file, 'not a directory'); + await expect(scanSkills(file)).resolves.toEqual([]); + }); +}); + +describe('scanSubagents', () => { + it('finds bare .md personas', async () => { + const root = join(tmp, 'agents'); + mkdirSync(root); + writeFileSync(join(root, 'tldraw-offline.md'), '---\nname: x\n---\n'); + + const hits = await scanSubagents(root); + expect(hits.map((h) => h.name)).toEqual(['tldraw-offline']); + }); + + it('finds /.md persona folders', async () => { + const root = join(tmp, 'agents'); + mkdirSync(join(root, 'code-reviewer'), { recursive: true }); + writeFileSync(join(root, 'code-reviewer', 'code-reviewer.md'), 'x'); + + const hits = await scanSubagents(root); + expect(hits.map((h) => h.name)).toEqual(['code-reviewer']); + }); + + it('handles both shapes in one directory, ignoring folders with no markdown', async () => { + const root = join(tmp, 'agents'); + mkdirSync(join(root, 'debugger'), { recursive: true }); + writeFileSync(join(root, 'debugger', 'debugger.md'), 'x'); + mkdirSync(join(root, 'empty-folder')); + writeFileSync(join(root, 'secure-reviewer.md'), 'x'); + writeFileSync(join(root, 'notes.txt'), 'x'); + + const hits = await scanSubagents(root); + expect(hits.map((h) => h.name).sort()).toEqual(['debugger', 'secure-reviewer']); + }); + + it('skips broken symlinks and missing roots', async () => { + const root = join(tmp, 'agents'); + mkdirSync(root); + symlinkSync(join(tmp, 'gone.md'), join(root, 'dangling.md')); + + await expect(scanSubagents(root)).resolves.toEqual([]); + await expect(scanSubagents(join(tmp, 'nope'))).resolves.toEqual([]); + }); +}); diff --git a/test/unit/manifest/security.test.ts b/test/unit/manifest/security.test.ts index 737684e..924b742 100644 --- a/test/unit/manifest/security.test.ts +++ b/test/unit/manifest/security.test.ts @@ -117,6 +117,18 @@ describe('manifest-only-sync (CLI-05)', () => { }, })); + // ─── Skills + subagents (report-only detections) ─────────────── + mkdirSync(join(tmpHome, '.claude', 'skills', 'deep-research'), { recursive: true }); + writeFileSync( + join(tmpHome, '.claude', 'skills', 'deep-research', 'SKILL.md'), + '---\nname: deep-research\n---\nEXA_API_KEY = "exa_test_secret_skill_body"\n', + ); + mkdirSync(join(tmpHome, '.claude', 'agents'), { recursive: true }); + writeFileSync( + join(tmpHome, '.claude', 'agents', 'code-reviewer.md'), + 'GITHUB_TOKEN: ghp_test_secret_agent_body\n', + ); + // ─── Activate homedir override so user-scope parsers see tmpHome ─ // (B2 fix: without this redirect, user-scope manifests would be read // from the test runner's real $HOME — not our planted fixtures — @@ -140,6 +152,10 @@ describe('manifest-only-sync (CLI-05)', () => { 'sk-test_secret_codex_user', // User-scope Cursor (B2 fix: now ACTUALLY tested) 'sbp_test_secret_cursor_user', + // Skill / subagent file bodies are never read at all — only directory + // and file names become entries. + 'exa_test_secret_skill_body', + 'ghp_test_secret_agent_body', // Absolute paths the manifest contained in args / installPath '/Users/test/', // Env-var KEY=value patterns @@ -156,7 +172,7 @@ describe('manifest-only-sync (CLI-05)', () => { // `source` path field that's only used for --json terminal output and is // NEVER sent to the server. The payload going to the server is just // {type, name} pairs. - const { detect } = await import('../../../src/manifest/index.js'); + const { detect, syncableTools } = await import('../../../src/manifest/index.js'); const result = await detect(tmpHome); // Sanity check — proves all 3 ecosystems' fixtures were loaded @@ -171,9 +187,10 @@ describe('manifest-only-sync (CLI-05)', () => { expect(names).toContain('openai-tools'); // Codex user (B2 — was missing) expect(names).toContain('supabase'); // Cursor user (B2 — was missing) - // Build the exact payload shape that gets POSTed to /api/sync + // Build the exact payload shape that gets POSTed to /api/sync. syncableTools() + // is what runSync passes to postSync; the compiler rejects the unfiltered list. const payload: Pick = { - tools: result.tools.map((t) => ({ type: t.type, name: t.name })), + tools: syncableTools(result.tools).map((t) => ({ type: t.type, name: t.name })), }; const payloadJson = JSON.stringify(payload); @@ -185,6 +202,25 @@ describe('manifest-only-sync (CLI-05)', () => { } }); + it('skills and subagents are detected locally but NEVER enter the sync payload', async () => { + const { detect, syncableTools } = await import('../../../src/manifest/index.js'); + const result = await detect(tmpHome); + + // Detected — the local report shows them. + expect(result.tools.filter((t) => t.type === 'skill').map((t) => t.name)).toContain( + 'deep-research', + ); + expect(result.tools.filter((t) => t.type === 'subagent').map((t) => t.name)).toContain( + 'code-reviewer', + ); + + // Absent from everything that goes to the server. + const payload = syncableTools(result.tools); + expect(payload.every((t) => t.type === 'mcp' || t.type === 'plugin')).toBe(true); + expect(payload.map((t) => t.name)).not.toContain('deep-research'); + expect(payload.map((t) => t.name)).not.toContain('code-reviewer'); + }); + it('SECONDARY: the parser internal output (stripped to {type, name}) excludes secret substrings', async () => { // W3 secondary check: even though `source` is filesystem path metadata // that never reaches the server, we still want to catch any leak from From af7f841f5b196c2623bd61a917d54ebcc027e6db Mon Sep 17 00:00:00 2001 From: AnobleSCM <211227905+AnobleSCM@users.noreply.github.com> Date: Sun, 2 Aug 2026 15:38:55 -0700 Subject: [PATCH 06/27] feat(report): emit the scan as JSON under --json MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `devcat --json` printed the human report, which broke anyone piping it to jq. It now emits one JSON object mirroring the report: totals, the project/user split, per-client groups with per-type names, and every path checked. One object rather than the newline-delimited event stream sync emits — this is a result, not a sequence of events. Sync's --json behaviour is untouched. --json wins when --markdown is passed too: a caller asking for machine-readable output is scripting, and a markdown document would break their parser. Co-authored-by: omnigent --- src/bin/devcat.ts | 3 +- src/commands/report.ts | 16 +++++- test/integration/report.test.ts | 88 ++++++++++++++++++++++++++++++--- test/unit/ui/report.test.ts | 75 +++++++++++++++++++++++++--- 4 files changed, 165 insertions(+), 17 deletions(-) diff --git a/src/bin/devcat.ts b/src/bin/devcat.ts index 1c28a75..90ca802 100644 --- a/src/bin/devcat.ts +++ b/src/bin/devcat.ts @@ -11,7 +11,7 @@ async function main(): Promise { program .name('devcat') .description( - 'DevCat CLI — see your whole AI-coding stack in one command. Scans this machine for the MCP servers and plugins 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, and Cursor.', ) .version(CLI_VERSION); @@ -25,6 +25,7 @@ async function main(): Promise { .command('report', { isDefault: true }) .description('Scan this machine and print your AI-coding stack (default)') .option('--markdown', 'emit a shareable "My AI stack" markdown snippet') + .option('--json', 'emit the scan as one machine-readable JSON object (wins over --markdown)') .action(async (options: { markdown?: boolean }) => { const exitCode = await runReport({ markdown: options.markdown === true }); process.exit(exitCode); diff --git a/src/commands/report.ts b/src/commands/report.ts index 0ec6270..e8095fb 100644 --- a/src/commands/report.ts +++ b/src/commands/report.ts @@ -1,5 +1,6 @@ import { detect } from '../manifest/index.js'; -import { renderStackReport, renderStackMarkdown } from '../ui/report.js'; +import { renderStackReport, renderStackMarkdown, renderStackJson } from '../ui/report.js'; +import { isJsonMode } from '../ui/jsonStream.js'; import { EXIT_OK, type ExitCode } from '../lib/exitCodes.js'; export interface ReportOptions { @@ -13,10 +14,21 @@ export interface ReportOptions { * Local-only: scans this machine's AI tool config files and prints what it * found. No network, no auth, no credentials touched. An empty result is a * valid outcome, not an error, so this always exits 0. + * + * Three renderings of one scan. `--json` wins over `--markdown` when both are + * passed: a caller asking for machine-readable output is scripting, and a + * markdown document would break their parser. */ export async function runReport(opts: ReportOptions): Promise { const manifest = await detect(process.cwd()); - const out = opts.markdown ? renderStackMarkdown(manifest) : renderStackReport(manifest); + let out: string; + if (isJsonMode()) { + out = renderStackJson(manifest); + } else if (opts.markdown) { + out = renderStackMarkdown(manifest); + } else { + out = renderStackReport(manifest); + } process.stdout.write(`${out}\n`); return EXIT_OK; } diff --git a/test/integration/report.test.ts b/test/integration/report.test.ts index 2a5ba8e..ea62107 100644 --- a/test/integration/report.test.ts +++ b/test/integration/report.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeAll, afterAll } 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'; @@ -41,6 +41,24 @@ beforeAll(() => { mkdirSync(join(tmpHome, '.codex'), { recursive: true }); writeFileSync(join(tmpHome, '.codex', 'config.toml'), '[mcp_servers.serena]\ncommand = "uv"\n'); + // Skills, one of them symlinked the way the real shelf link-farm is, plus + // one dangling link and one non-skill file that must both be ignored. + const canon = join(tmpHome, '.agents', 'skills', 'panel'); + mkdirSync(canon, { recursive: true }); + writeFileSync(join(canon, 'SKILL.md'), '# panel\n'); + const skillsRoot = join(tmpHome, '.claude', 'skills'); + mkdirSync(join(skillsRoot, 'handoff'), { recursive: true }); + writeFileSync(join(skillsRoot, 'handoff', 'SKILL.md'), '# handoff\n'); + symlinkSync(canon, join(skillsRoot, 'panel')); + symlinkSync(join(tmpHome, 'gone'), join(skillsRoot, 'dangling')); + writeFileSync(join(skillsRoot, 'AGENTS.md'), 'not a skill'); + + // Subagents in both shapes Claude Code accepts. + const agentsRoot = join(tmpHome, '.claude', 'agents'); + mkdirSync(join(agentsRoot, 'code-reviewer'), { recursive: true }); + writeFileSync(join(agentsRoot, 'code-reviewer', 'code-reviewer.md'), 'x'); + writeFileSync(join(agentsRoot, 'debugger.md'), 'x'); + // Project scope: one Cursor MCP server in the "current" directory. mkdirSync(join(projectDir, '.cursor'), { recursive: true }); writeFileSync( @@ -81,17 +99,25 @@ describe('report — default command', () => { const { exitCode, out } = await runAndCapture(false); expect(exitCode).toBe(0); - expect(out).toContain('Your AI-coding stack — 5 tools'); - // Claude Code: 2 MCP + 1 plugin + expect(out).toContain('Your AI-coding stack — 9 tools'); + // Claude Code: 2 MCP + 1 plugin + 2 skills + 2 subagents expect(out).toContain('Claude Code'); expect(out).toMatch(/2 mcp\s+exa, github/); expect(out).toMatch(/1 plugin\s+swift-lsp/); + expect(out).toMatch(/2 skill\s+handoff, panel/); + expect(out).toMatch(/2 subagent\s+code-reviewer, debugger/); // Codex + Cursor each contribute one expect(out).toMatch(/1 mcp\s+serena/); expect(out).toMatch(/1 mcp\s+supabase/); - expect(out).toContain('5 tools in Claude Code, Codex, and Cursor'); + expect(out).toContain('9 tools in Claude Code, Codex, and Cursor'); // The Cursor entry came from the project directory. - expect(out).toContain('1 project-scoped · 4 user-wide'); + expect(out).toContain('1 project-scoped · 8 user-wide'); + }); + + it('ignores dangling links and non-skill files in a skills directory', async () => { + const { out } = await runAndCapture(false); + expect(out).not.toContain('dangling'); + expect(out).not.toContain('AGENTS'); }); it('touches no network and no credentials', async () => { @@ -110,10 +136,60 @@ describe('report — default command', () => { expect(exitCode).toBe(0); expect(out.startsWith('## My AI stack')).toBe(true); - expect(out).toContain('5 tools across Claude Code, Codex, and Cursor.'); + expect(out).toContain('9 tools across Claude Code, Codex, and Cursor.'); expect(out).toContain('- **MCP servers (2):** `exa`, `github`'); expect(out).toContain('- **Plugins (1):** `swift-lsp`'); + expect(out).toContain('- **Skills (2):** `handoff`, `panel`'); + expect(out).toContain('- **Subagents (2):** `code-reviewer`, `debugger`'); expect(out).toContain('### Cursor'); expect(out.endsWith('\n')).toBe(true); }); }); + +describe('report — --json', () => { + const originalArgv = process.argv; + + async function runJson(extraArgs: string[]): Promise<{ exitCode: number; out: string }> { + process.argv = ['node', 'devcat', ...extraArgs]; + const { resetJsonModeCacheForTests } = await import('../../src/ui/jsonStream.js'); + resetJsonModeCacheForTests(); + try { + return await runAndCapture(extraArgs.includes('--markdown')); + } finally { + process.argv = originalArgv; + resetJsonModeCacheForTests(); + } + } + + it('emits one parseable JSON object mirroring the report', async () => { + const { exitCode, out } = await runJson(['--json']); + + expect(exitCode).toBe(0); + const parsed = JSON.parse(out); + expect(parsed.total).toBe(9); + expect(parsed.project_scoped).toBe(1); + expect(parsed.user_scoped).toBe(8); + expect(parsed.clients.map((c: { client: string }) => c.client)).toEqual([ + 'claude-code', + 'codex', + 'cursor', + ]); + const claude = parsed.clients[0]; + expect(claude.types.map((t: { type: string; count: number }) => [t.type, t.count])).toEqual([ + ['mcp', 2], + ['plugin', 1], + ['skill', 2], + ['subagent', 2], + ]); + expect(parsed.paths_checked.length).toBeGreaterThan(0); + // Not the human report, and not the NDJSON event stream sync emits. + expect(out).not.toContain('Your AI-coding stack'); + expect(out.trimEnd().split('\n').filter((l) => l === '}')).toHaveLength(1); + }); + + it('wins over --markdown when both are passed', async () => { + const { out } = await runJson(['--json', '--markdown']); + expect(() => JSON.parse(out)).not.toThrow(); + expect(out).not.toContain('## My AI stack'); + }); +}); diff --git a/test/unit/ui/report.test.ts b/test/unit/ui/report.test.ts index cdd4560..cb64555 100644 --- a/test/unit/ui/report.test.ts +++ b/test/unit/ui/report.test.ts @@ -1,5 +1,10 @@ import { describe, it, expect } from 'vitest'; -import { groupStack, renderStackReport, renderStackMarkdown } from '../../../src/ui/report.js'; +import { + groupStack, + renderStackReport, + renderStackMarkdown, + renderStackJson, +} from '../../../src/ui/report.js'; import type { DetectResult, ToolEntry } from '../../../src/manifest/index.js'; // Vitest fork pools leave process.stdout.isTTY undefined so colors auto-strip; @@ -21,6 +26,8 @@ const MIXED: DetectResult = { tool({ name: 'context7' }), tool({ name: 'atelier-board' }), tool({ name: 'swift-lsp', type: 'plugin' }), + tool({ name: 'deep-research', type: 'skill' }), + tool({ name: 'code-reviewer', type: 'subagent' }), tool({ name: 'supabase', client: 'cursor', scope: 'project' }), tool({ name: 'serena', client: 'codex' }), ], @@ -32,10 +39,13 @@ describe('groupStack', () => { const groups = groupStack(MIXED.tools); expect(groups.map((g) => g.client)).toEqual(['claude-code', 'codex', 'cursor']); expect(groups[0]!.label).toBe('Claude Code'); - expect(groups[0]!.total).toBe(3); + expect(groups[0]!.total).toBe(5); + // Type order is fixed: mcp, plugin, skill, subagent. expect(groups[0]!.byType).toEqual([ { type: 'mcp', names: ['atelier-board', 'context7'] }, { type: 'plugin', names: ['swift-lsp'] }, + { type: 'skill', names: ['deep-research'] }, + { type: 'subagent', names: ['code-reviewer'] }, ]); }); @@ -54,23 +64,26 @@ describe('groupStack', () => { describe('renderStackReport (default `npx devcat-cli` output)', () => { it('prints a header, a section per client, and a totals footer', () => { const out = renderStackReport(MIXED); - expect(out).toContain('Your AI-coding stack — 5 tools'); + expect(out).toContain('Your AI-coding stack — 7 tools'); expect(out).toContain('Claude Code'); expect(out).toContain('Codex'); expect(out).toContain('Cursor'); expect(out).toContain('atelier-board, context7'); expect(out).toContain('swift-lsp'); - expect(out).toContain('5 tools in Claude Code, Codex, and Cursor · 4 config files checked'); + expect(out).toContain('7 tools in Claude Code, Codex, and Cursor · 4 config files checked'); }); it('shows a per-type count next to each type label', () => { const out = renderStackReport(MIXED); expect(out).toMatch(/2 mcp\s+atelier-board, context7/); expect(out).toMatch(/1 plugin\s+swift-lsp/); + expect(out).toMatch(/1 skill\s+deep-research/); + // 'subagent' is exactly as wide as the type column, so it still needs a gap. + expect(out).toMatch(/1 subagent\s+code-reviewer/); }); it('reports the project-scoped split only when something is project-scoped', () => { - expect(renderStackReport(MIXED)).toContain('1 project-scoped · 4 user-wide'); + expect(renderStackReport(MIXED)).toContain('1 project-scoped · 6 user-wide'); const userOnly: DetectResult = { tools: [tool({ name: 'a' })], @@ -88,8 +101,11 @@ describe('renderStackReport (default `npx devcat-cli` output)', () => { const nameLines = lines.filter((l) => l.includes('mcp-server-number-')); expect(nameLines.length).toBeGreaterThan(1); for (const line of nameLines) expect(line.length).toBeLessThanOrEqual(78); - // Every wrapped line after the first starts at the name column. - for (const line of nameLines.slice(1)) expect(line.startsWith(' '.repeat(14))).toBe(true); + // Every wrapped line after the first starts exactly at the name column. + for (const line of nameLines.slice(1)) { + expect(line.startsWith(' '.repeat(15))).toBe(true); + expect(line[15]).not.toBe(' '); + } // Wrapping must not drop or duplicate entries. expect(nameLines.join(' ').match(/mcp-server-number-/g)).toHaveLength(20); }); @@ -114,10 +130,12 @@ describe('renderStackMarkdown (--markdown)', () => { it('emits a "My AI stack" snippet with a section per client', () => { const out = renderStackMarkdown(MIXED); expect(out.startsWith('## My AI stack\n')).toBe(true); - expect(out).toContain('5 tools across Claude Code, Codex, and Cursor.'); + expect(out).toContain('7 tools across Claude Code, Codex, and Cursor.'); expect(out).toContain('### Claude Code'); expect(out).toContain('- **MCP servers (2):** `atelier-board`, `context7`'); expect(out).toContain('- **Plugins (1):** `swift-lsp`'); + expect(out).toContain('- **Skills (1):** `deep-research`'); + expect(out).toContain('- **Subagents (1):** `code-reviewer`'); expect(out).toContain('### Cursor'); expect(out).toContain('devcat-cli'); }); @@ -143,3 +161,44 @@ describe('renderStackMarkdown (--markdown)', () => { expect(out).toContain('No AI tooling detected on this machine yet.'); }); }); + +describe('renderStackJson (--json)', () => { + it('is a single parseable object mirroring the report', () => { + const parsed = JSON.parse(renderStackJson(MIXED)); + expect(parsed.total).toBe(7); + expect(parsed.project_scoped).toBe(1); + expect(parsed.user_scoped).toBe(6); + expect(typeof parsed.cli_version).toBe('string'); + expect(parsed.paths_checked).toEqual(MIXED.pathsScanned); + }); + + it('carries the same grouping and ordering as the terminal report', () => { + const parsed = JSON.parse(renderStackJson(MIXED)); + expect(parsed.clients.map((c: { client: string }) => c.client)).toEqual([ + 'claude-code', + 'codex', + 'cursor', + ]); + const claude = parsed.clients[0]; + expect(claude.label).toBe('Claude Code'); + expect(claude.total).toBe(5); + expect(claude.types).toEqual([ + { type: 'mcp', count: 2, names: ['atelier-board', 'context7'] }, + { type: 'plugin', count: 1, names: ['swift-lsp'] }, + { type: 'skill', count: 1, names: ['deep-research'] }, + { type: 'subagent', count: 1, names: ['code-reviewer'] }, + ]); + }); + + it('empty scan is still valid JSON with zeroed counts', () => { + const parsed = JSON.parse(renderStackJson({ tools: [], pathsScanned: ['~/.claude.json'] })); + expect(parsed.total).toBe(0); + expect(parsed.clients).toEqual([]); + expect(parsed.paths_checked).toEqual(['~/.claude.json']); + }); + + it('emits no ANSI even when colour is enabled', () => { + // eslint-disable-next-line no-control-regex + expect(renderStackJson(MIXED)).not.toMatch(/\u001b\[/); + }); +}); From c20a3eab5ecb3359a970d213cabbd88c49840438 Mon Sep 17 00:00:00 2001 From: AnobleSCM <211227905+AnobleSCM@users.noreply.github.com> Date: Sun, 2 Aug 2026 15:39:02 -0700 Subject: [PATCH 07/27] docs: cover skills, subagents, and --json MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sample outputs regenerated from a real run against a fixture home, so the report, the markdown snippet, and the JSON object all match the code byte for byte again. Documents what the directory scan does and does not do — shallow, no recursion, symlinks resolved, names from folder names, no file contents — plus the new $HOME-is-not-a-project rule, and states plainly that skills and subagents never enter the sync payload. Co-authored-by: omnigent --- README.md | 69 +++++++++++++++++++++++++++++++++++++++++----------- package.json | 2 +- 2 files changed, 56 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index b0fb658..521ef33 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ See your whole AI-coding stack in one command. -`npx devcat-cli` scans this machine for the MCP servers and plugins 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, and Cursor, and prints them grouped in one report. No account, no sign-in, no network call. ## Quickstart @@ -13,20 +13,23 @@ npx devcat-cli That's the whole thing. Output: ``` -✓ Your AI-coding stack — 14 tools +✓ Your AI-coding stack — 23 tools -Claude Code · 9 tools - 6 mcp context7, github, linear, playwright, sentry, supabase - 3 plugin pr-review-toolkit, superpowers, typescript-lsp +Claude Code · 18 tools + 6 mcp context7, github, linear, playwright, sentry, supabase + 3 plugin pr-review-toolkit, superpowers, typescript-lsp + 6 skill brainstorming, deep-research, handoff, research-notes, tdd, + writing-plans + 3 subagent code-reviewer, debugger, test-engineer Codex · 3 tools - 3 mcp exa, node-repl, serena + 3 mcp exa, node-repl, serena Cursor · 2 tools - 2 mcp figma, postgres + 2 mcp figma, postgres -14 tools in Claude Code, Codex, and Cursor · 8 config files checked -2 project-scoped · 12 user-wide +23 tools in Claude Code, Codex, and Cursor · 12 config files checked +2 project-scoped · 21 user-wide ``` Requires Node.js 20 or later. Works on macOS, Linux, and Windows. @@ -42,11 +45,13 @@ npx devcat-cli --markdown ```markdown ## My AI stack -14 tools across Claude Code, Codex, and Cursor. +23 tools across Claude Code, Codex, and Cursor. ### Claude Code - **MCP servers (6):** `context7`, `github`, `linear`, `playwright`, `sentry`, `supabase` - **Plugins (3):** `pr-review-toolkit`, `superpowers`, `typescript-lsp` +- **Skills (6):** `brainstorming`, `deep-research`, `handoff`, `research-notes`, `tdd`, `writing-plans` +- **Subagents (3):** `code-reviewer`, `debugger`, `test-engineer` ### Codex - **MCP servers (3):** `exa`, `node-repl`, `serena` @@ -57,27 +62,61 @@ npx devcat-cli --markdown Generated by [devcat-cli](https://www.npmjs.com/package/devcat-cli) — `npx devcat-cli --markdown` ``` +## Script it + +`--json` prints the same scan as one JSON object — counts, per-client groups, and every path checked: + +```bash +npx devcat-cli --json | jq '.clients[] | {label, total}' +``` + +```json +{ + "cli_version": "0.1.1", + "total": 23, + "project_scoped": 2, + "user_scoped": 21, + "clients": [ + { + "client": "claude-code", + "label": "Claude Code", + "total": 18, + "types": [ + { "type": "mcp", "count": 6, "names": ["context7", "github", "..."] }, + { "type": "skill", "count": 6, "names": ["brainstorming", "..."] } + ] + } + ], + "paths_checked": ["..."] +} +``` + +`--json` wins if you pass `--markdown` too. + ## Commands | Command | What it does | |---|---| | `devcat` | Scan and print your stack. Same as `devcat report`. | | `devcat report --markdown` | Print the shareable "My AI stack" snippet instead. | +| `devcat report --json` | Print the scan as one JSON object. | | `devcat sync` | Push your manifest to devcat.dev. **Paused** — see below. | | `devcat logout` | Clear local DevCat credentials. | | `devcat --version` / `--help` | Version, help. | ## What it reads -Config files only, and only the tool **names and types** inside them: +Config files and directory names only — never file contents: -- **Claude Code** — `.mcp.json` (project), `~/.claude.json`, `~/.claude/settings.json`, `~/.claude/plugins/installed_plugins.json` +- **Claude Code** — `~/.claude.json`, `~/.claude/settings.json`, `~/.claude/plugins/installed_plugins.json`, `~/.claude/skills/`, `~/.claude/agents/`, and their project equivalents (`.mcp.json`, `.claude/skills/`, `.claude/agents/`) - **Codex CLI** — `.codex/config.toml` (project), `~/.codex/config.toml` - **Cursor** — `.cursor/mcp.json` (project), `~/.cursor/mcp.json` +Skills and subagents are folders, so they are found by listing a directory rather than parsing a file. That scan is deliberately shallow: it reads the config root and its immediate children, never recurses, resolves symlinks to dedupe the link-farm layouts these directories usually use, and skips broken links, unreadable folders, and anything without a `SKILL.md`. Skill and subagent **names come from the folder name** — no file is opened. + It never reads or reports environment variable values, command-line arguments, file paths, file contents, or any field other than the tool name and type. Missing and malformed config files are skipped silently — a broken `.mcp.json` never fails the scan. -Project-scoped entries are found by walking up from the current directory, so the report changes depending on where you run it. A tool configured in more than one place is listed once, under the first place it was found (project configs before user configs). +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, so your user-wide config is never double-counted as project config. A tool configured in more than one place is listed once, under the first place it was found (project configs before user configs). Install it globally if you run it often: @@ -98,6 +137,8 @@ Nothing else is affected: the scan, `--markdown`, and `logout` all work offline The sync path itself is intact — [RFC 8628 device authorization](https://datatracker.ietf.org/doc/html/rfc8628), tokens in your OS keychain via [`@napi-rs/keyring`](https://www.npmjs.com/package/@napi-rs/keyring), no plaintext token on disk. If you run your own instance, point `DEVCAT_API_URL` at it and set `DEVCAT_SYNC_ENABLED=1` to run the full flow. +Sync sends MCP servers and plugins only. Skills and subagents are local report detections — they are folders on your machine with no catalog entry behind them, and they never enter the sync payload. + ## Environment variables | Variable | Default | Purpose | @@ -119,7 +160,7 @@ The sync path itself is intact — [RFC 8628 device authorization](https://datat **The report is empty.** It lists every path it checked — if your config lives somewhere else, 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.** Only MCP servers and Claude Code plugins are detected today. Skills and subagents are not scanned yet. +**A tool is missing.** Detected today: MCP servers (Claude Code, Codex, Cursor), Claude Code plugins, skills, and subagents. Codex's own `~/.codex/skills` directory is not scanned yet. **`OS keychain unavailable` on Linux.** Only affects `sync`. Install libsecret: `sudo apt install libsecret-1-0` (Debian/Ubuntu), `sudo pacman -S libsecret` (Arch), or `sudo dnf install libsecret` (Fedora). diff --git a/package.json b/package.json index 8bb44ec..4e4cb1a 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "devcat-cli", "version": "0.1.1", - "description": "See your whole AI-coding stack in one command. npx devcat-cli scans this machine for the MCP servers and plugins installed across Claude Code, Codex, and Cursor and prints them grouped — locally, with no account and no network call.", + "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.", "license": "MIT", "author": "Andrew Noble (https://github.com/AnobleSCM)", "repository": { From 08c172d87bd579427057a7f19af81ae735089456 Mon Sep 17 00:00:00 2001 From: AnobleSCM <211227905+AnobleSCM@users.noreply.github.com> Date: Sun, 2 Aug 2026 15:41:45 -0700 Subject: [PATCH 08/27] fix(manifest): apply the $HOME guard to Codex and Cursor too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit described this guard as covering all three detectors but only staged the Claude Code half. Without it, a walk started anywhere under $HOME still "finds" ~/.codex/config.toml and ~/.cursor/mcp.json and labels the user's own config project-scoped. Both paths are read by the user-scope pass already, so skipping them in the project pass detects nothing less — it attributes correctly. Co-authored-by: omnigent --- src/manifest/codex.ts | 5 ++++- src/manifest/cursor.ts | 4 +++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/manifest/codex.ts b/src/manifest/codex.ts index da5adfe..e6e8442 100644 --- a/src/manifest/codex.ts +++ b/src/manifest/codex.ts @@ -2,7 +2,7 @@ import { readFile } from 'node:fs/promises'; import { homedir } from 'node:os'; import { join } from 'node:path'; import { parse as parseToml } from 'smol-toml'; -import { findUpward } from '../lib/findUpward.js'; +import { findUpward, isUserLevelPath } from '../lib/findUpward.js'; import type { ToolEntry } from './index.js'; interface CodexConfigToml { @@ -34,6 +34,9 @@ export async function detectCodex(opts: { cwd?: string; scope: 'project' | 'user } else { if (!opts.cwd) return { tools: [], pathsScanned: [] }; path = await findUpward(opts.cwd, '.codex', 'config.toml'); + // $HOME is an ancestor of most working directories; the user pass above + // already reads that exact file, so don't relabel it project-scoped. + if (path && isUserLevelPath(path, '.codex', 'config.toml')) path = null; scannedPath = path ?? join(opts.cwd, '.codex', 'config.toml'); if (!path) return { tools: [], pathsScanned: [scannedPath] }; } diff --git a/src/manifest/cursor.ts b/src/manifest/cursor.ts index d1ade08..85be992 100644 --- a/src/manifest/cursor.ts +++ b/src/manifest/cursor.ts @@ -1,7 +1,7 @@ import { readFile } from 'node:fs/promises'; import { homedir } from 'node:os'; import { join } from 'node:path'; -import { findUpward } from '../lib/findUpward.js'; +import { findUpward, isUserLevelPath } from '../lib/findUpward.js'; import type { ToolEntry } from './index.js'; interface CursorMcpFile { @@ -33,6 +33,8 @@ export async function detectCursor(opts: { cwd?: string; scope: 'project' | 'use } else { if (!opts.cwd) return { tools: [], pathsScanned: [] }; path = await findUpward(opts.cwd, '.cursor', 'mcp.json'); + // Same $HOME-is-an-ancestor guard as the Codex detector. + if (path && isUserLevelPath(path, '.cursor', 'mcp.json')) path = null; scannedPath = path ?? join(opts.cwd, '.cursor', 'mcp.json'); if (!path) return { tools: [], pathsScanned: [scannedPath] }; } From 5ae6d07a67be801e17cec2dd30045cca3b3547ad Mon Sep 17 00:00:00 2001 From: AnobleSCM <211227905+AnobleSCM@users.noreply.github.com> Date: Sun, 2 Aug 2026 15:45:03 -0700 Subject: [PATCH 09/27] feat(manifest): scan the Codex skills shelf MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ~/.codex/skills goes through the same bounded scanSkills pass as the Claude Code shelf: root plus immediate children, no recursion, symlinks resolved and aliases collapsed by canonical path, broken links and unreadable roots skipped, entry count capped. User scope only — Codex has no project skills root. On a machine where both shelves are link farms into one shared directory, two passes collapse the overlap and both are deterministic: aliases within a root collapse by resolved path, and across clients dedupe() keeps the first occurrence in a fixed scan order (project before user, Claude Code before Codex before Cursor). A skill on both shelves is therefore listed once under Claude Code, the same way on every run; a skill only Codex carries still appears under Codex. Co-authored-by: omnigent --- src/manifest/codex.ts | 46 ++++++- test/unit/manifest/codex.skills.test.ts | 158 ++++++++++++++++++++++++ test/unit/manifest/codex.test.ts | 10 +- 3 files changed, 208 insertions(+), 6 deletions(-) create mode 100644 test/unit/manifest/codex.skills.test.ts diff --git a/src/manifest/codex.ts b/src/manifest/codex.ts index e6e8442..e65ab1d 100644 --- a/src/manifest/codex.ts +++ b/src/manifest/codex.ts @@ -3,6 +3,7 @@ import { homedir } from 'node:os'; import { join } from 'node:path'; import { parse as parseToml } from 'smol-toml'; import { findUpward, isUserLevelPath } from '../lib/findUpward.js'; +import { scanSkills } from './dirScan.js'; import type { ToolEntry } from './index.js'; interface CodexConfigToml { @@ -15,7 +16,48 @@ interface SourceScan { } /** - * Detect Codex MCP servers from config.toml. + * Detect Codex tooling: MCP servers from config.toml, plus installed skills + * under ~/.codex/skills (user scope only — Codex has no project skills root). + * + * Skills are frequently the same shelf Claude Code reads: both ~/.claude/skills + * and ~/.codex/skills are usually link farms pointing at one shared directory. + * Two dedupe passes handle that, and both are order-independent in effect: + * + * - Within a root, scanSkills() collapses aliases by resolved path. + * - Across clients, dedupe() collapses by (type, name), keeping the first + * occurrence. detect() scans Claude Code before Codex, so a skill on both + * shelves is listed once under Claude Code — deterministically, not by + * whichever filesystem answered first. + */ +export async function detectCodex(opts: { cwd?: string; scope: 'project' | 'user' }): Promise { + const mcp = await detectCodexMcp(opts); + if (opts.scope !== 'user') return mcp; + + const skills = await readCodexSkillsDir(join(homedir(), '.codex', 'skills')); + return { + tools: [...mcp.tools, ...skills.tools], + pathsScanned: [...mcp.pathsScanned, ...skills.pathsScanned], + }; +} + +/** + * Installed skills under a Codex `skills/` root. Report-only, like every + * skill entry — never part of the /api/sync payload (see syncableTools). + */ +async function readCodexSkillsDir(path: string): Promise { + const hits = await scanSkills(path); + const tools: ToolEntry[] = hits.map((hit) => ({ + type: 'skill' as const, + name: hit.name, + source: path, + scope: 'user' as const, + client: 'codex' as const, + })); + return { tools, pathsScanned: [path] }; +} + +/** + * Codex MCP servers from config.toml. * * User scope: reads ~/.codex/config.toml. * Project scope: walks CWD upward to find .codex/config.toml. @@ -25,7 +67,7 @@ interface SourceScan { * (command, args, env, url, cwd, enabled) — we extract only the table key as * the tool name (CLI-05 manifest-only-sync). */ -export async function detectCodex(opts: { cwd?: string; scope: 'project' | 'user' }): Promise { +async function detectCodexMcp(opts: { cwd?: string; scope: 'project' | 'user' }): Promise { let path: string | null; let scannedPath: string; if (opts.scope === 'user') { diff --git a/test/unit/manifest/codex.skills.test.ts b/test/unit/manifest/codex.skills.test.ts new file mode 100644 index 0000000..be1e29e --- /dev/null +++ b/test/unit/manifest/codex.skills.test.ts @@ -0,0 +1,158 @@ +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-codex-skills-')); + homedirHolder.current = tmpHome; +}); + +afterEach(() => { + homedirHolder.current = null; + rmSync(tmpHome, { recursive: true, force: true }); +}); + +describe('detectCodex — ~/.codex/skills', () => { + it('reads the skills root and tags entries as Codex skills', async () => { + const skillsRoot = join(tmpHome, '.codex', 'skills'); + mkdirSync(skillsRoot, { recursive: true }); + makeSkill(skillsRoot, 'deep-research'); + makeSkill(skillsRoot, 'panel'); + + const { detectCodex } = await import('../../../src/manifest/codex.js'); + const result = await detectCodex({ scope: 'user' }); + + const skills = result.tools.filter((t) => t.type === 'skill'); + expect(skills.map((t) => t.name).sort()).toEqual(['deep-research', 'panel']); + for (const t of skills) { + expect(t.client).toBe('codex'); + expect(t.scope).toBe('user'); + } + expect(result.pathsScanned).toContain(skillsRoot); + }); + + it('applies the same bounded scan: symlinks resolved, aliases and broken links collapsed', async () => { + const canon = join(tmpHome, '.agents', 'skills'); + mkdirSync(canon, { recursive: true }); + const target = makeSkill(canon, 'panel'); + + const farm = join(tmpHome, '.codex', 'skills'); + mkdirSync(farm, { recursive: true }); + symlinkSync(target, join(farm, 'panel')); + symlinkSync(target, join(farm, 'panel-alias')); + symlinkSync(join(tmpHome, 'gone'), join(farm, 'dangling')); + mkdirSync(join(farm, '.system'), { recursive: true }); + writeFileSync(join(farm, 'AGENTS.md'), 'not a skill'); + + const { detectCodex } = await import('../../../src/manifest/codex.js'); + const result = await detectCodex({ scope: 'user' }); + + // Two aliases resolve to one target, so one entry survives. + expect(result.tools.filter((t) => t.type === 'skill')).toHaveLength(1); + }); + + it('reports no skills for project scope (Codex has no project skills root)', async () => { + const skillsRoot = join(tmpHome, '.codex', 'skills'); + mkdirSync(skillsRoot, { recursive: true }); + makeSkill(skillsRoot, 'user-only'); + + const projectDir = mkdtempSync(join(tmpdir(), 'devcat-codex-proj-')); + try { + const { detectCodex } = await import('../../../src/manifest/codex.js'); + const result = await detectCodex({ cwd: projectDir, scope: 'project' }); + expect(result.tools.filter((t) => t.type === 'skill')).toEqual([]); + } finally { + rmSync(projectDir, { recursive: true, force: true }); + } + }); + + it('is silent when the skills root does not exist', async () => { + mkdirSync(join(tmpHome, '.codex'), { recursive: true }); + writeFileSync(join(tmpHome, '.codex', 'config.toml'), '[mcp_servers.serena]\n'); + + const { detectCodex } = await import('../../../src/manifest/codex.js'); + const result = await detectCodex({ scope: 'user' }); + + expect(result.tools.filter((t) => t.type === 'skill')).toEqual([]); + expect(result.tools.map((t) => t.name)).toEqual(['serena']); + }); +}); + +describe('shared shelf — both link farms pointing at one directory', () => { + it('lists a skill on both shelves once, deterministically under Claude Code', async () => { + // The real-world layout: ~/.claude/skills and ~/.codex/skills are both + // link farms into one canonical directory. + const canon = join(tmpHome, '.agents', 'skills'); + mkdirSync(canon, { recursive: true }); + const shared = makeSkill(canon, 'panel'); + const claudeOnly = makeSkill(canon, 'handoff'); + const codexOnly = makeSkill(canon, 'codex-only-skill'); + + 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')); + symlinkSync(codexOnly, join(codexFarm, 'codex-only-skill')); + + const { detect } = await import('../../../src/manifest/index.js'); + const result = await detect(join(tmpHome, 'nowhere')); + + const skills = result.tools.filter((t) => t.type === 'skill'); + expect(skills.map((t) => t.name).sort()).toEqual(['codex-only-skill', 'handoff', 'panel']); + + // The shared one is listed once, under Claude Code — detect() scans + // Claude Code before Codex, so this is stable across runs. + const panel = skills.filter((t) => t.name === 'panel'); + expect(panel).toHaveLength(1); + expect(panel[0]!.client).toBe('claude-code'); + + // A skill only Codex provides still shows under Codex. + expect(skills.find((t) => t.name === 'codex-only-skill')!.client).toBe('codex'); + }); + + 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'); + expect(skills).toHaveLength(1); + expect(skills[0]!.client).toBe('claude-code'); + } + }); +}); diff --git a/test/unit/manifest/codex.test.ts b/test/unit/manifest/codex.test.ts index b99e863..fe4d034 100644 --- a/test/unit/manifest/codex.test.ts +++ b/test/unit/manifest/codex.test.ts @@ -56,9 +56,11 @@ describe('detectCodex', () => { homedirHolder.current = tmpHome; const result = await detectCodex({ scope: 'user' }); expect(result.tools).toEqual([]); - expect(result.pathsScanned).toHaveLength(1); - expect(result.pathsScanned[0]).toContain('.codex'); - expect(result.pathsScanned[0]).toContain('config.toml'); + // User scope checks two locations: config.toml and the skills directory. + expect(result.pathsScanned).toEqual([ + join(tmpHome, '.codex', 'config.toml'), + join(tmpHome, '.codex', 'skills'), + ]); }); it('handles malformed TOML gracefully (returns empty)', async () => { @@ -68,7 +70,7 @@ describe('detectCodex', () => { homedirHolder.current = tmpHome; const result = await detectCodex({ scope: 'user' }); expect(result.tools).toEqual([]); - expect(result.pathsScanned.length).toBe(1); + expect(result.pathsScanned).toContain(join(tmpHome, '.codex', 'config.toml')); }); // W5 fix — CONTEXT D-06 per-project Codex config support From 655311658a8d68aca7ddd1622d45ea758c8420dd Mon Sep 17 00:00:00 2001 From: AnobleSCM <211227905+AnobleSCM@users.noreply.github.com> Date: Sun, 2 Aug 2026 15:45:10 -0700 Subject: [PATCH 10/27] docs: document the Codex shelf and the dedupe rule Adds ~/.codex/skills to what the CLI reads, and states the two-pass dedupe explicitly: aliases collapse by resolved symlink target within a directory, and across clients the first occurrence wins in a fixed scan order. That is what makes a shared shelf list once, under Claude Code, identically on every run. Co-authored-by: omnigent --- README.md | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 521ef33..4b8d41d 100644 --- a/README.md +++ b/README.md @@ -109,14 +109,19 @@ npx devcat-cli --json | jq '.clients[] | {label, total}' Config files and directory names only — never file contents: - **Claude Code** — `~/.claude.json`, `~/.claude/settings.json`, `~/.claude/plugins/installed_plugins.json`, `~/.claude/skills/`, `~/.claude/agents/`, and their project equivalents (`.mcp.json`, `.claude/skills/`, `.claude/agents/`) -- **Codex CLI** — `.codex/config.toml` (project), `~/.codex/config.toml` +- **Codex CLI** — `.codex/config.toml` (project), `~/.codex/config.toml`, `~/.codex/skills/` - **Cursor** — `.cursor/mcp.json` (project), `~/.cursor/mcp.json` Skills and subagents are folders, so they are found by listing a directory rather than parsing a file. That scan is deliberately shallow: it reads the config root and its immediate children, never recurses, resolves symlinks to dedupe the link-farm layouts these directories usually use, and skips broken links, unreadable folders, and anything without a `SKILL.md`. Skill and subagent **names come from the folder name** — no file is opened. It never reads or reports environment variable values, command-line arguments, file paths, file contents, or any field other than the tool name and type. Missing and malformed config files are skipped silently — a broken `.mcp.json` never fails the scan. -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, so your user-wide config is never double-counted as project config. A tool configured in more than one place is listed once, under the first place it was found (project configs before user configs). +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, so your user-wide config is never double-counted as project config. + +**A tool configured in more than one place is listed once.** Two passes decide where it lands, and both are deterministic — the same machine always produces the same report: + +- Within one directory, aliases are collapsed by resolved symlink target. Two links to the same skill are one skill. +- Across clients, the first occurrence 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. Install it globally if you run it often: @@ -160,7 +165,7 @@ Sync sends MCP servers and plugins only. Skills and subagents are local report d **The report is empty.** It lists every path it checked — if your config lives somewhere else, 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, skills, and subagents. Codex's own `~/.codex/skills` directory is not scanned yet. +**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. **`OS keychain unavailable` on Linux.** Only affects `sync`. Install libsecret: `sudo apt install libsecret-1-0` (Debian/Ubuntu), `sudo pacman -S libsecret` (Arch), or `sudo dnf install libsecret` (Fedora). From 4f313e46fba2e4c1623468b38c6647a442c14f56 Mon Sep 17 00:00:00 2001 From: AnobleSCM <211227905+AnobleSCM@users.noreply.github.com> Date: Sun, 2 Aug 2026 16:16:32 -0700 Subject: [PATCH 11/27] fix(sync): make the type-level payload boundary real MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review finding 1. syncableTools() filtered the runtime path, but postSync still accepted the wide API ToolType — which includes 'skill' — so the claimed compile-time guarantee did not exist. A future caller passing a raw detection list would have compiled fine. Adds SyncableToolType ('mcp' | 'plugin') and types the whole request path with it: postSync and computeManifestHash. Passing detect().tools now fails to compile, naming 'skill' as the culprit. The security test no longer rebuilds the payload by hand. It runs the real runSync against the fixture machine with msw intercepting, and asserts on the bytes that actually reached the wire: no planted secrets, keys exactly {manifest_hash, tools}, each entry exactly {type, name} — no source, scope, client, or canonicalPath — and no skill or subagent anywhere in it. Co-authored-by: omnigent --- src/api/sync.ts | 10 +- src/lib/manifestHash.ts | 6 +- src/types/api.ts | 11 ++ test/unit/manifest/security.test.ts | 168 +++++++++++++++++++++------- 4 files changed, 150 insertions(+), 45 deletions(-) diff --git a/src/api/sync.ts b/src/api/sync.ts index 95a69d6..f96c883 100644 --- a/src/api/sync.ts +++ b/src/api/sync.ts @@ -1,5 +1,5 @@ import { v7 as uuidv7 } from 'uuid'; -import type { ToolType, SyncRequestBody, SyncResponseBody, ErrorEnvelope } from '../types/api.js'; +import type { SyncableToolType, SyncRequestBody, SyncResponseBody, ErrorEnvelope } from '../types/api.js'; import { authenticatedFetch } from './client.js'; import { getApiBase } from './types.js'; import { computeManifestHash } from '../lib/manifestHash.js'; @@ -38,8 +38,12 @@ export interface PostSyncOpts { accessToken: string; idempotencyKey?: string; emitStartEvent?: boolean; - /** Accepts the manifest layer's ToolEntry shape (structural superset of API ToolEntry). */ - tools: ReadonlyArray<{ type: ToolType; name: string }>; + /** + * Syncable detections only. Narrowed to SyncableToolType so a raw + * detection list — which can hold skills and subagents — does not type-check + * here. Callers pass syncableTools(manifest.tools). + */ + tools: ReadonlyArray<{ type: SyncableToolType; name: string }>; } export function createSyncIdempotencyKey(): string { diff --git a/src/lib/manifestHash.ts b/src/lib/manifestHash.ts index 8498dae..ead1702 100644 --- a/src/lib/manifestHash.ts +++ b/src/lib/manifestHash.ts @@ -1,5 +1,5 @@ import { createHash } from 'node:crypto'; -import type { ToolType } from '../types/api.js'; +import type { SyncableToolType } from '../types/api.js'; /** * Phase 40 syncPayloadSchema requires `manifest_hash: SHA-256 hex` matching @@ -14,7 +14,9 @@ import type { ToolType } from '../types/api.js'; * Accepts the structural superset { type, name, ... } so the manifest * detect()'s ToolEntry (which has source/scope) flows through cleanly. */ -export function computeManifestHash(tools: ReadonlyArray<{ type: ToolType; name: string }>): string { +export function computeManifestHash( + tools: ReadonlyArray<{ type: SyncableToolType; name: string }>, +): string { const canonical = [...tools] .map((t) => ({ type: t.type, name: t.name })) .sort((a, b) => (a.type !== b.type ? a.type.localeCompare(b.type) : a.name.localeCompare(b.name))); diff --git a/src/types/api.ts b/src/types/api.ts index 62a16c5..0d949ca 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -18,6 +18,17 @@ /** Tool type values accepted by /api/sync. Server rejects 'subagent' and 'agent'. */ export type ToolType = 'mcp' | 'skill' | 'plugin'; +/** + * The types this CLI actually sends: the two the devcat.dev catalog matches. + * + * Narrower than ToolType on purpose. Skills are a valid server-side type but + * are detected here as local folders with no catalog entry behind them, so + * they stay local — see syncableTools() in manifest/index.ts. Everything on + * the request path is typed with this, which is what makes handing an + * unfiltered detection list to postSync a compile error. + */ +export type SyncableToolType = Extract; + /** Single tool entry in the /api/sync request payload. */ export interface ToolEntry { type: ToolType; diff --git a/test/unit/manifest/security.test.ts b/test/unit/manifest/security.test.ts index 924b742..e06d39a 100644 --- a/test/unit/manifest/security.test.ts +++ b/test/unit/manifest/security.test.ts @@ -1,7 +1,9 @@ -import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest'; +import { describe, it, expect, vi, beforeAll, afterAll, afterEach } from 'vitest'; import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; +import { setupServer } from 'msw/node'; +import { http, HttpResponse } from 'msw'; import type { SyncRequestBody } from '../../../src/types/api.js'; /** @@ -23,6 +25,90 @@ vi.mock('node:os', async (importOriginal) => { }; }); +// A stored token, so runSync goes straight to the request without a device flow. +const { mockGetPassword, mockSetPassword } = vi.hoisted(() => ({ + mockGetPassword: vi.fn(), + mockSetPassword: vi.fn(), +})); + +vi.mock('@napi-rs/keyring', () => { + class AsyncEntry { + constructor(_service: string, _account: string) {} + getPassword(): Promise { + return mockGetPassword(); + } + setPassword(value: string): Promise { + return mockSetPassword(value); + } + deletePassword(): Promise { + return Promise.resolve(); + } + } + return { AsyncEntry }; +}); + +vi.mock('open', () => ({ default: vi.fn().mockResolvedValue(undefined) })); + +// This suite drives the live sync path on purpose — it is asserting what +// leaves the machine — so it opts past the devcat.dev-is-down pause gate. +process.env.DEVCAT_SYNC_ENABLED = '1'; +process.env.NO_COLOR = '1'; + +const server = setupServer(); + +beforeAll(() => server.listen({ onUnhandledRequest: 'error' })); +afterEach(() => server.resetHandlers()); +afterAll(() => server.close()); + +let tmpHome: string; + +/** + * Run the real `devcat sync` against the fixture tree and hand back the exact + * JSON body msw received. Nothing is reconstructed by hand: runSync detects, + * filters, and serialises; this only observes. + */ +async function captureSyncRequest(): Promise<{ body: SyncRequestBody }> { + let captured: SyncRequestBody | null = null; + server.use( + http.post('https://devcat.dev/api/sync', async ({ request }) => { + captured = (await request.json()) as SyncRequestBody; + return HttpResponse.json({ + synced_at: '2026-04-27T12:00:00Z', + session_id: 'sess', + results: [], + counts: { exact: 0, fuzzy: 0, unmatched: 0 }, + }); + }), + ); + + mockGetPassword.mockResolvedValue( + JSON.stringify({ + access_token: 'eyJa', + refresh_token: 'eyJr', + expires_in: 3600, + token_type: 'Bearer', + }), + ); + mockSetPassword.mockResolvedValue(undefined); + + const { resetTokenStoreForTests } = await import('../../../src/auth/tokenStore.js'); + resetTokenStoreForTests(); + + const writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); + const cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue(tmpHome); + try { + const { runSync } = await import('../../../src/commands/sync.js'); + const exitCode = await runSync({ noOpen: true }); + expect(exitCode).toBe(0); + } finally { + writeSpy.mockRestore(); + cwdSpy.mockRestore(); + } + + expect(captured, 'no request reached /api/sync').not.toBeNull(); + return { body: captured! }; +} + /** * CLI-05 success criterion 4 (ROADMAP Phase 39): * "The manifest payload sent to /api/sync contains only {type, name} @@ -40,8 +126,6 @@ vi.mock('node:os', async (importOriginal) => { * vacuously true. */ describe('manifest-only-sync (CLI-05)', () => { - let tmpHome: string; - beforeAll(() => { tmpHome = mkdtempSync(join(tmpdir(), 'devcat-sec-')); @@ -167,33 +251,24 @@ describe('manifest-only-sync (CLI-05)', () => { 'SETTINGS_SECRET', ] as const; - it('PRIMARY: the actual /api/sync payload {tools: [{type, name}]} has zero secret substrings', async () => { - // W3 fix: this is the load-bearing assertion — `result.tools` includes a - // `source` path field that's only used for --json terminal output and is - // NEVER sent to the server. The payload going to the server is just - // {type, name} pairs. - const { detect, syncableTools } = await import('../../../src/manifest/index.js'); - const result = await detect(tmpHome); + it('PRIMARY: the real request body sent by runSync has zero secret substrings', async () => { + // Load-bearing assertion. Nothing here reconstructs a payload by hand: + // runSync does its own detection and filtering, postSync serialises the + // body, and msw hands back the exact bytes that went over the wire. + const { body } = await captureSyncRequest(); - // Sanity check — proves all 3 ecosystems' fixtures were loaded - // (without this guard, an empty result.tools makes the assertion vacuous) - expect(result.tools.length).toBeGreaterThanOrEqual(7); - const names = result.tools.map((t) => t.name); - expect(names).toContain('context7'); // Claude project - expect(names).toContain('linear-mcp'); // Claude project - expect(names).toContain('github'); // Claude user (.claude.json) + // Sanity check — proves all 3 ecosystems' fixtures reached the payload + // (without this guard an empty tools array makes the assertion vacuous). + const names = body.tools.map((t) => t.name); + expect(names).toContain('context7'); // Claude project + expect(names).toContain('linear-mcp'); // Claude project + expect(names).toContain('github'); // Claude user (.claude.json) expect(names).toContain('settings-only'); // Claude user (settings.json) - expect(names).toContain('swift-lsp'); // Claude user (plugins, '@'-split) - expect(names).toContain('openai-tools'); // Codex user (B2 — was missing) - expect(names).toContain('supabase'); // Cursor user (B2 — was missing) - - // Build the exact payload shape that gets POSTed to /api/sync. syncableTools() - // is what runSync passes to postSync; the compiler rejects the unfiltered list. - const payload: Pick = { - tools: syncableTools(result.tools).map((t) => ({ type: t.type, name: t.name })), - }; - const payloadJson = JSON.stringify(payload); + expect(names).toContain('swift-lsp'); // Claude user (plugins, '@'-split) + expect(names).toContain('openai-tools'); // Codex user + expect(names).toContain('supabase'); // Cursor user + const payloadJson = JSON.stringify(body); for (const forbidden of FORBIDDEN_SUBSTRINGS) { expect( payloadJson, @@ -202,23 +277,35 @@ describe('manifest-only-sync (CLI-05)', () => { } }); - it('skills and subagents are detected locally but NEVER enter the sync payload', async () => { - const { detect, syncableTools } = await import('../../../src/manifest/index.js'); - const result = await detect(tmpHome); + it('the wire payload carries ONLY manifest_hash and {type, name} tools', async () => { + const { body } = await captureSyncRequest(); + + expect(Object.keys(body).sort()).toEqual(['manifest_hash', 'tools']); + expect(body.manifest_hash).toMatch(/^[a-f0-9]{64}$/); + for (const entry of body.tools) { + // No source, no scope, no client, no canonicalPath — local-only fields. + expect(Object.keys(entry).sort()).toEqual(['name', 'type']); + } + }); + + it('skills and subagents are detected locally but NEVER reach the wire', async () => { + const { detect } = await import('../../../src/manifest/index.js'); + const detected = await detect(tmpHome); // Detected — the local report shows them. - expect(result.tools.filter((t) => t.type === 'skill').map((t) => t.name)).toContain( + expect(detected.tools.filter((t) => t.type === 'skill').map((t) => t.name)).toContain( 'deep-research', ); - expect(result.tools.filter((t) => t.type === 'subagent').map((t) => t.name)).toContain( + expect(detected.tools.filter((t) => t.type === 'subagent').map((t) => t.name)).toContain( 'code-reviewer', ); - // Absent from everything that goes to the server. - const payload = syncableTools(result.tools); - expect(payload.every((t) => t.type === 'mcp' || t.type === 'plugin')).toBe(true); - expect(payload.map((t) => t.name)).not.toContain('deep-research'); - expect(payload.map((t) => t.name)).not.toContain('code-reviewer'); + // Absent from the actual request body. + const { body } = await captureSyncRequest(); + expect(body.tools.every((t) => t.type === 'mcp' || t.type === 'plugin')).toBe(true); + expect(body.tools.map((t) => t.name)).not.toContain('deep-research'); + expect(body.tools.map((t) => t.name)).not.toContain('code-reviewer'); + expect(JSON.stringify(body)).not.toContain('subagent'); }); it('SECONDARY: the parser internal output (stripped to {type, name}) excludes secret substrings', async () => { @@ -237,11 +324,12 @@ describe('manifest-only-sync (CLI-05)', () => { it('detect().tools entries have ONLY the expected keys (type, name, source, scope, client)', async () => { // `client` is a fixed literal set by the detector ('claude-code' | 'codex' - // | 'cursor'), never a value read out of a config file — so it cannot - // carry user data even though it never reaches the server either. + // | 'cursor'), never a value read out of a config file. `canonicalPath` is + // a local filesystem path used as the dedupe identity. Neither reaches the + // server — the wire-payload test above asserts the body is {type, name}. const { detect } = await import('../../../src/manifest/index.js'); const result = await detect(tmpHome); - const ALLOWED_KEYS = new Set(['type', 'name', 'source', 'scope', 'client']); + const ALLOWED_KEYS = new Set(['type', 'name', 'source', 'scope', 'client', 'canonicalPath']); for (const t of result.tools) { for (const k of Object.keys(t)) { expect(ALLOWED_KEYS.has(k), `unexpected key on ToolEntry: ${k}`).toBe(true); From cd8158d072da082db3e5819431cf54715e69e8fe Mon Sep 17 00:00:00 2001 From: AnobleSCM <211227905+AnobleSCM@users.noreply.github.com> Date: Sun, 2 Aug 2026 16:16:42 -0700 Subject: [PATCH 12/27] fix(manifest): dedupe by canonical path, not by name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review finding 2. Canonical paths were resolved inside a single root and then thrown away; the global pass keyed on (type, name). That was wrong in both directions — two differently-named aliases of one canonical skill both survived, and two genuinely different skills sharing a name collapsed into one. Entries that are a thing on disk now carry canonicalPath, and dedupe() uses it as identity when present, falling back to (type, name) for config keys that have no path of their own. The two key spaces are prefixed so a path can never collide with a name. First-occurrence order is unchanged, so the documented scan order still decides attribution. Real effect on the development machine: tldraw-offline exists on both the Claude and Codex shelves as separate directories, not links to one target. Name identity collapsed them into a single entry; path identity correctly reports both. Co-authored-by: omnigent --- src/manifest/claude.ts | 2 ++ src/manifest/codex.ts | 1 + src/manifest/dedupe.ts | 20 ++++++++++-- src/manifest/index.ts | 7 +++++ test/unit/manifest/dedupe.test.ts | 51 +++++++++++++++++++++++++++++++ 5 files changed, 79 insertions(+), 2 deletions(-) diff --git a/src/manifest/claude.ts b/src/manifest/claude.ts index eb51338..997531f 100644 --- a/src/manifest/claude.ts +++ b/src/manifest/claude.ts @@ -111,6 +111,7 @@ async function readSkillsDir(path: string, scope: 'project' | 'user'): Promise { source: path, scope: 'user' as const, client: 'codex' as const, + canonicalPath: hit.realPath, })); return { tools, pathsScanned: [path] }; } diff --git a/src/manifest/dedupe.ts b/src/manifest/dedupe.ts index 8fbf280..45a7bde 100644 --- a/src/manifest/dedupe.ts +++ b/src/manifest/dedupe.ts @@ -1,7 +1,21 @@ import type { ToolEntry } from './index.js'; /** - * Dedupe by (type, name) tuple. First occurrence wins. + * Dedupe by identity. First occurrence wins. + * + * Two identities, because the entries have two natures: + * + * - An entry with a canonicalPath IS a thing on disk. Two entries resolving + * to the same path are the same skill however they are named, and two + * entries at different paths are different skills however similarly they + * are named. `~/.claude/skills` and `~/.codex/skills` are usually link + * farms into one shared directory, so path identity is what actually + * collapses that overlap — and it is what stops two genuinely different + * skills that happen to share a name from swallowing each other. + * - An entry without one is a key inside a config file, where (type, name) + * is the only identity available. That is also what the server matches on. + * + * The two key spaces are prefixed so a path can never collide with a name. * * Caller orders the input array project-first so project-local entries win * over user-scoped duplicates per Phase 39 CONTEXT D-08. @@ -13,7 +27,9 @@ export function dedupe(entries: ToolEntry[]): ToolEntry[] { const seen = new Set(); const out: ToolEntry[] = []; for (const e of entries) { - const key = `${e.type}::${e.name}`; + const key = e.canonicalPath + ? `path::${e.canonicalPath}` + : `name::${e.type}::${e.name}`; if (seen.has(key)) continue; seen.add(key); out.push(e); diff --git a/src/manifest/index.ts b/src/manifest/index.ts index 53301fa..32ec59f 100644 --- a/src/manifest/index.ts +++ b/src/manifest/index.ts @@ -21,6 +21,13 @@ export interface ToolEntry { source: string; scope: 'project' | 'user'; client: ToolClient; + /** + * Symlink-resolved location, for detections that are a folder or file on + * disk (skills, subagents). Absent for entries that are a key inside a + * config file, which have no path of their own. When present it is the + * dedupe identity — see dedupe(). + */ + canonicalPath?: string; } /** diff --git a/test/unit/manifest/dedupe.test.ts b/test/unit/manifest/dedupe.test.ts index f601c67..5b91239 100644 --- a/test/unit/manifest/dedupe.test.ts +++ b/test/unit/manifest/dedupe.test.ts @@ -34,3 +34,54 @@ describe('dedupe', () => { expect(result).toHaveLength(2); }); }); + +describe('dedupe — canonical path identity', () => { + function skill(name: string, canonicalPath: string, client: ToolEntry['client']): ToolEntry { + return { type: 'skill', name, source: `/${client}/skills`, scope: 'user', client, canonicalPath }; + } + + it('collapses two DIFFERENTLY NAMED aliases of one canonical skill', () => { + // The link-farm case where the two shelves name the same target + // differently. (type, name) alone would let both survive. + const viaClaude = skill('panel', '/canon/skills/panel', 'claude-code'); + const viaCodex = skill('panel-v2', '/canon/skills/panel', 'codex'); + + const result = dedupe([viaClaude, viaCodex]); + expect(result).toHaveLength(1); + expect(result[0]!.client).toBe('claude-code'); + expect(result[0]!.name).toBe('panel'); + }); + + it('keeps two DISTINCT skills that happen to share a name', () => { + // (type, name) alone would wrongly collapse these into one. + const claudePanel = skill('panel', '/canon/claude/panel', 'claude-code'); + const codexPanel = skill('panel', '/canon/codex/panel', 'codex'); + + const result = dedupe([claudePanel, codexPanel]); + expect(result).toHaveLength(2); + expect(result.map((t) => t.client)).toEqual(['claude-code', 'codex']); + }); + + it('falls back to (type, name) for entries with no path of their own', () => { + // MCP servers are config keys — no canonicalPath, so name identity stands. + const a: ToolEntry = { type: 'mcp', name: 'context7', source: 'a', scope: 'project', client: 'claude-code' }; + const b: ToolEntry = { type: 'mcp', name: 'context7', source: 'b', scope: 'user', client: 'cursor' }; + expect(dedupe([a, b])).toHaveLength(1); + }); + + it('never lets a path key collide with a name key', () => { + const pathEntry = skill('x', 'skill::x', 'claude-code'); + const nameEntry: ToolEntry = { type: 'skill', name: 'x', source: 's', scope: 'user', client: 'codex' }; + expect(dedupe([pathEntry, nameEntry])).toHaveLength(2); + }); + + it('keeps first-occurrence order regardless of which identity applied', () => { + const entries: ToolEntry[] = [ + skill('a', '/canon/a', 'claude-code'), + { type: 'mcp', name: 'b', source: 's', scope: 'user', client: 'codex' }, + skill('a-alias', '/canon/a', 'codex'), + skill('c', '/canon/c', 'codex'), + ]; + expect(dedupe(entries).map((t) => t.name)).toEqual(['a', 'b', 'c']); + }); +}); From 8db8224d5e18887b1c34ef2ffe187f71f4819671 Mon Sep 17 00:00:00 2001 From: AnobleSCM <211227905+AnobleSCM@users.noreply.github.com> Date: Sun, 2 Aug 2026 16:16:52 -0700 Subject: [PATCH 13/27] fix(manifest): bound the directory scans properly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review findings 3 and 4. The cap was applied before sorting, so which entries survived a large root depended on the order the filesystem happened to return — the cap bounded the work but not the result. Sorting now happens first, so the kept subset is the alphabetical head and is identical on every run. readdir still materializes the listing, which is unavoidable: choosing deterministically requires knowing every candidate name first. That is now stated in the code rather than left implicit, and the expensive per-entry work is what the cap actually limits. The folder-shaped subagent match accepted any visible .md inside a directory, so agents/reviewer/README.md became a subagent called 'reviewer'. The contract is /.md and it is now enforced exactly — which also removes the uncapped second readdir that check used, replacing it with a single stat for a known filename. Co-authored-by: omnigent --- src/manifest/dirScan.ts | 34 ++++++++++++------------- test/unit/manifest/dirScan.test.ts | 41 ++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 17 deletions(-) diff --git a/src/manifest/dirScan.ts b/src/manifest/dirScan.ts index 3d81884..f572bd8 100644 --- a/src/manifest/dirScan.ts +++ b/src/manifest/dirScan.ts @@ -16,8 +16,9 @@ import { join } from 'node:path'; * path is what deduplicates aliases pointing at the same skill. * - A broken symlink, an unreadable directory, or a vanished entry is * skipped. A machine with a stale link never fails a scan. - * - Entry count per root is capped so a pathological directory cannot make - * the CLI hang. + * - Entry count per root is capped, and the cap is applied after sorting so + * the kept subset is the same on every run regardless of the order the + * filesystem happened to return. * * Names come from the directory (or file) name, which is how skills and * subagents are actually addressed — no file contents are read. @@ -50,10 +51,13 @@ export async function scanSkills(root: string): Promise { /** * Subagents: both shapes Claude Code accepts — * /.md (a bare persona file) - * //.md (a persona folder) + * //.md (a persona folder, matching name required) * - * Depth 2 at the most, and the second level is read only far enough to - * confirm the folder holds at least one .md. + * The folder shape is matched exactly: `reviewer/` counts only if it holds + * `reviewer.md`. A folder holding some other markdown — a README, notes — is + * not a persona and is skipped. That exactness also means the second level + * costs one stat for a known filename rather than a directory listing, so + * nothing here reads an unbounded number of entries. */ export async function scanSubagents(root: string): Promise { return scanRoot(root, async (entryPath, name) => { @@ -65,7 +69,7 @@ export async function scanSubagents(root: string): Promise { } const resolved = await resolveDir(entryPath); if (!resolved) return null; - if (!(await containsMarkdown(resolved))) return null; + if (!(await isFile(join(resolved, `${name}.md`)))) return null; return { name, realPath: resolved }; }); } @@ -80,6 +84,11 @@ async function scanRoot( ): Promise { let names: string[]; try { + // readdir materializes the whole listing. Node's streaming alternative + // (opendir) would avoid that, but a deterministic cap has to know every + // candidate name before choosing which to keep — so the names are read in + // full (strings only, cheap), then sorted, and only then is the cap + // applied to the expensive per-entry stat/realpath work below. names = await readdir(root); } catch { // Missing root, or no permission to read it. Either way: nothing here. @@ -88,8 +97,8 @@ async function scanRoot( const candidates = names .filter((name) => !name.startsWith('.')) - .slice(0, MAX_ENTRIES_PER_ROOT) - .sort((a, b) => a.localeCompare(b)); + .sort((a, b) => a.localeCompare(b)) + .slice(0, MAX_ENTRIES_PER_ROOT); const settled = await Promise.all( candidates.map(async (name) => { @@ -140,12 +149,3 @@ async function isFile(path: string): Promise { return false; } } - -async function containsMarkdown(dir: string): Promise { - try { - const names = await readdir(dir); - return names.some((n) => !n.startsWith('.') && n.toLowerCase().endsWith('.md')); - } catch { - return false; - } -} diff --git a/test/unit/manifest/dirScan.test.ts b/test/unit/manifest/dirScan.test.ts index b0be984..453fc81 100644 --- a/test/unit/manifest/dirScan.test.ts +++ b/test/unit/manifest/dirScan.test.ts @@ -109,6 +109,25 @@ describe('scanSkills', () => { writeFileSync(file, 'not a directory'); await expect(scanSkills(file)).resolves.toEqual([]); }); + + it('caps a large root deterministically — sorted first, so the kept subset is stable', async () => { + const root = join(tmp, 'skills'); + mkdirSync(root); + // 520 skills, over the 500 cap. Names are zero-padded so alphabetical + // order is also numeric order and the expected survivors are obvious. + for (let i = 0; i < 520; i++) makeSkill(root, `skill-${String(i).padStart(4, '0')}`); + + const first = await scanSkills(root); + const second = await scanSkills(root); + + expect(first).toHaveLength(500); + // Same subset every run, and it is the alphabetical head — not whatever + // order the filesystem happened to hand back. + expect(first.map((h) => h.name)).toEqual(second.map((h) => h.name)); + expect(first[0]!.name).toBe('skill-0000'); + expect(first[499]!.name).toBe('skill-0499'); + expect(first.map((h) => h.name)).not.toContain('skill-0500'); + }); }); describe('scanSubagents', () => { @@ -130,6 +149,28 @@ describe('scanSubagents', () => { expect(hits.map((h) => h.name)).toEqual(['code-reviewer']); }); + it('requires the folder name to match: reviewer/README.md is NOT subagent "reviewer"', async () => { + const root = join(tmp, 'agents'); + mkdirSync(join(root, 'reviewer'), { recursive: true }); + writeFileSync(join(root, 'reviewer', 'README.md'), '# just docs'); + writeFileSync(join(root, 'reviewer', 'notes.md'), 'x'); + + const hits = await scanSubagents(root); + expect(hits).toEqual([]); + }); + + it('matches the folder shape only on an exact name match', async () => { + const root = join(tmp, 'agents'); + mkdirSync(join(root, 'debugger'), { recursive: true }); + writeFileSync(join(root, 'debugger', 'debugger.md'), 'x'); + writeFileSync(join(root, 'debugger', 'README.md'), 'x'); + mkdirSync(join(root, 'impostor'), { recursive: true }); + writeFileSync(join(root, 'impostor', 'something-else.md'), 'x'); + + const hits = await scanSubagents(root); + expect(hits.map((h) => h.name)).toEqual(['debugger']); + }); + it('handles both shapes in one directory, ignoring folders with no markdown', async () => { const root = join(tmp, 'agents'); mkdirSync(join(root, 'debugger'), { recursive: true }); From ee1df947b5b1081b487b4cd0ef059325aa5425c3 Mon Sep 17 00:00:00 2001 From: AnobleSCM <211227905+AnobleSCM@users.noreply.github.com> Date: Sun, 2 Aug 2026 16:17:03 -0700 Subject: [PATCH 14/27] test(cli): exercise the real commander entrypoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review finding 5. The tests called runReport() directly and faked argv, so commander never parsed anything — the default-command dispatch and the flag wiring were both untested, and `devcat --json` in particular depended on reading process.argv behind commander's back. Extracts the program into cli.ts so tests can run the real parser over real argv arrays. bin/devcat.ts is now the shim that turns the returned code into process.exit, the one part that cannot run in a test process. Actions set an exit code rather than calling process.exit themselves, and exitOverride() routes commander's own exits — bad flag, --help, --version — through the same return path instead of killing the process. The report command now takes `json` as an argument from commander rather than sniffing process.argv, so the flag arrives the same way whether it is given to the program or the subcommand. Covers all three modes end to end plus `report`, both --json positions, --json winning over --markdown, an unknown flag, and --help/--version. Co-authored-by: omnigent --- src/bin/devcat.ts | 64 +---------- src/cli.ts | 94 ++++++++++++++++ src/commands/report.ts | 5 +- test/integration/cli.entrypoint.test.ts | 142 ++++++++++++++++++++++++ test/integration/report.test.ts | 27 ++--- 5 files changed, 252 insertions(+), 80 deletions(-) create mode 100644 src/cli.ts create mode 100644 test/integration/cli.entrypoint.test.ts diff --git a/src/bin/devcat.ts b/src/bin/devcat.ts index 90ca802..b81d266 100644 --- a/src/bin/devcat.ts +++ b/src/bin/devcat.ts @@ -1,64 +1,10 @@ #!/usr/bin/env node -import { Command } from 'commander'; -import { CLI_VERSION } from '../version.js'; -import { runReport } from '../commands/report.js'; -import { runSync } from '../commands/sync.js'; -import { runLogout } from '../commands/logout.js'; +import { runCli } from '../cli.js'; import { EXIT_GENERIC_ERROR } from '../lib/exitCodes.js'; -async function main(): Promise { - const program = new 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.', - ) - .version(CLI_VERSION); - - // Global flags. isJsonMode() reads process.argv directly (not commander - // state) so these declarations exist primarily to populate --help. - program - .option('--json', 'emit machine-readable JSON event stream') - .option('-v, --verbose', 'emit redacted HTTP trace to stderr'); - - program - .command('report', { isDefault: true }) - .description('Scan this machine and print your AI-coding stack (default)') - .option('--markdown', 'emit a shareable "My AI stack" markdown snippet') - .option('--json', 'emit the scan as one machine-readable JSON object (wins over --markdown)') - .action(async (options: { markdown?: boolean }) => { - const exitCode = await runReport({ markdown: options.markdown === true }); - process.exit(exitCode); - }); - - program - .command('sync') - .description('Push your AI tool manifest to devcat.dev (paused while the site is rebuilt)') - .option('--no-open', 'do not auto-open the browser at the verification URL') - .option('--json', 'emit machine-readable JSON event stream (for CI)') - .option('-v, --verbose', 'emit redacted HTTP trace to stderr') - .action(async (options: { open?: boolean }) => { - const exitCode = await runSync({ noOpen: options.open === false }); - process.exit(exitCode); - }); - - program - .command('logout') - .description('Clear local DevCat credentials') - .action(async () => { - const exitCode = await runLogout(); - process.exit(exitCode); - }); - - try { - await program.parseAsync(process.argv); - } catch (err) { +runCli(process.argv) + .then((exitCode) => process.exit(exitCode)) + .catch((err) => { process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`); process.exit(EXIT_GENERIC_ERROR); - } -} - -main().catch((err) => { - process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`); - process.exit(EXIT_GENERIC_ERROR); -}); + }); diff --git a/src/cli.ts b/src/cli.ts new file mode 100644 index 0000000..cc973e8 --- /dev/null +++ b/src/cli.ts @@ -0,0 +1,94 @@ +import { Command } from 'commander'; +import { CLI_VERSION } from './version.js'; +import { runReport } from './commands/report.js'; +import { runSync } from './commands/sync.js'; +import { runLogout } from './commands/logout.js'; +import { EXIT_GENERIC_ERROR, EXIT_OK, type ExitCode } from './lib/exitCodes.js'; + +/** + * Commander wiring, separated from the bin entrypoint so tests can run the + * real parser over a real argv array. bin/devcat.ts is then a three-line + * shim whose only extra job is turning the returned code into process.exit — + * the part that cannot run inside a test process. + * + * Actions return exit codes rather than calling process.exit themselves for + * the same reason. + */ +export interface ExitCodeSink { + code: ExitCode; +} + +export function buildProgram(sink: ExitCodeSink): Command { + const program = new Command(); + // Without this commander calls process.exit() itself on a bad flag, on + // --help, and on --version, which makes the parser impossible to test and + // takes the exit path out of one place. With it, those become throws that + // runCli turns into a returned code. + program.exitOverride(); + 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.', + ) + .version(CLI_VERSION); + + // Declared at program level too so `devcat --json` (the default command) + // parses. Commander consumes program-level flags before dispatching, so the + // report action reads both its own options and the program's. + program + .option('--json', 'emit machine-readable JSON output') + .option('-v, --verbose', 'emit redacted HTTP trace to stderr'); + + program + .command('report', { isDefault: true }) + .description('Scan this machine and print your AI-coding stack (default)') + .option('--markdown', 'emit a shareable "My AI stack" markdown snippet') + .option('--json', 'emit the scan as one machine-readable JSON object (wins over --markdown)') + .action(async (options: { markdown?: boolean; json?: boolean }) => { + sink.code = await runReport({ + markdown: options.markdown === true, + json: options.json === true || program.opts().json === true, + }); + }); + + program + .command('sync') + .description('Push your AI tool manifest to devcat.dev (paused while the site is rebuilt)') + .option('--no-open', 'do not auto-open the browser at the verification URL') + .option('--json', 'emit machine-readable JSON event stream (for CI)') + .option('-v, --verbose', 'emit redacted HTTP trace to stderr') + .action(async (options: { open?: boolean }) => { + sink.code = await runSync({ noOpen: options.open === false }); + }); + + program + .command('logout') + .description('Clear local DevCat credentials') + .action(async () => { + sink.code = await runLogout(); + }); + + return program; +} + +/** Parse `argv` with the real commander program and return the exit code. */ +export async function runCli(argv: string[]): Promise { + // Commander actions have no return channel, so they write the resolved code + // into this holder. + const sink: ExitCodeSink = { code: EXIT_OK }; + const program = buildProgram(sink); + try { + await program.parseAsync(argv); + return sink.code; + } catch (err) { + // Commander has already written its own output for these — the help text, + // the version, or an `error: unknown option ...` line. Reporting it again + // would double-print. exitCode 0 means it displayed help or version. + const commanderError = err as { code?: unknown; exitCode?: unknown }; + if (typeof commanderError.code === 'string' && typeof commanderError.exitCode === 'number') { + return commanderError.exitCode === 0 ? EXIT_OK : EXIT_GENERIC_ERROR; + } + process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`); + return EXIT_GENERIC_ERROR; + } +} diff --git a/src/commands/report.ts b/src/commands/report.ts index e8095fb..86c7415 100644 --- a/src/commands/report.ts +++ b/src/commands/report.ts @@ -1,11 +1,12 @@ import { detect } from '../manifest/index.js'; import { renderStackReport, renderStackMarkdown, renderStackJson } from '../ui/report.js'; -import { isJsonMode } from '../ui/jsonStream.js'; import { EXIT_OK, type ExitCode } from '../lib/exitCodes.js'; export interface ReportOptions { /** Emit the shareable "My AI stack" markdown snippet instead of the terminal report. */ markdown: boolean; + /** Emit one machine-readable JSON object. Comes from commander, not process.argv. */ + json: boolean; } /** @@ -22,7 +23,7 @@ export interface ReportOptions { export async function runReport(opts: ReportOptions): Promise { const manifest = await detect(process.cwd()); let out: string; - if (isJsonMode()) { + if (opts.json) { out = renderStackJson(manifest); } else if (opts.markdown) { out = renderStackMarkdown(manifest); diff --git a/test/integration/cli.entrypoint.test.ts b/test/integration/cli.entrypoint.test.ts new file mode 100644 index 0000000..192079e --- /dev/null +++ b/test/integration/cli.entrypoint.test.ts @@ -0,0 +1,142 @@ +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'; + +/** + * Drives the REAL commander program over real argv arrays, so the parser, + * the default-command dispatch, and the flag wiring are all exercised — + * not just the command function underneath them. + * + * runCli() is the whole of bin/devcat.ts apart from the process.exit call, + * which cannot run inside a test process. + */ +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'; + +let tmpHome: string; +let projectDir: string; + +beforeAll(() => { + tmpHome = mkdtempSync(join(tmpdir(), 'devcat-cli-home-')); + projectDir = mkdtempSync(join(tmpdir(), 'devcat-cli-proj-')); + + writeFileSync( + join(tmpHome, '.claude.json'), + JSON.stringify({ mcpServers: { github: {}, exa: {} } }), + ); + const skills = join(tmpHome, '.claude', 'skills', 'handoff'); + mkdirSync(skills, { recursive: true }); + writeFileSync(join(skills, 'SKILL.md'), '# handoff\n'); + const agents = join(tmpHome, '.claude', 'agents'); + mkdirSync(agents, { recursive: true }); + writeFileSync(join(agents, 'debugger.md'), 'x'); + + homedirHolder.current = tmpHome; +}); + +afterAll(() => { + homedirHolder.current = null; + rmSync(tmpHome, { recursive: true, force: true }); + rmSync(projectDir, { recursive: true, force: true }); +}); + +/** Run the real parser over `argv` and capture stdout. */ +async function invoke(argv: string[]): Promise<{ exitCode: number; out: string }> { + const chunks: 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 cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue(projectDir); + try { + const { runCli } = await import('../../src/cli.js'); + const exitCode = await runCli(['/usr/bin/node', '/usr/local/bin/devcat', ...argv]); + return { exitCode, out: chunks.join('') }; + } finally { + writeSpy.mockRestore(); + cwdSpy.mockRestore(); + } +} + +describe('CLI entrypoint — real commander parse', () => { + it('no args dispatches the default report command', async () => { + const { exitCode, out } = await invoke([]); + expect(exitCode).toBe(0); + expect(out).toContain('Your AI-coding stack — 4 tools'); + expect(out).toMatch(/2 mcp\s+exa, github/); + expect(out).toMatch(/1 skill\s+handoff/); + expect(out).toMatch(/1 subagent\s+debugger/); + }); + + it('the explicit `report` subcommand behaves identically to no args', async () => { + const bare = await invoke([]); + const explicit = await invoke(['report']); + expect(explicit.exitCode).toBe(0); + expect(explicit.out).toBe(bare.out); + }); + + it('--markdown reaches the default command as a real parsed flag', async () => { + const { exitCode, out } = await invoke(['--markdown']); + expect(exitCode).toBe(0); + expect(out.startsWith('## My AI stack')).toBe(true); + expect(out).toContain('- **Skills (1):** `handoff`'); + }); + + it('--json is parsed at program level and still reaches the default command', async () => { + // Commander consumes program-level flags before dispatching, so this is + // the case that silently regressed when the flag was read off argv. + const { exitCode, out } = await invoke(['--json']); + expect(exitCode).toBe(0); + const parsed = JSON.parse(out); + expect(parsed.total).toBe(4); + expect(parsed.clients[0].client).toBe('claude-code'); + }); + + it('--json also works when passed to the subcommand directly', async () => { + const { exitCode, out } = await invoke(['report', '--json']); + expect(exitCode).toBe(0); + expect(JSON.parse(out).total).toBe(4); + }); + + it('--json wins over --markdown through the real parser', async () => { + const { out } = await invoke(['--json', '--markdown']); + expect(() => JSON.parse(out)).not.toThrow(); + expect(out).not.toContain('## My AI stack'); + }); + + it('an unknown flag returns exit 1 without killing the process', async () => { + const exitSpy = vi.spyOn(process, 'exit').mockImplementation(((): never => { + throw new Error('process.exit must not be called from runCli'); + }) as never); + const errSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); + try { + const { exitCode } = await invoke(['--definitely-not-a-flag']); + expect(exitCode).toBe(1); + expect(exitSpy).not.toHaveBeenCalled(); + } finally { + exitSpy.mockRestore(); + errSpy.mockRestore(); + } + }); + + it('--help and --version exit 0 through the same path', async () => { + const help = await invoke(['--help']); + expect(help.exitCode).toBe(0); + expect(help.out).toContain('Usage: devcat'); + expect(help.out).toContain('report'); + expect(help.out).toContain('sync'); + + const version = await invoke(['--version']); + expect(version.exitCode).toBe(0); + expect(version.out.trim()).toMatch(/^\d+\.\d+\.\d+$/); + }); +}); diff --git a/test/integration/report.test.ts b/test/integration/report.test.ts index ea62107..293116a 100644 --- a/test/integration/report.test.ts +++ b/test/integration/report.test.ts @@ -75,7 +75,10 @@ afterAll(() => { rmSync(projectDir, { recursive: true, force: true }); }); -async function runAndCapture(markdown: boolean): Promise<{ exitCode: number; out: string }> { +async function runAndCapture( + markdown: boolean, + json = false, +): Promise<{ exitCode: number; out: string }> { const chunks: string[] = []; const writeSpy = vi .spyOn(process.stdout, 'write') @@ -86,7 +89,7 @@ async function runAndCapture(markdown: boolean): Promise<{ exitCode: number; out const cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue(projectDir); try { const { runReport } = await import('../../src/commands/report.js'); - const exitCode = await runReport({ markdown }); + const exitCode = await runReport({ markdown, json }); return { exitCode, out: chunks.join('') }; } finally { writeSpy.mockRestore(); @@ -147,22 +150,8 @@ describe('report — default command', () => { }); describe('report — --json', () => { - const originalArgv = process.argv; - - async function runJson(extraArgs: string[]): Promise<{ exitCode: number; out: string }> { - process.argv = ['node', 'devcat', ...extraArgs]; - const { resetJsonModeCacheForTests } = await import('../../src/ui/jsonStream.js'); - resetJsonModeCacheForTests(); - try { - return await runAndCapture(extraArgs.includes('--markdown')); - } finally { - process.argv = originalArgv; - resetJsonModeCacheForTests(); - } - } - it('emits one parseable JSON object mirroring the report', async () => { - const { exitCode, out } = await runJson(['--json']); + const { exitCode, out } = await runAndCapture(false, true); expect(exitCode).toBe(0); const parsed = JSON.parse(out); @@ -187,8 +176,8 @@ describe('report — --json', () => { expect(out.trimEnd().split('\n').filter((l) => l === '}')).toHaveLength(1); }); - it('wins over --markdown when both are passed', async () => { - const { out } = await runJson(['--json', '--markdown']); + it('wins over --markdown', async () => { + const { out } = await runAndCapture(true, true); expect(() => JSON.parse(out)).not.toThrow(); expect(out).not.toContain('## My AI stack'); }); From 4152387ab01889e33471a1ddd4ae4a014db0399f Mon Sep 17 00:00:00 2001 From: AnobleSCM <211227905+AnobleSCM@users.noreply.github.com> Date: Sun, 2 Aug 2026 16:17:15 -0700 Subject: [PATCH 15/27] fix(report): sanitize names, correct the footer, cover the $HOME guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review findings 7, 8, and 9. Names come from config keys and folder names, both of which can legally hold control characters — a directory named with an ANSI sequence could repaint the terminal report. Control characters are now stripped from the terminal and markdown renderings, and backticks additionally from markdown where they would break out of the inline code span. The JSON path needs nothing: stringify escapes them, and a test asserts the raw name survives there intact. pathsScanned holds directories as well as files now, so "config files checked" mislabelled them — it reads "locations checked". Adds the missing $HOME-guard regressions for Codex and Cursor. Only Claude Code had one, which is why that guard's other half once shipped in a commit that claimed to cover all three. Each asserts both halves: the user-level path is not claimed as project-scoped, and a genuine project-level config under $HOME still is. Co-authored-by: omnigent --- src/ui/report.ts | 33 +++++++-- test/unit/manifest/homeGuard.test.ts | 106 +++++++++++++++++++++++++++ test/unit/ui/report.test.ts | 47 +++++++++++- 3 files changed, 178 insertions(+), 8 deletions(-) create mode 100644 test/unit/manifest/homeGuard.test.ts diff --git a/src/ui/report.ts b/src/ui/report.ts index e869618..c7d0734 100644 --- a/src/ui/report.ts +++ b/src/ui/report.ts @@ -55,6 +55,25 @@ export interface StackGroup { byType: StackTypeGroup[]; } +/** + * Names come from config keys and folder names, both of which can legally + * contain control characters, ANSI escapes, and newlines. Rendering those + * straight into a terminal lets a directory name repaint the report; the + * JSON path is already safe because JSON.stringify escapes them. + * + * Control characters are dropped for every text rendering. Backticks are + * additionally dropped in markdown, where they would break out of the inline + * code span the name is wrapped in. + */ +function sanitizeName(name: string): string { + // eslint-disable-next-line no-control-regex + return name.replace(/[\u0000-\u001f\u007f-\u009f]/g, ''); +} + +function sanitizeMarkdownName(name: string): string { + return sanitizeName(name).replace(/`/g, ''); +} + /** * Group detected tools by client, then by type, with names sorted * alphabetically. Clients and types with nothing in them are omitted. @@ -86,8 +105,8 @@ export function groupStack(tools: readonly ToolEntry[]): StackGroup[] { * 12 mcp alpha, beta, gamma, … * 4 plugin swift-lsp, vercel * - * 21 tools in Claude Code, Codex, and Cursor · 8 config files scanned - * 3 project-scoped (this directory), 18 user-wide + * 21 tools in Claude Code, Codex, and Cursor · 8 locations checked + * 3 project-scoped · 18 user-wide */ export function renderStackReport(result: DetectResult): string { if (result.tools.length === 0) return renderEmptyStack(result.pathsScanned); @@ -103,7 +122,7 @@ export function renderStackReport(result: DetectResult): string { lines.push(`${c.bold(group.label)} ${c.dim(`· ${plural(group.total, 'tool')}`)}`); for (const { type, names } of group.byType) { const prefix = ` ${String(names.length).padStart(3)} ${type.padEnd(TYPE_WIDTH)}`; - const wrapped = wrap(names.join(', '), WRAP_WIDTH - NAME_COLUMN); + const wrapped = wrap(names.map(sanitizeName).join(', '), WRAP_WIDTH - NAME_COLUMN); lines.push(`${prefix}${wrapped[0]}`); for (const cont of wrapped.slice(1)) { lines.push(`${' '.repeat(NAME_COLUMN)}${cont}`); @@ -112,11 +131,13 @@ export function renderStackReport(result: DetectResult): string { } const clientLabels = groups.map((g) => g.label); - const files = result.pathsScanned.length; + // pathsScanned holds directories (skills, agents) as well as files, so + // "locations" rather than "config files". + const locations = result.pathsScanned.length; lines.push(''); lines.push( c.dim( - `${plural(total, 'tool')} in ${joinWithAnd(clientLabels)} · ${plural(files, 'config file')} checked`, + `${plural(total, 'tool')} in ${joinWithAnd(clientLabels)} · ${plural(locations, 'location')} checked`, ), ); @@ -153,7 +174,7 @@ export function renderStackMarkdown(result: DetectResult): string { lines.push(''); lines.push(`### ${group.label}`); for (const { type, names } of group.byType) { - const rendered = names.map((n) => `\`${n}\``).join(', '); + const rendered = names.map((n) => `\`${sanitizeMarkdownName(n)}\``).join(', '); lines.push(`- **${TYPE_LABEL_MARKDOWN[type]} (${names.length}):** ${rendered}`); } } diff --git a/test/unit/manifest/homeGuard.test.ts b/test/unit/manifest/homeGuard.test.ts new file mode 100644 index 0000000..d95890c --- /dev/null +++ b/test/unit/manifest/homeGuard.test.ts @@ -0,0 +1,106 @@ +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'; + +/** + * Regression cover for all three detectors: $HOME is an ancestor of most + * working directories, so an unguarded upward walk finds the user's own + * config and reports it as project-scoped. + * + * Every detector here has a user-scope reader for the same path, so the + * project pass must skip it — nothing is detected less, it is attributed + * correctly. Claude Code had cover for this; Codex and Cursor did not, and + * their half of the guard was once left out of a commit unnoticed. + */ +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; +let cwd: string; + +beforeEach(() => { + tmpHome = mkdtempSync(join(tmpdir(), 'devcat-homeguard-')); + homedirHolder.current = tmpHome; + // A working directory nested under $HOME, like any repo in ~/Developer. + cwd = join(tmpHome, 'Developer', 'some-repo'); + mkdirSync(cwd, { recursive: true }); +}); + +afterEach(() => { + homedirHolder.current = null; + rmSync(tmpHome, { recursive: true, force: true }); +}); + +describe('$HOME is not a project root — Codex', () => { + it('does not report ~/.codex/config.toml as project-scoped', async () => { + mkdirSync(join(tmpHome, '.codex'), { recursive: true }); + writeFileSync(join(tmpHome, '.codex', 'config.toml'), '[mcp_servers.serena]\n'); + + const { detectCodex } = await import('../../../src/manifest/codex.js'); + const project = await detectCodex({ cwd, scope: 'project' }); + expect(project.tools).toEqual([]); + + // Still detected — by the user pass, with the right scope. + const user = await detectCodex({ scope: 'user' }); + expect(user.tools.map((t) => t.name)).toContain('serena'); + expect(user.tools.every((t) => t.scope === 'user')).toBe(true); + }); + + it('still finds a genuine project-level .codex/config.toml under $HOME', async () => { + mkdirSync(join(cwd, '.codex'), { recursive: true }); + writeFileSync(join(cwd, '.codex', 'config.toml'), '[mcp_servers.repo-local]\n'); + + const { detectCodex } = await import('../../../src/manifest/codex.js'); + const result = await detectCodex({ cwd, scope: 'project' }); + expect(result.tools.map((t) => t.name)).toEqual(['repo-local']); + expect(result.tools[0]!.scope).toBe('project'); + }); +}); + +describe('$HOME is not a project root — Cursor', () => { + it('does not report ~/.cursor/mcp.json as project-scoped', async () => { + mkdirSync(join(tmpHome, '.cursor'), { recursive: true }); + writeFileSync(join(tmpHome, '.cursor', 'mcp.json'), JSON.stringify({ mcpServers: { figma: {} } })); + + const { detectCursor } = await import('../../../src/manifest/cursor.js'); + const project = await detectCursor({ cwd, scope: 'project' }); + expect(project.tools).toEqual([]); + + const user = await detectCursor({ scope: 'user' }); + expect(user.tools.map((t) => t.name)).toContain('figma'); + expect(user.tools.every((t) => t.scope === 'user')).toBe(true); + }); + + it('still finds a genuine project-level .cursor/mcp.json under $HOME', async () => { + mkdirSync(join(cwd, '.cursor'), { recursive: true }); + writeFileSync(join(cwd, '.cursor', 'mcp.json'), JSON.stringify({ mcpServers: { 'repo-local': {} } })); + + const { detectCursor } = await import('../../../src/manifest/cursor.js'); + const result = await detectCursor({ cwd, scope: 'project' }); + expect(result.tools.map((t) => t.name)).toEqual(['repo-local']); + expect(result.tools[0]!.scope).toBe('project'); + }); +}); + +describe('$HOME is not a project root — whole scan', () => { + it('reports a user-only machine as entirely user-scoped', async () => { + mkdirSync(join(tmpHome, '.codex'), { recursive: true }); + writeFileSync(join(tmpHome, '.codex', 'config.toml'), '[mcp_servers.serena]\n'); + mkdirSync(join(tmpHome, '.cursor'), { recursive: true }); + writeFileSync(join(tmpHome, '.cursor', 'mcp.json'), JSON.stringify({ mcpServers: { figma: {} } })); + const skills = join(tmpHome, '.claude', 'skills', 'panel'); + mkdirSync(skills, { recursive: true }); + writeFileSync(join(skills, 'SKILL.md'), '# panel\n'); + + const { detect } = await import('../../../src/manifest/index.js'); + const result = await detect(cwd); + + expect(result.tools.length).toBeGreaterThanOrEqual(3); + expect(result.tools.filter((t) => t.scope === 'project')).toEqual([]); + }); +}); diff --git a/test/unit/ui/report.test.ts b/test/unit/ui/report.test.ts index cb64555..8a5dd70 100644 --- a/test/unit/ui/report.test.ts +++ b/test/unit/ui/report.test.ts @@ -70,7 +70,7 @@ describe('renderStackReport (default `npx devcat-cli` output)', () => { expect(out).toContain('Cursor'); expect(out).toContain('atelier-board, context7'); expect(out).toContain('swift-lsp'); - expect(out).toContain('7 tools in Claude Code, Codex, and Cursor · 4 config files checked'); + expect(out).toContain('7 tools in Claude Code, Codex, and Cursor · 4 locations checked'); }); it('shows a per-type count next to each type label', () => { @@ -114,7 +114,7 @@ describe('renderStackReport (default `npx devcat-cli` output)', () => { const one: DetectResult = { tools: [tool({ name: 'solo' })], pathsScanned: ['~/.claude.json'] }; const out = renderStackReport(one); expect(out).toContain('Your AI-coding stack — 1 tool'); - expect(out).toContain('1 tool in Claude Code · 1 config file checked'); + expect(out).toContain('1 tool in Claude Code · 1 location checked'); }); it('empty scan: names the paths it checked instead of printing an empty stack', () => { @@ -162,6 +162,49 @@ describe('renderStackMarkdown (--markdown)', () => { }); }); +describe('name sanitization', () => { + // Config keys and folder names can legally hold control characters. A name + // carrying an ANSI sequence would otherwise repaint the terminal report. + const HOSTILE = '\u001b[31mred\u001b[0m\nfake-line\ttab'; + const SANITIZED = '[31mred[0mfake-linetab'; + // Every control character EXCEPT \n, which the report legitimately uses + // to separate its own lines. Tab and ESC must not survive. + // eslint-disable-next-line no-control-regex + const CONTROL_CHARS = /[\u0000-\u0009\u000b-\u001f\u007f-\u009f]/; + + const hostile: DetectResult = { + tools: [tool({ name: HOSTILE }), tool({ name: 'back`tick', type: 'skill' })], + pathsScanned: ['~/.claude.json'], + }; + + it('strips control characters from the terminal report', () => { + const out = renderStackReport(hostile); + expect(out).not.toMatch(CONTROL_CHARS); + expect(out).toContain(SANITIZED); + }); + + it('strips control characters and backticks from markdown', () => { + const out = renderStackMarkdown(hostile); + expect(out).not.toMatch(CONTROL_CHARS); + expect(out).toContain(SANITIZED); + // The backtick would otherwise break out of the inline code span. + expect(out).toContain('`backtick`'); + }); + + it('leaves ordinary names untouched', () => { + expect(renderStackReport(MIXED)).toContain('atelier-board, context7'); + expect(renderStackMarkdown(MIXED)).toContain('`swift-lsp`'); + }); + + it('needs no sanitizing in JSON - stringify escapes control characters', () => { + const parsed = JSON.parse(renderStackJson(hostile)); + const names = parsed.clients.flatMap((c: { types: { names: string[] }[] }) => + c.types.flatMap((t) => t.names), + ); + expect(names).toContain(HOSTILE); + }); +}); + describe('renderStackJson (--json)', () => { it('is a single parseable object mirroring the report', () => { const parsed = JSON.parse(renderStackJson(MIXED)); From 114ecfc70d364d06e92cad6c4a31a8c2fc2b9444 Mon Sep 17 00:00:00 2001 From: AnobleSCM <211227905+AnobleSCM@users.noreply.github.com> Date: Sun, 2 Aug 2026 16:17:26 -0700 Subject: [PATCH 16/27] docs: make every README claim true of the code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review finding 6. "Config files and directory names only — never file contents" was false: the JSON and TOML configs are read and parsed in full. What is true is narrower and worth stating precisely, so the section is now a table of what is read where and how. - Config files ARE read and parsed; only the names inside them are kept. - Directory scans open nothing — not even the SKILL.md, whose presence is all that is checked. - Names come from the folder for skills and folder-shaped subagents, but from the FILE for bare .md subagents, which the old blanket "names come from the folder name" got wrong. - The subagent folder shape requires a matching inner filename. The security bullets and the dedupe rules are restated to match the code as it now stands, and the sample output is regenerated from a real run. Co-authored-by: omnigent --- README.md | 30 +++++++++++++++++++----------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 4b8d41d..7bb8bcd 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ Codex · 3 tools Cursor · 2 tools 2 mcp figma, postgres -23 tools in Claude Code, Codex, and Cursor · 12 config files checked +23 tools in Claude Code, Codex, and Cursor · 13 locations checked 2 project-scoped · 21 user-wide ``` @@ -106,22 +106,28 @@ npx devcat-cli --json | jq '.clients[] | {label, total}' ## What it reads -Config files and directory names only — never file contents: +Two kinds of location, read two different ways: -- **Claude Code** — `~/.claude.json`, `~/.claude/settings.json`, `~/.claude/plugins/installed_plugins.json`, `~/.claude/skills/`, `~/.claude/agents/`, and their project equivalents (`.mcp.json`, `.claude/skills/`, `.claude/agents/`) -- **Codex CLI** — `.codex/config.toml` (project), `~/.codex/config.toml`, `~/.codex/skills/` -- **Cursor** — `.cursor/mcp.json` (project), `~/.cursor/mcp.json` +| | 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. | +| **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. | +| **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 nothing but the names inside them is kept. Directory scans open nothing at all. -Skills and subagents are folders, so they are found by listing a directory rather than parsing a file. That scan is deliberately shallow: it reads the config root and its immediate children, never recurses, resolves symlinks to dedupe the link-farm layouts these directories usually use, and skips broken links, unreadable folders, and anything without a `SKILL.md`. Skill and subagent **names come from the folder name** — no file is opened. +Either way, what leaves those locations is the tool's **name and type** and nothing else — no environment variable values, no command-line arguments, no file contents, no other field. Missing and malformed files are skipped silently: a broken `.mcp.json` never fails the scan. -It never reads or reports environment variable values, command-line arguments, file paths, file contents, or any field other than the tool name and type. Missing and malformed config files are skipped silently — a broken `.mcp.json` never fails the scan. +The directory scans are deliberately shallow. They read a known config root and its immediate children and never recurse, so a symlink cannot lead the scan out into a large tree. Symlinks are resolved to a canonical path — these directories are usually link farms. Broken links, unreadable directories, and entries that disappear mid-scan are skipped. The number of children examined per root is capped, and the cap is applied after sorting, so the same machine always yields the same result. 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, so your user-wide config is never double-counted as project config. -**A tool configured in more than one place is listed once.** Two passes decide where it lands, and both are deterministic — the same machine always produces the same report: +**A tool configured in more than one place is listed once.** Identity is deterministic, so the same machine always produces the same report: -- Within one directory, aliases are collapsed by resolved symlink target. Two links to the same skill are one skill. -- Across clients, the first occurrence 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. +- 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. Install it globally if you run it often: @@ -157,7 +163,9 @@ Sync sends MCP servers and plugins only. Skills and subagents are local report d ## Security - **Nothing leaves your machine on the default command.** The scan is local; `npx devcat-cli` makes no network request at all. -- **No env vars, no command args, no paths, no file contents** are read out of your configs — only tool names and types. A unit test proves this for every release. +- **Only names and types are kept.** Config files are parsed, but environment variable values, command-line arguments, paths, and every other field are discarded — they never enter the report, the markdown, the JSON, or a sync payload. +- **Skill and subagent files are never opened at all** — those scans only list directories and check that an expected filename exists. +- **A test asserts the real request body.** It plants secrets throughout a fixture machine, including inside a `SKILL.md` and a subagent file, runs the actual sync, and inspects the bytes that reached the wire — not a reconstruction of them. - **Tokens live in the OS keychain**, no plaintext fallback, and bearer tokens are redacted from `--verbose` output. - **Source is public** — read every line at [github.com/AnobleSCM/devcat-cli](https://github.com/AnobleSCM/devcat-cli). From 9378f7cd208f8730b1bd86f36b3caee444bdf5e6 Mon Sep 17 00:00:00 2001 From: AnobleSCM <211227905+AnobleSCM@users.noreply.github.com> Date: Sun, 2 Aug 2026 16:35:40 -0700 Subject: [PATCH 17/27] fix(manifest): include type in the canonical-path dedupe key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Blocker A. Path-backed entries keyed on `path::${canonicalPath}` alone, so one directory that legitimately represents two tools of different types — a folder holding both a SKILL.md and a matching .md, linked into the skills root and the agents root — collapsed to a single entry and silently lost the second. Key is now `path::${type}::${canonicalPath}`. Same-path entries of the same type still collapse; same-path entries of different types both survive. The existing different-type test used path-less entries, so it exercised the (type, name) fallback rather than this key. Adds one with two path-backed entries sharing canonicalPath and differing in type, plus a companion asserting same-type collapse still works. Co-authored-by: omnigent --- src/manifest/dedupe.ts | 7 ++++++- test/unit/manifest/dedupe.test.ts | 26 ++++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/src/manifest/dedupe.ts b/src/manifest/dedupe.ts index 45a7bde..5deaf15 100644 --- a/src/manifest/dedupe.ts +++ b/src/manifest/dedupe.ts @@ -12,6 +12,11 @@ import type { ToolEntry } from './index.js'; * farms into one shared directory, so path identity is what actually * collapses that overlap — and it is what stops two genuinely different * skills that happen to share a name from swallowing each other. + * + * Type is part of that key. One directory can legitimately be two tools: + * a folder holding both a SKILL.md and a matching .md, linked into + * the skills root and the agents root, is a skill AND a subagent. Keying + * on path alone would silently drop whichever was scanned second. * - An entry without one is a key inside a config file, where (type, name) * is the only identity available. That is also what the server matches on. * @@ -28,7 +33,7 @@ export function dedupe(entries: ToolEntry[]): ToolEntry[] { const out: ToolEntry[] = []; for (const e of entries) { const key = e.canonicalPath - ? `path::${e.canonicalPath}` + ? `path::${e.type}::${e.canonicalPath}` : `name::${e.type}::${e.name}`; if (seen.has(key)) continue; seen.add(key); diff --git a/test/unit/manifest/dedupe.test.ts b/test/unit/manifest/dedupe.test.ts index 5b91239..6a03d57 100644 --- a/test/unit/manifest/dedupe.test.ts +++ b/test/unit/manifest/dedupe.test.ts @@ -69,6 +69,32 @@ describe('dedupe — canonical path identity', () => { expect(dedupe([a, b])).toHaveLength(1); }); + it('keeps one directory that is BOTH a skill and a subagent', () => { + // A folder holding a SKILL.md and a matching .md, linked into both + // the skills root and the agents root, is legitimately two tools. Keying + // on path alone silently dropped whichever was scanned second. + const shared = '/canon/tldraw-offline'; + const asSkill: ToolEntry = { + type: 'skill', name: 'tldraw-offline', source: '/claude/skills', + scope: 'user', client: 'claude-code', canonicalPath: shared, + }; + const asSubagent: ToolEntry = { + type: 'subagent', name: 'tldraw-offline', source: '/claude/agents', + scope: 'user', client: 'claude-code', canonicalPath: shared, + }; + + const result = dedupe([asSkill, asSubagent]); + expect(result).toHaveLength(2); + expect(result.map((t) => t.type)).toEqual(['skill', 'subagent']); + }); + + it('still collapses same-path entries of the SAME type', () => { + const shared = '/canon/panel'; + const viaClaude = skill('panel', shared, 'claude-code'); + const viaCodex = skill('panel-alias', shared, 'codex'); + expect(dedupe([viaClaude, viaCodex])).toHaveLength(1); + }); + it('never lets a path key collide with a name key', () => { const pathEntry = skill('x', 'skill::x', 'claude-code'); const nameEntry: ToolEntry = { type: 'skill', name: 'x', source: 's', scope: 'user', client: 'codex' }; From c3b0d55bf6a797488d35922101cb5beb682283cf Mon Sep 17 00:00:00 2001 From: AnobleSCM <211227905+AnobleSCM@users.noreply.github.com> Date: Sun, 2 Aug 2026 16:36:04 -0700 Subject: [PATCH 18/27] fix(manifest): hard-bound the root scan and disclose truncation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Blocker B, both halves. The scan still called readdir(), which materializes an entire directory before the 500 cap could apply — the cap bounded the result, not the work, so a pathological root was unbounded in practice. Reading now streams via opendir and stops at a READ_CEILING of 10,000 entries; the remainder is never read. Dot-entries count toward the ceiling too, so a root full of them cannot spin either. Selection stays deterministic: what was read is sorted, then the first 500 get the per-entry filesystem work. Neither bound may fire silently. A known-valid 501st tool missing while every output claims completeness is precisely the claims-versus-reality failure this review exists to end. When either bites, the scan returns a RootTruncation, and it surfaces everywhere: - stderr: one line per root, naming it with entries read and examined, and saying when the read ceiling was the cause. Emitted in --json mode too, so stdout stays parseable while the operator still learns. - terminal and markdown: a short footnote that the list is incomplete. - --json: a `truncated` flag plus a `truncations` array carrying root, entries_seen, entries_kept, and hit_read_ceiling per root. scanSkills/scanSubagents take an optional readCeiling so tests can reach the ceiling path without creating ten thousand directories; production always uses the default. Co-authored-by: omnigent --- src/commands/report.ts | 16 +++- src/manifest/claude.ts | 13 ++- src/manifest/codex.ts | 19 ++-- src/manifest/cursor.ts | 2 + src/manifest/dirScan.ts | 141 +++++++++++++++++++++++------ src/manifest/index.ts | 13 +++ src/ui/report.ts | 45 ++++++++- test/integration/report.test.ts | 72 ++++++++++++++- test/unit/manifest/dirScan.test.ts | 92 ++++++++++++++----- test/unit/ui/report.test.ts | 94 ++++++++++++++++++- 10 files changed, 434 insertions(+), 73 deletions(-) diff --git a/src/commands/report.ts b/src/commands/report.ts index 86c7415..b5acf68 100644 --- a/src/commands/report.ts +++ b/src/commands/report.ts @@ -1,5 +1,11 @@ import { detect } from '../manifest/index.js'; -import { renderStackReport, renderStackMarkdown, renderStackJson } from '../ui/report.js'; +import { + renderStackReport, + renderStackMarkdown, + renderStackJson, + truncationWarnings, +} from '../ui/report.js'; +import { c } from '../ui/colors.js'; import { EXIT_OK, type ExitCode } from '../lib/exitCodes.js'; export interface ReportOptions { @@ -22,6 +28,14 @@ export interface ReportOptions { */ export async function runReport(opts: ReportOptions): Promise { const manifest = await detect(process.cwd()); + + // Disclosure goes to stderr in every mode, including --json, so a piped + // stdout stays parseable while the operator still learns the scan was + // incomplete. Named roots and counts, one line each. + for (const warning of truncationWarnings(manifest.truncations)) { + process.stderr.write(`${c.yellow(warning)}\n`); + } + let out: string; if (opts.json) { out = renderStackJson(manifest); diff --git a/src/manifest/claude.ts b/src/manifest/claude.ts index 997531f..549ced1 100644 --- a/src/manifest/claude.ts +++ b/src/manifest/claude.ts @@ -2,7 +2,7 @@ import { readFile } from 'node:fs/promises'; import { homedir } from 'node:os'; import { join } from 'node:path'; import { findUpward, findUpwardDir, isUserLevelPath } from '../lib/findUpward.js'; -import { scanSkills, scanSubagents } from './dirScan.js'; +import { scanSkills, scanSubagents, type RootTruncation } from './dirScan.js'; import type { ToolEntry } from './index.js'; interface McpServersFile { @@ -17,6 +17,8 @@ interface InstalledPluginsFile { interface SourceScan { tools: ToolEntry[]; pathsScanned: string[]; + /** Roots where a scan bound bit. Absent means nothing was left out. */ + truncations?: RootTruncation[]; } /** @@ -96,6 +98,7 @@ function mergeScans(scans: SourceScan[]): SourceScan { return { tools: scans.flatMap((s) => s.tools), pathsScanned: scans.flatMap((s) => s.pathsScanned), + truncations: scans.flatMap((s) => s.truncations ?? []), }; } @@ -104,7 +107,7 @@ function mergeScans(scans: SourceScan[]): SourceScan { * they never enter the /api/sync payload (see syncableTools in index.ts). */ async function readSkillsDir(path: string, scope: 'project' | 'user'): Promise { - const hits = await scanSkills(path); + const { hits, truncation } = await scanSkills(path); const tools: ToolEntry[] = hits.map((hit) => ({ type: 'skill' as const, name: hit.name, @@ -113,12 +116,12 @@ async function readSkillsDir(path: string, scope: 'project' | 'user'): Promise { - const hits = await scanSubagents(path); + const { hits, truncation } = await scanSubagents(path); const tools: ToolEntry[] = hits.map((hit) => ({ type: 'subagent' as const, name: hit.name, @@ -127,7 +130,7 @@ async function readSubagentsDir(path: string, scope: 'project' | 'user'): Promis client: 'claude-code' as const, canonicalPath: hit.realPath, })); - return { tools, pathsScanned: [path] }; + return { tools, pathsScanned: [path], truncations: truncation ? [truncation] : [] }; } async function readMcpServersJson(path: string, scope: 'project' | 'user'): Promise { diff --git a/src/manifest/codex.ts b/src/manifest/codex.ts index e3a6541..9656779 100644 --- a/src/manifest/codex.ts +++ b/src/manifest/codex.ts @@ -3,7 +3,7 @@ import { homedir } from 'node:os'; import { join } from 'node:path'; import { parse as parseToml } from 'smol-toml'; import { findUpward, isUserLevelPath } from '../lib/findUpward.js'; -import { scanSkills } from './dirScan.js'; +import { scanSkills, type RootTruncation } from './dirScan.js'; import type { ToolEntry } from './index.js'; interface CodexConfigToml { @@ -13,6 +13,8 @@ interface CodexConfigToml { interface SourceScan { tools: ToolEntry[]; pathsScanned: string[]; + /** Roots where a scan bound bit. Absent means nothing was left out. */ + truncations?: RootTruncation[]; } /** @@ -24,10 +26,12 @@ interface SourceScan { * Two dedupe passes handle that, and both are order-independent in effect: * * - Within a root, scanSkills() collapses aliases by resolved path. - * - Across clients, dedupe() collapses by (type, name), keeping the first - * occurrence. detect() scans Claude Code before Codex, so a skill on both - * shelves is listed once under Claude Code — deterministically, not by - * whichever filesystem answered first. + * - Across clients, dedupe() keys path-backed entries on (type, resolved + * path), keeping the first occurrence. detect() scans Claude Code before + * Codex, so a skill both shelves link to the same directory is listed + * once under Claude Code — deterministically, not by whichever filesystem + * answered first. Two same-named skills at DIFFERENT paths are different + * skills and both survive. */ export async function detectCodex(opts: { cwd?: string; scope: 'project' | 'user' }): Promise { const mcp = await detectCodexMcp(opts); @@ -37,6 +41,7 @@ export async function detectCodex(opts: { cwd?: string; scope: 'project' | 'user return { tools: [...mcp.tools, ...skills.tools], pathsScanned: [...mcp.pathsScanned, ...skills.pathsScanned], + truncations: [...(mcp.truncations ?? []), ...(skills.truncations ?? [])], }; } @@ -45,7 +50,7 @@ export async function detectCodex(opts: { cwd?: string; scope: 'project' | 'user * skill entry — never part of the /api/sync payload (see syncableTools). */ async function readCodexSkillsDir(path: string): Promise { - const hits = await scanSkills(path); + const { hits, truncation } = await scanSkills(path); const tools: ToolEntry[] = hits.map((hit) => ({ type: 'skill' as const, name: hit.name, @@ -54,7 +59,7 @@ async function readCodexSkillsDir(path: string): Promise { client: 'codex' as const, canonicalPath: hit.realPath, })); - return { tools, pathsScanned: [path] }; + return { tools, pathsScanned: [path], truncations: truncation ? [truncation] : [] }; } /** diff --git a/src/manifest/cursor.ts b/src/manifest/cursor.ts index 85be992..71d639e 100644 --- a/src/manifest/cursor.ts +++ b/src/manifest/cursor.ts @@ -11,6 +11,8 @@ interface CursorMcpFile { interface SourceScan { tools: ToolEntry[]; pathsScanned: string[]; + /** Cursor has no directory-shaped detections, so this is always absent. */ + truncations?: never[]; } /** diff --git a/src/manifest/dirScan.ts b/src/manifest/dirScan.ts index f572bd8..95c576b 100644 --- a/src/manifest/dirScan.ts +++ b/src/manifest/dirScan.ts @@ -1,4 +1,4 @@ -import { readdir, realpath, stat } from 'node:fs/promises'; +import { opendir, realpath, stat } from 'node:fs/promises'; import { join } from 'node:path'; /** @@ -16,16 +16,30 @@ import { join } from 'node:path'; * path is what deduplicates aliases pointing at the same skill. * - A broken symlink, an unreadable directory, or a vanished entry is * skipped. A machine with a stale link never fails a scan. - * - Entry count per root is capped, and the cap is applied after sorting so - * the kept subset is the same on every run regardless of the order the - * filesystem happened to return. + * - Two hard bounds, so a pathological root can neither hang the CLI nor + * quietly change what gets reported: + * READ_CEILING limits how many entries are ever pulled out of one + * directory — the iteration stops there, it does not read the rest. + * MAX_ENTRIES_EXAMINED limits how many of those get the expensive + * per-entry stat/realpath work, applied AFTER sorting so the kept + * subset is the same on every run. + * - Whenever either bound bites, the scan says so. Silently dropping a + * valid tool while every output claims completeness is the failure this + * reporting exists to prevent. * * Names come from the directory (or file) name, which is how skills and * subagents are actually addressed — no file contents are read. */ -/** Defensive bound, in the spirit of findUpward's 64-level cap. */ -const MAX_ENTRIES_PER_ROOT = 500; +/** + * Hard upper bound on entries pulled from a single directory. Reached only by + * a root that is not a real config directory; the shelves this scans hold + * dozens. Streaming via opendir means the remainder is never read at all. + */ +export const READ_CEILING = 10_000; + +/** Entries that get the per-entry filesystem work, after deterministic sort. */ +export const MAX_ENTRIES_EXAMINED = 500; export interface DirHit { name: string; @@ -33,14 +47,34 @@ export interface DirHit { realPath: string; } +/** Emitted when a bound bit, so every output layer can disclose it. */ +export interface RootTruncation { + root: string; + /** Candidate names actually read from the directory. */ + entriesSeen: number; + /** Of those, how many were examined (the rest were dropped by the cap). */ + entriesKept: number; + /** True when reading stopped at READ_CEILING — more names exist, unread. */ + hitReadCeiling: boolean; +} + +export interface DirScanResult { + hits: DirHit[]; + /** Null when the whole root was read and examined. */ + truncation: RootTruncation | null; +} + /** * Skills: an immediate child directory of `root` containing SKILL.md. * * Depth 1. Non-skill clutter that shares the directory (`.git`, `AGENTS.md`, * a README) is ignored by the SKILL.md requirement. + * + * `readCeiling` exists so tests can reach the ceiling path without creating + * ten thousand directories. Production always uses the default. */ -export async function scanSkills(root: string): Promise { - return scanRoot(root, async (entryPath, name) => { +export async function scanSkills(root: string, readCeiling = READ_CEILING): Promise { + return scanRoot(root, readCeiling, async (entryPath, name) => { const resolved = await resolveDir(entryPath); if (!resolved) return null; if (!(await isFile(join(resolved, 'SKILL.md')))) return null; @@ -59,8 +93,11 @@ export async function scanSkills(root: string): Promise { * costs one stat for a known filename rather than a directory listing, so * nothing here reads an unbounded number of entries. */ -export async function scanSubagents(root: string): Promise { - return scanRoot(root, async (entryPath, name) => { +export async function scanSubagents( + root: string, + readCeiling = READ_CEILING, +): Promise { + return scanRoot(root, readCeiling, async (entryPath, name) => { if (name.toLowerCase().endsWith('.md')) { if (!(await isFile(entryPath))) return null; const resolved = await realpathOrNull(entryPath); @@ -75,30 +112,64 @@ export async function scanSubagents(root: string): Promise { } /** - * Shared shape: list a root's immediate children, classify each with - * `classify`, drop the misses, and dedupe on resolved path. + * Read up to READ_CEILING candidate names out of `root`, streaming. + * + * opendir yields entries lazily, so hitting the ceiling means the remainder + * of a pathological directory is never read — the bound is on the work done, + * not just on the result. Dot-entries are skipped but still count toward the + * ceiling, so a directory full of them cannot spin forever either. + * + * Returns null when the root cannot be opened at all (missing, or no + * permission) — indistinguishable outcomes, both meaning "nothing here". */ -async function scanRoot( +async function readCandidateNames( root: string, - classify: (entryPath: string, name: string) => Promise, -): Promise { - let names: string[]; + readCeiling: number, +): Promise<{ names: string[]; hitReadCeiling: boolean } | null> { + let dir; try { - // readdir materializes the whole listing. Node's streaming alternative - // (opendir) would avoid that, but a deterministic cap has to know every - // candidate name before choosing which to keep — so the names are read in - // full (strings only, cheap), then sorted, and only then is the cap - // applied to the expensive per-entry stat/realpath work below. - names = await readdir(root); + dir = await opendir(root); } catch { - // Missing root, or no permission to read it. Either way: nothing here. - return []; + return null; } - const candidates = names - .filter((name) => !name.startsWith('.')) - .sort((a, b) => a.localeCompare(b)) - .slice(0, MAX_ENTRIES_PER_ROOT); + const names: string[] = []; + let iterated = 0; + let hitReadCeiling = false; + try { + for await (const entry of dir) { + if (iterated >= readCeiling) { + hitReadCeiling = true; + break; + } + iterated += 1; + if (entry.name.startsWith('.')) continue; + names.push(entry.name); + } + } catch { + // A directory that vanishes or errors mid-iteration still yields whatever + // was read. The async iterator closes the handle on the way out. + } + return { names, hitReadCeiling }; +} + +/** + * Shared shape: read a root's immediate children under both bounds, classify + * each with `classify`, drop the misses, dedupe on resolved path, and report + * whether anything was left out. + */ +async function scanRoot( + root: string, + readCeiling: number, + classify: (entryPath: string, name: string) => Promise, +): Promise { + const read = await readCandidateNames(root, readCeiling); + if (!read) return { hits: [], truncation: null }; + + // Sort before capping so the examined subset is the same on every run, + // whatever order the filesystem returned. + const sorted = [...read.names].sort((a, b) => a.localeCompare(b)); + const candidates = sorted.slice(0, MAX_ENTRIES_EXAMINED); const settled = await Promise.all( candidates.map(async (name) => { @@ -118,7 +189,19 @@ async function scanRoot( seen.add(hit.realPath); hits.push(hit); } - return hits; + + const droppedByCap = sorted.length > candidates.length; + const truncation: RootTruncation | null = + droppedByCap || read.hitReadCeiling + ? { + root, + entriesSeen: sorted.length, + entriesKept: candidates.length, + hitReadCeiling: read.hitReadCeiling, + } + : null; + + return { hits, truncation }; } /** Resolve `path` to a real directory, or null if it is not one / is broken. */ diff --git a/src/manifest/index.ts b/src/manifest/index.ts index 32ec59f..1f0eed9 100644 --- a/src/manifest/index.ts +++ b/src/manifest/index.ts @@ -2,6 +2,10 @@ import { detectClaudeCode } from './claude.js'; import { detectCodex } from './codex.js'; import { detectCursor } from './cursor.js'; import { dedupe } from './dedupe.js'; +import type { RootTruncation } from './dirScan.js'; + +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'; @@ -49,6 +53,13 @@ export function syncableTools(tools: readonly ToolEntry[]): SyncableToolEntry[] export interface DetectResult { tools: ToolEntry[]; pathsScanned: string[]; + /** + * Roots where a scan bound bit, so something installed is missing from + * `tools`. Empty on any ordinary machine. Every output layer discloses + * this — a report that quietly omits a tool while claiming completeness + * is worse than one that admits it stopped early. + */ + truncations: RootTruncation[]; } /** @@ -71,8 +82,10 @@ export async function detect(cwd: string): Promise { ]); const allTools = sources.flatMap((s) => s.tools); const allPaths = sources.flatMap((s) => s.pathsScanned); + const allTruncations = sources.flatMap((s) => s.truncations ?? []); return { tools: dedupe(allTools), pathsScanned: allPaths, + truncations: allTruncations, }; } diff --git a/src/ui/report.ts b/src/ui/report.ts index c7d0734..14bdd84 100644 --- a/src/ui/report.ts +++ b/src/ui/report.ts @@ -1,4 +1,5 @@ -import type { DetectResult, ToolEntry, ToolClient } from '../manifest/index.js'; +import type { DetectResult, ToolEntry, ToolClient, RootTruncation } from '../manifest/index.js'; +import { READ_CEILING } from '../manifest/index.js'; import { CLI_VERSION } from '../version.js'; import { c, SUCCESS_GLYPH } from './colors.js'; @@ -149,9 +150,37 @@ export function renderStackReport(result: DetectResult): string { lines.push(c.dim(`${projectScoped} project-scoped · ${total - projectScoped} user-wide`)); } + if (result.truncations.length > 0) { + lines.push(''); + lines.push(c.yellow(truncationFootnote(result.truncations))); + } + return lines.join('\n'); } +/** + * One line admitting the report is incomplete. Short on purpose — the detail + * goes to stderr and to --json; this exists so a reader of the report itself + * is never told a partial list is the whole list. + */ +export function truncationFootnote(truncations: readonly RootTruncation[]): string { + const count = truncations.length; + return `! ${count} ${count === 1 ? 'location was' : 'locations were'} truncated — some tools are not listed. See --json for details.`; +} + +/** + * Per-root detail, for stderr. Named locations and counts, so the user can + * see which directory is oversized and by how much. + */ +export function truncationWarnings(truncations: readonly RootTruncation[]): string[] { + return truncations.map((t) => { + const ceiling = t.hitReadCeiling + ? ` Reading stopped at the ${READ_CEILING}-entry ceiling, so more may exist unread.` + : ''; + return `! Truncated scan of ${sanitizeName(t.root)} — ${t.entriesSeen} entries read, ${t.entriesKept} examined. Some tools are not listed.${ceiling}`; + }); +} + /** * Shareable snippet for a README or gist. Plain markdown, no ANSI, stable * ordering so re-running it produces a clean diff rather than a reshuffle. @@ -179,6 +208,11 @@ export function renderStackMarkdown(result: DetectResult): string { } } + if (result.truncations.length > 0) { + lines.push(''); + lines.push(`> ${truncationFootnote(result.truncations)}`); + } + lines.push(''); lines.push(MARKDOWN_FOOTER); return lines.join('\n'); @@ -212,6 +246,13 @@ export function renderStackJson(result: DetectResult): string { })), })), paths_checked: result.pathsScanned, + truncated: result.truncations.length > 0, + truncations: result.truncations.map((t) => ({ + root: t.root, + entries_seen: t.entriesSeen, + entries_kept: t.entriesKept, + hit_read_ceiling: t.hitReadCeiling, + })), }; return JSON.stringify(payload, null, 2); } @@ -224,7 +265,7 @@ const MARKDOWN_FOOTER = * config location the CLI does not know about yet. */ function renderEmptyStack(pathsScanned: string[]): string { - const list = pathsScanned.map((p) => ` - ${p}`).join('\n'); + const list = pathsScanned.map((p) => ` - ${sanitizeName(p)}`).join('\n'); return [ `${SUCCESS_GLYPH} ${c.bold('No AI tooling detected.')}`, '', diff --git a/test/integration/report.test.ts b/test/integration/report.test.ts index 293116a..e5af2c7 100644 --- a/test/integration/report.test.ts +++ b/test/integration/report.test.ts @@ -78,21 +78,29 @@ afterAll(() => { async function runAndCapture( markdown: boolean, json = false, -): Promise<{ exitCode: number; out: string }> { +): 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(projectDir); try { const { runReport } = await import('../../src/commands/report.js'); const exitCode = await runReport({ markdown, json }); - return { exitCode, out: chunks.join('') }; + return { exitCode, out: chunks.join(''), err: errChunks.join('') }; } finally { writeSpy.mockRestore(); + errSpy.mockRestore(); cwdSpy.mockRestore(); } } @@ -181,4 +189,64 @@ describe('report — --json', () => { expect(() => JSON.parse(out)).not.toThrow(); expect(out).not.toContain('## My AI stack'); }); + + it('reports truncated:false and no warning on an ordinary machine', async () => { + const { out, err } = await runAndCapture(false, true); + expect(JSON.parse(out).truncated).toBe(false); + expect(err).toBe(''); + }); +}); + +describe('report — truncation is disclosed end to end', () => { + let bigHome: string; + + beforeAll(() => { + // A skills root over the 500 examine cap. + bigHome = mkdtempSync(join(tmpdir(), 'devcat-report-big-')); + const skillsRoot = join(bigHome, '.claude', 'skills'); + mkdirSync(skillsRoot, { recursive: true }); + for (let i = 0; i < 505; i++) { + const dir = join(skillsRoot, `skill-${String(i).padStart(4, '0')}`); + mkdirSync(dir); + writeFileSync(join(dir, 'SKILL.md'), '# s\n'); + } + }); + + afterAll(() => rmSync(bigHome, { recursive: true, force: true })); + + async function runBig(json: boolean): Promise<{ out: string; err: string }> { + const previous = homedirHolder.current; + homedirHolder.current = bigHome; + try { + const { out, err } = await runAndCapture(false, json); + return { out, err }; + } finally { + homedirHolder.current = previous; + } + } + + it('warns on stderr, naming the root and the counts', async () => { + const { err } = await runBig(false); + expect(err).toContain('Truncated scan of'); + expect(err).toContain(join(bigHome, '.claude', 'skills')); + expect(err).toContain('505 entries read'); + expect(err).toContain('500 examined'); + }); + + it('footnotes the terminal report instead of implying completeness', async () => { + const { out } = await runBig(false); + expect(out).toContain('truncated'); + expect(out).toContain('some tools are not listed'); + }); + + it('still warns on stderr under --json, leaving stdout parseable', async () => { + const { out, err } = await runBig(true); + const parsed = JSON.parse(out); + expect(parsed.truncated).toBe(true); + expect(parsed.truncations).toHaveLength(1); + expect(parsed.truncations[0].entries_seen).toBe(505); + expect(parsed.truncations[0].entries_kept).toBe(500); + expect(parsed.truncations[0].hit_read_ceiling).toBe(false); + expect(err).toContain('Truncated scan of'); + }); }); diff --git a/test/unit/manifest/dirScan.test.ts b/test/unit/manifest/dirScan.test.ts index 453fc81..5dcf134 100644 --- a/test/unit/manifest/dirScan.test.ts +++ b/test/unit/manifest/dirScan.test.ts @@ -33,7 +33,7 @@ describe('scanSkills', () => { makeSkill(root, 'deep-research'); makeSkill(root, 'panel'); - const hits = await scanSkills(root); + const { hits } = await scanSkills(root); expect(hits.map((h) => h.name).sort()).toEqual(['deep-research', 'panel']); }); @@ -46,7 +46,7 @@ describe('scanSkills', () => { writeFileSync(join(root, 'CLAUDE.md'), 'x'); mkdirSync(join(root, '.git')); - const hits = await scanSkills(root); + const { hits } = await scanSkills(root); expect(hits.map((h) => h.name)).toEqual(['real-skill']); }); @@ -59,7 +59,7 @@ describe('scanSkills', () => { mkdirSync(farm); symlinkSync(target, join(farm, 'handoff')); - const hits = await scanSkills(farm); + const { hits } = await scanSkills(farm); expect(hits).toHaveLength(1); expect(hits[0]!.name).toBe('handoff'); // realPath resolves through the link to the canonical location. @@ -76,7 +76,7 @@ describe('scanSkills', () => { symlinkSync(target, join(farm, 'panel')); symlinkSync(target, join(farm, 'panel-alias')); - const hits = await scanSkills(farm); + const { hits } = await scanSkills(farm); expect(hits).toHaveLength(1); }); @@ -86,7 +86,7 @@ describe('scanSkills', () => { makeSkill(farm, 'good'); symlinkSync(join(tmp, 'does-not-exist'), join(farm, 'dangling')); - const hits = await scanSkills(farm); + const { hits } = await scanSkills(farm); expect(hits.map((h) => h.name)).toEqual(['good']); }); @@ -96,38 +96,84 @@ describe('scanSkills', () => { const outer = makeSkill(root, 'outer'); makeSkill(outer, 'nested-should-be-invisible'); - const hits = await scanSkills(root); + const { hits } = await scanSkills(root); expect(hits.map((h) => h.name)).toEqual(['outer']); }); it('returns empty for a missing root', async () => { - await expect(scanSkills(join(tmp, 'nope'))).resolves.toEqual([]); + await expect(scanSkills(join(tmp, 'nope'))).resolves.toEqual({ hits: [], truncation: null }); }); it('returns empty when the root is unreadable rather than a directory', async () => { const file = join(tmp, 'a-file'); writeFileSync(file, 'not a directory'); - await expect(scanSkills(file)).resolves.toEqual([]); + await expect(scanSkills(file)).resolves.toEqual({ hits: [], truncation: null }); }); - it('caps a large root deterministically — sorted first, so the kept subset is stable', async () => { + it('caps a large root deterministically, and says so', async () => { const root = join(tmp, 'skills'); mkdirSync(root); - // 520 skills, over the 500 cap. Names are zero-padded so alphabetical - // order is also numeric order and the expected survivors are obvious. + // 520 skills, over the 500 examine cap. Names are zero-padded so + // alphabetical order is also numeric order. for (let i = 0; i < 520; i++) makeSkill(root, `skill-${String(i).padStart(4, '0')}`); const first = await scanSkills(root); const second = await scanSkills(root); - expect(first).toHaveLength(500); + expect(first.hits).toHaveLength(500); // Same subset every run, and it is the alphabetical head — not whatever // order the filesystem happened to hand back. - expect(first.map((h) => h.name)).toEqual(second.map((h) => h.name)); - expect(first[0]!.name).toBe('skill-0000'); - expect(first[499]!.name).toBe('skill-0499'); - expect(first.map((h) => h.name)).not.toContain('skill-0500'); + expect(first.hits.map((h) => h.name)).toEqual(second.hits.map((h) => h.name)); + expect(first.hits[0]!.name).toBe('skill-0000'); + expect(first.hits[499]!.name).toBe('skill-0499'); + expect(first.hits.map((h) => h.name)).not.toContain('skill-0500'); + + // The dropped 20 must be disclosed, not silently missing. + expect(first.truncation).toEqual({ + root, + entriesSeen: 520, + entriesKept: 500, + hitReadCeiling: false, + }); }); + + it('reports no truncation when the whole root fits', async () => { + const root = join(tmp, 'skills'); + mkdirSync(root); + makeSkill(root, 'only-one'); + const result = await scanSkills(root); + expect(result.truncation).toBeNull(); + }); + + it('stops reading at the ceiling and flags it, rather than draining the root', async () => { + const root = join(tmp, 'skills'); + mkdirSync(root); + for (let i = 0; i < 10; i++) makeSkill(root, `skill-${String(i).padStart(2, '0')}`); + + // Ceiling of 3: iteration stops after three entries, so at most three + // names are ever read out of a ten-entry directory. + const result = await scanSkills(root, 3); + + expect(result.hits.length).toBeLessThanOrEqual(3); + expect(result.truncation).not.toBeNull(); + expect(result.truncation!.hitReadCeiling).toBe(true); + expect(result.truncation!.entriesSeen).toBeLessThanOrEqual(3); + expect(result.truncation!.root).toBe(root); + }); + + it('counts dot-entries toward the ceiling, so a root full of them cannot spin', async () => { + const root = join(tmp, 'skills'); + mkdirSync(root); + for (let i = 0; i < 8; i++) mkdirSync(join(root, `.hidden-${i}`)); + makeSkill(root, 'real-skill'); + + const result = await scanSkills(root, 4); + // The ceiling was consumed by dot-entries; the scan stopped rather than + // reading on to find the real skill, and says so. + expect(result.truncation).not.toBeNull(); + expect(result.truncation!.hitReadCeiling).toBe(true); + }); + }); describe('scanSubagents', () => { @@ -136,7 +182,7 @@ describe('scanSubagents', () => { mkdirSync(root); writeFileSync(join(root, 'tldraw-offline.md'), '---\nname: x\n---\n'); - const hits = await scanSubagents(root); + const { hits } = await scanSubagents(root); expect(hits.map((h) => h.name)).toEqual(['tldraw-offline']); }); @@ -145,7 +191,7 @@ describe('scanSubagents', () => { mkdirSync(join(root, 'code-reviewer'), { recursive: true }); writeFileSync(join(root, 'code-reviewer', 'code-reviewer.md'), 'x'); - const hits = await scanSubagents(root); + const { hits } = await scanSubagents(root); expect(hits.map((h) => h.name)).toEqual(['code-reviewer']); }); @@ -155,7 +201,7 @@ describe('scanSubagents', () => { writeFileSync(join(root, 'reviewer', 'README.md'), '# just docs'); writeFileSync(join(root, 'reviewer', 'notes.md'), 'x'); - const hits = await scanSubagents(root); + const { hits } = await scanSubagents(root); expect(hits).toEqual([]); }); @@ -167,7 +213,7 @@ describe('scanSubagents', () => { mkdirSync(join(root, 'impostor'), { recursive: true }); writeFileSync(join(root, 'impostor', 'something-else.md'), 'x'); - const hits = await scanSubagents(root); + const { hits } = await scanSubagents(root); expect(hits.map((h) => h.name)).toEqual(['debugger']); }); @@ -179,7 +225,7 @@ describe('scanSubagents', () => { writeFileSync(join(root, 'secure-reviewer.md'), 'x'); writeFileSync(join(root, 'notes.txt'), 'x'); - const hits = await scanSubagents(root); + const { hits } = await scanSubagents(root); expect(hits.map((h) => h.name).sort()).toEqual(['debugger', 'secure-reviewer']); }); @@ -188,7 +234,7 @@ describe('scanSubagents', () => { mkdirSync(root); symlinkSync(join(tmp, 'gone.md'), join(root, 'dangling.md')); - await expect(scanSubagents(root)).resolves.toEqual([]); - await expect(scanSubagents(join(tmp, 'nope'))).resolves.toEqual([]); + await expect((await scanSubagents(root)).hits).toEqual([]); + await expect((await scanSubagents(join(tmp, 'nope'))).hits).toEqual([]); }); }); diff --git a/test/unit/ui/report.test.ts b/test/unit/ui/report.test.ts index 8a5dd70..7eac92e 100644 --- a/test/unit/ui/report.test.ts +++ b/test/unit/ui/report.test.ts @@ -4,6 +4,7 @@ import { renderStackReport, renderStackMarkdown, renderStackJson, + truncationWarnings, } from '../../../src/ui/report.js'; import type { DetectResult, ToolEntry } from '../../../src/manifest/index.js'; @@ -32,6 +33,7 @@ const MIXED: DetectResult = { tool({ name: 'serena', client: 'codex' }), ], pathsScanned: ['/p/.mcp.json', '~/.claude.json', '~/.codex/config.toml', '~/.cursor/mcp.json'], + truncations: [], }; describe('groupStack', () => { @@ -88,6 +90,7 @@ describe('renderStackReport (default `npx devcat-cli` output)', () => { const userOnly: DetectResult = { tools: [tool({ name: 'a' })], pathsScanned: ['~/.claude.json'], + truncations: [], }; expect(renderStackReport(userOnly)).not.toContain('project-scoped'); }); @@ -96,6 +99,7 @@ describe('renderStackReport (default `npx devcat-cli` output)', () => { const many: DetectResult = { tools: Array.from({ length: 20 }, (_, i) => tool({ name: `mcp-server-number-${i}` })), pathsScanned: ['~/.claude.json'], + truncations: [], }; const lines = renderStackReport(many).split('\n'); const nameLines = lines.filter((l) => l.includes('mcp-server-number-')); @@ -111,14 +115,18 @@ describe('renderStackReport (default `npx devcat-cli` output)', () => { }); it('singularizes a one-tool stack', () => { - const one: DetectResult = { tools: [tool({ name: 'solo' })], pathsScanned: ['~/.claude.json'] }; + const one: DetectResult = { + tools: [tool({ name: 'solo' })], + pathsScanned: ['~/.claude.json'], + truncations: [], + }; const out = renderStackReport(one); expect(out).toContain('Your AI-coding stack — 1 tool'); expect(out).toContain('1 tool in Claude Code · 1 location checked'); }); it('empty scan: names the paths it checked instead of printing an empty stack', () => { - const out = renderStackReport({ tools: [], pathsScanned: ['/p/.mcp.json', '~/.cursor/mcp.json'] }); + const out = renderStackReport({ tools: [], pathsScanned: ['/p/.mcp.json', '~/.cursor/mcp.json'], truncations: [] }); expect(out).toContain('No AI tooling detected.'); expect(out).toContain('/p/.mcp.json'); expect(out).toContain('~/.cursor/mcp.json'); @@ -149,6 +157,7 @@ describe('renderStackMarkdown (--markdown)', () => { const many: DetectResult = { tools: Array.from({ length: 20 }, (_, i) => tool({ name: `mcp-server-number-${i}` })), pathsScanned: ['~/.claude.json'], + truncations: [], }; const bullets = renderStackMarkdown(many).split('\n').filter((l) => l.startsWith('- **')); expect(bullets).toHaveLength(1); @@ -156,7 +165,7 @@ describe('renderStackMarkdown (--markdown)', () => { }); it('empty scan still produces a valid snippet', () => { - const out = renderStackMarkdown({ tools: [], pathsScanned: ['~/.claude.json'] }); + const out = renderStackMarkdown({ tools: [], pathsScanned: ['~/.claude.json'], truncations: [] }); expect(out).toContain('## My AI stack'); expect(out).toContain('No AI tooling detected on this machine yet.'); }); @@ -175,6 +184,7 @@ describe('name sanitization', () => { const hostile: DetectResult = { tools: [tool({ name: HOSTILE }), tool({ name: 'back`tick', type: 'skill' })], pathsScanned: ['~/.claude.json'], + truncations: [], }; it('strips control characters from the terminal report', () => { @@ -205,6 +215,82 @@ describe('name sanitization', () => { }); }); +describe('truncation disclosure', () => { + const TRUNCATED: DetectResult = { + tools: [tool({ name: 'context7' })], + pathsScanned: ['~/.claude/skills'], + truncations: [ + { root: '~/.claude/skills', entriesSeen: 520, entriesKept: 500, hitReadCeiling: false }, + ], + }; + + const CEILINGED: DetectResult = { + ...TRUNCATED, + truncations: [ + { root: '~/.claude/skills', entriesSeen: 10000, entriesKept: 500, hitReadCeiling: true }, + ], + }; + + it('the terminal report admits it is incomplete', () => { + const out = renderStackReport(TRUNCATED); + expect(out).toContain('1 location was truncated'); + expect(out).toContain('some tools are not listed'); + }); + + it('the markdown snippet admits it too', () => { + const out = renderStackMarkdown(TRUNCATED); + expect(out).toContain('truncated'); + expect(out).toContain('some tools are not listed'); + }); + + it('neither says anything when nothing was truncated', () => { + expect(renderStackReport(MIXED)).not.toContain('truncated'); + expect(renderStackMarkdown(MIXED)).not.toContain('truncated'); + }); + + it('JSON carries structured per-root metadata', () => { + const parsed = JSON.parse(renderStackJson(TRUNCATED)); + expect(parsed.truncated).toBe(true); + expect(parsed.truncations).toEqual([ + { + root: '~/.claude/skills', + entries_seen: 520, + entries_kept: 500, + hit_read_ceiling: false, + }, + ]); + }); + + it('JSON reports truncated:false and an empty array on a clean scan', () => { + const parsed = JSON.parse(renderStackJson(MIXED)); + expect(parsed.truncated).toBe(false); + expect(parsed.truncations).toEqual([]); + }); + + it('the stderr warning names the root and the counts', () => { + const [warning] = truncationWarnings(TRUNCATED.truncations); + expect(warning).toContain('~/.claude/skills'); + expect(warning).toContain('520 entries read'); + expect(warning).toContain('500 examined'); + expect(warning).not.toContain('ceiling'); + }); + + it('the stderr warning says so when the read ceiling was hit', () => { + const [warning] = truncationWarnings(CEILINGED.truncations); + expect(warning).toContain('10000-entry ceiling'); + expect(warning).toContain('more may exist unread'); + }); + + it('sanitizes a hostile root path in the warning', () => { + const [warning] = truncationWarnings([ + { root: 'evil\u001b[31m/skills', entriesSeen: 600, entriesKept: 500, hitReadCeiling: false }, + ]); + // eslint-disable-next-line no-control-regex + expect(warning).not.toMatch(/[\u0000-\u001f\u007f-\u009f]/); + expect(warning).toContain('evil[31m/skills'); + }); +}); + describe('renderStackJson (--json)', () => { it('is a single parseable object mirroring the report', () => { const parsed = JSON.parse(renderStackJson(MIXED)); @@ -234,7 +320,7 @@ describe('renderStackJson (--json)', () => { }); it('empty scan is still valid JSON with zeroed counts', () => { - const parsed = JSON.parse(renderStackJson({ tools: [], pathsScanned: ['~/.claude.json'] })); + const parsed = JSON.parse(renderStackJson({ tools: [], pathsScanned: ['~/.claude.json'], truncations: [] })); expect(parsed.total).toBe(0); expect(parsed.clients).toEqual([]); expect(parsed.paths_checked).toEqual(['~/.claude.json']); From 7d5bab1bf8afff7e459bda4a84efb311431852df Mon Sep 17 00:00:00 2001 From: AnobleSCM <211227905+AnobleSCM@users.noreply.github.com> Date: Sun, 2 Aug 2026 16:36:34 -0700 Subject: [PATCH 19/27] docs: correct the data-retention claim and document the bounds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Blocker C. The README said paths "never enter the report, the markdown, the JSON" while renderStackJson deliberately emits paths_checked — visible in the README's own JSON example — and entries carry source, scope, client, and canonicalPath locally. Re-audited every absolute claim against the code and narrowed each to what is true: - What is discarded is everything inside a parsed config except the tool's name. That claim holds. - Scan provenance — where the CLI looked — IS recorded and IS present in local output: paths_checked, the roots named in truncation warnings, and the locations listed in the empty-state report. Now stated plainly rather than denied. - The only thing transmitted remains {type, name} for mcp and plugin, by sync alone, which the wire test asserts. - "$HOME is never double-counted" was overbroad: it holds for the four locations that have a user-scope reader, and ~/.mcp.json is genuinely still found by the upward walk. Both stated. Also documents the two scan bounds, their values, and the fact that hitting either is disclosed rather than silent. Co-authored-by: omnigent --- README.md | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 7bb8bcd..fe829b0 100644 --- a/README.md +++ b/README.md @@ -115,13 +115,22 @@ Two kinds of location, read two different ways: | **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. | | **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 nothing but the names inside them is kept. Directory scans open nothing at all. +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. -Either way, what leaves those locations is the tool's **name and type** and nothing else — no environment variable values, no command-line arguments, no file contents, no other field. Missing and malformed files are skipped silently: a broken `.mcp.json` never fails the scan. +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. -The directory scans are deliberately shallow. They read a known config root and its immediate children and never recurse, so a symlink cannot lead the scan out into a large tree. Symlinks are resolved to a canonical path — these directories are usually link farms. Broken links, unreadable directories, and entries that disappear mid-scan are skipped. The number of children examined per root is capped, and the cap is applied after sorting, so the same machine always yields the same result. +Separately from file contents, the CLI records **where it looked**. Each entry keeps the config location it came from, its scope, its client, and — for skills and subagents — the resolved directory path used to deduplicate. Those are scan facts, not file contents. They appear in `--json` output (`paths_checked`, and the locations named in any truncation warning), and the scanned locations are listed in the terminal report when nothing is found. None of it is ever transmitted: see [Profile sync](#profile-sync) for the only thing that leaves the machine. -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, so your user-wide config is never double-counted as project config. +The directory scans are deliberately shallow and doubly bounded. They read a known config root and its immediate children and never recurse, so a symlink cannot lead the scan out into a large tree. Symlinks are resolved to a canonical path — these directories are usually link farms. Broken links, unreadable directories, and entries that disappear mid-scan are skipped. + +| Bound | Value | Effect | +|---|---|---| +| Read ceiling | 10,000 entries | The directory is streamed, and reading stops there. The rest is never read. | +| Examined per root | 500 entries | Of what was read, sorted alphabetically, the first 500 get the per-entry filesystem work. | + +Both are far above any real config directory. 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 with `entries_seen`, `entries_kept`, and `hit_read_ceiling` per root. + +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. **A tool configured in more than one place is listed once.** Identity is deterministic, so the same machine always produces the same report: @@ -163,9 +172,10 @@ Sync sends MCP servers and plugins only. Skills and subagents are local report d ## Security - **Nothing leaves your machine on the default command.** The scan is local; `npx devcat-cli` makes no network request at all. -- **Only names and types are kept.** Config files are parsed, but environment variable values, command-line arguments, paths, and every other field are discarded — they never enter the report, the markdown, the JSON, or a sync payload. +- **Nothing inside a config file is retained but the tool's name.** Environment variable values, command-line arguments, install paths, and every other field are discarded at the parser — they reach no output and no payload. - **Skill and subagent files are never opened at all** — those scans only list directories and check that an expected filename exists. -- **A test asserts the real request body.** It plants secrets throughout a fixture machine, including inside a `SKILL.md` and a subagent file, runs the actual sync, and inspects the bytes that reached the wire — not a reconstruction of them. +- **Local output does include local paths.** `--json` reports the locations checked, and truncation warnings name the oversized directory. That is scan provenance, printed on your own terminal; it is not transmitted. +- **Only `{type, name}` for MCP servers and plugins is ever sent**, and only by `devcat sync`. A test asserts this on the wire: it plants secrets throughout a fixture machine — including inside a `SKILL.md` body and a subagent file — runs the actual sync against an interceptor, and inspects the received bytes rather than a reconstruction of them. - **Tokens live in the OS keychain**, no plaintext fallback, and bearer tokens are redacted from `--verbose` output. - **Source is public** — read every line at [github.com/AnobleSCM/devcat-cli](https://github.com/AnobleSCM/devcat-cli). From 7edcd8c16bf0381b64f46c386955002f1f979b89 Mon Sep 17 00:00:00 2001 From: AnobleSCM <211227905+AnobleSCM@users.noreply.github.com> Date: Sun, 2 Aug 2026 16:58:33 -0700 Subject: [PATCH 20/27] fix(manifest): close the $HOME guard hole when cwd IS $HOME MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Blocker 1. The guard nulled the upward hit, but the project pass then fell back to join(cwd, '.claude', 'skills'|'agents') — which, run from the home directory, is that same guarded root. It got scanned as project scope, and project-first dedupe kept every entry that way. `cd ~ && npx devcat-cli` misattributed the entire user shelf. The fallback now carries the same guard as the hit, and the comparison is canonical rather than textual: realpath both sides, so `cd ~`, a symlinked $HOME, and a cwd reached through a symlink all resolve to the same place instead of slipping past string equality. isUserLevelPath is async for that reason; Codex and Cursor were audited and now use the same comparison. Neither had the fallback hole — both return early without reading — but both shared the string-equality weakness. A location skipped because it is the user root is not reported among the locations checked either; the user pass already names it. The existing tests all used directories nested BENEATH $HOME, so none of them could reach this. New ones run with cwd === homedir() for skills and subagents separately, through a symlinked route, across the whole scan, and for Codex and Cursor — plus one asserting a genuine project shelf inside $HOME is still project-scoped. Co-authored-by: omnigent --- src/lib/findUpward.ts | 29 ++++++++- src/manifest/claude.ts | 51 ++++++++++++++-- src/manifest/codex.ts | 2 +- src/manifest/cursor.ts | 2 +- test/unit/manifest/homeGuard.test.ts | 90 +++++++++++++++++++++++++++- 5 files changed, 162 insertions(+), 12 deletions(-) diff --git a/src/lib/findUpward.ts b/src/lib/findUpward.ts index 3afdde9..4476eac 100644 --- a/src/lib/findUpward.ts +++ b/src/lib/findUpward.ts @@ -1,4 +1,4 @@ -import { stat } from 'node:fs/promises'; +import { realpath, stat } from 'node:fs/promises'; import { homedir } from 'node:os'; import { dirname, join, parse } from 'node:path'; @@ -40,11 +40,34 @@ export async function findUpward(start: string, ...relativePathSegments: string[ * path use this to skip the hit rather than mislabel it — nothing is lost, * the same tools are still detected, with the right scope. * + * Compares canonically, not by string. A symlinked $HOME, a cwd reached + * through a symlink, or `cd ~ && devcat` all produce a path that names the + * same file by a different route, and string equality misses every one. + * * Only for paths that HAVE a user-scope reader. `.mcp.json` has none, so * ~/.mcp.json is still legitimately picked up by the project walk. */ -export function isUserLevelPath(found: string, ...relativePathSegments: string[]): boolean { - return found === join(homedir(), ...relativePathSegments); +export async function isUserLevelPath( + found: string, + ...relativePathSegments: string[] +): Promise { + const userPath = join(homedir(), ...relativePathSegments); + if (found === userPath) return true; + return resolvesToSame(found, userPath); +} + +/** True when both paths resolve, through symlinks, to the same location. */ +export async function resolvesToSame(a: string, b: string): Promise { + const [ra, rb] = await Promise.all([realpathOrNull(a), realpathOrNull(b)]); + return ra !== null && ra === rb; +} + +async function realpathOrNull(path: string): Promise { + try { + return await realpath(path); + } catch { + return null; + } } /** diff --git a/src/manifest/claude.ts b/src/manifest/claude.ts index 549ced1..a552e77 100644 --- a/src/manifest/claude.ts +++ b/src/manifest/claude.ts @@ -50,17 +50,25 @@ async function detectClaudeProjectScope(cwd: string): Promise { ]); // The user-scope pass reads ~/.claude/skills and ~/.claude/agents directly, - // so a walk that climbed all the way to $HOME is dropped here — otherwise - // the whole user shelf would be reported as project-scoped. - const skillsDir = skillsHit && !isUserLevelPath(skillsHit, '.claude', 'skills') ? skillsHit : null; - const agentsDir = agentsHit && !isUserLevelPath(agentsHit, '.claude', 'agents') ? agentsHit : null; + // so a walk that reached them is dropped here — otherwise the whole user + // shelf would be reported as project-scoped. + // + // The fallback needs the same guard, not just the hit. Running from $HOME + // itself, the walk finds the user root, the guard nulls it, and then + // join(cwd, '.claude', 'skills') IS that same directory — so the fallback + // would scan the user shelf and project-first dedupe would keep every + // entry as project-scoped. `cd ~ && npx devcat-cli` is not an exotic case. + const [skillsDir, agentsDir] = await Promise.all([ + projectDirToScan(skillsHit, cwd, '.claude', 'skills'), + projectDirToScan(agentsHit, cwd, '.claude', 'agents'), + ]); const [mcp, skills, subagents] = await Promise.all([ mcpPath ? readMcpServersJson(mcpPath, 'project') : Promise.resolve({ tools: [], pathsScanned: [join(cwd, '.mcp.json')] }), - readSkillsDir(skillsDir ?? join(cwd, '.claude', 'skills'), 'project'), - readSubagentsDir(agentsDir ?? join(cwd, '.claude', 'agents'), 'project'), + scanProjectDir(skillsDir, readSkillsDir), + scanProjectDir(agentsDir, readSubagentsDir), ]); return mergeScans([mcp, skills, subagents]); @@ -94,6 +102,37 @@ async function detectClaudeUserScope(): Promise { ]); } +/** + * 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. + */ +async function projectDirToScan( + hit: string | null, + cwd: string, + ...segments: string[] +): Promise<{ scan: string | null; report: string | null }> { + const fallback = join(cwd, ...segments); + const candidate = hit ?? fallback; + // Canonical comparison: `cd ~`, a symlinked $HOME, and a cwd reached via a + // symlink all name the user root by a route string equality would miss. + 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] : [] }; +} + function mergeScans(scans: SourceScan[]): SourceScan { return { tools: scans.flatMap((s) => s.tools), diff --git a/src/manifest/codex.ts b/src/manifest/codex.ts index 9656779..61a90f6 100644 --- a/src/manifest/codex.ts +++ b/src/manifest/codex.ts @@ -84,7 +84,7 @@ async function detectCodexMcp(opts: { cwd?: string; scope: 'project' | 'user' }) path = await findUpward(opts.cwd, '.codex', 'config.toml'); // $HOME is an ancestor of most working directories; the user pass above // already reads that exact file, so don't relabel it project-scoped. - if (path && isUserLevelPath(path, '.codex', 'config.toml')) path = null; + if (path && (await isUserLevelPath(path, '.codex', 'config.toml'))) path = null; scannedPath = path ?? join(opts.cwd, '.codex', 'config.toml'); if (!path) return { tools: [], pathsScanned: [scannedPath] }; } diff --git a/src/manifest/cursor.ts b/src/manifest/cursor.ts index 71d639e..899e61f 100644 --- a/src/manifest/cursor.ts +++ b/src/manifest/cursor.ts @@ -36,7 +36,7 @@ export async function detectCursor(opts: { cwd?: string; scope: 'project' | 'use if (!opts.cwd) return { tools: [], pathsScanned: [] }; path = await findUpward(opts.cwd, '.cursor', 'mcp.json'); // Same $HOME-is-an-ancestor guard as the Codex detector. - if (path && isUserLevelPath(path, '.cursor', 'mcp.json')) path = null; + if (path && (await isUserLevelPath(path, '.cursor', 'mcp.json'))) path = null; scannedPath = path ?? join(opts.cwd, '.cursor', 'mcp.json'); if (!path) return { tools: [], pathsScanned: [scannedPath] }; } diff --git a/test/unit/manifest/homeGuard.test.ts b/test/unit/manifest/homeGuard.test.ts index d95890c..34af036 100644 --- a/test/unit/manifest/homeGuard.test.ts +++ b/test/unit/manifest/homeGuard.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'; @@ -87,6 +87,94 @@ describe('$HOME is not a project root — Cursor', () => { }); }); +describe('$HOME is not a project root — when cwd IS $HOME', () => { + // The guard nulls the upward hit, but the project pass then fell back to + // join(cwd, '.claude', 'skills') — which, run from $HOME, IS the guarded + // user root. Project-first dedupe then kept the whole user shelf as + // project-scoped. `cd ~ && npx devcat-cli` is the commonest invocation + // there is, and the earlier tests all used directories nested BENEATH + // $HOME, so none of them reached this. + function seedShelf(): void { + const skills = join(tmpHome, '.claude', 'skills', 'panel'); + mkdirSync(skills, { recursive: true }); + writeFileSync(join(skills, 'SKILL.md'), '# panel\n'); + const agents = join(tmpHome, '.claude', 'agents'); + mkdirSync(agents, { recursive: true }); + writeFileSync(join(agents, 'debugger.md'), 'x'); + } + + it('does not claim user skills as project-scoped', async () => { + seedShelf(); + const { detectClaudeCode } = await import('../../../src/manifest/claude.js'); + const result = await detectClaudeCode({ cwd: tmpHome, scope: 'project' }); + expect(result.tools.filter((t) => t.type === 'skill')).toEqual([]); + }); + + it('does not claim user subagents as project-scoped', async () => { + seedShelf(); + const { detectClaudeCode } = await import('../../../src/manifest/claude.js'); + const result = await detectClaudeCode({ cwd: tmpHome, scope: 'project' }); + expect(result.tools.filter((t) => t.type === 'subagent')).toEqual([]); + }); + + it('the whole scan from $HOME reports the shelf as user-wide, exactly once', async () => { + seedShelf(); + const { detect } = await import('../../../src/manifest/index.js'); + const result = await detect(tmpHome); + + const skills = result.tools.filter((t) => t.type === 'skill'); + const subagents = result.tools.filter((t) => t.type === 'subagent'); + expect(skills.map((t) => t.name)).toEqual(['panel']); + expect(subagents.map((t) => t.name)).toEqual(['debugger']); + expect([...skills, ...subagents].every((t) => t.scope === 'user')).toBe(true); + expect(result.tools.filter((t) => t.scope === 'project')).toEqual([]); + }); + + it('sees through a symlinked route to $HOME', async () => { + // A cwd that reaches $HOME by a different textual path — string equality + // misses this, canonical comparison does not. + seedShelf(); + const link = join(tmpdir(), `devcat-homelink-${process.pid}`); + rmSync(link, { force: true }); + symlinkSync(tmpHome, link); + try { + const { detectClaudeCode } = await import('../../../src/manifest/claude.js'); + const result = await detectClaudeCode({ cwd: link, scope: 'project' }); + expect(result.tools.filter((t) => t.type === 'skill')).toEqual([]); + expect(result.tools.filter((t) => t.type === 'subagent')).toEqual([]); + } finally { + rmSync(link, { force: true }); + } + }); + + it('a genuine project shelf inside $HOME is still project-scoped', async () => { + seedShelf(); + const projectSkills = join(tmpHome, 'repo', '.claude', 'skills', 'repo-skill'); + mkdirSync(projectSkills, { recursive: true }); + writeFileSync(join(projectSkills, 'SKILL.md'), '# repo\n'); + + const { detectClaudeCode } = await import('../../../src/manifest/claude.js'); + const result = await detectClaudeCode({ cwd: join(tmpHome, 'repo'), scope: 'project' }); + const skills = result.tools.filter((t) => t.type === 'skill'); + expect(skills.map((t) => t.name)).toEqual(['repo-skill']); + expect(skills[0]!.scope).toBe('project'); + }); +}); + +describe('$HOME is not a project root — Codex and Cursor from $HOME', () => { + it('neither claims its user config as project-scoped when cwd is $HOME', async () => { + mkdirSync(join(tmpHome, '.codex'), { recursive: true }); + writeFileSync(join(tmpHome, '.codex', 'config.toml'), '[mcp_servers.serena]\n'); + mkdirSync(join(tmpHome, '.cursor'), { recursive: true }); + writeFileSync(join(tmpHome, '.cursor', 'mcp.json'), JSON.stringify({ mcpServers: { figma: {} } })); + + const { detectCodex } = await import('../../../src/manifest/codex.js'); + const { detectCursor } = await import('../../../src/manifest/cursor.js'); + expect((await detectCodex({ cwd: tmpHome, scope: 'project' })).tools).toEqual([]); + expect((await detectCursor({ cwd: tmpHome, scope: 'project' })).tools).toEqual([]); + }); +}); + describe('$HOME is not a project root — whole scan', () => { it('reports a user-only machine as entirely user-scoped', async () => { mkdirSync(join(tmpHome, '.codex'), { recursive: true }); From dad3b6663de6cf38f540122bf4ac7b7257bad125 Mon Sep 17 00:00:00 2001 From: AnobleSCM <211227905+AnobleSCM@users.noreply.github.com> Date: Sun, 2 Aug 2026 16:58:48 -0700 Subject: [PATCH 21/27] fix(manifest): gate the read ceiling before pulling, and disclose coherently MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Blocker 2 plus residuals R1 and R2. `for await` fetches the next entry and only then runs the body, so a top-of-body ceiling check had already read one entry past the limit — the README's "reading stops there, the rest is never read" was false by one. The loop now drives the iterator manually and checks before each pull, then calls return() to close the handle. Off-by-one gone, promise now true. R1: entriesSeen counted only non-dot candidates while the ceiling counts every entry, so a dot-heavy root could report "0 entries read" beside "ceiling reached". RootTruncation now carries entriesRead — what the ceiling actually bounds — alongside entriesSeen, and the warning quotes entriesRead whenever the ceiling is the reason. R2: a directory erroring partway was swallowed, returning a short list with no truncation metadata at all, which reads exactly like a complete scan. That now sets readFailed and produces a truncation regardless of how few names were collected. The ceiling test no longer infers behaviour from output counts. It hands the scanner a fake directory whose async iterator counts next() calls and asserts the count exactly — the only way to tell "stopped at N" from "read N+1 and kept N". Co-authored-by: omnigent --- src/manifest/dirScan.ts | 63 ++++++++---- test/unit/manifest/dirScan.test.ts | 2 + test/unit/manifest/readCeiling.test.ts | 128 +++++++++++++++++++++++++ 3 files changed, 174 insertions(+), 19 deletions(-) create mode 100644 test/unit/manifest/readCeiling.test.ts diff --git a/src/manifest/dirScan.ts b/src/manifest/dirScan.ts index 95c576b..f9a53ac 100644 --- a/src/manifest/dirScan.ts +++ b/src/manifest/dirScan.ts @@ -50,12 +50,21 @@ export interface DirHit { /** Emitted when a bound bit, so every output layer can disclose it. */ export interface RootTruncation { root: string; - /** Candidate names actually read from the directory. */ + /** + * Directory entries pulled, including dot-entries. This is what the read + * ceiling bounds, so it is the number the ceiling message must quote — + * quoting candidates instead lets a dot-heavy root claim "0 entries read" + * in the same breath as "ceiling reached". + */ + entriesRead: number; + /** Of those, the non-dot candidate names worth examining. */ entriesSeen: number; - /** Of those, how many were examined (the rest were dropped by the cap). */ + /** Of the candidates, how many were examined (the rest dropped by the cap). */ entriesKept: number; - /** True when reading stopped at READ_CEILING — more names exist, unread. */ + /** True when reading stopped at READ_CEILING — more entries exist, unread. */ hitReadCeiling: boolean; + /** True when the directory errored partway; the list is short for that reason. */ + readFailed: boolean; } export interface DirScanResult { @@ -112,12 +121,17 @@ export async function scanSubagents( } /** - * Read up to READ_CEILING candidate names out of `root`, streaming. + * Read up to `readCeiling` entries out of `root`, streaming. * - * opendir yields entries lazily, so hitting the ceiling means the remainder - * of a pathological directory is never read — the bound is on the work done, - * not just on the result. Dot-entries are skipped but still count toward the - * ceiling, so a directory full of them cannot spin forever either. + * The ceiling gates BEFORE each pull, using a manual iterator rather than + * `for await`: `for await` fetches the next entry and only then runs the + * body, so a top-of-loop check there would already have read one entry past + * the limit. Breaking early calls the iterator's return(), which closes the + * directory handle, so the remainder is genuinely never read. + * + * Dot-entries are skipped as candidates but still consume the ceiling — a + * directory full of them cannot spin. That is why two counts come back: + * `read` is what the ceiling bounds, `names` is what is worth examining. * * Returns null when the root cannot be opened at all (missing, or no * permission) — indistinguishable outcomes, both meaning "nothing here". @@ -125,7 +139,7 @@ export async function scanSubagents( async function readCandidateNames( root: string, readCeiling: number, -): Promise<{ names: string[]; hitReadCeiling: boolean } | null> { +): Promise<{ names: string[]; read: number; hitReadCeiling: boolean; readFailed: boolean } | null> { let dir; try { dir = await opendir(root); @@ -134,23 +148,32 @@ async function readCandidateNames( } const names: string[] = []; - let iterated = 0; + let read = 0; let hitReadCeiling = false; + let readFailed = false; + + const iterator = dir[Symbol.asyncIterator](); try { - for await (const entry of dir) { - if (iterated >= readCeiling) { + for (;;) { + if (read >= readCeiling) { hitReadCeiling = true; + await iterator.return?.(); break; } - iterated += 1; - if (entry.name.startsWith('.')) continue; - names.push(entry.name); + const next = await iterator.next(); + if (next.done) break; + read += 1; + const name = next.value.name; + if (name.startsWith('.')) continue; + names.push(name); } } catch { - // A directory that vanishes or errors mid-iteration still yields whatever - // was read. The async iterator closes the handle on the way out. + // The directory vanished or errored partway. Whatever was read is still + // usable, but the caller must be told the list is short for a reason. + readFailed = true; } - return { names, hitReadCeiling }; + + return { names, read, hitReadCeiling, readFailed }; } /** @@ -192,12 +215,14 @@ async function scanRoot( const droppedByCap = sorted.length > candidates.length; const truncation: RootTruncation | null = - droppedByCap || read.hitReadCeiling + droppedByCap || read.hitReadCeiling || read.readFailed ? { root, + entriesRead: read.read, entriesSeen: sorted.length, entriesKept: candidates.length, hitReadCeiling: read.hitReadCeiling, + readFailed: read.readFailed, } : null; diff --git a/test/unit/manifest/dirScan.test.ts b/test/unit/manifest/dirScan.test.ts index 5dcf134..7fccfe5 100644 --- a/test/unit/manifest/dirScan.test.ts +++ b/test/unit/manifest/dirScan.test.ts @@ -131,9 +131,11 @@ describe('scanSkills', () => { // The dropped 20 must be disclosed, not silently missing. expect(first.truncation).toEqual({ root, + entriesRead: 520, entriesSeen: 520, entriesKept: 500, hitReadCeiling: false, + readFailed: false, }); }); diff --git a/test/unit/manifest/readCeiling.test.ts b/test/unit/manifest/readCeiling.test.ts new file mode 100644 index 0000000..f2a5348 --- /dev/null +++ b/test/unit/manifest/readCeiling.test.ts @@ -0,0 +1,128 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +/** + * The read ceiling has to bound the READS, not just the output. Asserting on + * the returned counts cannot tell "stopped at 10,000" from "read 10,001 and + * kept 10,000" — and the README promises the remainder is never read. + * + * So this suite hands the scanner a fake directory whose async iterator + * counts next() calls, and asserts on that count directly. + */ +const reads = { count: 0 }; +const dirNames: { current: string[] } = { current: [] }; + +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + opendir: async () => ({ + [Symbol.asyncIterator]() { + let index = 0; + return { + async next() { + reads.count += 1; + if (index >= dirNames.current.length) { + return { done: true as const, value: undefined }; + } + return { done: false as const, value: { name: dirNames.current[index++]! } }; + }, + async return() { + return { done: true as const, value: undefined }; + }, + }; + }, + }), + }; +}); + +beforeEach(() => { + reads.count = 0; + dirNames.current = Array.from({ length: 50 }, (_, i) => `entry-${String(i).padStart(3, '0')}`); +}); + +describe('read ceiling bounds the reads, not just the results', () => { + it('pulls exactly `readCeiling` entries and no more', async () => { + const { scanSkills } = await import('../../../src/manifest/dirScan.js'); + const result = await scanSkills('/fake/root', 10); + + // The off-by-one this covers: `for await` fetches the next entry BEFORE + // the loop body can check the ceiling, so a top-of-body check reads + // ceiling + 1. Exactly 10 means the 11th was never requested. + expect(reads.count).toBe(10); + expect(result.truncation).not.toBeNull(); + expect(result.truncation!.hitReadCeiling).toBe(true); + expect(result.truncation!.entriesRead).toBe(10); + }); + + it('reads the whole directory when it fits under the ceiling', async () => { + dirNames.current = ['a', 'b', 'c']; + const { scanSkills } = await import('../../../src/manifest/dirScan.js'); + const result = await scanSkills('/fake/root', 10); + + // Three entries plus the one next() that reports done. + expect(reads.count).toBe(4); + expect(result.truncation).toBeNull(); + }); + + it('stops at the ceiling even when every entry is a dot-entry', async () => { + // Dot-entries are not candidates but must still consume the ceiling, + // or a directory full of them would be read to the end. + dirNames.current = Array.from({ length: 50 }, (_, i) => `.hidden-${i}`); + const { scanSkills } = await import('../../../src/manifest/dirScan.js'); + const result = await scanSkills('/fake/root', 6); + + expect(reads.count).toBe(6); + expect(result.truncation!.hitReadCeiling).toBe(true); + // R1: the message must quote what the ceiling counts. entriesSeen is 0 + // here (no candidates), so entriesRead is what makes the disclosure + // coherent rather than self-contradictory. + expect(result.truncation!.entriesRead).toBe(6); + expect(result.truncation!.entriesSeen).toBe(0); + }); + + it('uses the production ceiling by default', async () => { + const { READ_CEILING } = await import('../../../src/manifest/dirScan.js'); + expect(READ_CEILING).toBe(10_000); + }); +}); + +describe('a directory that errors partway is disclosed', () => { + it('marks the scan truncated with readFailed even when few names were read', async () => { + // R2: an error-shortened scan used to return a short list with no + // truncation metadata at all, which reads as a complete scan. + const failingNames = ['a', 'b']; + dirNames.current = failingNames; + vi.resetModules(); + + vi.doMock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + opendir: async () => ({ + [Symbol.asyncIterator]() { + let index = 0; + return { + async next() { + if (index >= failingNames.length) throw new Error('EIO: directory vanished'); + return { done: false as const, value: { name: failingNames[index++]! } }; + }, + async return() { + return { done: true as const, value: undefined }; + }, + }; + }, + }), + }; + }); + + const { scanSkills } = await import('../../../src/manifest/dirScan.js'); + const result = await scanSkills('/fake/root'); + + expect(result.truncation).not.toBeNull(); + expect(result.truncation!.readFailed).toBe(true); + expect(result.truncation!.hitReadCeiling).toBe(false); + expect(result.truncation!.entriesRead).toBe(2); + vi.doUnmock('node:fs/promises'); + vi.resetModules(); + }); +}); From f4107ede4f03fc9cbf524958db2d44cd8f0dc2ee Mon Sep 17 00:00:00 2001 From: AnobleSCM <211227905+AnobleSCM@users.noreply.github.com> Date: Sun, 2 Aug 2026 16:59:01 -0700 Subject: [PATCH 22/27] fix(report): keep the truncation footnote on the empty state Blocker 3. Both renderers early-returned on "no tools" before reaching their footnote branch, so a truncated scan that surfaced nothing announced "No AI tooling detected" with no hint that it had stopped looking. That is the worst place to lose the notice: an empty result is exactly where a user concludes there is nothing installed. The empty state now carries the same footnote in terminal and markdown. Also updates the warning text for the richer counts: it quotes entriesRead when the ceiling fired, says so plainly when a read failed, and reports candidates and examined separately so the numbers cannot contradict. Co-authored-by: omnigent --- src/ui/report.ts | 50 ++++++++++++++----- test/unit/ui/report.test.ts | 95 +++++++++++++++++++++++++++++++++++-- 2 files changed, 129 insertions(+), 16 deletions(-) diff --git a/src/ui/report.ts b/src/ui/report.ts index 14bdd84..92163c0 100644 --- a/src/ui/report.ts +++ b/src/ui/report.ts @@ -110,7 +110,7 @@ export function groupStack(tools: readonly ToolEntry[]): StackGroup[] { * 3 project-scoped · 18 user-wide */ export function renderStackReport(result: DetectResult): string { - if (result.tools.length === 0) return renderEmptyStack(result.pathsScanned); + if (result.tools.length === 0) return renderEmptyStack(result); const groups = groupStack(result.tools); const total = result.tools.length; @@ -174,13 +174,25 @@ export function truncationFootnote(truncations: readonly RootTruncation[]): stri */ export function truncationWarnings(truncations: readonly RootTruncation[]): string[] { return truncations.map((t) => { - const ceiling = t.hitReadCeiling - ? ` Reading stopped at the ${READ_CEILING}-entry ceiling, so more may exist unread.` - : ''; - return `! Truncated scan of ${sanitizeName(t.root)} — ${t.entriesSeen} entries read, ${t.entriesKept} examined. Some tools are not listed.${ceiling}`; + const parts = [`! Truncated scan of ${sanitizeName(t.root)} —`]; + if (t.hitReadCeiling) { + // Quote entriesRead, not entriesSeen: the ceiling counts every entry + // including dot-entries, so a dot-heavy root would otherwise report + // "0 entries read" in the same line as "ceiling reached". + parts.push(`reading stopped at the ${READ_CEILING}-entry ceiling after ${t.entriesRead} entries,`); + } else if (t.readFailed) { + parts.push(`reading failed after ${t.entriesRead} entries,`); + } else { + parts.push(`${t.entriesRead} entries read,`); + } + parts.push(`${t.entriesSeen} candidates, ${t.entriesKept} examined.`); + parts.push('Some tools are not listed.'); + if (t.hitReadCeiling) parts.push('More may exist unread.'); + return parts.join(' '); }); } + /** * Shareable snippet for a README or gist. Plain markdown, no ANSI, stable * ordering so re-running it produces a clean diff rather than a reshuffle. @@ -190,6 +202,10 @@ export function renderStackMarkdown(result: DetectResult): string { if (result.tools.length === 0) { lines.push('No AI tooling detected on this machine yet.'); + if (result.truncations.length > 0) { + lines.push(''); + lines.push(`> ${truncationFootnote(result.truncations)}`); + } lines.push(''); lines.push(MARKDOWN_FOOTER); return lines.join('\n'); @@ -249,9 +265,11 @@ export function renderStackJson(result: DetectResult): string { truncated: result.truncations.length > 0, truncations: result.truncations.map((t) => ({ root: t.root, + entries_read: t.entriesRead, entries_seen: t.entriesSeen, entries_kept: t.entriesKept, hit_read_ceiling: t.hitReadCeiling, + read_failed: t.readFailed, })), }; return JSON.stringify(payload, null, 2); @@ -264,16 +282,26 @@ const MARKDOWN_FOOTER = * Nothing found. Print the paths that were checked so the user can spot the * config location the CLI does not know about yet. */ -function renderEmptyStack(pathsScanned: string[]): string { - const list = pathsScanned.map((p) => ` - ${sanitizeName(p)}`).join('\n'); - return [ +function renderEmptyStack(result: DetectResult): string { + const list = result.pathsScanned.map((p) => ` - ${sanitizeName(p)}`).join('\n'); + const lines = [ `${SUCCESS_GLYPH} ${c.bold('No AI tooling detected.')}`, '', 'Looked in:', list, - '', - c.dim('Add an MCP server to Claude Code, Codex, or Cursor and run this again.'), - ].join('\n'); + ]; + + // A truncated scan that turned up nothing is NOT "nothing is installed" — + // it is "we stopped looking". Saying the first would be a lie by omission, + // and this is the path where it would be least visible. + if (result.truncations.length > 0) { + lines.push(''); + lines.push(c.yellow(truncationFootnote(result.truncations))); + } + + lines.push(''); + lines.push(c.dim('Add an MCP server to Claude Code, Codex, or Cursor and run this again.')); + return lines.join('\n'); } /** diff --git a/test/unit/ui/report.test.ts b/test/unit/ui/report.test.ts index 7eac92e..2faa199 100644 --- a/test/unit/ui/report.test.ts +++ b/test/unit/ui/report.test.ts @@ -220,14 +220,28 @@ describe('truncation disclosure', () => { tools: [tool({ name: 'context7' })], pathsScanned: ['~/.claude/skills'], truncations: [ - { root: '~/.claude/skills', entriesSeen: 520, entriesKept: 500, hitReadCeiling: false }, + { + root: '~/.claude/skills', + entriesRead: 520, + entriesSeen: 520, + entriesKept: 500, + hitReadCeiling: false, + readFailed: false, + }, ], }; const CEILINGED: DetectResult = { ...TRUNCATED, truncations: [ - { root: '~/.claude/skills', entriesSeen: 10000, entriesKept: 500, hitReadCeiling: true }, + { + root: '~/.claude/skills', + entriesRead: 10000, + entriesSeen: 9998, + entriesKept: 500, + hitReadCeiling: true, + readFailed: false, + }, ], }; @@ -254,9 +268,11 @@ describe('truncation disclosure', () => { expect(parsed.truncations).toEqual([ { root: '~/.claude/skills', + entries_read: 520, entries_seen: 520, entries_kept: 500, hit_read_ceiling: false, + read_failed: false, }, ]); }); @@ -267,23 +283,92 @@ describe('truncation disclosure', () => { expect(parsed.truncations).toEqual([]); }); + it('the empty-state terminal report still discloses truncation', () => { + // Early-returning on "no tools" skipped the footnote entirely, so a + // truncated scan that found nothing claimed nothing was installed. + const out = renderStackReport({ + tools: [], + pathsScanned: ['~/.claude/skills'], + truncations: TRUNCATED.truncations, + }); + expect(out).toContain('No AI tooling detected.'); + expect(out).toContain('1 location was truncated'); + expect(out).toContain('some tools are not listed'); + }); + + it('the empty-state markdown still discloses truncation', () => { + const out = renderStackMarkdown({ + tools: [], + pathsScanned: ['~/.claude/skills'], + truncations: TRUNCATED.truncations, + }); + expect(out).toContain('No AI tooling detected on this machine yet.'); + expect(out).toContain('truncated'); + expect(out).toContain('some tools are not listed'); + }); + + it('an untruncated empty scan says nothing about truncation', () => { + const empty = { tools: [], pathsScanned: ['~/.claude.json'], truncations: [] }; + expect(renderStackReport(empty)).not.toContain('truncated'); + expect(renderStackMarkdown(empty)).not.toContain('truncated'); + }); + + it('the warning discloses a read failure rather than looking like a clean short list', () => { + const [warning] = truncationWarnings([ + { + root: '~/.claude/skills', + entriesRead: 3, + entriesSeen: 3, + entriesKept: 3, + hitReadCeiling: false, + readFailed: true, + }, + ]); + expect(warning).toContain('reading failed after 3 entries'); + expect(warning).toContain('Some tools are not listed'); + }); + + it('a dot-heavy ceiling hit reports coherent numbers', () => { + // entriesSeen is 0 (no candidates) but the ceiling counted 10000 entries. + // The message must not say "0 entries read" and "ceiling reached". + const [warning] = truncationWarnings([ + { + root: '~/.claude/skills', + entriesRead: 10000, + entriesSeen: 0, + entriesKept: 0, + hitReadCeiling: true, + readFailed: false, + }, + ]); + expect(warning).toContain('after 10000 entries'); + expect(warning).not.toContain('0 entries read'); + }); + it('the stderr warning names the root and the counts', () => { const [warning] = truncationWarnings(TRUNCATED.truncations); expect(warning).toContain('~/.claude/skills'); expect(warning).toContain('520 entries read'); - expect(warning).toContain('500 examined'); + expect(warning).toContain('520 candidates, 500 examined'); expect(warning).not.toContain('ceiling'); }); it('the stderr warning says so when the read ceiling was hit', () => { const [warning] = truncationWarnings(CEILINGED.truncations); expect(warning).toContain('10000-entry ceiling'); - expect(warning).toContain('more may exist unread'); + expect(warning).toContain('More may exist unread'); }); it('sanitizes a hostile root path in the warning', () => { const [warning] = truncationWarnings([ - { root: 'evil\u001b[31m/skills', entriesSeen: 600, entriesKept: 500, hitReadCeiling: false }, + { + root: 'evil\u001b[31m/skills', + entriesRead: 600, + entriesSeen: 600, + entriesKept: 500, + hitReadCeiling: false, + readFailed: false, + }, ]); // eslint-disable-next-line no-control-regex expect(warning).not.toMatch(/[\u0000-\u001f\u007f-\u009f]/); From 4e1c4828bd22d021f8a977c1424fa59311871fb6 Mon Sep 17 00:00:00 2001 From: AnobleSCM <211227905+AnobleSCM@users.noreply.github.com> Date: Sun, 2 Aug 2026 16:59:17 -0700 Subject: [PATCH 23/27] fix(bin): stop forcing process.exit, which can truncate piped stdout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Blocker 4. The shim called process.exit() the moment runCli resolved. process.exit terminates immediately and discards whatever is still queued in stdout — and stdout is a pipe when piped, pipes are asynchronous, and a large --json report does not fit in one write. `devcat --json | jq` could therefore receive JSON cut mid-object together with exit 0. It now sets process.exitCode and returns, letting Node drain the stream and exit with the same code. Nothing holds the loop open, so there is no cost. The error path does the same rather than hard-exiting after a write. A true pipe test would tie the suite to dist/ existing and to build ordering, so instead the real shim module is imported with runCli mocked and asserted directly: process.exit is never called, exitCode carries the returned code including on rejection, and the whole payload reaches stdout before the shim resolves. The reasoning is recorded in the test file. Co-authored-by: omnigent --- src/bin/devcat.ts | 19 ++++- test/integration/bin.exit.test.ts | 118 ++++++++++++++++++++++++++++++ 2 files changed, 135 insertions(+), 2 deletions(-) create mode 100644 test/integration/bin.exit.test.ts diff --git a/src/bin/devcat.ts b/src/bin/devcat.ts index b81d266..07b80fa 100644 --- a/src/bin/devcat.ts +++ b/src/bin/devcat.ts @@ -2,9 +2,24 @@ import { runCli } from '../cli.js'; import { EXIT_GENERIC_ERROR } from '../lib/exitCodes.js'; +/** + * Set process.exitCode and let Node exit on its own. + * + * process.exit() terminates immediately, discarding anything still queued in + * stdout. That matters here: process.stdout is a pipe when output is piped + * (`devcat --json | jq`), pipes are asynchronous, and a large report does not + * fit in one write — so exiting on the spot could cut the JSON mid-object and + * still report success. Setting exitCode lets the event loop drain the stream + * first and then exit with the same code. + * + * Nothing here holds the loop open — no servers, no timers — so "let it exit + * naturally" costs nothing. + */ runCli(process.argv) - .then((exitCode) => process.exit(exitCode)) + .then((exitCode) => { + process.exitCode = exitCode; + }) .catch((err) => { process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`); - process.exit(EXIT_GENERIC_ERROR); + process.exitCode = EXIT_GENERIC_ERROR; }); diff --git a/test/integration/bin.exit.test.ts b/test/integration/bin.exit.test.ts new file mode 100644 index 0000000..7f99327 --- /dev/null +++ b/test/integration/bin.exit.test.ts @@ -0,0 +1,118 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; + +/** + * The bin shim must NOT call process.exit(). + * + * process.exit terminates immediately and discards whatever is still queued + * in stdout. stdout is a pipe when output is piped, pipes are asynchronous, + * and a large `--json` report does not fit in a single write — so exiting on + * the spot could truncate the JSON mid-object while still reporting success. + * + * A true end-to-end pipe test would have to spawn the built CLI, which makes + * the suite depend on `dist/` existing and on build ordering. This asserts the + * property that actually prevents the truncation instead: the real shim module + * sets process.exitCode and returns, leaving Node to drain stdout and exit on + * its own. The module self-executes on import, which is exactly the behaviour + * under test. + */ +const { runCliMock } = vi.hoisted(() => ({ runCliMock: vi.fn() })); + +vi.mock('../../src/cli.js', () => ({ runCli: runCliMock })); + +const originalExitCode = process.exitCode; + +afterEach(() => { + process.exitCode = originalExitCode; + vi.resetModules(); + runCliMock.mockReset(); +}); + +async function importShim(): Promise { + vi.resetModules(); + await import('../../src/bin/devcat.js'); + // The shim's promise chain settles on the microtask queue. + await new Promise((resolve) => setImmediate(resolve)); +} + +describe('bin shim — exit handling', () => { + it('sets process.exitCode instead of calling process.exit', async () => { + runCliMock.mockResolvedValue(0); + const exitSpy = vi.spyOn(process, 'exit').mockImplementation(((): never => { + throw new Error('process.exit must not be called — it can truncate piped stdout'); + }) as never); + + try { + await importShim(); + expect(exitSpy).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(0); + } finally { + exitSpy.mockRestore(); + } + }); + + it('propagates a non-zero code the same way', async () => { + runCliMock.mockResolvedValue(2); + const exitSpy = vi.spyOn(process, 'exit').mockImplementation(((): never => { + throw new Error('process.exit must not be called'); + }) as never); + + try { + await importShim(); + expect(exitSpy).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(2); + } finally { + exitSpy.mockRestore(); + } + }); + + it('reports a thrown error on stderr and exits 1, still without process.exit', async () => { + runCliMock.mockRejectedValue(new Error('boom')); + const exitSpy = vi.spyOn(process, 'exit').mockImplementation(((): never => { + throw new Error('process.exit must not be called'); + }) as never); + const errChunks: string[] = []; + const errSpy = vi + .spyOn(process.stderr, 'write') + .mockImplementation((chunk: string | Uint8Array): boolean => { + errChunks.push(typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString()); + return true; + }); + + try { + await importShim(); + expect(exitSpy).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + expect(errChunks.join('')).toContain('boom'); + } finally { + exitSpy.mockRestore(); + errSpy.mockRestore(); + } + }); + + it('the whole report is handed to stdout before the shim resolves', async () => { + // The other half of the guarantee: nothing is written after the process + // is already on its way out. runCli must finish its writes first. + let writesAtResolve = 0; + const chunks: string[] = []; + const outSpy = vi + .spyOn(process.stdout, 'write') + .mockImplementation((chunk: string | Uint8Array): boolean => { + chunks.push(typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString()); + return true; + }); + runCliMock.mockImplementation(async () => { + process.stdout.write('x'.repeat(100_000)); + writesAtResolve = chunks.length; + return 0; + }); + + try { + await importShim(); + expect(writesAtResolve).toBe(1); + expect(chunks.join('')).toHaveLength(100_000); + expect(process.exitCode).toBe(0); + } finally { + outSpy.mockRestore(); + } + }); +}); From 22bb7b5d7e40fa4dce792ca7f82b30ca6e367cc8 Mon Sep 17 00:00:00 2001 From: AnobleSCM <211227905+AnobleSCM@users.noreply.github.com> Date: Sun, 2 Aug 2026 16:59:31 -0700 Subject: [PATCH 24/27] docs: state what paths_checked is, and bound the determinism claim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Blocker 5 and residual R3. "every path checked" overstated the data. findUpwardDir stats a candidate at every ancestor while walking up, but only the resolved location is recorded — the intermediate probes are not. Kept the field name, since it is accurate for what it holds, and made the prose exact: paths_checked is the location each detector resolved to, one per config file or directory consulted, including candidates that turned out not to exist, and explicitly not a trace of the walk. R3: "the same machine always produces the same report" is only true below the read ceiling. Above it, which 10,000 entries were read is the directory's enumeration order, which the CLI does not control — sorting happens after. Now qualified, with the note that a run in that state says so rather than implying stability it does not have. Also documents the two new truncation fields and that a directory erroring partway is reported as truncated. Co-authored-by: omnigent --- README.md | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index fe829b0..a207fac 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,7 @@ npx devcat-cli --markdown ## Script it -`--json` prints the same scan as one JSON object — counts, per-client groups, and every path checked: +`--json` prints the same scan as one JSON object — counts, per-client groups, and the scan locations it resolved: ```bash npx devcat-cli --json | jq '.clients[] | {label, total}' @@ -119,20 +119,22 @@ So: config files are read and parsed, and the only thing taken **out of their co 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. -Separately from file contents, the CLI records **where it looked**. Each entry keeps the config location it came from, its scope, its client, and — for skills and subagents — the resolved directory path used to deduplicate. Those are scan facts, not file contents. They appear in `--json` output (`paths_checked`, and the locations named in any truncation warning), and the scanned locations are listed in the terminal report when nothing is found. None of it is ever transmitted: see [Profile sync](#profile-sync) for the only thing that leaves the machine. +Separately from file contents, the CLI records **where it looked**. Each entry keeps the config location it came from, its scope, its client, and — for skills and subagents — the resolved directory path used to deduplicate. Those are scan facts, not file contents. They appear in `--json` output (`paths_checked`, and the locations named in any truncation warning), and the same locations are listed in the terminal report when nothing is found. -The directory scans are deliberately shallow and doubly bounded. They read a known config root and its immediate children and never recurse, so a symlink cannot lead the scan out into a large tree. Symlinks are resolved to a canonical path — these directories are usually link farms. Broken links, unreadable directories, and entries that disappear mid-scan are skipped. +`paths_checked` is **the location each detector resolved to** — one per config file or directory it consulted, including candidates that turned out not to exist. It is not a trace of the upward walk: finding a project config stats a candidate at every ancestor directory, and those intermediate probes are not recorded. None of it is ever transmitted: see [Profile sync](#profile-sync) for the only thing that leaves the machine. + +The directory scans are deliberately shallow and doubly bounded. They read a known config root and its immediate children and never recurse, so a symlink cannot lead the scan out into a large tree. Symlinks are resolved to a canonical path — these directories are usually link farms. Broken links, unreadable directories, and entries that disappear mid-scan are skipped. A directory that errors partway through being read is reported as truncated too — a short list caused by an error is still a short list. | Bound | Value | Effect | |---|---|---| | Read ceiling | 10,000 entries | The directory is streamed, and reading stops there. The rest is never read. | | Examined per root | 500 entries | Of what was read, sorted alphabetically, the first 500 get the per-entry filesystem work. | -Both are far above any real config directory. 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 with `entries_seen`, `entries_kept`, and `hit_read_ceiling` per root. +Both are far above any real config directory, 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. -**A tool configured in more than one place is listed once.** Identity is deterministic, so the same machine always produces the same report: +**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. @@ -174,7 +176,7 @@ Sync sends MCP servers and plugins only. Skills and subagents are local report d - **Nothing leaves your machine on the default command.** The scan is local; `npx devcat-cli` makes no network request at all. - **Nothing inside a config file is retained but the tool's name.** Environment variable values, command-line arguments, install paths, and every other field are discarded at the parser — they reach no output and no payload. - **Skill and subagent files are never opened at all** — those scans only list directories and check that an expected filename exists. -- **Local output does include local paths.** `--json` reports the locations checked, and truncation warnings name the oversized directory. That is scan provenance, printed on your own terminal; it is not transmitted. +- **Local output does include local paths.** `--json` reports the resolved scan locations, and truncation warnings name the oversized directory. That is scan provenance, printed on your own terminal; it is not transmitted. - **Only `{type, name}` for MCP servers and plugins is ever sent**, and only by `devcat sync`. A test asserts this on the wire: it plants secrets throughout a fixture machine — including inside a `SKILL.md` body and a subagent file — runs the actual sync against an interceptor, and inspects the received bytes rather than a reconstruction of them. - **Tokens live in the OS keychain**, no plaintext fallback, and bearer tokens are redacted from `--verbose` output. - **Source is public** — read every line at [github.com/AnobleSCM/devcat-cli](https://github.com/AnobleSCM/devcat-cli). From 6186a652bfaeb8bab1f79a225b8e01ad1b3d037e Mon Sep 17 00:00:00 2001 From: AnobleSCM <211227905+AnobleSCM@users.noreply.github.com> Date: Sun, 2 Aug 2026 17:14:34 -0700 Subject: [PATCH 25/27] fix(manifest): stop reporting the user location twice from $HOME MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Blocker 1. Attribution was already right, but the project pass still named the user file among the locations it checked: after the guard nulled the hit, scannedPath fell back to join(cwd, ...), which running from $HOME is that same user file. The user pass names it too and detect() concatenates without dedupe, so one location appeared twice — and the README's "one per config file or directory" was false. Both detectors now test the candidate rather than the hit, with the same canonical comparison, and bow out entirely when it resolves to the user location: no tools, no reported path. That mirrors what Claude's detector already does. An ordinary miss still reports the candidate it looked for, which is what the empty-state message is built from. Tests assert pathsScanned exactly, not just tools: the project pass reports nothing from $HOME for both detectors, a real project config is still reported, a miss still names its candidate, and a full scan from $HOME lists every location exactly once. Claude's detector is pinned by the same whole-scan assertion. Verified on this machine: run from $HOME, 9 locations, zero duplicates. Co-authored-by: omnigent --- src/manifest/codex.ts | 17 ++++-- src/manifest/cursor.ts | 11 ++-- test/unit/manifest/homeGuard.test.ts | 78 +++++++++++++++++++++++++++- 3 files changed, 98 insertions(+), 8 deletions(-) diff --git a/src/manifest/codex.ts b/src/manifest/codex.ts index 61a90f6..7b8080a 100644 --- a/src/manifest/codex.ts +++ b/src/manifest/codex.ts @@ -82,10 +82,19 @@ async function detectCodexMcp(opts: { cwd?: string; scope: 'project' | 'user' }) } else { if (!opts.cwd) return { tools: [], pathsScanned: [] }; path = await findUpward(opts.cwd, '.codex', 'config.toml'); - // $HOME is an ancestor of most working directories; the user pass above - // already reads that exact file, so don't relabel it project-scoped. - if (path && (await isUserLevelPath(path, '.codex', 'config.toml'))) path = null; - scannedPath = path ?? join(opts.cwd, '.codex', 'config.toml'); + // $HOME is an ancestor of most working directories, and running FROM + // $HOME the fallback candidate is the user file itself — so test the + // candidate, not just the hit. Canonical comparison, so a symlinked route + // does not slip past. + // + // Bow out entirely when it is the user location: scanning it would + // relabel user config as project-scoped, and reporting it as a scanned + // location would list the same place twice, since the user pass names it. + const candidate = path ?? join(opts.cwd, '.codex', 'config.toml'); + if (await isUserLevelPath(candidate, '.codex', 'config.toml')) { + return { tools: [], pathsScanned: [] }; + } + scannedPath = candidate; if (!path) return { tools: [], pathsScanned: [scannedPath] }; } diff --git a/src/manifest/cursor.ts b/src/manifest/cursor.ts index 899e61f..533b62e 100644 --- a/src/manifest/cursor.ts +++ b/src/manifest/cursor.ts @@ -35,9 +35,14 @@ export async function detectCursor(opts: { cwd?: string; scope: 'project' | 'use } else { if (!opts.cwd) return { tools: [], pathsScanned: [] }; path = await findUpward(opts.cwd, '.cursor', 'mcp.json'); - // Same $HOME-is-an-ancestor guard as the Codex detector. - if (path && (await isUserLevelPath(path, '.cursor', 'mcp.json'))) path = null; - scannedPath = path ?? join(opts.cwd, '.cursor', 'mcp.json'); + // Same guard as the Codex detector, candidate and all: run from $HOME, + // the fallback IS the user file, so the project pass reports nothing + // rather than duplicating a location the user pass already names. + const candidate = path ?? join(opts.cwd, '.cursor', 'mcp.json'); + if (await isUserLevelPath(candidate, '.cursor', 'mcp.json')) { + return { tools: [], pathsScanned: [] }; + } + scannedPath = candidate; if (!path) return { tools: [], pathsScanned: [scannedPath] }; } diff --git a/test/unit/manifest/homeGuard.test.ts b/test/unit/manifest/homeGuard.test.ts index 34af036..9f21a80 100644 --- a/test/unit/manifest/homeGuard.test.ts +++ b/test/unit/manifest/homeGuard.test.ts @@ -162,17 +162,93 @@ describe('$HOME is not a project root — when cwd IS $HOME', () => { }); describe('$HOME is not a project root — Codex and Cursor from $HOME', () => { - it('neither claims its user config as project-scoped when cwd is $HOME', async () => { + function seedUserConfigs(): void { mkdirSync(join(tmpHome, '.codex'), { recursive: true }); writeFileSync(join(tmpHome, '.codex', 'config.toml'), '[mcp_servers.serena]\n'); mkdirSync(join(tmpHome, '.cursor'), { recursive: true }); writeFileSync(join(tmpHome, '.cursor', 'mcp.json'), JSON.stringify({ mcpServers: { figma: {} } })); + } + it('neither claims its user config as project-scoped when cwd is $HOME', async () => { + seedUserConfigs(); const { detectCodex } = await import('../../../src/manifest/codex.js'); const { detectCursor } = await import('../../../src/manifest/cursor.js'); expect((await detectCodex({ cwd: tmpHome, scope: 'project' })).tools).toEqual([]); expect((await detectCursor({ cwd: tmpHome, scope: 'project' })).tools).toEqual([]); }); + + it('neither REPORTS the user location from the project pass', async () => { + // Attribution was already right, but the project pass still named the + // user file as a location it checked — and the user pass names it too, + // so the same place was listed twice. + seedUserConfigs(); + const { detectCodex } = await import('../../../src/manifest/codex.js'); + const { detectCursor } = await import('../../../src/manifest/cursor.js'); + expect((await detectCodex({ cwd: tmpHome, scope: 'project' })).pathsScanned).toEqual([]); + expect((await detectCursor({ cwd: tmpHome, scope: 'project' })).pathsScanned).toEqual([]); + }); + + it('a real project config under $HOME is still reported as a scanned location', async () => { + seedUserConfigs(); + mkdirSync(join(cwd, '.codex'), { recursive: true }); + writeFileSync(join(cwd, '.codex', 'config.toml'), '[mcp_servers.repo-local]\n'); + + const { detectCodex } = await import('../../../src/manifest/codex.js'); + const result = await detectCodex({ cwd, scope: 'project' }); + expect(result.pathsScanned).toEqual([join(cwd, '.codex', 'config.toml')]); + }); + + it('a missing project config still reports the candidate it looked for', async () => { + // Only the user-root case is silent. An ordinary miss must still say + // where it looked, which is what the empty-state message is built from. + const { detectCursor } = await import('../../../src/manifest/cursor.js'); + const result = await detectCursor({ cwd, scope: 'project' }); + expect(result.pathsScanned).toEqual([join(cwd, '.cursor', 'mcp.json')]); + }); +}); + +describe('locations are reported once each when run from $HOME', () => { + it('a full scan from $HOME lists every location exactly once', async () => { + // README promises paths_checked holds one entry per config file or + // directory consulted. Running from $HOME is where that was false. + mkdirSync(join(tmpHome, '.codex'), { recursive: true }); + writeFileSync(join(tmpHome, '.codex', 'config.toml'), '[mcp_servers.serena]\n'); + mkdirSync(join(tmpHome, '.cursor'), { recursive: true }); + writeFileSync(join(tmpHome, '.cursor', 'mcp.json'), JSON.stringify({ mcpServers: { figma: {} } })); + const skills = join(tmpHome, '.claude', 'skills', 'panel'); + mkdirSync(skills, { recursive: true }); + writeFileSync(join(skills, 'SKILL.md'), '# panel\n'); + mkdirSync(join(tmpHome, '.claude', 'agents'), { recursive: true }); + writeFileSync(join(tmpHome, '.claude', 'agents', 'debugger.md'), 'x'); + + const { detect } = await import('../../../src/manifest/index.js'); + const result = await detect(tmpHome); + + const duplicates = result.pathsScanned.filter( + (p, i) => result.pathsScanned.indexOf(p) !== i, + ); + expect(duplicates).toEqual([]); + expect(new Set(result.pathsScanned).size).toBe(result.pathsScanned.length); + + // The four guarded user locations each appear exactly once. + for (const location of [ + join(tmpHome, '.claude', 'skills'), + join(tmpHome, '.claude', 'agents'), + join(tmpHome, '.codex', 'config.toml'), + join(tmpHome, '.cursor', 'mcp.json'), + ]) { + expect(result.pathsScanned.filter((p) => p === location)).toHaveLength(1); + } + }); + + it('lists each location once from a directory nested under $HOME too', async () => { + mkdirSync(join(tmpHome, '.codex'), { recursive: true }); + writeFileSync(join(tmpHome, '.codex', 'config.toml'), '[mcp_servers.serena]\n'); + + const { detect } = await import('../../../src/manifest/index.js'); + const result = await detect(cwd); + expect(new Set(result.pathsScanned).size).toBe(result.pathsScanned.length); + }); }); describe('$HOME is not a project root — whole scan', () => { From 78df5887a3b39940146d1816fcd1933cac1da86c Mon Sep 17 00:00:00 2001 From: AnobleSCM <211227905+AnobleSCM@users.noreply.github.com> Date: Sun, 2 Aug 2026 17:14:49 -0700 Subject: [PATCH 26/27] docs: remove the last contradictory line and re-audit the absolutes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Blocker 2 and residual R2. Troubleshooting still told users the empty report "lists every path it checked", contradicting the corrected paragraph above it that says the upward walk's intermediate probes are not recorded. It now says what the list is — the locations each detector resolved to — and points at that paragraph. Then a pass over every remaining every/always/never/all in the file, checked against the code rather than against memory: - "no file is opened, not even the SKILL.md" — true, presence is stat'd - "directory scans open nothing at all" — true - "every other field dropped at the parser" — true - "a broken .mcp.json never fails the scan" — true - "the rest is never read" — true since the ceiling gates before the pull - "never double-counted as project config" — true, and now also of the reported locations - "never enter the sync payload" — true, enforced by type and wire test - "makes no network request at all" — true - "skill and subagent files are never opened at all" — true Two did not survive intact. "stats a candidate at every ancestor directory" overstated a walk that stops after 64 levels — now "each directory the walk passes through". And R2's "far above any real config directory" traded an unnecessary absolute for the actual figures. Co-authored-by: omnigent --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index a207fac..94a7b1b 100644 --- a/README.md +++ b/README.md @@ -121,7 +121,7 @@ Nothing else inside a config is retained: environment variable values, command-l Separately from file contents, the CLI records **where it looked**. Each entry keeps the config location it came from, its scope, its client, and — for skills and subagents — the resolved directory path used to deduplicate. Those are scan facts, not file contents. They appear in `--json` output (`paths_checked`, and the locations named in any truncation warning), and the same locations are listed in the terminal report when nothing is found. -`paths_checked` is **the location each detector resolved to** — one per config file or directory it consulted, including candidates that turned out not to exist. It is not a trace of the upward walk: finding a project config stats a candidate at every ancestor directory, and those intermediate probes are not recorded. None of it is ever transmitted: see [Profile sync](#profile-sync) for the only thing that leaves the machine. +`paths_checked` is **the location each detector resolved to** — one per config file or directory it consulted, including candidates that turned out not to exist. It is not a trace of the upward walk: finding a project config stats a candidate in each directory the walk passes through, and those intermediate probes are not recorded. None of it is ever transmitted: see [Profile sync](#profile-sync) for the only thing that leaves the machine. The directory scans are deliberately shallow and doubly bounded. They read a known config root and its immediate children and never recurse, so a symlink cannot lead the scan out into a large tree. Symlinks are resolved to a canonical path — these directories are usually link farms. Broken links, unreadable directories, and entries that disappear mid-scan are skipped. A directory that errors partway through being read is reported as truncated too — a short list caused by an error is still a short list. @@ -130,7 +130,7 @@ The directory scans are deliberately shallow and doubly bounded. They read a kno | Read ceiling | 10,000 entries | The directory is streamed, and reading stops there. The rest is never read. | | Examined per root | 500 entries | Of what was read, sorted alphabetically, the first 500 get the per-entry filesystem work. | -Both are far above any real config directory, 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`. +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. @@ -183,7 +183,7 @@ Sync sends MCP servers and plugins only. Skills and subagents are local report d ## Troubleshooting -**The report is empty.** It lists every path it checked — if your config lives somewhere else, that's the gap. Open an [issue](https://github.com/AnobleSCM/devcat-cli/issues) with the location and it can be added. +**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. From eb4307217f041fed2155d060e5c29a97b37ee270 Mon Sep 17 00:00:00 2001 From: AnobleSCM <211227905+AnobleSCM@users.noreply.github.com> Date: Sun, 2 Aug 2026 17:15:01 -0700 Subject: [PATCH 27/27] docs(cli): correct the comments that still described a process.exit shim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Residual R1. cli.ts and cli.entrypoint.test.ts both still described bin/devcat.ts as turning the returned code into a process.exit call. It has assigned process.exitCode since the truncation fix; the comments now say so, and say why it matters — a hard exit after writing stdout can cut a piped report. Also records the one place the ceiling is deliberately conservative: a root holding exactly READ_CEILING entries is flagged truncated although nothing was missed. Detecting that would cost the extra read the gate exists to prevent, and over-disclosing never hides a tool. Co-authored-by: omnigent --- src/cli.ts | 16 +++++++++------- src/manifest/dirScan.ts | 5 +++++ test/integration/cli.entrypoint.test.ts | 8 +++++--- 3 files changed, 19 insertions(+), 10 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index cc973e8..c9ab532 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -7,12 +7,14 @@ import { EXIT_GENERIC_ERROR, EXIT_OK, type ExitCode } from './lib/exitCodes.js'; /** * Commander wiring, separated from the bin entrypoint so tests can run the - * real parser over a real argv array. bin/devcat.ts is then a three-line - * shim whose only extra job is turning the returned code into process.exit — + * real parser over a real argv array. bin/devcat.ts is then a small shim + * whose only extra job is assigning the returned code to process.exitCode — * the part that cannot run inside a test process. * - * Actions return exit codes rather than calling process.exit themselves for - * the same reason. + * Actions record an exit code rather than terminating the process, so the + * parser is testable and every exit flows through one place. Nothing here + * calls process.exit: doing so after writing stdout can truncate a piped + * report (see bin/devcat.ts). */ export interface ExitCodeSink { code: ExitCode; @@ -21,9 +23,9 @@ export interface ExitCodeSink { export function buildProgram(sink: ExitCodeSink): Command { const program = new Command(); // Without this commander calls process.exit() itself on a bad flag, on - // --help, and on --version, which makes the parser impossible to test and - // takes the exit path out of one place. With it, those become throws that - // runCli turns into a returned code. + // --help, and on --version — untestable, and a hard exit that could cut a + // half-written stdout. With it, those become throws that runCli turns into + // a returned code, which the shim assigns to process.exitCode. program.exitOverride(); program .name('devcat') diff --git a/src/manifest/dirScan.ts b/src/manifest/dirScan.ts index f9a53ac..d75b0a8 100644 --- a/src/manifest/dirScan.ts +++ b/src/manifest/dirScan.ts @@ -156,6 +156,11 @@ async function readCandidateNames( try { for (;;) { if (read >= readCeiling) { + // Deliberately conservative at the boundary: a directory holding + // exactly `readCeiling` entries is flagged truncated even though + // nothing was actually missed. Checking would cost the extra read + // this gate exists to prevent, and over-disclosing is the safe + // direction — it never hides a tool. hitReadCeiling = true; await iterator.return?.(); break; diff --git a/test/integration/cli.entrypoint.test.ts b/test/integration/cli.entrypoint.test.ts index 192079e..0174b14 100644 --- a/test/integration/cli.entrypoint.test.ts +++ b/test/integration/cli.entrypoint.test.ts @@ -8,8 +8,10 @@ import { join } from 'node:path'; * the default-command dispatch, and the flag wiring are all exercised — * not just the command function underneath them. * - * runCli() is the whole of bin/devcat.ts apart from the process.exit call, - * which cannot run inside a test process. + * runCli() is the whole of bin/devcat.ts apart from assigning the returned + * code to process.exitCode, which cannot be exercised in-process here. + * bin.exit.test.ts covers that assignment, and why it is an assignment + * rather than a process.exit call. */ const homedirHolder: { current: string | null } = { current: null }; @@ -115,7 +117,7 @@ describe('CLI entrypoint — real commander parse', () => { it('an unknown flag returns exit 1 without killing the process', async () => { const exitSpy = vi.spyOn(process, 'exit').mockImplementation(((): never => { - throw new Error('process.exit must not be called from runCli'); + throw new Error('runCli must return an exit code, never terminate the process'); }) as never); const errSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); try {