Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,19 @@

All notable changes to this project are documented in this file.

## [0.2.1]

Presentation-only delight pass on the local stack report. No change to what
is scanned, no new commands or flags, and `--json`/`--markdown` output is
byte-for-byte unchanged apart from the `cli_version` field.

- Terminal report: type labels (`mcp`/`plugin`/`skill`/`subagent`) get a
cyan accent and per-type counts are bold, TTY-only and `NO_COLOR`-aware
- Empty state: warmer copy ("No AI tooling detected on this machine yet.")
and `Looked in:` paths now print home-relative (`~/.claude/skills`)
- Fixed `NO_COLOR=''` (empty value) not being treated as "set" — the
no-color.org standard disables color on presence, not truthiness

## [0.2.0]

Standalone-first re-aim: local stack report for Claude Code, Codex, and Cursor, including skills and subagents.
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ npx devcat-cli --json | jq '.clients[] | {label, total}'

```json
{
"cli_version": "0.2.0",
"cli_version": "0.2.1",
"total": 23,
"project_scoped": 2,
"user_scoped": 21,
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "devcat-cli",
"version": "0.2.0",
"version": "0.2.1",
"description": "See your whole AI-coding stack in one command. npx devcat-cli scans this machine for the MCP servers, plugins, skills, and subagents installed across Claude Code, Codex, and Cursor and prints them grouped — locally, with no account and no network call.",
"license": "MIT",
"author": "Andrew Noble (https://github.com/AnobleSCM)",
Expand Down
4 changes: 3 additions & 1 deletion src/lib/isHeadless.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,9 @@ export function detectEnv(): EnvFlags {
!!process.env.BUILDKITE ||
!!process.env.CIRCLECI ||
!!process.env.JENKINS_URL,
noColor: !!process.env.NO_COLOR,
// Presence, not truthiness: no-color.org disables color "when [NO_COLOR
// is] present (regardless of its value)", so NO_COLOR='' must count too.
noColor: process.env.NO_COLOR !== undefined,
};
}

Expand Down
57 changes: 53 additions & 4 deletions src/ui/report.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { homedir } from 'node:os';
import { sep } from 'node:path';
import type { DetectResult, ToolEntry, ToolClient, RootTruncation } from '../manifest/index.js';
import { READ_CEILING } from '../manifest/index.js';
import { CLI_VERSION } from '../version.js';
Expand Down Expand Up @@ -75,6 +77,46 @@ function sanitizeMarkdownName(name: string): string {
return sanitizeName(name).replace(/`/g, '');
}

/** Whether the home-prefix comparison in {@link toHomeRelative} folds case. */
export type PathCaseSensitivity = 'sensitive' | 'insensitive';

/**
* win32 and macOS both default to case-insensitive (but case-preserving)
* filesystems, so a homedir() or $HOME that is cased differently from a
* scanned path can still name the same directory. Linux filesystems are
* case-sensitive, so a cased difference there is a genuinely different path.
*/
function defaultPathCaseSensitivity(platform: NodeJS.Platform = process.platform): PathCaseSensitivity {
return platform === 'win32' || platform === 'darwin' ? 'insensitive' : 'sensitive';
}

/**
* Display-only: rewrite a path under `home` to start with `~`. Requires a
* separator (or exact equality) at the boundary so a sibling directory that
* merely shares the prefix — /Users/name2 next to /Users/name — is left
* absolute rather than mangled into ~2.
*
* The prefix comparison is case-insensitive on win32/darwin and
* case-sensitive elsewhere (see {@link defaultPathCaseSensitivity}) — this
* is a policy about matching, not about display: casing is folded only to
* decide whether `path` sits under `home`, never to build the return value.
* The output always keeps `path`'s original casing; the matched prefix span
* is simply discarded in favor of `~`, not re-cased. `caseSensitivity`
* defaults to the real platform policy but is an explicit parameter so tests
* can exercise both modes deterministically regardless of which OS actually
* runs them.
*/
export function toHomeRelative(
path: string,
home: string,
caseSensitivity: PathCaseSensitivity = defaultPathCaseSensitivity(),
): string {
const fold = caseSensitivity === 'insensitive' ? (s: string) => s.toLowerCase() : (s: string) => s;
if (fold(path) === fold(home)) return '~';
const prefix = home.endsWith(sep) ? home : `${home}${sep}`;
return fold(path).startsWith(fold(prefix)) ? `~${sep}${path.slice(prefix.length)}` : path;
}

