From e3fdccfcaad50a3ee33cf76dc721f815e0942a47 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 03:09:43 +0000 Subject: [PATCH] feat(cli): add --help and --version flags The published CLI had no way to print usage or the installed version: `sbom-diff --help` threw `Unknown option: --help` and exited non-zero, which is a poor first-run experience for an npm-distributed binary. - Add `-h`/`--help` (prints usage, arguments, options, examples; exits 0) - Add `-v`/`--version` (prints the version read from the shipped package.json, resolved relative to the module so it works via npx) - Both flags short-circuit parsing so they succeed even alongside otherwise-invalid arguments, and never throw - Document the flags in the README and add parseArgs test coverage Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Wsn1rRnwNaxcCqP1vRuzPZ --- README.md | 4 +++ src/__tests__/cli.test.ts | 22 ++++++++++++++ src/cli.ts | 61 +++++++++++++++++++++++++++++++++++++-- 3 files changed, 85 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 6fe9bef..2d2c9e9 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,10 @@ npx @hailbytes/sbom-diff old.json new.json --format json # Output as Markdown (great for PR comments) npx @hailbytes/sbom-diff old.json new.json --format markdown + +# Show help or print the installed version +npx @hailbytes/sbom-diff --help +npx @hailbytes/sbom-diff --version ``` ### Programmatic diff --git a/src/__tests__/cli.test.ts b/src/__tests__/cli.test.ts index 3532b91..0374ebb 100644 --- a/src/__tests__/cli.test.ts +++ b/src/__tests__/cli.test.ts @@ -37,4 +37,26 @@ describe('parseArgs', () => { it('throws on an unknown option', () => { expect(() => parseArgs(['old.json', 'new.json', '--bogus'])).toThrow(/Unknown option/); }); + + it('sets help for --help and -h', () => { + expect(parseArgs(['--help']).help).toBe(true); + expect(parseArgs(['-h']).help).toBe(true); + }); + + it('sets version for --version and -v', () => { + expect(parseArgs(['--version']).version).toBe(true); + expect(parseArgs(['-v']).version).toBe(true); + }); + + it('short-circuits --help even alongside otherwise-invalid args', () => { + // Would normally throw on the bad --format value; --help wins instead. + expect(() => parseArgs(['--help', '--format=yaml'])).not.toThrow(); + expect(parseArgs(['--help', '--format=yaml']).help).toBe(true); + }); + + it('does not treat normal invocations as help or version', () => { + const parsed = parseArgs(['old.json', 'new.json']); + expect(parsed.help).toBe(false); + expect(parsed.version).toBe(false); + }); }); diff --git a/src/cli.ts b/src/cli.ts index 969f741..ff48fe7 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -7,6 +7,7 @@ */ import { readFile } from 'node:fs/promises'; +import { readFileSync } from 'node:fs'; import { pathToFileURL } from 'node:url'; import { parse } from './parser.js'; import { diff } from './diff.js'; @@ -16,9 +17,31 @@ import type { ReportFormat } from './types.js'; const USAGE = 'Usage: sbom-diff [--format text|json|markdown]'; const VALID_FORMATS: ReportFormat[] = ['text', 'json', 'markdown']; +const HELP = `sbom-diff — diff two CycloneDX or SPDX SBOMs into a change report. + +${USAGE} + +Arguments: + Baseline SBOM (the "before" document) + Updated SBOM (the "after" document) + +Options: + --format Output format: text (default), json, or markdown + -h, --help Show this help and exit + -v, --version Print the installed version and exit + +Examples: + sbom-diff old.json new.json + sbom-diff old.json new.json --format json + sbom-diff old.json new.json --format markdown`; + export interface ParsedArgs { positional: string[]; format: ReportFormat; + /** true when -h/--help was requested */ + help: boolean; + /** true when -v/--version was requested */ + version: boolean; } /** @@ -27,9 +50,19 @@ export interface ParsedArgs { * Supports `--format text`, `--format=text`, and flags appearing in any * position relative to the positional file paths. Defaults to `text`. * + * `-h`/`--help` and `-v`/`--version` short-circuit parsing so they always + * work — even alongside otherwise-invalid arguments — and never throw. + * * @throws if an unknown flag or unsupported format value is supplied. */ export function parseArgs(argv: string[]): ParsedArgs { + if (argv.some(a => a === '-h' || a === '--help')) { + return { positional: [], format: 'text', help: true, version: false }; + } + if (argv.some(a => a === '-v' || a === '-V' || a === '--version')) { + return { positional: [], format: 'text', help: false, version: true }; + } + const positional: string[] = []; let format: ReportFormat = 'text'; @@ -46,7 +79,21 @@ export function parseArgs(argv: string[]): ParsedArgs { } } - return { positional, format }; + return { positional, format, help: false, version: false }; +} + +/** + * Read the package version from the shipped package.json, resolved relative to + * this module so it works whether invoked from `dist/` or via `npx`. + */ +export function resolveVersion(): string { + try { + const pkgUrl = new URL('../package.json', import.meta.url); + const pkg = JSON.parse(readFileSync(pkgUrl, 'utf-8')) as { version?: string }; + return typeof pkg.version === 'string' ? pkg.version : '0.0.0'; + } catch { + return '0.0.0'; + } } function assertFormat(value: string | undefined): ReportFormat { @@ -59,7 +106,17 @@ function assertFormat(value: string | undefined): ReportFormat { } async function main(): Promise { - const { positional, format } = parseArgs(process.argv.slice(2)); + const { positional, format, help, version } = parseArgs(process.argv.slice(2)); + + if (help) { + console.log(HELP); + return; + } + + if (version) { + console.log(resolveVersion()); + return; + } if (positional.length < 2) { console.error(USAGE);