From 7aa83be8780e061836a3c19aedab8cea070da3b3 Mon Sep 17 00:00:00 2001 From: AnobleSCM <211227905+AnobleSCM@users.noreply.github.com> Date: Mon, 3 Aug 2026 08:12:32 -0700 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20v0.2.1=20delight=20pass=20=E2=80=94?= =?UTF-8?q?=20color=20hierarchy,=20warmer=20empty=20state,=20home-relative?= =?UTF-8?q?=20paths?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Presentation-only polish on the local stack report, scoped to exactly three things: a restrained cyan/bold color hierarchy for type labels and counts (TTY-only, NO_COLOR-aware), warmer empty-state copy with home-relative "Looked in:" paths, and the 0.2.0 -> 0.2.1 release bump. --json/--markdown output is byte-identical to v0.2.0 apart from the cli_version field. Also fixes NO_COLOR='' (empty value) not being treated as "set" per the no-color.org spec this README already claims to follow. Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 13 ++++ README.md | 2 +- package.json | 2 +- src/lib/isHeadless.ts | 4 +- src/ui/report.ts | 29 ++++++-- src/version.ts | 2 +- test/unit/api/client.test.ts | 2 +- test/unit/auth/deviceFlow.test.ts | 2 +- test/unit/lib/isHeadless.test.ts | 10 +++ test/unit/ui/colors.test.ts | 63 ++++++++++++++++++ test/unit/ui/report.test.ts | 81 ++++++++++++++++++++++- test/unit/ui/report.tty-color.test.ts | 95 +++++++++++++++++++++++++++ 12 files changed, 292 insertions(+), 13 deletions(-) create mode 100644 test/unit/ui/colors.test.ts create mode 100644 test/unit/ui/report.tty-color.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d34c8d..fd7f9c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,19 @@ All notable changes to this project are documented in this file. +## [0.2.1] + +Presentation-only delight pass on the local stack report. No change to what +is scanned, no new commands or flags, and `--json`/`--markdown` output is +byte-for-byte unchanged apart from the `cli_version` field. + +- Terminal report: type labels (`mcp`/`plugin`/`skill`/`subagent`) get a + cyan accent and per-type counts are bold, TTY-only and `NO_COLOR`-aware +- Empty state: warmer copy ("No AI tooling detected on this machine yet.") + and `Looked in:` paths now print home-relative (`~/.claude/skills`) +- Fixed `NO_COLOR=''` (empty value) not being treated as "set" — the + no-color.org standard disables color on presence, not truthiness + ## [0.2.0] Standalone-first re-aim: local stack report for Claude Code, Codex, and Cursor, including skills and subagents. diff --git a/README.md b/README.md index acb3070..5a90632 100644 --- a/README.md +++ b/README.md @@ -97,7 +97,7 @@ npx devcat-cli --json | jq '.clients[] | {label, total}' ```json { - "cli_version": "0.2.0", + "cli_version": "0.2.1", "total": 23, "project_scoped": 2, "user_scoped": 21, diff --git a/package.json b/package.json index 8a1177d..425f943 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "devcat-cli", - "version": "0.2.0", + "version": "0.2.1", "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)", diff --git a/src/lib/isHeadless.ts b/src/lib/isHeadless.ts index 0ddb43a..13567f8 100644 --- a/src/lib/isHeadless.ts +++ b/src/lib/isHeadless.ts @@ -35,7 +35,9 @@ export function detectEnv(): EnvFlags { !!process.env.BUILDKITE || !!process.env.CIRCLECI || !!process.env.JENKINS_URL, - noColor: !!process.env.NO_COLOR, + // Presence, not truthiness: no-color.org disables color "when [NO_COLOR + // is] present (regardless of its value)", so NO_COLOR='' must count too. + noColor: process.env.NO_COLOR !== undefined, }; } diff --git a/src/ui/report.ts b/src/ui/report.ts index 92163c0..d79937a 100644 --- a/src/ui/report.ts +++ b/src/ui/report.ts @@ -1,3 +1,5 @@ +import { homedir } from 'node:os'; +import { sep } from 'node:path'; import type { DetectResult, ToolEntry, ToolClient, RootTruncation } from '../manifest/index.js'; import { READ_CEILING } from '../manifest/index.js'; import { CLI_VERSION } from '../version.js'; @@ -75,6 +77,18 @@ function sanitizeMarkdownName(name: string): string { return sanitizeName(name).replace(/`/g, ''); } +/** + * Display-only: rewrite a path under `home` to start with `~`. Requires a + * separator (or exact equality) at the boundary so a sibling directory that + * merely shares the prefix — /Users/name2 next to /Users/name — is left + * absolute rather than mangled into ~2. + */ +function toHomeRelative(path: string, home: string): string { + if (path === home) return '~'; + const prefix = home.endsWith(sep) ? home : `${home}${sep}`; + return path.startsWith(prefix) ? `~${sep}${path.slice(prefix.length)}` : path; +} + /** * Group detected tools by client, then by type, with names sorted * alphabetically. Clients and types with nothing in them are omitted. @@ -122,7 +136,9 @@ 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(TYPE_WIDTH)}`; + const count = c.bold(String(names.length).padStart(3)); + const label = c.cyan(type.padEnd(TYPE_WIDTH)); + const prefix = ` ${count} ${label}`; const wrapped = wrap(names.map(sanitizeName).join(', '), WRAP_WIDTH - NAME_COLUMN); lines.push(`${prefix}${wrapped[0]}`); for (const cont of wrapped.slice(1)) { @@ -280,12 +296,17 @@ 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. + * config location the CLI does not know about yet. Paths under $HOME print + * home-relative (~/.claude/skills) — display only; --json's paths_checked + * stays absolute for scripts. */ function renderEmptyStack(result: DetectResult): string { - const list = result.pathsScanned.map((p) => ` - ${sanitizeName(p)}`).join('\n'); + const home = homedir(); + const list = result.pathsScanned + .map((p) => ` - ${toHomeRelative(sanitizeName(p), home)}`) + .join('\n'); const lines = [ - `${SUCCESS_GLYPH} ${c.bold('No AI tooling detected.')}`, + `${SUCCESS_GLYPH} ${c.bold('No AI tooling detected on this machine yet.')}`, '', 'Looked in:', list, diff --git a/src/version.ts b/src/version.ts index 209908e..da186b9 100644 --- a/src/version.ts +++ b/src/version.ts @@ -4,4 +4,4 @@ * Sent as cli_version on POST /api/device/token per Phase 40 D-06. * Server validates against semver regex /^\d+\.\d+\.\d+(-[a-zA-Z0-9.-]+)?$/. */ -export const CLI_VERSION = '0.2.0'; +export const CLI_VERSION = '0.2.1'; diff --git a/test/unit/api/client.test.ts b/test/unit/api/client.test.ts index 0c28a07..1e66bc6 100644 --- a/test/unit/api/client.test.ts +++ b/test/unit/api/client.test.ts @@ -20,7 +20,7 @@ describe('authenticatedFetch', () => { }), ); await authenticatedFetch(`${API_BASE}/probe`); - expect(capturedUA).toMatch(/^devcat-cli\/0\.2\.0 \((darwin|linux|win32); node /); + expect(capturedUA).toMatch(/^devcat-cli\/0\.2\.1 \((darwin|linux|win32); node /); }); it('sets Authorization: Bearer when bearerToken passed', async () => { diff --git a/test/unit/auth/deviceFlow.test.ts b/test/unit/auth/deviceFlow.test.ts index 90931e6..9938889 100644 --- a/test/unit/auth/deviceFlow.test.ts +++ b/test/unit/auth/deviceFlow.test.ts @@ -263,7 +263,7 @@ describe('pollForToken', () => { const body = capturedBody as { client_metadata?: { cli_version?: string; hostname?: string; platform?: string }; }; - expect(body.client_metadata?.cli_version).toBe('0.2.0'); + expect(body.client_metadata?.cli_version).toBe('0.2.1'); expect(['darwin', 'linux', 'win32']).toContain(body.client_metadata?.platform); expect(typeof body.client_metadata?.hostname).toBe('string'); }); diff --git a/test/unit/lib/isHeadless.test.ts b/test/unit/lib/isHeadless.test.ts index c4a694e..1422511 100644 --- a/test/unit/lib/isHeadless.test.ts +++ b/test/unit/lib/isHeadless.test.ts @@ -33,7 +33,17 @@ describe('detectEnv', () => { it('reads NO_COLOR env var into noColor flag', () => { vi.stubEnv('NO_COLOR', '1'); expect(detectEnv().noColor).toBe(true); + }); + + it('treats an empty-string NO_COLOR as present, per the no-color.org spec', () => { + // no-color.org: disable color "when [NO_COLOR is] present (regardless of + // its value)" — presence, not truthiness, is what must gate this. vi.stubEnv('NO_COLOR', ''); + expect(detectEnv().noColor).toBe(true); + }); + + it('is false when NO_COLOR is not present at all', () => { + vi.stubEnv('NO_COLOR', undefined); expect(detectEnv().noColor).toBe(false); }); diff --git a/test/unit/ui/colors.test.ts b/test/unit/ui/colors.test.ts new file mode 100644 index 0000000..890ba5f --- /dev/null +++ b/test/unit/ui/colors.test.ts @@ -0,0 +1,63 @@ +import { describe, it, expect, afterEach, vi } from 'vitest'; + +/** + * colors.ts computes its `enabled` flag once at module load time from + * process.stdout.isTTY + NO_COLOR, so exercising both the colored and plain + * paths in one run requires a fresh module graph per scenario: + * vi.resetModules() then a dynamic import, after setting the env that + * decides `enabled`. The rest of the suite relies on isTTY being falsy in + * Vitest's fork pool (colors auto-strip); this file is where the TTY-colored + * path itself gets exercised. + */ + +const ESC = String.fromCharCode(27); + +const ORIGINAL_ISTTY = process.stdout.isTTY; +const ORIGINAL_NO_COLOR = process.env.NO_COLOR; + +afterEach(() => { + if (ORIGINAL_ISTTY === undefined) delete (process.stdout as { isTTY?: boolean }).isTTY; + else process.stdout.isTTY = ORIGINAL_ISTTY; + if (ORIGINAL_NO_COLOR === undefined) delete process.env.NO_COLOR; + else process.env.NO_COLOR = ORIGINAL_NO_COLOR; + vi.resetModules(); +}); + +async function loadColors(opts: { isTTY: boolean; noColor?: string }) { + process.stdout.isTTY = opts.isTTY; + if (opts.noColor === undefined) delete process.env.NO_COLOR; + else process.env.NO_COLOR = opts.noColor; + vi.resetModules(); + return import('../../../src/ui/colors.js'); +} + +describe('colors — TTY + NO_COLOR gating', () => { + it('emits ANSI codes on a real TTY with NO_COLOR unset', async () => { + const { c, SUCCESS_GLYPH, FAILURE_GLYPH } = await loadColors({ isTTY: true }); + expect(c.cyan('mcp')).toContain(`${ESC}[`); + expect(c.bold('7')).toContain(`${ESC}[`); + expect(c.dim('x')).toContain(`${ESC}[`); + expect(SUCCESS_GLYPH).toContain(`${ESC}[`); + expect(FAILURE_GLYPH).toContain(`${ESC}[`); + }); + + it('stays plain when stdout is not a TTY', async () => { + const { c, SUCCESS_GLYPH, FAILURE_GLYPH } = await loadColors({ isTTY: false }); + expect(c.cyan('mcp')).toBe('mcp'); + expect(c.bold('7')).toBe('7'); + expect(SUCCESS_GLYPH).toBe('✓'); + expect(FAILURE_GLYPH).toBe('✗'); + }); + + it('NO_COLOR forces plain output even on a TTY', async () => { + const { c, SUCCESS_GLYPH } = await loadColors({ isTTY: true, noColor: '1' }); + expect(c.cyan('mcp')).toBe('mcp'); + expect(c.bold('7')).toBe('7'); + expect(SUCCESS_GLYPH).toBe('✓'); + }); + + it('an empty-string NO_COLOR still forces plain output on a TTY', async () => { + const { c } = await loadColors({ isTTY: true, noColor: '' }); + expect(c.cyan('mcp')).toBe('mcp'); + }); +}); diff --git a/test/unit/ui/report.test.ts b/test/unit/ui/report.test.ts index 2faa199..75f2390 100644 --- a/test/unit/ui/report.test.ts +++ b/test/unit/ui/report.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { groupStack, renderStackReport, @@ -12,6 +12,17 @@ import type { DetectResult, ToolEntry } from '../../../src/manifest/index.js'; // NO_COLOR is belt-and-suspenders for any CI shape where isTTY is truthy. process.env.NO_COLOR = '1'; +// Only the home-relative-path tests below care what homedir() returns; every +// other test in this file uses fixture paths that never match a real home +// dir, so this mock is a no-op for them (same pattern as the integration +// suite's homedirHolder). +const homedirHolder: { current: string | null } = { current: null }; + +vi.mock('node:os', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, homedir: () => homedirHolder.current ?? actual.homedir() }; +}); + function tool(partial: Partial & Pick): ToolEntry { return { type: 'mcp', @@ -127,13 +138,77 @@ describe('renderStackReport (default `npx devcat-cli` output)', () => { 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'], truncations: [] }); - expect(out).toContain('No AI tooling detected.'); + expect(out).toContain('No AI tooling detected on this machine yet.'); expect(out).toContain('/p/.mcp.json'); expect(out).toContain('~/.cursor/mcp.json'); expect(out).not.toContain('Your AI-coding stack'); }); }); +describe('renderEmptyStack — copy and home-relative paths (terminal report only)', () => { + const HOME = '/Users/testuser'; + + beforeEach(() => { + homedirHolder.current = HOME; + }); + + afterEach(() => { + homedirHolder.current = null; + }); + + it('uses warm, honest, marketing-free copy', () => { + const out = renderStackReport({ tools: [], pathsScanned: [`${HOME}/.claude.json`], truncations: [] }); + expect(out).toContain('No AI tooling detected on this machine yet.'); + }); + + it('keeps the Looked in: transparency section', () => { + const out = renderStackReport({ tools: [], pathsScanned: [`${HOME}/.claude.json`], truncations: [] }); + expect(out).toContain('Looked in:'); + }); + + it('shows paths under $HOME as home-relative', () => { + const out = renderStackReport({ + tools: [], + pathsScanned: [`${HOME}/.claude.json`, `${HOME}/.claude/skills`], + truncations: [], + }); + expect(out).toContain('~/.claude.json'); + expect(out).toContain('~/.claude/skills'); + expect(out).not.toContain(HOME); + }); + + it('leaves paths outside $HOME absolute', () => { + const out = renderStackReport({ + tools: [], + pathsScanned: ['/opt/shared/.mcp.json'], + truncations: [], + }); + expect(out).toContain('/opt/shared/.mcp.json'); + }); + + it('does not rewrite a sibling directory that merely shares the home prefix', () => { + // /Users/testuser2 must not become ~2 from a naive (non-boundary) prefix match. + const out = renderStackReport({ + tools: [], + pathsScanned: [`${HOME}2/.mcp.json`], + truncations: [], + }); + expect(out).toContain(`${HOME}2/.mcp.json`); + }); + + it('renders the home directory itself as bare ~', () => { + const out = renderStackReport({ tools: [], pathsScanned: [HOME], truncations: [] }); + expect(out).toContain(' - ~'); + }); + + it('does not home-relativize paths_checked in --json output', () => { + const parsed = JSON.parse( + renderStackJson({ tools: [], pathsScanned: [`${HOME}/.claude.json`], truncations: [] }), + ); + expect(parsed.paths_checked).toEqual([`${HOME}/.claude.json`]); + }); +}); + describe('renderStackMarkdown (--markdown)', () => { it('emits a "My AI stack" snippet with a section per client', () => { const out = renderStackMarkdown(MIXED); @@ -291,7 +366,7 @@ describe('truncation disclosure', () => { pathsScanned: ['~/.claude/skills'], truncations: TRUNCATED.truncations, }); - expect(out).toContain('No AI tooling detected.'); + expect(out).toContain('No AI tooling detected on this machine yet.'); expect(out).toContain('1 location was truncated'); expect(out).toContain('some tools are not listed'); }); diff --git a/test/unit/ui/report.tty-color.test.ts b/test/unit/ui/report.tty-color.test.ts new file mode 100644 index 0000000..23111a1 --- /dev/null +++ b/test/unit/ui/report.tty-color.test.ts @@ -0,0 +1,95 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import type { DetectResult, ToolEntry } from '../../../src/manifest/index.js'; + +/** + * report.ts colorizes through colors.ts, whose `enabled` flag is fixed at + * module load time — so the colored path needs a fresh module graph per + * scenario (vi.resetModules() + dynamic import), same technique as + * colors.test.ts. report.test.ts covers everything else statically, relying + * on isTTY being falsy in Vitest's fork pool; this file is the one place the + * TTY-colored render itself gets exercised end to end. + */ + +const ESC = String.fromCharCode(27); + +const ORIGINAL_ISTTY = process.stdout.isTTY; +const ORIGINAL_NO_COLOR = process.env.NO_COLOR; + +afterEach(() => { + if (ORIGINAL_ISTTY === undefined) delete (process.stdout as { isTTY?: boolean }).isTTY; + else process.stdout.isTTY = ORIGINAL_ISTTY; + if (ORIGINAL_NO_COLOR === undefined) delete process.env.NO_COLOR; + else process.env.NO_COLOR = ORIGINAL_NO_COLOR; + vi.resetModules(); +}); + +function tool(partial: Partial & Pick): ToolEntry { + return { type: 'mcp', source: '/fake/path.json', scope: 'user', client: 'claude-code', ...partial }; +} + +const SAMPLE: DetectResult = { + tools: [tool({ name: 'context7' }), tool({ name: 'swift-lsp', type: 'plugin' })], + pathsScanned: ['/home/x/.claude.json'], + truncations: [], +}; + +/** Strips `ESC [ ... m` SGR sequences without a control-character regex. */ +function stripAnsi(s: string): string { + let result = ''; + let i = 0; + while (i < s.length) { + if (s[i] === ESC && s[i + 1] === '[') { + const end = s.indexOf('m', i); + i = end === -1 ? s.length : end + 1; + } else { + result += s[i]; + i += 1; + } + } + return result; +} + +async function renderWith(opts: { isTTY: boolean; noColor?: string }): Promise { + process.stdout.isTTY = opts.isTTY; + if (opts.noColor === undefined) delete process.env.NO_COLOR; + else process.env.NO_COLOR = opts.noColor; + vi.resetModules(); + const { renderStackReport } = await import('../../../src/ui/report.js'); + return renderStackReport(SAMPLE); +} + +describe('renderStackReport — color hierarchy (TTY only)', () => { + it('colorizes type labels cyan and emphasizes counts bold on a real TTY', async () => { + const out = await renderWith({ isTTY: true }); + expect(out).toContain(`${ESC}[`); + expect(out).toContain(`${ESC}[36mmcp`); + expect(out).toContain(`${ESC}[36mplugin`); + expect(out).toContain(`${ESC}[1m`); + expect(out).toContain(`1${ESC}[22m`); + }); + + it('never colors the name list itself, only the type label and the count', async () => { + const out = await renderWith({ isTTY: true }); + expect(out).not.toContain(`${ESC}[36mcontext7`); + expect(out).not.toContain(`${ESC}[36mswift-lsp`); + }); + + it('colorizing never changes the underlying text — stripped output matches the plain render', async () => { + const colored = await renderWith({ isTTY: true }); + const plain = await renderWith({ isTTY: false }); + expect(stripAnsi(colored)).toBe(plain); + }); + + it('stays fully plain when stdout is not a TTY', async () => { + const out = await renderWith({ isTTY: false }); + expect(out).not.toContain(`${ESC}[`); + expect(out).toContain('1 mcp'); + expect(out).toContain('1 plugin'); + }); + + it('NO_COLOR forces plain output even on a TTY', async () => { + const out = await renderWith({ isTTY: true, noColor: '1' }); + expect(out).not.toContain(`${ESC}[`); + expect(out).toContain('1 mcp'); + }); +}); From 7a4a4d97e5afb7a2249a6ddb8a8017e31ec1af40 Mon Sep 17 00:00:00 2001 From: AnobleSCM <211227905+AnobleSCM@users.noreply.github.com> Date: Mon, 3 Aug 2026 10:16:29 -0700 Subject: [PATCH 2/3] fix: sync package-lock.json version to 0.2.1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit package.json and src/version.ts were bumped to 0.2.1 but package-lock.json's top-level "version" and packages[""].version were left at 0.2.0. Updated both fields directly — `npm install --package-lock-only` on this npm version (10.9.8) also strips unrelated "peer": true flags from 8 packages, so hand editing keeps the diff to just the version bump. Extends the version-parity test to assert both lockfile fields track package.json, so a partial bump fails loudly instead of shipping quietly. Co-Authored-By: Claude Sonnet 5 --- package-lock.json | 4 ++-- test/unit/version.parity.test.ts | 15 +++++++++++++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index 6adfbb7..4ad1c18 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "devcat-cli", - "version": "0.2.0", + "version": "0.2.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "devcat-cli", - "version": "0.2.0", + "version": "0.2.1", "license": "MIT", "dependencies": { "@napi-rs/keyring": "^1.2.0", diff --git a/test/unit/version.parity.test.ts b/test/unit/version.parity.test.ts index 243e258..11c9b13 100644 --- a/test/unit/version.parity.test.ts +++ b/test/unit/version.parity.test.ts @@ -10,4 +10,19 @@ describe('CLI_VERSION parity', () => { expect(CLI_VERSION).toBe(packageJson.version); }); + + // A lockfile regenerated only in part (e.g. hand-editing package.json + // without ever re-running npm install) silently drifts from the source of + // truth. Both the top-level version and packages[""].version must track + // package.json, or a future bump can land half-done again. + it('matches package-lock.json top-level version and packages[""].version', () => { + const packageJsonPath = join(__dirname, '..', '..', 'package.json'); + const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8')); + + const packageLockPath = join(__dirname, '..', '..', 'package-lock.json'); + const packageLock = JSON.parse(readFileSync(packageLockPath, 'utf8')); + + expect(packageLock.version).toBe(packageJson.version); + expect(packageLock.packages['']?.version).toBe(packageJson.version); + }); }); From 44bcb27810b5013540bf89a4eb07836dadebaafc Mon Sep 17 00:00:00 2001 From: AnobleSCM <211227905+AnobleSCM@users.noreply.github.com> Date: Mon, 3 Aug 2026 10:16:45 -0700 Subject: [PATCH 3/3] fix: cross-platform home-relative path handling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two issues in the v0.2.1 delight pass's home-relative path work, both in toHomeRelative (src/ui/report.ts) and its tests: - The new home-relative tests in test/unit/ui/report.test.ts hardcoded POSIX fixtures ('/Users/testuser', '~/.claude/skills'). toHomeRelative's separator-boundary check uses native path.sep, so on win32 (sep is '\') those fixtures silently failed to match — both windows-latest CI lanes (Node 20 + 22) were red while all four Unix lanes passed. Fixtures now build from node:path (sep/join) so every lane exercises its own real native-separator behavior. - toHomeRelative's home-prefix match is now a deliberate policy instead of an accidental one: case-insensitive on win32/darwin (both default to case-insensitive filesystems) and case-sensitive elsewhere. The match folds case; the returned string never does — only the matched home-prefix span is replaced by '~', so the rest of the path keeps its original casing. The sibling-directory boundary guard (/Users/name2 must not become ~2) holds in both modes. caseSensitivity is exported as an explicit parameter (default derived from process.platform) so both modes run deterministically on every platform in CI, not just whatever the host happens to do. Neither change touches the non-TTY/--json/--markdown byte-identical-to-0.2.0 invariant or its stripAnsi(colored) === plain test. Co-Authored-By: Claude Sonnet 5 --- src/ui/report.ts | 34 ++++++++++++++++-- test/unit/ui/report.test.ts | 72 ++++++++++++++++++++++++++++++------- 2 files changed, 90 insertions(+), 16 deletions(-) diff --git a/src/ui/report.ts b/src/ui/report.ts index d79937a..88f4a0f 100644 --- a/src/ui/report.ts +++ b/src/ui/report.ts @@ -77,16 +77,44 @@ function sanitizeMarkdownName(name: string): string { return sanitizeName(name).replace(/`/g, ''); } +/** Whether the home-prefix comparison in {@link toHomeRelative} folds case. */ +export type PathCaseSensitivity = 'sensitive' | 'insensitive'; + +/** + * win32 and macOS both default to case-insensitive (but case-preserving) + * filesystems, so a homedir() or $HOME that is cased differently from a + * scanned path can still name the same directory. Linux filesystems are + * case-sensitive, so a cased difference there is a genuinely different path. + */ +function defaultPathCaseSensitivity(platform: NodeJS.Platform = process.platform): PathCaseSensitivity { + return platform === 'win32' || platform === 'darwin' ? 'insensitive' : 'sensitive'; +} + /** * Display-only: rewrite a path under `home` to start with `~`. Requires a * separator (or exact equality) at the boundary so a sibling directory that * merely shares the prefix — /Users/name2 next to /Users/name — is left * absolute rather than mangled into ~2. + * + * The prefix comparison is case-insensitive on win32/darwin and + * case-sensitive elsewhere (see {@link defaultPathCaseSensitivity}) — this + * is a policy about matching, not about display: casing is folded only to + * decide whether `path` sits under `home`, never to build the return value. + * The output always keeps `path`'s original casing; the matched prefix span + * is simply discarded in favor of `~`, not re-cased. `caseSensitivity` + * defaults to the real platform policy but is an explicit parameter so tests + * can exercise both modes deterministically regardless of which OS actually + * runs them. */ -function toHomeRelative(path: string, home: string): string { - if (path === home) return '~'; +export function toHomeRelative( + path: string, + home: string, + caseSensitivity: PathCaseSensitivity = defaultPathCaseSensitivity(), +): string { + const fold = caseSensitivity === 'insensitive' ? (s: string) => s.toLowerCase() : (s: string) => s; + if (fold(path) === fold(home)) return '~'; const prefix = home.endsWith(sep) ? home : `${home}${sep}`; - return path.startsWith(prefix) ? `~${sep}${path.slice(prefix.length)}` : path; + return fold(path).startsWith(fold(prefix)) ? `~${sep}${path.slice(prefix.length)}` : path; } /** diff --git a/test/unit/ui/report.test.ts b/test/unit/ui/report.test.ts index 75f2390..04f682a 100644 --- a/test/unit/ui/report.test.ts +++ b/test/unit/ui/report.test.ts @@ -1,10 +1,12 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { sep, join } from 'node:path'; import { groupStack, renderStackReport, renderStackMarkdown, renderStackJson, truncationWarnings, + toHomeRelative, } from '../../../src/ui/report.js'; import type { DetectResult, ToolEntry } from '../../../src/manifest/index.js'; @@ -146,7 +148,11 @@ describe('renderStackReport (default `npx devcat-cli` output)', () => { }); describe('renderEmptyStack — copy and home-relative paths (terminal report only)', () => { - const HOME = '/Users/testuser'; + // Built from node:path primitives rather than hardcoded POSIX literals: + // toHomeRelative keys off path.sep, and a hardcoded '/' fixture silently + // no-ops the whole boundary check on win32 (sep is '\\' there), which is + // exactly how this suite went green on Unix while failing on Windows CI. + const HOME = join(sep, 'Users', 'testuser'); beforeEach(() => { homedirHolder.current = HOME; @@ -157,43 +163,46 @@ describe('renderEmptyStack — copy and home-relative paths (terminal report onl }); it('uses warm, honest, marketing-free copy', () => { - const out = renderStackReport({ tools: [], pathsScanned: [`${HOME}/.claude.json`], truncations: [] }); + const out = renderStackReport({ tools: [], pathsScanned: [join(HOME, '.claude.json')], truncations: [] }); expect(out).toContain('No AI tooling detected on this machine yet.'); }); it('keeps the Looked in: transparency section', () => { - const out = renderStackReport({ tools: [], pathsScanned: [`${HOME}/.claude.json`], truncations: [] }); + const out = renderStackReport({ tools: [], pathsScanned: [join(HOME, '.claude.json')], truncations: [] }); expect(out).toContain('Looked in:'); }); it('shows paths under $HOME as home-relative', () => { const out = renderStackReport({ tools: [], - pathsScanned: [`${HOME}/.claude.json`, `${HOME}/.claude/skills`], + pathsScanned: [join(HOME, '.claude.json'), join(HOME, '.claude', 'skills')], truncations: [], }); - expect(out).toContain('~/.claude.json'); - expect(out).toContain('~/.claude/skills'); + expect(out).toContain(`~${sep}.claude.json`); + expect(out).toContain(`~${sep}.claude${sep}skills`); expect(out).not.toContain(HOME); }); it('leaves paths outside $HOME absolute', () => { + const outside = join(sep, 'opt', 'shared', '.mcp.json'); const out = renderStackReport({ tools: [], - pathsScanned: ['/opt/shared/.mcp.json'], + pathsScanned: [outside], truncations: [], }); - expect(out).toContain('/opt/shared/.mcp.json'); + expect(out).toContain(outside); }); it('does not rewrite a sibling directory that merely shares the home prefix', () => { - // /Users/testuser2 must not become ~2 from a naive (non-boundary) prefix match. + // HOME + '2' (e.g. /Users/testuser2) must not become ~2 from a naive + // (non-boundary) prefix match. + const sibling = `${HOME}2${sep}.mcp.json`; const out = renderStackReport({ tools: [], - pathsScanned: [`${HOME}2/.mcp.json`], + pathsScanned: [sibling], truncations: [], }); - expect(out).toContain(`${HOME}2/.mcp.json`); + expect(out).toContain(sibling); }); it('renders the home directory itself as bare ~', () => { @@ -203,9 +212,46 @@ describe('renderEmptyStack — copy and home-relative paths (terminal report onl it('does not home-relativize paths_checked in --json output', () => { const parsed = JSON.parse( - renderStackJson({ tools: [], pathsScanned: [`${HOME}/.claude.json`], truncations: [] }), + renderStackJson({ tools: [], pathsScanned: [join(HOME, '.claude.json')], truncations: [] }), ); - expect(parsed.paths_checked).toEqual([`${HOME}/.claude.json`]); + expect(parsed.paths_checked).toEqual([join(HOME, '.claude.json')]); + }); +}); + +describe('toHomeRelative — case-sensitivity policy', () => { + // caseSensitivity is an explicit parameter precisely so these can run + // deterministically on every CI lane instead of only asserting whatever + // the host platform's own case-folding happens to do. + const HOME = join(sep, 'Users', 'testuser'); + + it('matches a differently-cased home prefix in insensitive mode (win32/darwin policy)', () => { + const shouted = `${HOME.toUpperCase()}${sep}.claude.json`; + expect(toHomeRelative(shouted, HOME, 'insensitive')).toBe(`~${sep}.claude.json`); + }); + + it('does not match a differently-cased home prefix in sensitive mode (linux policy)', () => { + const shouted = `${HOME.toUpperCase()}${sep}.claude.json`; + expect(toHomeRelative(shouted, HOME, 'sensitive')).toBe(shouted); + }); + + it('preserves the original path casing beyond the matched prefix', () => { + // The comparison folds case; the output must not — only the matched + // home-prefix span is replaced by ~, the remainder prints as scanned. + const shouted = `${HOME.toUpperCase()}${sep}MixedCase.JSON`; + expect(toHomeRelative(shouted, HOME, 'insensitive')).toBe(`~${sep}MixedCase.JSON`); + }); + + it('holds the sibling-directory boundary guard case-insensitively', () => { + // home '/users/name' + path '/Users/NAME2/x' must stay absolute even + // when folding case — NAME2 is a sibling, not home itself. + const home = join(sep, 'users', 'name'); + const sibling = `${join(sep, 'Users', 'NAME')}2${sep}x`; + expect(toHomeRelative(sibling, home, 'insensitive')).toBe(sibling); + }); + + it('exact-equality shortcut also folds case in insensitive mode', () => { + expect(toHomeRelative(HOME.toUpperCase(), HOME, 'insensitive')).toBe('~'); + expect(toHomeRelative(HOME.toUpperCase(), HOME, 'sensitive')).toBe(HOME.toUpperCase()); }); });