From 0b7802792435f30109532e859f6c9b861e86e4b0 Mon Sep 17 00:00:00 2001 From: AnobleSCM <211227905+AnobleSCM@users.noreply.github.com> Date: Mon, 3 Aug 2026 12:52:00 -0700 Subject: [PATCH 1/2] v0.2.2 wow pass: branded header, per-type accents, count bars, share hint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Presentation-only pass on the terminal stack report. No scanner, data, or network change; --json and --markdown are byte-identical to main apart from the cli_version bump. - Masthead: `devcat vX.Y.Z` above every report, bold wordmark + dim version, so a screenshot says what produced it and which release - One accent per tool type on both its label and its new count bar — mcp cyan, plugin magenta, skill green, subagent blue. Four hues of the standard ANSI 16 that read on light and dark terminals; red and yellow stay reserved for failure and truncation - Proportional count bars scaled against the largest count anywhere in the report, so equal bar lengths mean equal counts in every section. Full and half block are both CP437, so they render in a legacy Windows console - Dim closing hint pointing at `npx devcat-cli --markdown` - Empty state: locations under the current directory print ./-relative (.\ on win32) instead of long absolute paths, and it carries the masthead - Name column moves 15 -> 22 to make room; wrap width follows automatically Colour stays decoration only: the strip-invariant is now asserted across a populated, empty, and truncated scan, and for NO_COLOR set and set-but-empty. Two pre-existing tests pinned the version as a literal; they now read CLI_VERSION, which version.parity.test.ts already ties to package.json. README example text and the demo SVG are regenerated from a real run against a fixture machine — the SVG now shows the actual colours the CLI emits, which the hand-styled previous one did not. --- CHANGELOG.md | 20 +++ README.md | 20 ++- assets/devcat-report.svg | 38 ++--- package-lock.json | 4 +- package.json | 2 +- src/ui/colors.ts | 11 ++ src/ui/report.ts | 176 ++++++++++++++++++++-- src/version.ts | 2 +- test/unit/api/client.test.ts | 7 +- test/unit/auth/deviceFlow.test.ts | 6 +- test/unit/ui/colors.test.ts | 10 ++ test/unit/ui/report.test.ts | 202 +++++++++++++++++++++++++- test/unit/ui/report.tty-color.test.ts | 153 ++++++++++++++++--- 13 files changed, 579 insertions(+), 72 deletions(-) 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..1ad4411 100644 --- a/src/ui/report.ts +++ b/src/ui/report.ts @@ -39,12 +39,63 @@ 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; +/** Width of the count column — three digits, right-aligned. */ +const 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. + */ +const NAME_COLUMN = ROW_INDENT + BAR_WIDTH + 1 + COUNT_WIDTH + 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 +162,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 +242,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,15 +278,26 @@ 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. + const maxTypeCount = Math.max(...groups.flatMap((g) => g.byType.map((t) => t.names.length))); + 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 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(COUNT_WIDTH)); + const label = accent(type.padEnd(TYPE_WIDTH)); + const prefix = `${' '.repeat(ROW_INDENT)}${barCell} ${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)) { @@ -199,9 +330,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 +467,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..b05b262 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,68 @@ 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('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 +255,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 +328,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'); }); }); From 53469f564482451d6bfa0679f344704ee479f28d Mon Sep 17 00:00:00 2001 From: AnobleSCM <211227905+AnobleSCM@users.noreply.github.com> Date: Mon, 3 Aug 2026 12:56:52 -0700 Subject: [PATCH 2/2] fix(report): widen the count column instead of overflowing it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A four-digit count in the fixed three-wide column pushed that row's label and names one character right of every other row — and out of line with its own wrapped continuation. The column is now sized from the largest count in the report, so the alignment the derived name column promises actually holds at every data shape. Unreachable on an ordinary machine (a single type needs 1000+ entries), and the same overflow existed before this branch — but the bar column makes the break visible, and the fix is four lines. No change to any report that fits in three digits: the demo capture, the README block, and the demo SVG are byte-identical before and after. --- src/ui/report.ts | 26 +++++++++++++++++++------- test/unit/ui/report.test.ts | 21 +++++++++++++++++++++ 2 files changed, 40 insertions(+), 7 deletions(-) diff --git a/src/ui/report.ts b/src/ui/report.ts index 1ad4411..cf4e419 100644 --- a/src/ui/report.ts +++ b/src/ui/report.ts @@ -64,14 +64,23 @@ const TYPE_WIDTH = 9; const ROW_INDENT = 2; /** Cells in the proportional count bar. Full block = one cell, half block = a half. */ const BAR_WIDTH = 6; -/** Width of the count column — three digits, right-aligned. */ -const COUNT_WIDTH = 3; +/** + * 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. */ -const NAME_COLUMN = ROW_INDENT + BAR_WIDTH + 1 + COUNT_WIDTH + 1 + TYPE_WIDTH; +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 @@ -283,8 +292,11 @@ export function renderStackReport(result: DetectResult): string { 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. + // 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(''); @@ -295,13 +307,13 @@ export function renderStackReport(result: DetectResult): string { // 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(COUNT_WIDTH)); + 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 - NAME_COLUMN); + 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}`); } } } diff --git a/test/unit/ui/report.test.ts b/test/unit/ui/report.test.ts index b05b262..8526b37 100644 --- a/test/unit/ui/report.test.ts +++ b/test/unit/ui/report.test.ts @@ -227,6 +227,27 @@ describe('count bars — one scale for the whole report', () => { 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')) {