Skip to content
18 changes: 18 additions & 0 deletions .changeset/stash-cli-manifest.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
"stash": minor
---

Add a command-descriptor registry and `stash manifest --json` — a structured,
versioned command surface for the docs generator and agents to consume instead
of scraping `--help`.

- `stash manifest --json` emits `{ name, version, groups[] }`, where each command
carries its summary, optional long description, examples, and flags. `version`
comes from the CLI's own `package.json`, so a page generated from the manifest
is always stamped with the version it describes.
- `stash manifest` (no flag) prints a grouped, human-readable command list.
- The registry (`src/cli/registry.ts`) is intended to become the single source of
truth for command metadata. This is phase 1 of
`docs/plans/cli-help-and-manifest.md`; it is additive — `bin/main.ts` still
hand-maintains the `HELP` string that renders `--help`, so until the documented
follow-on renders `--help` from the registry the two are kept in sync by hand.
19 changes: 19 additions & 0 deletions .changeset/stash-non-interactive-agent-cli.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
"stash": minor
---

Add non-interactive / agent-friendly affordances so `stash init` and
`stash auth login` can run without a TTY (agents, CI, pipes). All changes are
additive — interactive behaviour in a real terminal is unchanged.

- `--region <slug>` / `STASH_REGION` on `stash auth login` and `stash init`
skip the interactive region picker. An unknown or missing region in a
non-TTY context now exits with an actionable message instead of hanging on
the picker (region resolution mirrors the `DATABASE_URL` resolver's
`TTY && !CI` gate).
- `stash auth login --json` emits newline-delimited device-code events. The
first event (`authorization_required`) carries the verification URL, so an
agent can trigger auth and hand the browser step to a human — only a human
completes it in the browser. `--no-open` suppresses the browser launch.
- `stash auth regions` lists the regions valid for `--region` / `STASH_REGION`;
`stash auth regions --json` emits `[{ slug, label }]` for programmatic use.
11 changes: 10 additions & 1 deletion packages/cli/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,9 +62,18 @@ exercise the same code paths.
test run; if not, manually `chmod +x` the helper under
`node_modules/.pnpm/node-pty@*/node_modules/node-pty/prebuilds/<plat>/spawn-helper`.
- **Don't broaden the cancel test target.** `auth login` was chosen because
`selectRegion()` runs before any network I/O. Don't move the cancel
the region picker runs before any network I/O. Don't move the cancel
assertion to a command that hits the auth server or DB before the first
prompt — flaky.
- **Region resolution is CI-aware.** `resolveRegion` (in
`src/commands/auth/region.ts`) mirrors the `DATABASE_URL` resolver: it only
renders the interactive picker when `stdin.isTTY` **and** `CI` is unset, and
otherwise honours `--region` / `STASH_REGION` or exits with an actionable
error (JSON in `--json` mode). Because the pty harness defaults `CI=true`,
the interactive-cancel test passes `env: { CI: '' }` to force the picker to
render. The pure helpers (`normalizeRegion`, `regionSlugs`) and the resolver
policy have unit coverage in `src/commands/auth/__tests__/region.test.ts` —
the module imports no native code, so it runs under the fast unit config.
- **Use `src/messages.ts` for assertion-stable strings.** The module is a
single typed `as const` object grouping copy by area (`cli`, `auth`,
`db`). Prod call sites import the same constants the tests do, so a copy
Expand Down
54 changes: 52 additions & 2 deletions packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,13 +72,14 @@ Commands that consume `stash.config.ts`: `eql install`, `eql upgrade`, `db push`
Set up CipherStash end-to-end: authenticate, introspect your database, install dependencies, install EQL, and hand off the rest to your local coding agent.

```bash
npx stash init [--supabase] [--drizzle]
npx stash init [--supabase] [--drizzle] [--region <slug>]
```

| Flag | Description |
|------|-------------|
| `--supabase` | Use the Supabase-specific setup flow |
| `--drizzle` | Use the Drizzle-specific setup flow |
| `--region <slug>` | Region to authenticate against (e.g. `us-east-1`). Skips the interactive region picker. Also settable via `STASH_REGION`. |

What `init` does, in order:

Expand All @@ -93,18 +94,67 @@ The full pipeline state — integration, columns, env-key names, paths, versions

`CIPHERSTASH_WIZARD_URL` overrides the gateway endpoint for the rulebook fetch. Useful for local-dev against a wizard gateway running on `localhost`.

