Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
0a44592
feat(manifest): record which client each detected tool came from
AnobleSCM Aug 2, 2026
618688d
feat(cli): make the local stack report the default command
AnobleSCM Aug 2, 2026
d8f68b2
feat(sync): stop on one line while devcat.dev is down
AnobleSCM Aug 2, 2026
03ba2a1
docs: re-aim positioning on the standalone stack report
AnobleSCM Aug 2, 2026
0bcca9e
feat(manifest): detect installed skills and subagents
AnobleSCM Aug 2, 2026
af7f841
feat(report): emit the scan as JSON under --json
AnobleSCM Aug 2, 2026
c20a3ea
docs: cover skills, subagents, and --json
AnobleSCM Aug 2, 2026
08c172d
fix(manifest): apply the $HOME guard to Codex and Cursor too
AnobleSCM Aug 2, 2026
5ae6d07
feat(manifest): scan the Codex skills shelf
AnobleSCM Aug 2, 2026
6553116
docs: document the Codex shelf and the dedupe rule
AnobleSCM Aug 2, 2026
4f313e4
fix(sync): make the type-level payload boundary real
AnobleSCM Aug 2, 2026
cd8158d
fix(manifest): dedupe by canonical path, not by name
AnobleSCM Aug 2, 2026
8db8224
fix(manifest): bound the directory scans properly
AnobleSCM Aug 2, 2026
ee1df94
test(cli): exercise the real commander entrypoint
AnobleSCM Aug 2, 2026
4152387
fix(report): sanitize names, correct the footer, cover the $HOME guard
AnobleSCM Aug 2, 2026
114ecfc
docs: make every README claim true of the code
AnobleSCM Aug 2, 2026
9378f7c
fix(manifest): include type in the canonical-path dedupe key
AnobleSCM Aug 2, 2026
c3b0d55
fix(manifest): hard-bound the root scan and disclose truncation
AnobleSCM Aug 2, 2026
7d5bab1
docs: correct the data-retention claim and document the bounds
AnobleSCM Aug 2, 2026
7edcd8c
fix(manifest): close the $HOME guard hole when cwd IS $HOME
AnobleSCM Aug 2, 2026
dad3b66
fix(manifest): gate the read ceiling before pulling, and disclose coh…
AnobleSCM Aug 2, 2026
f4107ed
fix(report): keep the truncation footnote on the empty state
AnobleSCM Aug 2, 2026
4e1c482
fix(bin): stop forcing process.exit, which can truncate piped stdout
AnobleSCM Aug 2, 2026
22bb7b5
docs: state what paths_checked is, and bound the determinism claim
AnobleSCM Aug 2, 2026
6186a65
fix(manifest): stop reporting the user location twice from $HOME
AnobleSCM Aug 3, 2026
78df588
docs: remove the last contradictory line and re-audit the absolutes
AnobleSCM Aug 3, 2026
eb43072
docs(cli): correct the comments that still described a process.exit shim
AnobleSCM Aug 3, 2026
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
215 changes: 129 additions & 86 deletions README.md

Large diffs are not rendered by default.

6 changes: 5 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "devcat-cli",
"version": "0.1.1",
"description": "DevCat CLI — npx devcat-cli sync. Pushes your AI tool manifest to devcat.dev. Manifest-only sync via RFC 8628 device authorization with OS keychain credentials.",
"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)",
"repository": {
Expand Down Expand Up @@ -55,7 +55,11 @@
"cli",
"devcat",
"claude-code",
"codex",
"cursor",
"mcp",
"mcp-servers",
"developer-tools",
"device-flow",
"rfc-8628"
]
Expand Down
10 changes: 7 additions & 3 deletions src/api/sync.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { v7 as uuidv7 } from 'uuid';
import type { ToolType, SyncRequestBody, SyncResponseBody, ErrorEnvelope } from '../types/api.js';
import type { SyncableToolType, SyncRequestBody, SyncResponseBody, ErrorEnvelope } from '../types/api.js';
import { authenticatedFetch } from './client.js';
import { getApiBase } from './types.js';
import { computeManifestHash } from '../lib/manifestHash.js';
Expand Down Expand Up @@ -38,8 +38,12 @@ export interface PostSyncOpts {
accessToken: string;
idempotencyKey?: string;
emitStartEvent?: boolean;
/** Accepts the manifest layer's ToolEntry shape (structural superset of API ToolEntry). */
tools: ReadonlyArray<{ type: ToolType; name: string }>;
/**
* Syncable detections only. Narrowed to SyncableToolType so a raw
* detection list — which can hold skills and subagents — does not type-check
* here. Callers pass syncableTools(manifest.tools).
*/
tools: ReadonlyArray<{ type: SyncableToolType; name: string }>;
}

