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
18 changes: 18 additions & 0 deletions .changeset/stash-cli-per-command-help.md
Original file line number Diff line number Diff line change
@@ -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 <command> --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 `<command> --help` for the detail.
106 changes: 25 additions & 81 deletions packages/cli/src/bin/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 <slug> 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 <slug> 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 <name> 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 <name> 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 <path> (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 <url> (all db / eql / schema commands) Override DATABASE_URL for this run only — never written to disk
Run \`${STASH} <command> --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 {
Expand Down Expand Up @@ -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 <command> -h` too (not just a bare `stash -h`).
flags.help = true
} else if (arg === '-v') {
flags.version = true
} else {
commandArgs.push(arg)
}
Expand Down Expand Up @@ -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
}
Expand All @@ -485,6 +420,15 @@ export async function run() {
return
}

// `stash <command> --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)
Expand Down
75 changes: 75 additions & 0 deletions packages/cli/src/cli/__tests__/help.test.ts
Original file line number Diff line number Diff line change
@@ -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 <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 <command> [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 <command> --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 <command> [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')
})
})
104 changes: 104 additions & 0 deletions packages/cli/src/cli/help.ts
Original file line number Diff line number Diff line change
@@ -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 <command> --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 " <col> <desc>" 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} <command> [options]`,
`Commands:\n${renderColumns(rows)}`,
`Run \`${runner} ${path} <command> --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
}
16 changes: 9 additions & 7 deletions packages/cli/src/cli/registry.ts
Original file line number Diff line number Diff line change
@@ -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 <command> --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. */
Expand Down
Loading
Loading