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');
});
});