Skip to content

Commit 6eddeb4

Browse files
authored
Merge pull request #569 from cipherstash/agent-friendly-cli
feat(cli): non-interactive / agent-friendly auth + region flags
2 parents af103cd + f55226a commit 6eddeb4

27 files changed

Lines changed: 2149 additions & 69 deletions

.changeset/stash-cli-manifest.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
"stash": minor
3+
---
4+
5+
Add a command-descriptor registry and `stash manifest --json` — a structured,
6+
versioned command surface for the docs generator and agents to consume instead
7+
of scraping `--help`.
8+
9+
- `stash manifest --json` emits `{ name, version, groups[] }`, where each command
10+
carries its summary, optional long description, examples, and flags. `version`
11+
comes from the CLI's own `package.json`, so a page generated from the manifest
12+
is always stamped with the version it describes.
13+
- `stash manifest` (no flag) prints a grouped, human-readable command list.
14+
- The registry (`src/cli/registry.ts`) is intended to become the single source of
15+
truth for command metadata. This is phase 1 of
16+
`docs/plans/cli-help-and-manifest.md`; it is additive — `bin/main.ts` still
17+
hand-maintains the `HELP` string that renders `--help`, so until the documented
18+
follow-on renders `--help` from the registry the two are kept in sync by hand.
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
"stash": minor
3+
---
4+
5+
Add non-interactive / agent-friendly affordances so `stash init` and
6+
`stash auth login` can run without a TTY (agents, CI, pipes). All changes are
7+
additive — interactive behaviour in a real terminal is unchanged.
8+
9+
- `--region <slug>` / `STASH_REGION` on `stash auth login` and `stash init`
10+
skip the interactive region picker. An unknown or missing region in a
11+
non-TTY context now exits with an actionable message instead of hanging on
12+
the picker (region resolution mirrors the `DATABASE_URL` resolver's
13+
`TTY && !CI` gate).
14+
- `stash auth login --json` emits newline-delimited device-code events. The
15+
first event (`authorization_required`) carries the verification URL, so an
16+
agent can trigger auth and hand the browser step to a human — only a human
17+
completes it in the browser. `--no-open` suppresses the browser launch.
18+
- `stash auth regions` lists the regions valid for `--region` / `STASH_REGION`;
19+
`stash auth regions --json` emits `[{ slug, label }]` for programmatic use.

packages/cli/AGENTS.md

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,9 +62,18 @@ exercise the same code paths.
6262
test run; if not, manually `chmod +x` the helper under
6363
`node_modules/.pnpm/node-pty@*/node_modules/node-pty/prebuilds/<plat>/spawn-helper`.
6464
- **Don't broaden the cancel test target.** `auth login` was chosen because
65-
`selectRegion()` runs before any network I/O. Don't move the cancel
65+
the region picker runs before any network I/O. Don't move the cancel
6666
assertion to a command that hits the auth server or DB before the first
6767
prompt — flaky.
68+
- **Region resolution is CI-aware.** `resolveRegion` (in
69+
`src/commands/auth/region.ts`) mirrors the `DATABASE_URL` resolver: it only
70+
renders the interactive picker when `stdin.isTTY` **and** `CI` is unset, and
71+
otherwise honours `--region` / `STASH_REGION` or exits with an actionable
72+
error (JSON in `--json` mode). Because the pty harness defaults `CI=true`,
73+
the interactive-cancel test passes `env: { CI: '' }` to force the picker to
74+
render. The pure helpers (`normalizeRegion`, `regionSlugs`) and the resolver
75+
policy have unit coverage in `src/commands/auth/__tests__/region.test.ts`
76+
the module imports no native code, so it runs under the fast unit config.
6877
- **Use `src/messages.ts` for assertion-stable strings.** The module is a
6978
single typed `as const` object grouping copy by area (`cli`, `auth`,
7079
`db`). Prod call sites import the same constants the tests do, so a copy

packages/cli/README.md

Lines changed: 52 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,13 +72,14 @@ Commands that consume `stash.config.ts`: `eql install`, `eql upgrade`, `db push`
7272
Set up CipherStash end-to-end: authenticate, introspect your database, install dependencies, install EQL, and hand off the rest to your local coding agent.
7373

7474
```bash
75-
npx stash init [--supabase] [--drizzle]
75+
npx stash init [--supabase] [--drizzle] [--region <slug>]
7676
```
7777

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

8384
What `init` does, in order:
8485

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

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

97+
**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.
98+
99+
```bash
100+
STASH_REGION=us-east-1 DATABASE_URL=postgres://… npx stash init --no-proxy
101+
```
102+
96103
---
97104

98105
### `npx stash auth login`
99106

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

102109
```bash
103-
npx stash auth login
110+
npx stash auth login [--region <slug>] [--json] [--no-open]
104111
```
105112