export function createSyncIdempotencyKey(): string {
Expand Down
70 changes: 21 additions & 49 deletions src/bin/devcat.ts
Original file line number Diff line number Diff line change
@@ -1,53 +1,25 @@
#!/usr/bin/env node
import { Command } from 'commander';
import { CLI_VERSION } from '../version.js';
import { runSync } from '../commands/sync.js';
import { runLogout } from '../commands/logout.js';
import { runCli } from '../cli.js';
import { EXIT_GENERIC_ERROR } from '../lib/exitCodes.js';

async function main(): Promise<void> {
const program = new Command();
program
.name('devcat')
.description(
'DevCat CLI — push your AI tool manifest to devcat.dev (manifest-only sync via RFC 8628 device authorization)',
)
.version(CLI_VERSION);

// Global flags. isJsonMode() reads process.argv directly (not commander
// state) so these declarations exist primarily to populate --help.
program
.option('--json', 'emit machine-readable JSON event stream')
.option('-v, --verbose', 'emit redacted HTTP trace to stderr');

program
.command('sync', { isDefault: true })
.description('Push your AI tool manifest to devcat.dev')
.option('--no-open', 'do not auto-open the browser at the verification URL')
.option('--json', 'emit machine-readable JSON event stream (for CI)')
.option('-v, --verbose', 'emit redacted HTTP trace to stderr')
.action(async (options: { open?: boolean }) => {
const exitCode = await runSync({ noOpen: options.open === false });
process.exit(exitCode);
});

program
.command('logout')
.description('Clear local DevCat credentials')
.action(async () => {
const exitCode = await runLogout();
process.exit(exitCode);
});

try {
await program.parseAsync(process.argv);
} catch (err) {
/**
* Set process.exitCode and let Node exit on its own.
*
* process.exit() terminates immediately, discarding anything still queued in
* stdout. That matters here: process.stdout is a pipe when output is piped
* (`devcat --json | jq`), pipes are asynchronous, and a large report does not
* fit in one write — so exiting on the spot could cut the JSON mid-object and
* still report success. Setting exitCode lets the event loop drain the stream
* first and then exit with the same code.
*
* Nothing here holds the loop open — no servers, no timers — so "let it exit
* naturally" costs nothing.
*/
runCli(process.argv)
.then((exitCode) => {
process.exitCode = exitCode;
})
.catch((err) => {
process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`);
process.exit(EXIT_GENERIC_ERROR);
}
}

main().catch((err) => {
process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`);
process.exit(EXIT_GENERIC_ERROR);
});
process.exitCode = EXIT_GENERIC_ERROR;
});
96 changes: 96 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { Command } from 'commander';
import { CLI_VERSION } from './version.js';
import { runReport } from './commands/report.js';
import { runSync } from './commands/sync.js';
import { runLogout } from './commands/logout.js';
Comment on lines +4 to +5

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Load authentication commands lazily for the local report

When the native @napi-rs/keyring binding cannot load—for example, on an unsupported Linux architecture or an installation where optional platform binaries were omitted—these eager imports load sync.ts/logout.ts and their keyring dependency before Commander can dispatch the new local-only report. Consequently even plain npx devcat-cli, which does not use authentication, exits during module initialization; dynamically importing the auth-backed handlers inside their command actions would keep the standalone report usable in those environments.

Useful? React with 👍 / 👎.

import { EXIT_GENERIC_ERROR, EXIT_OK, type ExitCode } from './lib/exitCodes.js';

/**
* Commander wiring, separated from the bin entrypoint so tests can run the
* real parser over a real argv array. bin/devcat.ts is then a small shim
* whose only extra job is assigning the returned code to process.exitCode —
* the part that cannot run inside a test process.
*
* Actions record an exit code rather than terminating the process, so the
* parser is testable and every exit flows through one place. Nothing here
* calls process.exit: doing so after writing stdout can truncate a piped
* report (see bin/devcat.ts).
*/
export interface ExitCodeSink {
code: ExitCode;
}