**Running `init` non-interactively** (CI, agents, pipes): every prompt has an escape hatch, so `init` never blocks waiting on a TTY. Provide the region up front (`--region` / `STASH_REGION`) if you aren't already logged in, the database URL (`--database-url` / `DATABASE_URL`), the proxy choice (`--proxy` / `--no-proxy`), and — for the closing agent handoff — nothing is required (init exits at a clean checkpoint and points you at `stash plan --target …`). When a required value is missing in a non-TTY context the command exits non-zero with an actionable message rather than hanging.

```bash
STASH_REGION=us-east-1 DATABASE_URL=postgres://… npx stash init --no-proxy
```

---

### `npx stash auth login`

Authenticate with CipherStash using a browser-based device code flow.

```bash
npx stash auth login
npx stash auth login [--region <slug>] [--json] [--no-open]
```

| Flag | Description |
|------|-------------|
| `--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 (see below). Implies non-interactive — never renders the region picker, and never auto-opens a browser (the human opens the URL you hand them). |
| `--no-open` | Don't auto-open the verification URL in a browser (already implied by `--json`). |
| `--supabase` / `--drizzle` | Track the integration as the referrer. |

Saves the token to `~/.cipherstash/auth.json`. Database-touching commands check for this file before running.

#### Triggering auth from an agent (device-code flow)

The device-code flow is designed so an **agent can trigger** authentication but only a **human completes** it in the browser. Run `auth login --json` in the background and read the first line — `authorization_required` carries the verification URL to hand to the user:

```bash
npx stash auth login --region us-east-1 --json
```

```jsonc
// stdout is newline-delimited JSON, one event per line:
{"status":"authorization_required","userCode":"ABCD-1234","verificationUri":"https://…/activate","verificationUriComplete":"https://…/activate?user_code=ABCD-1234","expiresIn":899}
// … the process then blocks polling until the human authorizes in the browser …
{"status":"authorized","expiresAt":1751990400,"expiresAtIso":"2025-07-08T12:00:00.000Z"}
{"status":"device_bound"}
```

Errors are emitted as `{"status":"error","code":"…","message":"…"}` and exit non-zero. In a non-TTY context without `--region`/`STASH_REGION` the command exits immediately with `code: "region_required"` instead of hanging on the picker.

---

### `npx stash auth regions`

List the regions you can authenticate against — a first-contact affordance so you (or an agent) can discover valid `--region` / `STASH_REGION` values up front instead of learning them from an error.

```bash
npx stash auth regions # human-readable list
npx stash auth regions --json # machine-readable [{ "slug": "…", "label": "…" }]
```

```jsonc
// --json output:
[{"slug":"us-east-1","label":"us-east-1 (Virginia, USA)"}, {"slug":"us-east-2","label":"us-east-2 (Ohio, USA)"}, …]
```

> The region list is currently maintained in the CLI. The intended long-term source of truth is the CipherStash region API (tracked by a `TODO` in `src/commands/auth/region.ts`); when that lands, this command and the runtime SDK should both read from it.

---

### `npx stash wizard`
Expand Down
31 changes: 29 additions & 2 deletions packages/cli/src/bin/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
implCommand,
initCommand,
installCommand,
manifestCommand,
planCommand,
statusCommand,
testConnectionCommand,
Expand Down Expand Up @@ -89,6 +90,7 @@ Commands:
auth <subcommand> Authenticate with CipherStash
wizard AI-guided encryption setup (reads your codebase)
doctor Diagnose install problems (native binaries, runtime)
manifest Print the structured, versioned command surface (--json for docs/agents)

eql install Scaffold stash.config.ts (if missing) and install EQL extensions
eql upgrade Upgrade EQL extensions to the latest version
Expand Down Expand Up @@ -120,6 +122,22 @@ Init Flags:
--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)
Expand Down Expand Up @@ -165,17 +183,21 @@ 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
`.trim()