/**
* Group detected tools by client, then by type, with names sorted
* alphabetically. Clients and types with nothing in them are omitted.
Expand Down Expand Up @@ -122,7 +164,9 @@ export function renderStackReport(result: DetectResult): string {
lines.push('');
lines.push(`${c.bold(group.label)} ${c.dim(`· ${plural(group.total, 'tool')}`)}`);
for (const { type, names } of group.byType) {
const prefix = ` ${String(names.length).padStart(3)} ${type.padEnd(TYPE_WIDTH)}`;
const count = c.bold(String(names.length).padStart(3));
const label = c.cyan(type.padEnd(TYPE_WIDTH));
const prefix = ` ${count} ${label}`;
const wrapped = wrap(names.map(sanitizeName).join(', '), WRAP_WIDTH - NAME_COLUMN);
lines.push(`${prefix}${wrapped[0]}`);
for (const cont of wrapped.slice(1)) {
Expand Down Expand Up @@ -280,12 +324,17 @@ const MARKDOWN_FOOTER =

/**
* Nothing found. Print the paths that were checked so the user can spot the
* config location the CLI does not know about yet.
* config location the CLI does not know about yet. Paths under $HOME print
* home-relative (~/.claude/skills) — display only; --json's paths_checked
* stays absolute for scripts.
*/
function renderEmptyStack(result: DetectResult): string {
const list = result.pathsScanned.map((p) => ` - ${sanitizeName(p)}`).join('\n');
const home = homedir();
const list = result.pathsScanned
.map((p) => ` - ${toHomeRelative(sanitizeName(p), home)}`)
.join('\n');
const lines = [
`${SUCCESS_GLYPH} ${c.bold('No AI tooling detected.')}`,
`${SUCCESS_GLYPH} ${c.bold('No AI tooling detected on this machine yet.')}`,
'',
'Looked in:',
list,
Expand Down
2 changes: 1 addition & 1 deletion src/version.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,4 @@
* Sent as cli_version on POST /api/device/token per Phase 40 D-06.
* Server validates against semver regex /^\d+\.\d+\.\d+(-[a-zA-Z0-9.-]+)?$/.
*/
export const CLI_VERSION = '0.2.0';
export const CLI_VERSION = '0.2.1';
2 changes: 1 addition & 1 deletion test/unit/api/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ describe('authenticatedFetch', () => {
}),
);
await authenticatedFetch(`${API_BASE}/probe`);
expect(capturedUA).toMatch(/^devcat-cli\/0\.2\.0 \((darwin|linux|win32); node /);
expect(capturedUA).toMatch(/^devcat-cli\/0\.2\.1 \((darwin|linux|win32); node /);
});

it('sets Authorization: Bearer when bearerToken passed', async () => {
Expand Down
2 changes: 1 addition & 1 deletion test/unit/auth/deviceFlow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -263,7 +263,7 @@ describe('pollForToken', () => {
const body = capturedBody as {
client_metadata?: { cli_version?: string; hostname?: string; platform?: string };
};
expect(body.client_metadata?.cli_version).toBe('0.2.0');
expect(body.client_metadata?.cli_version).toBe('0.2.1');
expect(['darwin', 'linux', 'win32']).toContain(body.client_metadata?.platform);
expect(typeof body.client_metadata?.hostname).toBe('string');
});
Expand Down
10 changes: 10 additions & 0 deletions test/unit/lib/isHeadless.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,17 @@ describe('detectEnv', () => {
it('reads NO_COLOR env var into noColor flag', () => {
vi.stubEnv('NO_COLOR', '1');
expect(detectEnv().noColor).toBe(true);
});

it('treats an empty-string NO_COLOR as present, per the no-color.org spec', () => {
// no-color.org: disable color "when [NO_COLOR is] present (regardless of
// its value)" — presence, not truthiness, is what must gate this.
vi.stubEnv('NO_COLOR', '');
expect(detectEnv().noColor).toBe(true);
});

it('is false when NO_COLOR is not present at all', () => {
vi.stubEnv('NO_COLOR', undefined);
expect(detectEnv().noColor).toBe(false);
});

Expand Down
63 changes: 63 additions & 0 deletions test/unit/ui/colors.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { describe, it, expect, afterEach, vi } from 'vitest';

/**
* colors.ts computes its `enabled` flag once at module load time from
* process.stdout.isTTY + NO_COLOR, so exercising both the colored and plain
* paths in one run requires a fresh module graph per scenario:
* vi.resetModules() then a dynamic import, after setting the env that
* decides `enabled`. The rest of the suite relies on isTTY being falsy in
* Vitest's fork pool (colors auto-strip); this file is where the TTY-colored
* path itself gets exercised.
*/

const ESC = String.fromCharCode(27);

const ORIGINAL_ISTTY = process.stdout.isTTY;
const ORIGINAL_NO_COLOR = process.env.NO_COLOR;

afterEach(() => {
if (ORIGINAL_ISTTY === undefined) delete (process.stdout as { isTTY?: boolean }).isTTY;
else process.stdout.isTTY = ORIGINAL_ISTTY;
if (ORIGINAL_NO_COLOR === undefined) delete process.env.NO_COLOR;
else process.env.NO_COLOR = ORIGINAL_NO_COLOR;
vi.resetModules();
});

async function loadColors(opts: { isTTY: boolean; noColor?: string }) {
process.stdout.isTTY = opts.isTTY;
if (opts.noColor === undefined) delete process.env.NO_COLOR;
else process.env.NO_COLOR = opts.noColor;
vi.resetModules();
return import('../../../src/ui/colors.js');
}

describe('colors — TTY + NO_COLOR gating', () => {
it('emits ANSI codes on a real TTY with NO_COLOR unset', async () => {
const { c, SUCCESS_GLYPH, FAILURE_GLYPH } = await loadColors({ isTTY: true });
expect(c.cyan('mcp')).toContain(`${ESC}[`);
expect(c.bold('7')).toContain(`${ESC}[`);
expect(c.dim('x')).toContain(`${ESC}[`);
expect(SUCCESS_GLYPH).toContain(`${ESC}[`);
expect(FAILURE_GLYPH).toContain(`${ESC}[`);
});

it('stays plain when stdout is not a TTY', async () => {
const { c, SUCCESS_GLYPH, FAILURE_GLYPH } = await loadColors({ isTTY: false });
expect(c.cyan('mcp')).toBe('mcp');
expect(c.bold('7')).toBe('7');
expect(SUCCESS_GLYPH).toBe('✓');
expect(FAILURE_GLYPH).toBe('✗');
});

it('NO_COLOR forces plain output even on a TTY', async () => {
const { c, SUCCESS_GLYPH } = await loadColors({ isTTY: true, noColor: '1' });
expect(c.cyan('mcp')).toBe('mcp');
expect(c.bold('7')).toBe('7');
expect(SUCCESS_GLYPH).toBe('✓');
});

it('an empty-string NO_COLOR still forces plain output on a TTY', async () => {
const { c } = await loadColors({ isTTY: true, noColor: '' });
expect(c.cyan('mcp')).toBe('mcp');
});
});
Loading
Loading