113+
| Flag | Description |
114+
|------|-------------|
115+
| `--region <slug>` | Region to authenticate against (e.g. `us-east-1`). Skips the interactive region picker. Also settable via `STASH_REGION`. |
116+
| `--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). |
117+
| `--no-open` | Don't auto-open the verification URL in a browser (already implied by `--json`). |
118+
| `--supabase` / `--drizzle` | Track the integration as the referrer. |
119+
106120
Saves the token to `~/.cipherstash/auth.json`. Database-touching commands check for this file before running.
107121

122+
#### Triggering auth from an agent (device-code flow)
123+
124+
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:
125+
126+
```bash
127+
npx stash auth login --region us-east-1 --json
128+
```
129+
130+
```jsonc
131+
// stdout is newline-delimited JSON, one event per line:
132+
{"status":"authorization_required","userCode":"ABCD-1234","verificationUri":"https://…/activate","verificationUriComplete":"https://…/activate?user_code=ABCD-1234","expiresIn":899}
133+
// … the process then blocks polling until the human authorizes in the browser …
134+
{"status":"authorized","expiresAt":1751990400,"expiresAtIso":"2025-07-08T12:00:00.000Z"}
135+
{"status":"device_bound"}
136+
```
137+
138+
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.
139+
140+
---
141+
142+
### `npx stash auth regions`
143+
144+
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.
145+
146+
```bash
147+
npx stash auth regions # human-readable list
148+
npx stash auth regions --json # machine-readable [{ "slug": "…", "label": "…" }]
149+
```
150+
151+
```jsonc
152+
// --json output:
153+
[{"slug":"us-east-1","label":"us-east-1 (Virginia, USA)"}, {"slug":"us-east-2","label":"us-east-2 (Ohio, USA)"}, …]
154+
```
155+
156+
> 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.
157+
108158
---
109159

110160
### `npx stash wizard`

packages/cli/src/bin/main.ts

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import {
2727
implCommand,
2828
initCommand,
2929
installCommand,
30+
manifestCommand,
3031
planCommand,
3132
statusCommand,
3233
testConnectionCommand,
@@ -89,6 +90,7 @@ Commands:
8990
auth <subcommand> Authenticate with CipherStash
9091
wizard AI-guided encryption setup (reads your codebase)
9192
doctor Diagnose install problems (native binaries, runtime)
93+
manifest Print the structured, versioned command surface (--json for docs/agents)
9294
9395
eql install Scaffold stash.config.ts (if missing) and install EQL extensions
9496
eql upgrade Upgrade EQL extensions to the latest version
@@ -120,6 +122,22 @@ Init Flags:
120122
--prisma-next Use Prisma Next-specific setup flow (EQL bundle installed via prisma-next migration apply)
121123
--proxy Query encrypted data via CipherStash Proxy
122124
--no-proxy Query encrypted data directly via the SDK (default)
125+
--region <slug> Region to authenticate against (e.g. us-east-1). Skips the
126+
interactive region picker. Also settable via STASH_REGION.
127+
Required for non-interactive init when not already logged in.
128+
129+
Auth Flags:
130+
--region <slug> Region to authenticate against (e.g. us-east-1). Skips the
131+
interactive region picker. Also settable via STASH_REGION.
132+
--json Emit newline-delimited JSON events instead of prose. The
133+
first event (authorization_required) carries the device
134+
verification URL for a human to open. Implies no prompt
135+
and no browser auto-open — an agent can trigger auth
136+
non-interactively; only a human can complete it in the
137+
browser. Run it in the background, read the URL from the
138+
first line, then hand it to the user.
139+
--no-open Don't auto-open the verification URL in a browser
140+
(already implied by --json).
123141
124142
Plan Flags:
125143
--complete-rollout Plan the entire encryption lifecycle (schema-add through drop)
@@ -165,17 +183,21 @@ Examples:
165183
${STASH} init
166184
${STASH} init --supabase
167185
${STASH} init --prisma-next
186+
${STASH} init --region us-east-1 # non-interactive: skip the region picker
168187
${STASH} plan
169188
${STASH} impl
170189
${STASH} impl --continue-without-plan
171190
${STASH} impl --target claude-code
172191
${STASH} status
173192
${STASH} auth login
193+
${STASH} auth regions # list regions valid for --region
194+
${STASH} auth login --region us-east-1 --json # agent triggers; human finishes in browser
174195
${STASH} wizard
175196
${STASH} eql install
176197
${STASH} db push
177198
${STASH} schema build
178199
${STASH} doctor
200+
${STASH} manifest --json # structured command surface for docs / agents
179201
`.trim()
180202