export function buildProgram(sink: ExitCodeSink): Command {
const program = new Command();
// Without this commander calls process.exit() itself on a bad flag, on
// --help, and on --version — untestable, and a hard exit that could cut a
// half-written stdout. With it, those become throws that runCli turns into
// a returned code, which the shim assigns to process.exitCode.
program.exitOverride();
program
.name('devcat')
.description(
'DevCat CLI — see your whole AI-coding stack in one command. Scans this machine for the MCP servers, plugins, skills, and subagents installed across Claude Code, Codex, and Cursor.',
)
.version(CLI_VERSION);

// Declared at program level too so `devcat --json` (the default command)
// parses. Commander consumes program-level flags before dispatching, so the
// report action reads both its own options and the program's.
program
.option('--json', 'emit machine-readable JSON output')
.option('-v, --verbose', 'emit redacted HTTP trace to stderr');

program
.command('report', { isDefault: true })
.description('Scan this machine and print your AI-coding stack (default)')
.option('--markdown', 'emit a shareable "My AI stack" markdown snippet')
.option('--json', 'emit the scan as one machine-readable JSON object (wins over --markdown)')
.action(async (options: { markdown?: boolean; json?: boolean }) => {
sink.code = await runReport({
markdown: options.markdown === true,
json: options.json === true || program.opts().json === true,
});
});

program
.command('sync')
.description('Push your AI tool manifest to devcat.dev (paused while the site is rebuilt)')
.option('--no-open', 'do not auto-open the browser at the verification URL')
.option('--json', 'emit machine-readable JSON event stream (for CI)')
.option('-v, --verbose', 'emit redacted HTTP trace to stderr')
.action(async (options: { open?: boolean }) => {
sink.code = await runSync({ noOpen: options.open === false });
});

program
.command('logout')
.description('Clear local DevCat credentials')
.action(async () => {
sink.code = await runLogout();
});

return program;
}