interface ParsedArgs {
Expand Down Expand Up @@ -465,7 +487,7 @@ export async function run() {

switch (command) {
case 'init':
await initCommand(flags)
await initCommand(flags, values)
break
case 'plan':
await planCommand(flags, values)
Expand All @@ -482,7 +504,7 @@ export async function run() {
break
case 'auth': {
const authArgs = subcommand ? [subcommand, ...commandArgs] : commandArgs
await authCommand(authArgs, flags)
await authCommand(authArgs, flags, values)
break
}
case 'eql':
Expand All @@ -500,6 +522,11 @@ export async function run() {
case 'env':
await envCommand({ write: flags.write })
break
case 'manifest':
// Pure metadata (no native code) — safe to run anywhere, including when
// the native binary is missing.
manifestCommand({ json: flags.json, version: pkg.version })
break
case 'wizard': {
// Forward everything after `stash wizard` verbatim. The wizard package
// owns its own flag parsing; we don't try to interpret its surface
Expand Down
107 changes: 107 additions & 0 deletions packages/cli/src/cli/__tests__/manifest.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { describe, expect, it } from 'vitest'
import { buildManifest } from '../manifest.js'
import { type CommandGroup, registry } from '../registry.js'

describe('buildManifest', () => {
it('stamps the name and the passed-in version', () => {
const m = buildManifest('9.9.9')
expect(m.name).toBe('stash')
expect(m.version).toBe('9.9.9')
})

it('emits every non-hidden command across all groups', () => {
const m = buildManifest('0.0.0')
const registryCount = registry
.flatMap((g) => g.commands)
.filter((c) => !c.hidden).length
const manifestCount = m.groups.flatMap((g) => g.commands).length
expect(manifestCount).toBe(registryCount)
expect(m.groups.length).toBe(registry.length)
})

it('drops commands marked hidden', () => {
// Drive the hidden filter with a stub so coverage doesn't depend on the
// live registry happening to contain a hidden command (it currently
// contains none — a real-registry assertion would be vacuously green).
const groups: CommandGroup[] = [
{
title: 'T',
commands: [
{ name: 'shown', summary: 's' },
{ name: 'gone', summary: 'g', hidden: true },
],
},
]
const names = buildManifest('0.0.0', groups)
.groups.flatMap((g) => g.commands)
.map((c) => c.name)
expect(names).toEqual(['shown'])
})

it('carries examples through into the manifest', () => {
// The one optional-field passthrough not covered by the auth-login (long +
// flags) or wizard (all-undefined) cases.
const init = buildManifest('0.0.0')
.groups.flatMap((g) => g.commands)
.find((c) => c.name === 'init')
expect(init?.examples).toContain('init --supabase')
})

it('defensively copies flags so a consumer cannot corrupt the registry', () => {
const dbUrlOf = (m: ReturnType<typeof buildManifest>) =>
m.groups
.flatMap((g) => g.commands)
.flatMap((c) => c.flags ?? [])
.find((f) => f.name === '--database-url')

const first = dbUrlOf(buildManifest('0.0.0'))
expect(first).toBeDefined()
// Mutate a manifest flag; the shared registry singleton must be untouched.
;(first as { description: string }).description = 'MUTATED'
expect(dbUrlOf(buildManifest('0.0.0'))?.description).not.toBe('MUTATED')
})

it('gives every command a non-empty name and summary', () => {
for (const group of buildManifest('0.0.0').groups) {
expect(group.title.length).toBeGreaterThan(0)
for (const cmd of group.commands) {
expect(cmd.name.length).toBeGreaterThan(0)
expect(cmd.summary.length).toBeGreaterThan(0)
}
}
})

it('includes the worked-example auth login descriptor with its flags', () => {
const cmds = buildManifest('0.0.0').groups.flatMap((g) => g.commands)
const authLogin = cmds.find((c) => c.name === 'auth login')
expect(authLogin).toBeDefined()
expect(authLogin?.long).toContain('device authorization flow')
const flagNames = authLogin?.flags?.map((f) => f.name) ?? []
expect(flagNames).toContain('--region')
expect(flagNames).toContain('--json')
expect(flagNames).toContain('--no-open')
})

it('surfaces the shared --database-url flag with its env var', () => {
const eqlInstall = buildManifest('0.0.0')
.groups.flatMap((g) => g.commands)
.find((c) => c.name === 'eql install')
const dbUrl = eqlInstall?.flags?.find((f) => f.name === '--database-url')
expect(dbUrl?.env).toBe('DATABASE_URL')
expect(dbUrl?.value).toBe('<url>')
})

it('drops undefined optionals so the JSON round-trips cleanly', () => {
const m = buildManifest('1.2.3')
const json = JSON.stringify(m)
expect(JSON.parse(json)).toEqual(m)
// A summary-only command must not carry empty long/examples/flags keys.
const wizard = m.groups
.flatMap((g) => g.commands)
.find((c) => c.name === 'wizard')
expect(wizard).toEqual({
name: 'wizard',
summary: 'AI-guided encryption setup (reads your codebase)',
})
})
})
Loading
Loading