181203
interface ParsedArgs {
@@ -465,7 +487,7 @@ export async function run() {
465487

466488
switch (command) {
467489
case 'init':
468-
await initCommand(flags)
490+
await initCommand(flags, values)
469491
break
470492
case 'plan':
471493
await planCommand(flags, values)
@@ -482,7 +504,7 @@ export async function run() {
482504
break
483505
case 'auth': {
484506
const authArgs = subcommand ? [subcommand, ...commandArgs] : commandArgs
485-
await authCommand(authArgs, flags)
507+
await authCommand(authArgs, flags, values)
486508
break
487509
}
488510
case 'eql':
@@ -500,6 +522,11 @@ export async function run() {
500522
case 'env':
501523
await envCommand({ write: flags.write })
502524
break
525+
case 'manifest':
526+
// Pure metadata (no native code) — safe to run anywhere, including when
527+
// the native binary is missing.
528+
manifestCommand({ json: flags.json, version: pkg.version })
529+
break
503530
case 'wizard': {
504531
// Forward everything after `stash wizard` verbatim. The wizard package
505532
// owns its own flag parsing; we don't try to interpret its surface
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { buildManifest } from '../manifest.js'
3+
import { type CommandGroup, registry } from '../registry.js'
4+
5+
describe('buildManifest', () => {
6+
it('stamps the name and the passed-in version', () => {
7+
const m = buildManifest('9.9.9')
8+
expect(m.name).toBe('stash')
9+
expect(m.version).toBe('9.9.9')
10+
})
11+
12+
it('emits every non-hidden command across all groups', () => {
13+
const m = buildManifest('0.0.0')
14+
const registryCount = registry
15+
.flatMap((g) => g.commands)
16+
.filter((c) => !c.hidden).length
17+
const manifestCount = m.groups.flatMap((g) => g.commands).length
18+
expect(manifestCount).toBe(registryCount)
19+
expect(m.groups.length).toBe(registry.length)
20+
})
21+
22+
it('drops commands marked hidden', () => {
23+
// Drive the hidden filter with a stub so coverage doesn't depend on the
24+
// live registry happening to contain a hidden command (it currently
25+
// contains none — a real-registry assertion would be vacuously green).
26+
const groups: CommandGroup[] = [
27+
{
28+
title: 'T',
29+
commands: [
30+
{ name: 'shown', summary: 's' },
31+
{ name: 'gone', summary: 'g', hidden: true },
32+
],
33+
},
34+
]
35+
const names = buildManifest('0.0.0', groups)
36+
.groups.flatMap((g) => g.commands)
37+
.map((c) => c.name)
38+
expect(names).toEqual(['shown'])
39+
})
40+
41+
it('carries examples through into the manifest', () => {
42+
// The one optional-field passthrough not covered by the auth-login (long +
43+
// flags) or wizard (all-undefined) cases.
44+
const init = buildManifest('0.0.0')
45+
.groups.flatMap((g) => g.commands)
46+
.find((c) => c.name === 'init')
47+
expect(init?.examples).toContain('init --supabase')
48+
})
49+
50+
it('defensively copies flags so a consumer cannot corrupt the registry', () => {
51+
const dbUrlOf = (m: ReturnType<typeof buildManifest>) =>
52+
m.groups
53+
.flatMap((g) => g.commands)
54+
.flatMap((c) => c.flags ?? [])
55+
.find((f) => f.name === '--database-url')
56+
57+
const first = dbUrlOf(buildManifest('0.0.0'))
58+
expect(first).toBeDefined()
59+
// Mutate a manifest flag; the shared registry singleton must be untouched.
60+
;(first as { description: string }).description = 'MUTATED'
61+
expect(dbUrlOf(buildManifest('0.0.0'))?.description).not.toBe('MUTATED')
62+
})
63+
64+
it('gives every command a non-empty name and summary', () => {
65+
for (const group of buildManifest('0.0.0').groups) {
66+
expect(group.title.length).toBeGreaterThan(0)
67+
for (const cmd of group.commands) {
68+
expect(cmd.name.length).toBeGreaterThan(0)
69+
expect(cmd.summary.length).toBeGreaterThan(0)
70+
}
71+
}
72+
})
73+
74+
it('includes the worked-example auth login descriptor with its flags', () => {
75+
const cmds = buildManifest('0.0.0').groups.flatMap((g) => g.commands)
76+
const authLogin = cmds.find((c) => c.name === 'auth login')
77+
expect(authLogin).toBeDefined()
78+
expect(authLogin?.long).toContain('device authorization flow')
79+
const flagNames = authLogin?.flags?.map((f) => f.name) ?? []
80+
expect(flagNames).toContain('--region')
81+
expect(flagNames).toContain('--json')
82+
expect(flagNames).toContain('--no-open')
83+
})
84+
85+
it('surfaces the shared --database-url flag with its env var', () => {
86+
const eqlInstall = buildManifest('0.0.0')
87+
.groups.flatMap((g) => g.commands)
88+
.find((c) => c.name === 'eql install')
89+
const dbUrl = eqlInstall?.flags?.find((f) => f.name === '--database-url')
90+
expect(dbUrl?.env).toBe('DATABASE_URL')
91+
expect(dbUrl?.value).toBe('<url>')
92+
})
93+
94+
it('drops undefined optionals so the JSON round-trips cleanly', () => {
95+
const m = buildManifest('1.2.3')
96+
const json = JSON.stringify(m)
97+
expect(JSON.parse(json)).toEqual(m)
98+
// A summary-only command must not carry empty long/examples/flags keys.
99+
const wizard = m.groups
100+
.flatMap((g) => g.commands)
101+
.find((c) => c.name === 'wizard')
102+
expect(wizard).toEqual({
103+
name: 'wizard',
104+
summary: 'AI-guided encryption setup (reads your codebase)',
105+
})
106+
})
107+
})

0 commit comments

Comments
 (0)