/** Parse `argv` with the real commander program and return the exit code. */
export async function runCli(argv: string[]): Promise<ExitCode> {
// Commander actions have no return channel, so they write the resolved code
// into this holder.
const sink: ExitCodeSink = { code: EXIT_OK };
const program = buildProgram(sink);
try {
await program.parseAsync(argv);
return sink.code;
} catch (err) {
// Commander has already written its own output for these — the help text,
// the version, or an `error: unknown option ...` line. Reporting it again
// would double-print. exitCode 0 means it displayed help or version.
const commanderError = err as { code?: unknown; exitCode?: unknown };
if (typeof commanderError.code === 'string' && typeof commanderError.exitCode === 'number') {
return commanderError.exitCode === 0 ? EXIT_OK : EXIT_GENERIC_ERROR;
}
process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`);
return EXIT_GENERIC_ERROR;
}
}
49 changes: 49 additions & 0 deletions src/commands/report.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { detect } from '../manifest/index.js';
import {
renderStackReport,
renderStackMarkdown,
renderStackJson,
truncationWarnings,
} from '../ui/report.js';
import { c } from '../ui/colors.js';
import { EXIT_OK, type ExitCode } from '../lib/exitCodes.js';

export interface ReportOptions {
/** Emit the shareable "My AI stack" markdown snippet instead of the terminal report. */
markdown: boolean;
/** Emit one machine-readable JSON object. Comes from commander, not process.argv. */
json: boolean;
}

/**
* `devcat report` — also the default command when devcat is run with no args.
*
* Local-only: scans this machine's AI tool config files and prints what it
* found. No network, no auth, no credentials touched. An empty result is a
* valid outcome, not an error, so this always exits 0.
*
* Three renderings of one scan. `--json` wins over `--markdown` when both are
* passed: a caller asking for machine-readable output is scripting, and a
* markdown document would break their parser.
*/
export async function runReport(opts: ReportOptions): Promise<ExitCode> {
const manifest = await detect(process.cwd());

// Disclosure goes to stderr in every mode, including --json, so a piped
// stdout stays parseable while the operator still learns the scan was
// incomplete. Named roots and counts, one line each.
for (const warning of truncationWarnings(manifest.truncations)) {
process.stderr.write(`${c.yellow(warning)}\n`);
}

let out: string;
if (opts.json) {
out = renderStackJson(manifest);
} else if (opts.markdown) {
out = renderStackMarkdown(manifest);
} else {
out = renderStackReport(manifest);
}
process.stdout.write(`${out}\n`);
return EXIT_OK;
}
40 changes: 32 additions & 8 deletions src/commands/sync.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { detect, type DetectResult } from '../manifest/index.js';
import { detect, syncableTools, type SyncableToolEntry } from '../manifest/index.js';
import {
loadToken,
saveToken,
Expand Down Expand Up @@ -46,6 +46,23 @@ export interface SyncOptions {
noOpen: boolean;
}

/**
* devcat.dev is being rebuilt, so the hosted sync endpoint is not answering.
* Sync stops on the one line below instead of running the device flow and
* failing deep inside an HTTP call — no browser, no polling, no retries.
*
* The whole sync path underneath is untouched and still compiles. Set
* DEVCAT_SYNC_ENABLED=1 to run it anyway (integration tests do this, as does
* anyone pointing DEVCAT_API_URL at their own host). Delete this gate when
* devcat.dev is back.
*/
const SYNC_PAUSED_MESSAGE =
'Profile sync is paused while devcat.dev is rebuilt. Your local stack report still works — run `npx devcat-cli`.';

function isSyncPaused(): boolean {
return process.env.DEVCAT_SYNC_ENABLED !== '1';
}

/**
* Top-level `devcat sync` orchestrator.
*
Expand All @@ -60,9 +77,16 @@ export interface SyncOptions {
* 5. Render success summary (Phase 40 D-19).
*/
export async function runSync(opts: SyncOptions): Promise<ExitCode> {
// 1. Detect manifest
if (isSyncPaused()) {
writeErrorOutput({ message: SYNC_PAUSED_MESSAGE, exitCode: EXIT_GENERIC_ERROR });
return EXIT_GENERIC_ERROR;
}

// 1. Detect manifest. Skills and subagents are report-only detections, so
// only the syncable subset is ever considered here or sent.
const manifest = await detect(process.cwd());
if (manifest.tools.length === 0) {
const tools = syncableTools(manifest.tools);
if (tools.length === 0) {
if (isJsonMode()) {
emitEvent({ type: 'sync.start', tool_count: 0 });
emitEvent({
Expand All @@ -76,7 +100,7 @@ export async function runSync(opts: SyncOptions): Promise<ExitCode> {
return EXIT_OK;
}
const idempotencyKey = createSyncIdempotencyKey();
emitEvent({ type: 'sync.start', tool_count: manifest.tools.length, idempotency_key: idempotencyKey });
emitEvent({ type: 'sync.start', tool_count: tools.length, idempotency_key: idempotencyKey });

// 2. Ensure token
let tokens: TokenPair;
Expand All @@ -95,7 +119,7 @@ export async function runSync(opts: SyncOptions): Promise<ExitCode> {
try {
const body = await postSync({
accessToken: tokens.access_token,
tools: manifest.tools,
tools,
idempotencyKey,
emitStartEvent: false,
});
Expand All @@ -108,7 +132,7 @@ export async function runSync(opts: SyncOptions): Promise<ExitCode> {
} catch (err) {
if (err instanceof TokenInvalidError) {
// D-16 recovery
return await recoverFromTokenInvalid(tokens, manifest, opts, idempotencyKey);
return await recoverFromTokenInvalid(tokens, tools, opts, idempotencyKey);
}
return handleSyncError(err);
}
Expand Down Expand Up @@ -174,7 +198,7 @@ async function runDeviceFlowInline(opts: SyncOptions): Promise<TokenPair> {

async function recoverFromTokenInvalid(
tokens: TokenPair,
manifest: DetectResult,
tools: SyncableToolEntry[],
opts: SyncOptions,
idempotencyKey: string,
): Promise<ExitCode> {
Expand Down Expand Up @@ -224,7 +248,7 @@ async function recoverFromTokenInvalid(
try {
const body = await postSync({
accessToken: nextAccessToken,
tools: manifest.tools,
tools,
idempotencyKey,
emitStartEvent: false,
});
Expand Down
Loading
Loading