diff --git a/CHANGELOG.md b/CHANGELOG.md index 3cda18d..2c0da2f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,26 @@ All notable changes to this project are documented in this file. +## [0.2.2] + +Presentation-only polish pass on the local stack report. Nothing about what is +scanned changed, there are no new commands or flags, and `--json`/`--markdown` +output is byte-for-byte unchanged apart from the `cli_version` field. + +- Terminal report opens with the `devcat` wordmark and version, so a shared + screenshot says what produced it and which release +- Each tool type gets its own accent — `mcp` cyan, `plugin` magenta, `skill` + green, `subagent` blue — on both its label and its new count bar. Four hues + of the standard ANSI 16 that read on light and dark terminals; red and + yellow stay reserved for failure and truncation +- Per-type counts gain a proportional bar, scaled against the largest count + anywhere in the report so equal lengths mean equal counts in every section +- Closing dim hint pointing at `npx devcat-cli --markdown` +- Empty state: locations under the current directory now print `./`-relative + instead of as long absolute paths, and it carries the same wordmark header +- Color remains decoration only: ANSI-stripped output is byte-for-byte the + plain render, for a populated, empty, and truncated scan alike + ## [0.2.1] Presentation-only delight pass on the local stack report. No change to what diff --git a/README.md b/README.md index ad0886d..dd8c9b2 100644 --- a/README.md +++ b/README.md @@ -20,23 +20,27 @@ That's the whole thing — the report above is what it prints. The same output as text ``` +devcat v0.2.2 + ✓ Your AI-coding stack — 23 tools 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 + ██████ 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 23 tools in Claude Code, Codex, and Cursor · 13 locations checked 2 project-scoped · 21 user-wide + +Share it — npx devcat-cli --markdown ``` @@ -97,7 +101,7 @@ npx devcat-cli --json | jq '.clients[] | {label, total}' ```json { - "cli_version": "0.2.1", + "cli_version": "0.2.2", "total": 23, "project_scoped": 2, "user_scoped": 21, diff --git a/assets/devcat-report.svg b/assets/devcat-report.svg index e00bfc9..478e76a 100644 --- a/assets/devcat-report.svg +++ b/assets/devcat-report.svg @@ -1,24 +1,26 @@ - - - - + + + + - npx devcat-cli + npx devcat-cli - ✓ Your AI-coding stack — 23 tools - 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 - Cursor · 2 tools - 2 mcp figma, postgres - 23 tools in Claude Code, Codex, and Cursor · 13 locations checked - 2 project-scoped · 21 user-wide + devcat v0.2.2 + ✓ Your AI-coding stack — 23 tools + 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 + Cursor · 2 tools + ██ 2 mcp figma, postgres + 23 tools in Claude Code, Codex, and Cursor · 13 locations checked + 2 project-scoped · 21 user-wide + Share it — npx devcat-cli --markdown diff --git a/package-lock.json b/package-lock.json index 4ad1c18..f600a8e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "devcat-cli", - "version": "0.2.1", + "version": "0.2.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "devcat-cli", - "version": "0.2.1", + "version": "0.2.2", "license": "MIT", "dependencies": { "@napi-rs/keyring": "^1.2.0", diff --git a/package.json b/package.json index 425f943..07ba0e9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "devcat-cli", - "version": "0.2.1", + "version": "0.2.2", "description": "See your whole AI-coding stack in one command. npx devcat-cli scans this machine for the MCP servers, plugins, skills, and subagents installed across Claude Code, Codex, and Cursor and prints them grouped — locally, with no account and no network call.", "license": "MIT", "author": "Andrew Noble (https://github.com/AnobleSCM)", diff --git a/src/ui/colors.ts b/src/ui/colors.ts index d35598c..c09668c 100644 --- a/src/ui/colors.ts +++ b/src/ui/colors.ts @@ -9,6 +9,15 @@ import { detectEnv, shouldUseColor } from '../lib/isHeadless.js'; const env = detectEnv(); const enabled = shouldUseColor(env); +/** + * Only the six chromatic colors of the standard ANSI 16 are offered, and + * white/black are deliberately absent: a terminal theme picks the background, + * so anything at either end of the ramp is invisible on half of them. Bold and + * dim carry emphasis instead, because they hold in every theme. + * + * red and yellow stay reserved for failure and truncation — the report never + * spends them on ordinary data, so seeing one always means something. + */ export const c = { bold: (s: string): string => (enabled ? pc.bold(s) : s), green: (s: string): string => (enabled ? pc.green(s) : s), @@ -16,6 +25,8 @@ export const c = { yellow: (s: string): string => (enabled ? pc.yellow(s) : s), dim: (s: string): string => (enabled ? pc.dim(s) : s), cyan: (s: string): string => (enabled ? pc.cyan(s) : s), + magenta: (s: string): string => (enabled ? pc.magenta(s) : s), + blue: (s: string): string => (enabled ? pc.blue(s) : s), }; export const SUCCESS_GLYPH = enabled ? pc.green('✓') : '✓'; diff --git a/src/ui/report.ts b/src/ui/report.ts index 88f4a0f..cf4e419 100644 --- a/src/ui/report.ts +++ b/src/ui/report.ts @@ -39,12 +39,72 @@ const TYPE_LABEL_MARKDOWN: Record = { subagent: 'Subagents', }; +/** + * One accent per tool type, used for both the type label and its count bar so + * the two always agree. Drawn from the standard ANSI 16 (see colors.ts): each + * of these four reads on a light and a dark background, which `white`, `black` + * and `yellow` do not. + * + * Color is never the only signal — every row still spells its type out, and + * every bar is a length as well as a hue — so the report survives NO_COLOR, a + * pipe, and a reader who cannot distinguish two of the four. + */ +const TYPE_ACCENT: Record string> = { + mcp: c.cyan, + plugin: c.magenta, + skill: c.green, + subagent: c.blue, +}; + /** 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 = 15; +/** Indent of every type row under its client heading. */ +const ROW_INDENT = 2; +/** Cells in the proportional count bar. Full block = one cell, half block = a half. */ +const BAR_WIDTH = 6; +/** + * Minimum width of the count column, right-aligned. A report whose biggest + * count needs more digits widens the column rather than overflowing it — a + * four-digit count in a three-wide column would push that one row's label and + * names a character right of every other row, and out of line with its own + * wrapped continuation. + */ +const MIN_COUNT_WIDTH = 3; + +/** + * Column where the comma-separated names start (and continuation lines indent + * to). Derived from the columns before it — indent, bar, gap, count, gap, + * label — so widening any of them cannot silently break the alignment. + */ +function nameColumn(countWidth: number): number { + return ROW_INDENT + BAR_WIDTH + 1 + countWidth + 1 + TYPE_WIDTH; +} + +/** + * Both blocks are in CP437, so they render even in a legacy Windows console + * with a raster font — unlike the finer eighth-blocks (U+2589-U+258F), which + * are not. + */ +const BAR_FULL = '█'; +const BAR_HALF = '▌'; + +/** + * A count as a bar, scaled against the largest single type count anywhere in + * the report — not the largest within its own client. Local scaling would draw + * Codex's 3 MCP servers as wide as Claude Code's 12, which is the one thing a + * bar chart must never do. + * + * Any nonzero count keeps at least a half block: a row that exists must be + * visible, however it compares to the biggest one. + */ +function renderBar(count: number, max: number): string { + if (count <= 0 || max <= 0) return ''; + const cells = Math.max(0.5, Math.round((count / max) * BAR_WIDTH * 2) / 2); + const full = Math.floor(cells); + return `${BAR_FULL.repeat(full)}${cells > full ? BAR_HALF : ''}`; +} export interface StackTypeGroup { type: ToolType; @@ -111,10 +171,62 @@ export function toHomeRelative( 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 fold(path).startsWith(fold(prefix)) ? `~${sep}${path.slice(prefix.length)}` : path; + return toPrefixRelative(path, home, '~', caseSensitivity); +} + +/** + * Display-only: rewrite a path under `cwd` to start with `.`, so a project + * config reads as `.${sep}.mcp.json` rather than as the machine-specific + * absolute path it was resolved from. Same boundary and case rules as + * {@link toHomeRelative} — a sibling directory sharing the prefix stays + * absolute. + * + * The marker is `.${sep}`, not a hardcoded `./`: on win32 the idiomatic + * relative form is `.\`, and mixing separators in one printed path is how a + * report starts looking machine-generated. + */ +export function toCwdRelative( + path: string, + cwd: string, + caseSensitivity: PathCaseSensitivity = defaultPathCaseSensitivity(), +): string { + return toPrefixRelative(path, cwd, '.', caseSensitivity); +} + +function fold(s: string, caseSensitivity: PathCaseSensitivity): string { + return caseSensitivity === 'insensitive' ? s.toLowerCase() : s; +} + +function toPrefixRelative( + path: string, + base: string, + marker: string, + caseSensitivity: PathCaseSensitivity, +): string { + if (fold(path, caseSensitivity) === fold(base, caseSensitivity)) return marker; + const prefix = base.endsWith(sep) ? base : `${base}${sep}`; + return fold(path, caseSensitivity).startsWith(fold(prefix, caseSensitivity)) + ? `${marker}${sep}${path.slice(prefix.length)}` + : path; +} + +/** + * How one scanned location prints in the empty-state report. Project-scoped + * candidates come back as absolute paths under the current directory, which + * tell the reader nothing they do not already know; `.${sep}.mcp.json` says + * "here" in the width of two characters. + * + * cwd wins over home when a path sits under both, being the more specific of + * the two answers — except when the two are the same directory, where `~` + * is the more informative marker for a config that is user-wide by nature. + */ +function displayPath(path: string, home: string, cwd: string): string { + const caseSensitivity = defaultPathCaseSensitivity(); + if (fold(cwd, caseSensitivity) !== fold(home, caseSensitivity)) { + const relative = toCwdRelative(path, cwd, caseSensitivity); + if (relative !== path) return relative; + } + return toHomeRelative(path, home, caseSensitivity); } /** @@ -139,17 +251,34 @@ export function groupStack(tools: readonly ToolEntry[]): StackGroup[] { return groups; } +/** + * The wordmark, printed above every terminal report. A report that gets + * screenshotted should say what produced it, and the version is what makes a + * screenshot answerable a year later. + * + * Bold with a dim version rather than a brand color: bold is the one emphasis + * that survives every terminal theme, and the four colors this report does own + * are spent on the tool types, where they carry meaning. + */ +function masthead(): string { + return `${c.bold('devcat')} ${c.dim(`v${CLI_VERSION}`)}`; +} + /** * Grouped terminal report: * + * devcat v0.2.2 + * * ✓ Your AI-coding stack — 21 tools * * Claude Code · 16 tools - * 12 mcp alpha, beta, gamma, … - * 4 plugin swift-lsp, vercel + * ██████ 12 mcp alpha, beta, gamma, … + * ██ 4 plugin swift-lsp, vercel * * 21 tools in Claude Code, Codex, and Cursor · 8 locations checked * 3 project-scoped · 18 user-wide + * + * Share it — npx devcat-cli --markdown */ export function renderStackReport(result: DetectResult): string { if (result.tools.length === 0) return renderEmptyStack(result); @@ -158,19 +287,33 @@ export function renderStackReport(result: DetectResult): string { const total = result.tools.length; const lines: string[] = []; + lines.push(masthead()); + lines.push(''); lines.push(`${SUCCESS_GLYPH} ${c.bold(`Your AI-coding stack — ${plural(total, 'tool')}`)}`); + // One scale for every bar in the report, so two rows of equal length mean + // equal counts wherever they sit — and one column width, wide enough for + // the longest count, so every row lines up with every other. + const maxTypeCount = Math.max(...groups.flatMap((g) => g.byType.map((t) => t.names.length))); + const countWidth = Math.max(MIN_COUNT_WIDTH, String(maxTypeCount).length); + const nameStart = nameColumn(countWidth); + 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 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); + const accent = TYPE_ACCENT[type]; + // Padding stays outside the color wrapper: the escape codes hug the + // glyphs, so a stripped line is the plain line, space for space. + const bar = renderBar(names.length, maxTypeCount); + const barCell = `${accent(bar)}${' '.repeat(BAR_WIDTH - bar.length)}`; + const count = c.bold(String(names.length).padStart(countWidth)); + const label = accent(type.padEnd(TYPE_WIDTH)); + const prefix = `${' '.repeat(ROW_INDENT)}${barCell} ${count} ${label}`; + const wrapped = wrap(names.map(sanitizeName).join(', '), WRAP_WIDTH - nameStart); lines.push(`${prefix}${wrapped[0]}`); for (const cont of wrapped.slice(1)) { - lines.push(`${' '.repeat(NAME_COLUMN)}${cont}`); + lines.push(`${' '.repeat(nameStart)}${cont}`); } } } @@ -199,9 +342,21 @@ export function renderStackReport(result: DetectResult): string { lines.push(c.yellow(truncationFootnote(result.truncations))); } + lines.push(''); + lines.push(c.dim(SHARE_HINT)); + return lines.join('\n'); } +/** + * Closing line of a non-empty report. Dim, so it sits under the result rather + * than competing with it — the report is the product, this is the next step. + * + * Deliberately absent from the empty-state report: there is nothing to share + * yet, and that state already ends on the step that is actually worth taking. + */ +const SHARE_HINT = 'Share it — npx devcat-cli --markdown'; + /** * 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 @@ -324,16 +479,19 @@ 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. Paths under $HOME print - * home-relative (~/.claude/skills) — display only; --json's paths_checked - * stays absolute for scripts. + * config location the CLI does not know about yet. Paths under the current + * directory print `.`-relative and paths under $HOME print home-relative — + * display only; --json's paths_checked stays absolute for scripts. */ function renderEmptyStack(result: DetectResult): string { const home = homedir(); + const cwd = process.cwd(); const list = result.pathsScanned - .map((p) => ` - ${toHomeRelative(sanitizeName(p), home)}`) + .map((p) => ` ${c.dim('-')} ${displayPath(sanitizeName(p), home, cwd)}`) .join('\n'); const lines = [ + masthead(), + '', `${SUCCESS_GLYPH} ${c.bold('No AI tooling detected on this machine yet.')}`, '', 'Looked in:', diff --git a/src/version.ts b/src/version.ts index da186b9..fc5ce59 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.1'; +export const CLI_VERSION = '0.2.2'; diff --git a/test/unit/api/client.test.ts b/test/unit/api/client.test.ts index 1e66bc6..b39f546 100644 --- a/test/unit/api/client.test.ts +++ b/test/unit/api/client.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest'; import { setupServer } from 'msw/node'; import { http, HttpResponse } from 'msw'; import { authenticatedFetch } from '../../../src/api/client.js'; +import { CLI_VERSION } from '../../../src/version.js'; const API_BASE = 'https://devcat.dev'; const server = setupServer(); @@ -20,7 +21,11 @@ describe('authenticatedFetch', () => { }), ); await authenticatedFetch(`${API_BASE}/probe`); - expect(capturedUA).toMatch(/^devcat-cli\/0\.2\.1 \((darwin|linux|win32); node /); + // Against CLI_VERSION rather than a literal: version.parity.test.ts is + // what pins that constant to package.json, so a release bump should not + // also have to remember this file. + expect(capturedUA.startsWith(`devcat-cli/${CLI_VERSION} `)).toBe(true); + expect(capturedUA).toMatch(/^devcat-cli\/\S+ \((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 9938889..7266902 100644 --- a/test/unit/auth/deviceFlow.test.ts +++ b/test/unit/auth/deviceFlow.test.ts @@ -9,6 +9,7 @@ import { CodeAlreadyUsedError, ClockDriftError, } from '../../../src/auth/deviceFlow.js'; +import { CLI_VERSION } from '../../../src/version.js'; const API_BASE = 'https://devcat.dev'; const server = setupServer(); @@ -263,7 +264,10 @@ describe('pollForToken', () => { const body = capturedBody as { client_metadata?: { cli_version?: string; hostname?: string; platform?: string }; }; - expect(body.client_metadata?.cli_version).toBe('0.2.1'); + // Against CLI_VERSION rather than a literal: version.parity.test.ts is + // what pins that constant to package.json, so a release bump should not + // also have to remember this file. + expect(body.client_metadata?.cli_version).toBe(CLI_VERSION); expect(['darwin', 'linux', 'win32']).toContain(body.client_metadata?.platform); expect(typeof body.client_metadata?.hostname).toBe('string'); }); diff --git a/test/unit/ui/colors.test.ts b/test/unit/ui/colors.test.ts index 890ba5f..3f85077 100644 --- a/test/unit/ui/colors.test.ts +++ b/test/unit/ui/colors.test.ts @@ -35,6 +35,9 @@ 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.magenta('plugin')).toContain(`${ESC}[`); + expect(c.green('skill')).toContain(`${ESC}[`); + expect(c.blue('subagent')).toContain(`${ESC}[`); expect(c.bold('7')).toContain(`${ESC}[`); expect(c.dim('x')).toContain(`${ESC}[`); expect(SUCCESS_GLYPH).toContain(`${ESC}[`); @@ -44,6 +47,9 @@ describe('colors — TTY + NO_COLOR gating', () => { 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.magenta('plugin')).toBe('plugin'); + expect(c.green('skill')).toBe('skill'); + expect(c.blue('subagent')).toBe('subagent'); expect(c.bold('7')).toBe('7'); expect(SUCCESS_GLYPH).toBe('✓'); expect(FAILURE_GLYPH).toBe('✗'); @@ -52,6 +58,8 @@ describe('colors — TTY + NO_COLOR gating', () => { 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.magenta('plugin')).toBe('plugin'); + expect(c.blue('subagent')).toBe('subagent'); expect(c.bold('7')).toBe('7'); expect(SUCCESS_GLYPH).toBe('✓'); }); @@ -59,5 +67,7 @@ describe('colors — TTY + NO_COLOR gating', () => { 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'); + expect(c.magenta('plugin')).toBe('plugin'); + expect(c.blue('subagent')).toBe('subagent'); }); }); diff --git a/test/unit/ui/report.test.ts b/test/unit/ui/report.test.ts index 04f682a..8526b37 100644 --- a/test/unit/ui/report.test.ts +++ b/test/unit/ui/report.test.ts @@ -7,7 +7,9 @@ import { renderStackJson, truncationWarnings, toHomeRelative, + toCwdRelative, } from '../../../src/ui/report.js'; +import { CLI_VERSION } from '../../../src/version.js'; import type { DetectResult, ToolEntry } from '../../../src/manifest/index.js'; // Vitest fork pools leave process.stdout.isTTY undefined so colors auto-strip; @@ -77,6 +79,21 @@ describe('groupStack', () => { }); describe('renderStackReport (default `npx devcat-cli` output)', () => { + it('opens with the wordmark and version, above the result line', () => { + // A screenshot of this report should say what produced it, and which + // version — the report is the thing people share. + const lines = renderStackReport(MIXED).split('\n'); + expect(lines[0]).toBe(`devcat v${CLI_VERSION}`); + expect(lines[1]).toBe(''); + expect(lines[2]).toContain('Your AI-coding stack'); + }); + + it('closes on a dim share hint naming the markdown flag', () => { + const lines = renderStackReport(MIXED).split('\n'); + expect(lines[lines.length - 1]).toBe('Share it — npx devcat-cli --markdown'); + expect(lines[lines.length - 2]).toBe(''); + }); + it('prints a header, a section per client, and a totals footer', () => { const out = renderStackReport(MIXED); expect(out).toContain('Your AI-coding stack — 7 tools'); @@ -97,6 +114,15 @@ describe('renderStackReport (default `npx devcat-cli` output)', () => { expect(out).toMatch(/1 subagent\s+code-reviewer/); }); + it('draws a proportional bar in front of each count', () => { + // MIXED's largest single type count is 2 (Claude Code's MCP servers), so + // that row fills the bar and every 1-count row draws half of it. + const out = renderStackReport(MIXED); + expect(out).toContain('██████ 2 mcp'); + expect(out).toContain('███ 1 plugin'); + expect(out).toContain('███ 1 subagent'); + }); + it('reports the project-scoped split only when something is project-scoped', () => { expect(renderStackReport(MIXED)).toContain('1 project-scoped · 6 user-wide'); @@ -120,8 +146,8 @@ describe('renderStackReport (default `npx devcat-cli` output)', () => { for (const line of nameLines) expect(line.length).toBeLessThanOrEqual(78); // 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(' '); + expect(line.startsWith(' '.repeat(22))).toBe(true); + expect(line[22]).not.toBe(' '); } // Wrapping must not drop or duplicate entries. expect(nameLines.join(' ').match(/mcp-server-number-/g)).toHaveLength(20); @@ -147,6 +173,89 @@ describe('renderStackReport (default `npx devcat-cli` output)', () => { }); }); +describe('count bars — one scale for the whole report', () => { + function stack(counts: { client: ToolEntry['client']; type: ToolEntry['type']; n: number }[]): DetectResult { + return { + tools: counts.flatMap(({ client, type, n }) => + Array.from({ length: n }, (_, i) => tool({ name: `${client}-${type}-${i}`, client, type })), + ), + pathsScanned: ['~/.claude.json'], + truncations: [], + }; + } + + /** The bar cell of every type row, in report order. */ + function bars(result: DetectResult): string[] { + return renderStackReport(result) + .split('\n') + .map((l) => /^ {2}([█▌]+) /.exec(l)) + .filter((m): m is RegExpExecArray => m !== null) + .map((m) => m[1]!); + } + + it('scales every bar against the largest count anywhere, not per client', () => { + // Codex's 3 MCP servers must not draw as wide as Claude Code's 12 just + // because each is the biggest thing in its own section. + const out = bars( + stack([ + { client: 'claude-code', type: 'mcp', n: 12 }, + { client: 'codex', type: 'mcp', n: 3 }, + ]), + ); + expect(out).toEqual(['██████', '█▌']); + }); + + it('keeps a half block for any nonzero count, however small its share', () => { + // A row that exists has to be visible; 1-in-100 would otherwise round to + // an empty bar and read as a rendering bug. + const out = bars( + stack([ + { client: 'claude-code', type: 'mcp', n: 100 }, + { client: 'codex', type: 'mcp', n: 1 }, + ]), + ); + expect(out).toEqual(['██████', '▌']); + }); + + it('draws a full bar when every type has the same count', () => { + const out = bars( + stack([ + { client: 'claude-code', type: 'mcp', n: 4 }, + { client: 'claude-code', type: 'skill', n: 4 }, + ]), + ); + expect(out).toEqual(['██████', '██████']); + }); + + it('widens the count column rather than overflowing it, keeping every row aligned', () => { + // A four-digit count in a three-wide column used to push that row's label + // and names one character right of every other row — including its own + // wrapped continuation. + const out = renderStackReport( + stack([ + { client: 'claude-code', type: 'skill', n: 1000 }, + { client: 'claude-code', type: 'mcp', n: 1 }, + ]), + ).split('\n'); + const typeRows = out.filter((l) => /^ {2}[█▌]/.test(l)); + const labelColumns = typeRows.map((l) => l.indexOf(/mcp/.test(l) ? 'mcp' : 'skill')); + expect(new Set(labelColumns).size).toBe(1); + // Continuation lines indent to the same widened name column. + const nameColumn = labelColumns[0]! + 9; + for (const line of out.filter((l) => /^ +s\d/.test(l))) { + expect(line.startsWith(' '.repeat(nameColumn))).toBe(true); + expect(line[nameColumn]).not.toBe(' '); + } + }); + + it('never lets a bar row exceed the wrap width', () => { + const wide = stack([{ client: 'claude-code', type: 'subagent', n: 40 }]); + for (const line of renderStackReport(wide).split('\n')) { + expect([...line].length).toBeLessThanOrEqual(78); + } + }); +}); + describe('renderEmptyStack — copy and home-relative paths (terminal report only)', () => { // Built from node:path primitives rather than hardcoded POSIX literals: // toHomeRelative keys off path.sep, and a hardcoded '/' fixture silently @@ -167,6 +276,28 @@ describe('renderEmptyStack — copy and home-relative paths (terminal report onl expect(out).toContain('No AI tooling detected on this machine yet.'); }); + it('carries the same wordmark header as a full report', () => { + const lines = renderStackReport({ + tools: [], + pathsScanned: [join(HOME, '.claude.json')], + truncations: [], + }).split('\n'); + expect(lines[0]).toBe(`devcat v${CLI_VERSION}`); + expect(lines[1]).toBe(''); + }); + + it('ends on the next step rather than a share hint — there is nothing to share yet', () => { + const lines = renderStackReport({ + tools: [], + pathsScanned: [join(HOME, '.claude.json')], + truncations: [], + }).split('\n'); + expect(lines[lines.length - 1]).toBe( + 'Add an MCP server to Claude Code, Codex, or Cursor and run this again.', + ); + expect(lines.join('\n')).not.toContain('Share it'); + }); + it('keeps the Looked in: transparency section', () => { const out = renderStackReport({ tools: [], pathsScanned: [join(HOME, '.claude.json')], truncations: [] }); expect(out).toContain('Looked in:'); @@ -218,6 +349,94 @@ describe('renderEmptyStack — copy and home-relative paths (terminal report onl }); }); +describe('renderEmptyStack — project paths print relative to the current directory', () => { + // Same platform-aware construction as the home-relative block: the marker + // is `.${sep}`, so a POSIX-literal fixture would silently pass on win32 + // while asserting nothing. + const HOME = join(sep, 'Users', 'testuser'); + const CWD = join(HOME, 'Developer', 'my-project'); + let cwdSpy: ReturnType; + + beforeEach(() => { + homedirHolder.current = HOME; + cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue(CWD); + }); + + afterEach(() => { + homedirHolder.current = null; + cwdSpy.mockRestore(); + }); + + function lookedIn(pathsScanned: string[]): string[] { + return renderStackReport({ tools: [], pathsScanned, truncations: [] }) + .split('\n') + .filter((l) => l.startsWith(' - ')) + .map((l) => l.slice(4)); + } + + it('shows a scanned path under the current directory as ./-relative', () => { + expect(lookedIn([join(CWD, '.mcp.json'), join(CWD, '.cursor', 'mcp.json')])).toEqual([ + `.${sep}.mcp.json`, + `.${sep}.cursor${sep}mcp.json`, + ]); + }); + + it('prefers ./ over ~/ when a path sits under both', () => { + // The project directory is inside $HOME, so both rules match. The more + // specific answer is the useful one. + expect(lookedIn([join(CWD, '.mcp.json')])).toEqual([`.${sep}.mcp.json`]); + }); + + it('still prints user-wide locations home-relative', () => { + expect(lookedIn([join(HOME, '.claude', 'skills')])).toEqual([`~${sep}.claude${sep}skills`]); + }); + + it('renders the current directory itself as bare .', () => { + expect(lookedIn([CWD])).toEqual(['.']); + }); + + it('falls back to ~ when the current directory IS $HOME', () => { + // `cd ~ && npx devcat-cli`: "./.claude.json" would be true and useless. + cwdSpy.mockReturnValue(HOME); + expect(lookedIn([join(HOME, '.claude.json')])).toEqual([`~${sep}.claude.json`]); + }); + + it('leaves a location above the current directory home-relative, not ./', () => { + // The upward walk reaches ancestors; those are genuinely not "here". + const ancestor = join(HOME, 'Developer', '.mcp.json'); + expect(lookedIn([ancestor])).toEqual([`~${sep}Developer${sep}.mcp.json`]); + }); +}); + +describe('toCwdRelative — same boundary and case rules as toHomeRelative', () => { + const CWD = join(sep, 'srv', 'project'); + + it('rewrites a path under the base directory', () => { + expect(toCwdRelative(join(CWD, '.mcp.json'), CWD, 'sensitive')).toBe(`.${sep}.mcp.json`); + }); + + it('renders the base directory itself as bare .', () => { + expect(toCwdRelative(CWD, CWD, 'sensitive')).toBe('.'); + }); + + it('does not rewrite a sibling that merely shares the prefix', () => { + // /srv/project2 next to /srv/project must not become ".2". + const sibling = `${CWD}2${sep}.mcp.json`; + expect(toCwdRelative(sibling, CWD, 'sensitive')).toBe(sibling); + }); + + it('leaves a path outside the base directory alone', () => { + const outside = join(sep, 'opt', 'shared', '.mcp.json'); + expect(toCwdRelative(outside, CWD, 'sensitive')).toBe(outside); + }); + + it('folds case in insensitive mode and preserves the original casing after the prefix', () => { + const shouted = `${CWD.toUpperCase()}${sep}MixedCase.JSON`; + expect(toCwdRelative(shouted, CWD, 'insensitive')).toBe(`.${sep}MixedCase.JSON`); + expect(toCwdRelative(shouted, CWD, 'sensitive')).toBe(shouted); + }); +}); + 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 diff --git a/test/unit/ui/report.tty-color.test.ts b/test/unit/ui/report.tty-color.test.ts index 23111a1..64cf638 100644 --- a/test/unit/ui/report.tty-color.test.ts +++ b/test/unit/ui/report.tty-color.test.ts @@ -8,6 +8,12 @@ import type { DetectResult, ToolEntry } from '../../../src/manifest/index.js'; * 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. + * + * The load-bearing assertion here is the strip-invariant: ANSI-stripped + * colored output must equal the plain render byte for byte, for every shape + * the report can take. Color is decoration layered onto text that already + * says everything — the moment an escape sequence carries meaning of its own, + * `devcat > stack.txt` and a NO_COLOR terminal start lying. */ const ESC = String.fromCharCode(27); @@ -27,12 +33,42 @@ function tool(partial: Partial & Pick): ToolEntry return { type: 'mcp', source: '/fake/path.json', scope: 'user', client: 'claude-code', ...partial }; } +/** Every accented type, plus a second client, so all four colors are exercised. */ const SAMPLE: DetectResult = { - tools: [tool({ name: 'context7' }), tool({ name: 'swift-lsp', type: 'plugin' })], + tools: [ + 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: 'serena', client: 'codex', scope: 'project' }), + ], pathsScanned: ['/home/x/.claude.json'], truncations: [], }; +/** Nothing found: masthead, the Looked in: list, and the closing next step. */ +const EMPTY: DetectResult = { + tools: [], + pathsScanned: ['/home/x/.claude.json', '/home/x/.claude/skills'], + truncations: [], +}; + +/** A scan that stopped early — the one place the report spends yellow. */ +const TRUNCATED: DetectResult = { + ...SAMPLE, + truncations: [ + { + root: '/home/x/.claude/skills', + entriesRead: 520, + entriesSeen: 520, + entriesKept: 500, + hitReadCeiling: false, + readFailed: false, + }, + ], +}; + /** Strips `ESC [ ... m` SGR sequences without a control-character regex. */ function stripAnsi(s: string): string { let result = ''; @@ -49,47 +85,118 @@ function stripAnsi(s: string): string { return result; } -async function renderWith(opts: { isTTY: boolean; noColor?: string }): Promise { +async function renderWith( + opts: { isTTY: boolean; noColor?: string }, + result: DetectResult = SAMPLE, +): 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); + return renderStackReport(result); } describe('renderStackReport — color hierarchy (TTY only)', () => { - it('colorizes type labels cyan and emphasizes counts bold on a real TTY', async () => { + it('gives each tool type its own accent, on both its bar and its label', async () => { const out = await renderWith({ isTTY: true }); - expect(out).toContain(`${ESC}[`); + // 36 cyan, 35 magenta, 32 green, 34 blue — four hues of the standard + // ANSI 16 that read on a light and a dark background. + expect(out).toContain(`${ESC}[36m██████${ESC}[39m`); expect(out).toContain(`${ESC}[36mmcp`); - expect(out).toContain(`${ESC}[36mplugin`); + expect(out).toContain(`${ESC}[35m███${ESC}[39m`); + expect(out).toContain(`${ESC}[35mplugin`); + expect(out).toContain(`${ESC}[32m███${ESC}[39m`); + expect(out).toContain(`${ESC}[32mskill`); + expect(out).toContain(`${ESC}[34m███${ESC}[39m`); + expect(out).toContain(`${ESC}[34msubagent`); + }); + + it('spends no color on a type it does not own', async () => { + const out = await renderWith({ isTTY: true }); + // red and yellow stay reserved for failure and truncation, so a clean + // report must not contain either. + expect(out).not.toContain(`${ESC}[31m`); + expect(out).not.toContain(`${ESC}[33m`); + }); + + it('prints the wordmark bold and its version dim', async () => { + const out = await renderWith({ isTTY: true }); + expect(out.startsWith(`${ESC}[1mdevcat${ESC}[22m ${ESC}[2mv`)).toBe(true); + }); + + it('emphasizes counts bold and keeps the success glyph green', async () => { + const out = await renderWith({ isTTY: true }); expect(out).toContain(`${ESC}[1m`); - expect(out).toContain(`1${ESC}[22m`); + expect(out).toContain(`2${ESC}[22m`); + expect(out).toContain(`${ESC}[32m✓${ESC}[39m`); }); - it('never colors the name list itself, only the type label and the count', async () => { + it('dims the share hint so it sits under the result', async () => { const out = await renderWith({ isTTY: true }); - expect(out).not.toContain(`${ESC}[36mcontext7`); - expect(out).not.toContain(`${ESC}[36mswift-lsp`); + expect(out).toContain(`${ESC}[2mShare it — npx devcat-cli --markdown${ESC}[22m`); }); - 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('never colors the name list itself, only the bar, the label and the count', async () => { + const out = await renderWith({ isTTY: true }); + for (const name of ['context7', 'swift-lsp', 'deep-research', 'code-reviewer', 'serena']) { + for (const code of ['36', '35', '32', '34']) { + expect(out).not.toContain(`${ESC}[${code}m${name}`); + } + } }); - 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('spends yellow only on the truncation footnote', async () => { + const out = await renderWith({ isTTY: true }, TRUNCATED); + expect(out).toContain(`${ESC}[33m!`); }); +}); + +describe('renderStackReport — strip-invariant', () => { + // One case per shape the report can take. Any new colored element has to + // appear in one of these renders, or it is not covered. + const shapes: [string, DetectResult][] = [ + ['a populated stack', SAMPLE], + ['an empty stack', EMPTY], + ['a truncated scan', TRUNCATED], + ]; + + for (const [label, result] of shapes) { + it(`colorizing never changes the underlying text — ${label}`, async () => { + const colored = await renderWith({ isTTY: true }, result); + const plain = await renderWith({ isTTY: false }, result); + expect(colored).toContain(`${ESC}[`); + expect(stripAnsi(colored)).toBe(plain); + }); - 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'); + it(`stays fully plain when stdout is not a TTY — ${label}`, async () => { + const out = await renderWith({ isTTY: false }, result); + expect(out).not.toContain(`${ESC}[`); + }); + + it(`NO_COLOR forces plain output even on a TTY — ${label}`, async () => { + const out = await renderWith({ isTTY: true, noColor: '1' }, result); + expect(out).not.toContain(`${ESC}[`); + expect(out).toBe(await renderWith({ isTTY: false }, result)); + }); + + it(`an empty-string NO_COLOR forces plain output too — ${label}`, async () => { + // no-color.org disables on presence, not truthiness. + const out = await renderWith({ isTTY: true, noColor: '' }, result); + expect(out).not.toContain(`${ESC}[`); + expect(out).toBe(await renderWith({ isTTY: false }, result)); + }); + } + + it('the plain render still carries every element the colored one does', async () => { + // Color is never the only signal: bars are lengths, types are words. + const out = await renderWith({ isTTY: false }); + expect(out).toContain('devcat v'); + expect(out).toContain('2 mcp'); + expect(out).toContain('1 plugin'); + expect(out).toContain('1 skill'); + expect(out).toContain('1 subagent'); + expect(out).toContain('██████'); + expect(out).toContain('Share it — npx devcat-cli --markdown'); }); });