-
Notifications
You must be signed in to change notification settings - Fork 0
Re-aim the CLI on a standalone local stack report #10
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
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 618688d
feat(cli): make the local stack report the default command
AnobleSCM d8f68b2
feat(sync): stop on one line while devcat.dev is down
AnobleSCM 03ba2a1
docs: re-aim positioning on the standalone stack report
AnobleSCM 0bcca9e
feat(manifest): detect installed skills and subagents
AnobleSCM af7f841
feat(report): emit the scan as JSON under --json
AnobleSCM c20a3ea
docs: cover skills, subagents, and --json
AnobleSCM 08c172d
fix(manifest): apply the $HOME guard to Codex and Cursor too
AnobleSCM 5ae6d07
feat(manifest): scan the Codex skills shelf
AnobleSCM 6553116
docs: document the Codex shelf and the dedupe rule
AnobleSCM 4f313e4
fix(sync): make the type-level payload boundary real
AnobleSCM cd8158d
fix(manifest): dedupe by canonical path, not by name
AnobleSCM 8db8224
fix(manifest): bound the directory scans properly
AnobleSCM ee1df94
test(cli): exercise the real commander entrypoint
AnobleSCM 4152387
fix(report): sanitize names, correct the footer, cover the $HOME guard
AnobleSCM 114ecfc
docs: make every README claim true of the code
AnobleSCM 9378f7c
fix(manifest): include type in the canonical-path dedupe key
AnobleSCM c3b0d55
fix(manifest): hard-bound the root scan and disclose truncation
AnobleSCM 7d5bab1
docs: correct the data-retention claim and document the bounds
AnobleSCM 7edcd8c
fix(manifest): close the $HOME guard hole when cwd IS $HOME
AnobleSCM dad3b66
fix(manifest): gate the read ceiling before pulling, and disclose coh…
AnobleSCM f4107ed
fix(report): keep the truncation footnote on the empty state
AnobleSCM 4e1c482
fix(bin): stop forcing process.exit, which can truncate piped stdout
AnobleSCM 22bb7b5
docs: state what paths_checked is, and bound the determinism claim
AnobleSCM 6186a65
fix(manifest): stop reporting the user location twice from $HOME
AnobleSCM 78df588
docs: remove the last contradictory line and re-audit the absolutes
AnobleSCM eb43072
docs(cli): correct the comments that still described a process.exit shim
AnobleSCM File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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'; | ||
| 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; | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When the native
@napi-rs/keyringbinding cannot load—for example, on an unsupported Linux architecture or an installation where optional platform binaries were omitted—these eager imports loadsync.ts/logout.tsand their keyring dependency before Commander can dispatch the new local-only report. Consequently even plainnpx 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 👍 / 👎.