diff --git a/.changeset/stash-cli-per-command-help.md b/.changeset/stash-cli-per-command-help.md new file mode 100644 index 000000000..53a0b6a80 --- /dev/null +++ b/.changeset/stash-cli-per-command-help.md @@ -0,0 +1,18 @@ +--- +"stash": patch +--- + +Render per-command `--help` from the command-descriptor registry, and slim the +global banner. This is the documented follow-on to the manifest/registry work in +`docs/plans/cli-help-and-manifest.md`. + +- `stash --help` now prints command-specific help instead of the global + banner. A leaf command (`stash eql install --help`, `stash auth login --help`) + shows its usage, summary, long description, flags, and examples; a command + group (`stash eql --help`, `stash auth --help`) lists its subcommands and points + at their own `--help`. All of it renders from `src/cli/registry.ts`, so it can't + drift from `stash manifest`. +- `-h` is now honoured after a command too (`stash eql install -h`), not just as a + bare `stash -h`. +- The global `stash --help` banner no longer inlines every command's flags; it + lists the commands and directs users to ` --help` for the detail. diff --git a/packages/cli/src/bin/main.ts b/packages/cli/src/bin/main.ts index a0b01f33f..3bebf4386 100644 --- a/packages/cli/src/bin/main.ts +++ b/packages/cli/src/bin/main.ts @@ -19,6 +19,7 @@ import { readFileSync } from 'node:fs' import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' import * as p from '@clack/prompts' +import { renderCommandHelp } from '../cli/help.js' // Commands that depend on @cipherstash/stack are lazy-loaded in the switch below. import { authCommand, @@ -116,88 +117,15 @@ Options: --help, -h Show help --version, -v Show version -Init Flags: - --supabase Use Supabase-specific setup flow - --drizzle Use Drizzle-specific setup flow - --prisma-next Use Prisma Next-specific setup flow (EQL bundle installed via prisma-next migration apply) - --proxy Query encrypted data via CipherStash Proxy - --no-proxy Query encrypted data directly via the SDK (default) - --region Region to authenticate against (e.g. us-east-1). Skips the - interactive region picker. Also settable via STASH_REGION. - Required for non-interactive init when not already logged in. - -Auth Flags: - --region Region to authenticate against (e.g. us-east-1). Skips the - interactive region picker. Also settable via STASH_REGION. - --json Emit newline-delimited JSON events instead of prose. The - first event (authorization_required) carries the device - verification URL for a human to open. Implies no prompt - and no browser auto-open — an agent can trigger auth - non-interactively; only a human can complete it in the - browser. Run it in the background, read the URL from the - first line, then hand it to the user. - --no-open Don't auto-open the verification URL in a browser - (already implied by --json). - -Plan Flags: - --complete-rollout Plan the entire encryption lifecycle (schema-add through drop) - in one document. Skips the production-deploy gate that - normally separates rollout from cutover. Only safe when this - database is not backing a deployed application (local dev, - sandbox, freshly seeded test environment). - --target Skip the agent-target picker and hand off directly to one of - claude-code | codex | agents-md | wizard. Safe to call from - non-TTY contexts (CI, pipes). Without --target in non-TTY, - the command prints a hint and exits cleanly instead of hanging. - -Status Flags: - --quest Force the quest-log output (emoji + progress bars) - even in non-TTY contexts. Default is auto: fancy - in a terminal, plain in CI / pipes / agents. - --plain Force the plain-text output even in TTY contexts. - --json Emit a structured JSON document instead. - -Impl Flags: - --continue-without-plan Skip planning and go straight to implementation - (interactively confirms before proceeding) - --target Skip the agent-target picker and hand off directly to one of - claude-code | codex | agents-md | wizard. Safe to call from - non-TTY contexts (CI, pipes). Without --target in non-TTY, - the command prints a hint and exits cleanly instead of hanging. - -DB / EQL Flags: - --force (eql install) Reinstall / overwrite even if already installed - --dry-run (eql install, eql upgrade, db push) Show what would happen without making changes - --supabase (eql install, eql upgrade, db validate) Use Supabase-compatible mode (auto-detected from DATABASE_URL) - --drizzle (eql install) Generate a Drizzle migration instead of direct install (auto-detected from project) - --migration (eql install, requires --supabase) Write a Supabase migration file instead of running SQL directly - --direct (eql install, requires --supabase) Run the SQL directly against the database (mutually exclusive with --migration) - --migrations-dir (eql install, requires --supabase) Override the Supabase migrations directory (default: supabase/migrations) - --exclude-operator-family (eql install, eql upgrade, db validate) Skip operator family creation - --eql-version <2|3> (eql install, eql upgrade) EQL generation to target (default: 2). v3 is the - native eql_v3.* domain schema; direct install only for now - --latest (eql install, eql upgrade) Fetch the latest EQL from GitHub (v2 only) - --database-url (all db / eql / schema commands) Override DATABASE_URL for this run only — never written to disk +Run \`${STASH} --help\` for a command's flags and examples +(e.g. \`${STASH} eql install --help\`, \`${STASH} auth login --help\`). Examples: - ${STASH} init - ${STASH} init --supabase - ${STASH} init --prisma-next - ${STASH} init --region us-east-1 # non-interactive: skip the region picker - ${STASH} plan - ${STASH} impl - ${STASH} impl --continue-without-plan - ${STASH} impl --target claude-code - ${STASH} status - ${STASH} auth login - ${STASH} auth regions # list regions valid for --region - ${STASH} auth login --region us-east-1 --json # agent triggers; human finishes in browser - ${STASH} wizard - ${STASH} eql install - ${STASH} db push - ${STASH} schema build - ${STASH} doctor - ${STASH} manifest --json # structured command surface for docs / agents + ${STASH} init # set up CipherStash in this project + ${STASH} auth login # authenticate + ${STASH} eql install # install EQL extensions + ${STASH} db push # push encryption schema + ${STASH} manifest --json # structured command surface for docs / agents `.trim() interface ParsedArgs { @@ -229,6 +157,13 @@ function parseArgs(argv: string[]): ParsedArgs { } else { flags[key] = true } + } else if (arg === '-h') { + // Short aliases for the two global boolean flags, normalized to their + // long-form keys so downstream `flags.help` / `flags.version` checks catch + // `stash -h` too (not just a bare `stash -h`). + flags.help = true + } else if (arg === '-v') { + flags.version = true } else { commandArgs.push(arg) } @@ -475,7 +410,7 @@ export async function run() { process.argv, ) - if (!command || command === '--help' || command === '-h' || flags.help) { + if (!command || command === '--help' || command === '-h') { console.log(HELP) return } @@ -485,6 +420,15 @@ export async function run() { return } + // `stash --help` / `-h`: render command-specific help from the + // descriptor registry (e.g. `stash eql --help`, `stash eql install --help`). + // Falls back to the global banner when the command path matches no descriptor. + if (flags.help) { + const path = subcommand ? `${command} ${subcommand}` : command + console.log(renderCommandHelp(path, STASH) ?? HELP) + return + } + switch (command) { case 'init': await initCommand(flags, values) diff --git a/packages/cli/src/cli/__tests__/help.test.ts b/packages/cli/src/cli/__tests__/help.test.ts new file mode 100644 index 000000000..81bacb695 --- /dev/null +++ b/packages/cli/src/cli/__tests__/help.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from 'vitest' +import { renderCommandHelp } from '../help.js' + +const RUNNER = 'npx stash' + +describe('renderCommandHelp', () => { + it('renders full help for a leaf command with flags and examples', () => { + const out = renderCommandHelp('eql install', RUNNER) + expect(out).not.toBeNull() + // Usage line names the specific command, not the global surface. + expect(out).toContain('Usage: npx stash eql install [options]') + // Summary + a representative flag from the descriptor. + expect(out).toContain( + 'Scaffold stash.config.ts (if missing) and install EQL extensions', + ) + expect(out).toContain('Options:') + expect(out).toContain('--force') + // Value-taking flag renders its placeholder + default annotation. + expect(out).toContain('--eql-version <2|3>') + expect(out).toContain('(default: 2)') + }) + + it('surfaces a flag env var alongside its description', () => { + const out = renderCommandHelp('eql install', RUNNER) + expect(out).toContain('--database-url ') + expect(out).toContain('Also settable via DATABASE_URL.') + }) + + it('renders a group listing for a command prefix', () => { + const out = renderCommandHelp('eql', RUNNER) + expect(out).not.toBeNull() + expect(out).toContain('Usage: npx stash eql [options]') + expect(out).toContain('Commands:') + expect(out).toContain('eql install') + expect(out).toContain('eql upgrade') + expect(out).toContain('eql status') + // Points the user at the per-command help. + expect(out).toContain('Run `npx stash eql --help`') + // A group listing shows no Options/Examples block. + expect(out).not.toContain('Options:') + }) + + it('renders the long description when present', () => { + const out = renderCommandHelp('auth login', RUNNER) + expect(out).toContain('device authorization flow') + expect(out).toContain('Examples:') + expect(out).toContain('npx stash auth login --region us-east-1 --json') + }) + + it('resolves a subcommand-bearing group prefix (auth)', () => { + const out = renderCommandHelp('auth', RUNNER) + expect(out).toContain('Usage: npx stash auth [options]') + expect(out).toContain('auth login') + expect(out).toContain('auth regions') + }) + + it('renders a summary-only command without empty Options/Examples', () => { + const out = renderCommandHelp('wizard', RUNNER) + expect(out).toContain('Usage: npx stash wizard [options]') + expect(out).toContain('AI-guided encryption setup') + expect(out).not.toContain('Options:') + expect(out).not.toContain('Examples:') + }) + + it('returns null for an unknown command so the caller can fall back', () => { + expect(renderCommandHelp('bogus', RUNNER)).toBeNull() + expect(renderCommandHelp('eql bogus', RUNNER)).toBeNull() + }) + + it('threads the package-manager runner into the rendered text', () => { + const out = renderCommandHelp('init', 'pnpm dlx stash') + expect(out).toContain('Usage: pnpm dlx stash init [options]') + expect(out).toContain('pnpm dlx stash init --supabase') + }) +}) diff --git a/packages/cli/src/cli/help.ts b/packages/cli/src/cli/help.ts new file mode 100644 index 000000000..b48ab7f17 --- /dev/null +++ b/packages/cli/src/cli/help.ts @@ -0,0 +1,104 @@ +/** + * Per-command `--help` rendering, driven by the command-descriptor registry so + * it can't drift from `stash manifest` or the real command set. `bin/main.ts` + * still owns the global `HELP` banner (the no-command / `stash --help` surface); + * this module handles `stash --help` for a specific command or command + * group. See `docs/plans/cli-help-and-manifest.md`. + */ +import { messages } from '../messages.js' +import { type CommandDescriptor, type Flag, registry } from './registry.js' + +/** Two-space indent used throughout the help surface. */ +const INDENT = ' ' + +/** Render a flag's left column, e.g. `--eql-version <2|3>` or `--force`. */ +function flagSignature(flag: Flag): string { + return flag.value ? `${flag.name} ${flag.value}` : flag.name +} + +/** + * Compose a flag's description with any default / env annotation, matching the + * style of the global HELP ("… (default: 2)", "Also settable via STASH_REGION."). + */ +function flagDescription(flag: Flag): string { + const parts = [flag.description] + if (flag.default !== undefined) parts.push(`(default: ${flag.default})`) + if (flag.env !== undefined) parts.push(`Also settable via ${flag.env}.`) + return parts.join(' ') +} + +/** Left-align a two-column list into " " rows. */ +function renderColumns(rows: Array<[string, string]>): string { + const width = Math.max(...rows.map(([left]) => left.length)) + return rows + .map(([left, right]) => `${INDENT}${left.padEnd(width)} ${right}`) + .join('\n') +} + +/** Full help for a single leaf command, e.g. `stash eql install --help`. */ +function renderSingleCommand(cmd: CommandDescriptor, runner: string): string { + const sections: string[] = [] + + sections.push( + `${messages.cli.usagePrefix}${runner} ${cmd.name} [options]`, + cmd.summary, + ) + + if (cmd.long) sections.push(cmd.long) + + if (cmd.flags && cmd.flags.length > 0) { + const rows = cmd.flags.map((f): [string, string] => [ + flagSignature(f), + flagDescription(f), + ]) + sections.push(`Options:\n${renderColumns(rows)}`) + } + + if (cmd.examples && cmd.examples.length > 0) { + const lines = cmd.examples.map((ex) => `${INDENT}${runner} ${ex}`) + sections.push(`Examples:\n${lines.join('\n')}`) + } + + return sections.join('\n\n') +} + +/** + * Listing for a command group prefix, e.g. `stash eql --help` → + * the eql install/upgrade/status commands with a pointer to their own `--help`. + */ +function renderGroup( + path: string, + children: CommandDescriptor[], + runner: string, +): string { + const rows = children.map((c): [string, string] => [c.name, c.summary]) + return [ + `${messages.cli.usagePrefix}${runner} ${path} [options]`, + `Commands:\n${renderColumns(rows)}`, + `Run \`${runner} ${path} --help\` for details on a command.`, + ].join('\n\n') +} + +/** + * Resolve `--help` for a command path into rendered help text, using the + * descriptor registry as the single source of truth. Returns `null` when the + * path matches no command or command-group prefix, so the caller can fall back + * to the global HELP banner. + * + * `path` is the command without the runner prefix, e.g. `"eql"`, `"eql install"`, + * `"auth login"`, `"init"`. `runner` is the package-manager-aware prefix, e.g. + * `"npx stash"`. + */ +export function renderCommandHelp(path: string, runner: string): string | null { + const commands = registry.flatMap((g) => g.commands).filter((c) => !c.hidden) + + // Exact command match → full single-command help. + const exact = commands.find((c) => c.name === path) + if (exact) return renderSingleCommand(exact, runner) + + // Prefix match (e.g. "eql" → eql install / upgrade / status) → group listing. + const children = commands.filter((c) => c.name.startsWith(`${path} `)) + if (children.length > 0) return renderGroup(path, children, runner) + + return null +} diff --git a/packages/cli/src/cli/registry.ts b/packages/cli/src/cli/registry.ts index 3be476c4b..9f4176b28 100644 --- a/packages/cli/src/cli/registry.ts +++ b/packages/cli/src/cli/registry.ts @@ -1,13 +1,15 @@ /** * The command-descriptor registry — one descriptor per `stash` command, grouped - * for display. It backs the `stash manifest --json` surface and is *intended* to - * become the single source of truth for command metadata. + * for display. It is the single source of truth for command metadata: it backs + * both `stash manifest --json` (via `cli/manifest.ts`) and per-command + * `stash --help` (via `cli/help.ts`). A flag/summary/example edit here + * flows into both automatically. * - * ⚠️ Phase 1 (this file) is additive: `bin/main.ts` still hand-maintains the - * `HELP` string that renders `--help`, so until a later phase renders `--help` - * from these descriptors, the two are maintained separately and MUST be kept in - * sync — a command/flag/summary edit belongs in both places or `--help` and - * `stash manifest` will diverge. See `docs/plans/cli-help-and-manifest.md`. + * ⚠️ The one remaining hand-maintained surface is the global `HELP` banner in + * `bin/main.ts` (bare `stash` / `stash --help`) — it duplicates only the + * command *list* (names + summaries), not their flags. A new command, or a + * summary edit, still belongs in both places or the banner and `stash manifest` + * will diverge. See `docs/plans/cli-help-and-manifest.md`. */ /** A single flag/option on a command. */ diff --git a/packages/cli/tests/e2e/auth-non-interactive.e2e.test.ts b/packages/cli/tests/e2e/auth-non-interactive.e2e.test.ts index 0a7ef1081..0071f5872 100644 --- a/packages/cli/tests/e2e/auth-non-interactive.e2e.test.ts +++ b/packages/cli/tests/e2e/auth-non-interactive.e2e.test.ts @@ -128,17 +128,29 @@ describe('stash auth regions — list available regions', () => { }) describe('stash CLI help — non-interactive auth flags are documented', () => { - it('top-level --help lists --region and --json', async () => { + it('top-level --help points at per-command help for flags', async () => { + // The banner no longer inlines every command's flags; it lists the commands + // and directs users to ` --help` for the flag detail (which is + // asserted per-command below). const r = await runPiped(['--help']) expect(r.exitCode).toBe(0) - expect(r.stdout).toContain('--region') - expect(r.stdout).toContain('--json') - expect(r.stdout).toContain('STASH_REGION') + expect(r.stdout).toContain('auth login') + expect(r.stdout).toContain('--help') }) - it('auth --help lists --region, --json and --no-open', async () => { + it('auth --help lists the auth subcommands', async () => { + // `auth` is a command group: its `--help` lists the subcommands and points + // at their per-command help, rather than dumping every subcommand's flags. const r = await runPiped(['auth', '--help']) expect(r.exitCode).toBe(0) + expect(r.stdout).toContain('auth login') + expect(r.stdout).toContain('auth regions') + expect(r.stdout).toContain('--help') + }) + + it('auth login --help lists --region, --json and --no-open', async () => { + const r = await runPiped(['auth', 'login', '--help']) + expect(r.exitCode).toBe(0) expect(r.stdout).toContain('--region') expect(r.stdout).toContain('--json') expect(r.stdout).toContain('--no-open') diff --git a/packages/cli/tests/e2e/command-help.e2e.test.ts b/packages/cli/tests/e2e/command-help.e2e.test.ts new file mode 100644 index 000000000..b8e27777d --- /dev/null +++ b/packages/cli/tests/e2e/command-help.e2e.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest' +import { run } from '../helpers/run.js' + +/** + * E2E coverage for per-command `--help`. The renderer itself is unit-tested in + * `src/cli/__tests__/help.test.ts`; these tests close the gap between "the + * renderer works in isolation" and "`stash --help` actually routes + * to it" — i.e. the `run()` dispatch in `bin/main.ts` and the `-h` short-flag + * handling in `parseArgs`. + */ + +describe('per-command --help', () => { + it('renders a group listing for `eql --help`', async () => { + const r = await run(['eql', '--help'], { + env: { npm_config_user_agent: '' }, + }) + expect(r.exitCode).toBe(0) + expect(r.output).toContain('Usage: npx stash eql [options]') + expect(r.output).toContain('eql install') + expect(r.output).toContain('eql upgrade') + expect(r.output).toContain('eql status') + // A group listing must NOT be the global banner. + expect(r.output).not.toContain('CipherStash CLI v') + }) + + it('renders full command help for `eql install --help`', async () => { + const r = await run(['eql', 'install', '--help'], { + env: { npm_config_user_agent: '' }, + }) + expect(r.exitCode).toBe(0) + expect(r.output).toContain('Usage: npx stash eql install [options]') + expect(r.output).toContain('--eql-version <2|3>') + expect(r.output).toContain('Also settable via DATABASE_URL.') + }) + + it('honours the `-h` short flag after a command', async () => { + const r = await run(['eql', 'install', '-h'], { + env: { npm_config_user_agent: '' }, + }) + expect(r.exitCode).toBe(0) + expect(r.output).toContain('Usage: npx stash eql install [options]') + }) + + it('falls back to the global banner for an unknown command path', async () => { + // `wizard` forwards to its own parser, but an unknown top-level token with + // --help should still surface the global help rather than crashing. + const r = await run(['definitely-not-a-command', '--help'], { + env: { npm_config_user_agent: '' }, + }) + expect(r.exitCode).toBe(0) + expect(r.output).toContain('CipherStash CLI v') + }) + + it('still renders the global banner for a bare `--help`', async () => { + const r = await run(['--help'], { env: { npm_config_user_agent: '' } }) + expect(r.exitCode).toBe(0) + expect(r.output).toContain('CipherStash CLI v') + expect(r.output).toContain('Usage: npx stash [options]') + }) +}) diff --git a/packages/cli/tests/e2e/impl-non-tty.e2e.test.ts b/packages/cli/tests/e2e/impl-non-tty.e2e.test.ts index 3937bd805..f9ff03f49 100644 --- a/packages/cli/tests/e2e/impl-non-tty.e2e.test.ts +++ b/packages/cli/tests/e2e/impl-non-tty.e2e.test.ts @@ -72,12 +72,14 @@ describe('stash impl — non-TTY safety (BUGS.md reproducer)', () => { expect(combined).toContain('wizard') }) - it('--help documents the --target flag under Impl Flags', async () => { - const r = await runPiped(['--help'], { timeoutMs: 5_000 }) + it('`impl --help` documents the --target flag and its values', async () => { + // The global banner no longer inlines per-command flags; the --target + // escape hatch is documented under `impl --help` (rendered from the + // command-descriptor registry). + const r = await runPiped(['impl', '--help'], { timeoutMs: 5_000 }) expect(r.timedOut).toBe(false) expect(r.exitCode).toBe(0) - expect(r.stdout).toContain('Impl Flags:') expect(r.stdout).toContain('--target') expect(r.stdout).toContain('claude-code | codex | agents-md | wizard') })