From e01d5767b4665ea10288b55da54530adc9f356af Mon Sep 17 00:00:00 2001 From: Lindsay Holmwood Date: Thu, 14 May 2026 20:54:40 +1000 Subject: [PATCH] fix: make `stash impl` and `stash plan` not hang in non-TTY contexts The agent-target picker previously read from `/dev/tty` and waited forever. This caused problems when running `impl` or `plan` in CI, pipes, and automation harnesses. This fix allows users to pass `--target ` to select a handoff target non-interactively. When neither `--target` nor a TTY is available the command prints a hint and exits cleanly instead of blocking. Fixes #445. --- .changeset/handle-stdin-not-tty.md | 5 + packages/cli/src/bin/stash.ts | 13 ++- .../impl/__tests__/how-to-proceed.test.ts | 26 ++++- .../src/commands/impl/__tests__/impl.test.ts | 99 +++++++++++++++++++ packages/cli/src/commands/impl/index.ts | 35 ++++++- .../src/commands/impl/steps/how-to-proceed.ts | 55 +++++++++-- packages/cli/src/commands/init/index.ts | 11 ++- packages/cli/src/commands/plan/index.ts | 48 +++++++-- .../cli/tests/e2e/impl-non-tty.e2e.test.ts | 81 +++++++++++++++ packages/cli/tests/helpers/spawn-piped.ts | 75 ++++++++++++++ skills/stash-cli/SKILL.md | 23 ++++- 11 files changed, 443 insertions(+), 28 deletions(-) create mode 100644 .changeset/handle-stdin-not-tty.md create mode 100644 packages/cli/src/commands/impl/__tests__/impl.test.ts create mode 100644 packages/cli/tests/e2e/impl-non-tty.e2e.test.ts create mode 100644 packages/cli/tests/helpers/spawn-piped.ts diff --git a/.changeset/handle-stdin-not-tty.md b/.changeset/handle-stdin-not-tty.md new file mode 100644 index 00000000..5b4630aa --- /dev/null +++ b/.changeset/handle-stdin-not-tty.md @@ -0,0 +1,5 @@ +--- +"stash": patch +--- + +`stash impl` and `stash plan` no longer hang in non-TTY contexts (CI, pipes, automation harnesses). The agent-target picker previously read from `/dev/tty` and waited forever. You can now pass `--target ` to select a handoff target non-interactively, and when neither `--target` nor a TTY is available the command prints a hint and exits cleanly instead of blocking. diff --git a/packages/cli/src/bin/stash.ts b/packages/cli/src/bin/stash.ts index e8169ca5..9da81702 100644 --- a/packages/cli/src/bin/stash.ts +++ b/packages/cli/src/bin/stash.ts @@ -116,6 +116,10 @@ Plan Flags: 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) @@ -127,6 +131,10 @@ Status Flags: 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 Flags: --force (install) Reinstall / overwrite even if already installed @@ -146,6 +154,7 @@ Examples: ${STASH} plan ${STASH} impl ${STASH} impl --continue-without-plan + ${STASH} impl --target claude-code ${STASH} status ${STASH} auth login ${STASH} wizard @@ -396,10 +405,10 @@ async function main() { await initCommand(flags) break case 'plan': - await planCommand(flags) + await planCommand(flags, values) break case 'impl': - await implCommand(flags) + await implCommand(flags, values) break case 'status': await statusCommand({ diff --git a/packages/cli/src/commands/impl/__tests__/how-to-proceed.test.ts b/packages/cli/src/commands/impl/__tests__/how-to-proceed.test.ts index 5f1e375e..8ebdc886 100644 --- a/packages/cli/src/commands/impl/__tests__/how-to-proceed.test.ts +++ b/packages/cli/src/commands/impl/__tests__/how-to-proceed.test.ts @@ -1,7 +1,12 @@ import { describe, expect, it } from 'vitest' import type { AgentEnvironment } from '../../init/detect-agents.js' import type { InitState } from '../../init/types.js' -import { buildOptions, defaultChoice } from '../steps/how-to-proceed.js' +import { + HANDOFF_CHOICES, + buildOptions, + defaultChoice, + resolveTarget, +} from '../steps/how-to-proceed.js' function makeAgents(claudeCode: boolean, codex: boolean): AgentEnvironment { return { @@ -64,3 +69,22 @@ describe('howToProceed — defaultChoice', () => { expect(defaultChoice(noAgents, 'plan')).toBe('agents-md') }) }) + +describe('howToProceed — resolveTarget', () => { + it('accepts every documented handoff target', () => { + for (const choice of HANDOFF_CHOICES) { + expect(resolveTarget(choice)).toBe(choice) + } + }) + + it('returns null for unknown values', () => { + expect(resolveTarget('claude')).toBeNull() + expect(resolveTarget('CLAUDE-CODE')).toBeNull() + expect(resolveTarget('agents.md')).toBeNull() + expect(resolveTarget('')).toBeNull() + }) + + it('returns null when the flag is absent', () => { + expect(resolveTarget(undefined)).toBeNull() + }) +}) diff --git a/packages/cli/src/commands/impl/__tests__/impl.test.ts b/packages/cli/src/commands/impl/__tests__/impl.test.ts new file mode 100644 index 00000000..dcb7c0e2 --- /dev/null +++ b/packages/cli/src/commands/impl/__tests__/impl.test.ts @@ -0,0 +1,99 @@ +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { implCommand } from '../index.js' +import { howToProceedStep } from '../steps/how-to-proceed.js' + +let originalIsTTY: boolean | undefined +let originalCwd: string +let tmpDir: string + +function writeContext() { + fs.mkdirSync(path.join(tmpDir, '.cipherstash'), { recursive: true }) + fs.writeFileSync( + path.join(tmpDir, '.cipherstash', 'context.json'), + JSON.stringify({ + integration: 'postgresql', + packageManager: 'npm', + schemas: [], + }), + ) +} + +function writePlan() { + fs.writeFileSync(path.join(tmpDir, '.cipherstash', 'plan.md'), '# Plan') +} + +function setIsTTY(value: boolean) { + Object.defineProperty(process.stdout, 'isTTY', { + value, + configurable: true, + }) +} + +beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'stash-impl-test-')) + originalCwd = process.cwd() + originalIsTTY = process.stdout.isTTY + process.chdir(tmpDir) + writeContext() + writePlan() +}) + +afterEach(() => { + process.chdir(originalCwd) + Object.defineProperty(process.stdout, 'isTTY', { + value: originalIsTTY, + configurable: true, + }) + vi.restoreAllMocks() + if (tmpDir && fs.existsSync(tmpDir)) { + fs.rmSync(tmpDir, { recursive: true, force: true }) + } +}) + +describe('implCommand — TTY handling', () => { + it('exits cleanly without running the agent-target picker when stdout is not a TTY and no --target is given', async () => { + setIsTTY(false) + const runSpy = vi + .spyOn(howToProceedStep, 'run') + .mockResolvedValue({} as never) + + await expect(implCommand({}, {})).resolves.toBeUndefined() + + // The whole point of the fix: when there's no TTY and no target, the + // command must NOT reach the picker (which reads from /dev/tty and + // would hang forever in automation). + expect(runSpy).not.toHaveBeenCalled() + }) + + it('runs the handoff with the pre-resolved target when --target is given in a non-TTY context', async () => { + setIsTTY(false) + const runSpy = vi + .spyOn(howToProceedStep, 'run') + .mockResolvedValue({} as never) + + await implCommand({}, { target: 'agents-md' }) + + expect(runSpy).toHaveBeenCalledTimes(1) + const state = runSpy.mock.calls[0][0] + expect(state.handoff).toBe('agents-md') + }) + + it('exits with status 1 when --target is an unknown value', async () => { + setIsTTY(false) + const runSpy = vi + .spyOn(howToProceedStep, 'run') + .mockResolvedValue({} as never) + const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => { + throw new Error('process.exit') + }) as never) + + await expect(implCommand({}, { target: 'bogus' })).rejects.toThrow( + 'process.exit', + ) + expect(exitSpy).toHaveBeenCalledWith(1) + expect(runSpy).not.toHaveBeenCalled() + }) +}) diff --git a/packages/cli/src/commands/impl/index.ts b/packages/cli/src/commands/impl/index.ts index bd2e7902..aa7c2cc2 100644 --- a/packages/cli/src/commands/impl/index.ts +++ b/packages/cli/src/commands/impl/index.ts @@ -21,7 +21,11 @@ import { } from '../init/lib/write-context.js' import { CancelledError, type InitState } from '../init/types.js' import { detectPackageManager, runnerCommand } from '../init/utils.js' -import { howToProceedStep } from './steps/how-to-proceed.js' +import { + HANDOFF_CHOICES, + howToProceedStep, + resolveTarget, +} from './steps/how-to-proceed.js' function buildStateFromContext( ctx: ContextFile, @@ -131,7 +135,10 @@ function printDeployGateBanner(cli: string): void { * dual-write code). After a rollout-step handoff, the outro is the * deploy-gate banner instead of a generic "verify state" pointer. */ -export async function implCommand(flags: Record) { +export async function implCommand( + flags: Record, + values: Record = {}, +) { const cwd = process.cwd() const pm = detectPackageManager() const cli = runnerCommand(pm, 'stash') @@ -144,6 +151,17 @@ export async function implCommand(flags: Record) { process.exit(1) } + // Validate `--target` before printing the intro so the error sits at + // the top of the output instead of after a half-rendered prompt frame. + const targetFlag = values.target + const target = resolveTarget(targetFlag) + if (targetFlag && !target) { + p.log.error( + `Unknown --target \`${targetFlag}\`. Valid values: ${HANDOFF_CHOICES.join(', ')}.`, + ) + process.exit(1) + } + p.intro('CipherStash Implementation') const planPath = resolve(cwd, PLAN_REL_PATH) @@ -247,7 +265,18 @@ export async function implCommand(flags: Record) { const agents = detectAgents(cwd, process.env) const state = buildStateFromContext(ctx, agents) - await howToProceedStep.run(state) + // Non-TTY without an explicit target would block forever on the + // agent-target picker (it reads from /dev/tty). Make the command + // safe to call from automation by short-circuiting with a hint. + if (!target && !isTTY) { + p.log.info( + `No agent selected. Plan is at \`${PLAN_REL_PATH}\`. Pass --target <${HANDOFF_CHOICES.join('|')}> to run the handoff non-interactively.`, + ) + p.outro('No handoff performed.') + return + } + + await howToProceedStep.run(target ? { ...state, handoff: target } : state) if (planStep === 'rollout') { printDeployGateBanner(cli) diff --git a/packages/cli/src/commands/impl/steps/how-to-proceed.ts b/packages/cli/src/commands/impl/steps/how-to-proceed.ts index ab1233b0..baa614e9 100644 --- a/packages/cli/src/commands/impl/steps/how-to-proceed.ts +++ b/packages/cli/src/commands/impl/steps/how-to-proceed.ts @@ -11,6 +11,31 @@ import { handoffClaudeStep } from './handoff-claude.js' import { handoffCodexStep } from './handoff-codex.js' import { handoffWizardStep } from './handoff-wizard.js' +/** + * The complete set of handoff targets accepted by `--target`. Kept as a + * runtime array (not just a type) so the CLI can validate user input + * against the same source of truth that drives the picker. + */ +export const HANDOFF_CHOICES: readonly HandoffChoice[] = [ + 'claude-code', + 'codex', + 'agents-md', + 'wizard', +] as const + +/** + * Validate a user-supplied `--target` value. Returns the canonical + * `HandoffChoice` if valid, or `null` otherwise. `undefined` input + * (flag absent) returns `null` too — callers distinguish absence from + * invalidity before calling this. + */ +export function resolveTarget(target: string | undefined): HandoffChoice | null { + if (!target) return null + return (HANDOFF_CHOICES as readonly string[]).includes(target) + ? (target as HandoffChoice) + : null +} + /** * Pick the default option in the menu. * @@ -77,18 +102,28 @@ export const howToProceedStep: HandoffStep = { name: 'How to proceed', async run(state: InitState): Promise { const mode: InitMode = state.mode ?? 'implement' - const message = - mode === 'plan' - ? 'Which agent should write the plan?' - : 'How would you like to finish setup?' - const choice = await p.select({ - message, - options: buildOptions(state, mode), - initialValue: defaultChoice(state, mode), - }) + // Caller pre-resolved the handoff target (e.g. via `--target` on the + // CLI). Skip the interactive picker entirely so the command is safe + // to run from automation / non-TTY contexts. + let choice: HandoffChoice + if (state.handoff) { + choice = state.handoff + } else { + const message = + mode === 'plan' + ? 'Which agent should write the plan?' + : 'How would you like to finish setup?' - if (p.isCancel(choice)) throw new CancelledError() + const picked = await p.select({ + message, + options: buildOptions(state, mode), + initialValue: defaultChoice(state, mode), + }) + + if (p.isCancel(picked)) throw new CancelledError() + choice = picked + } const next: InitState = { ...state, handoff: choice } diff --git a/packages/cli/src/commands/init/index.ts b/packages/cli/src/commands/init/index.ts index ee15b3cf..266c8207 100644 --- a/packages/cli/src/commands/init/index.ts +++ b/packages/cli/src/commands/init/index.ts @@ -105,9 +105,16 @@ export async function initCommand(flags: Record) { await planCommand() return } + p.outro(`Next: run \`${cli} plan\` to draft your encryption plan.`) + } else { + // Non-TTY users (CI, agent Bash tools, pipes) will hit the same + // agent-target picker in `stash plan`, which only reads from + // /dev/tty. Steer them at `--target` up front so the next command + // doesn't surprise them. + p.outro( + `Next: run \`${cli} plan --target \` to draft your encryption plan. The \`--target\` flag is required when running non-interactively (skips the agent-target picker).`, + ) } - - p.outro(`Next: run \`${cli} plan\` to draft your encryption plan.`) } catch (err) { if (err instanceof CancelledError) { p.cancel('Setup cancelled.') diff --git a/packages/cli/src/commands/plan/index.ts b/packages/cli/src/commands/plan/index.ts index 51862dd7..d9377a7e 100644 --- a/packages/cli/src/commands/plan/index.ts +++ b/packages/cli/src/commands/plan/index.ts @@ -2,7 +2,11 @@ import { existsSync } from 'node:fs' import { resolve } from 'node:path' import { readManifest } from '@cipherstash/migrate' import * as p from '@clack/prompts' -import { howToProceedStep } from '../impl/steps/how-to-proceed.js' +import { + HANDOFF_CHOICES, + howToProceedStep, + resolveTarget, +} from '../impl/steps/how-to-proceed.js' import { type AgentEnvironment, detectAgents } from '../init/detect-agents.js' import { readContextFile } from '../init/lib/read-context.js' import { detectColumnStates, rollupPlanStep } from '../init/lib/rollout-state.js' @@ -118,7 +122,10 @@ async function detectPlanStep(cwd: string): Promise { * one document with no deploy gate. Confirms * (default-no) before generating. */ -export async function planCommand(flags: Record = {}) { +export async function planCommand( + flags: Record = {}, + values: Record = {}, +) { const cwd = process.cwd() const pm = detectPackageManager() const cli = runnerCommand(pm, 'stash') @@ -131,6 +138,15 @@ export async function planCommand(flags: Record = {}) { process.exit(1) } + const targetFlag = values.target + const target = resolveTarget(targetFlag) + if (targetFlag && !target) { + p.log.error( + `Unknown --target \`${targetFlag}\`. Valid values: ${HANDOFF_CHOICES.join(', ')}.`, + ) + process.exit(1) + } + p.intro('CipherStash Plan') try { @@ -160,7 +176,17 @@ export async function planCommand(flags: Record = {}) { const agents = detectAgents(cwd, process.env) const state = buildStateFromContext(ctx, agents, planStep) - await howToProceedStep.run(state) + // Non-TTY without --target would hang on the agent-target picker. + // Exit cleanly with a hint so automation users discover the flag. + if (!target && !process.stdout.isTTY) { + p.log.info( + `No agent selected. Pass --target <${HANDOFF_CHOICES.join('|')}> to run the handoff non-interactively.`, + ) + p.outro('No handoff performed.') + return + } + + await howToProceedStep.run(target ? { ...state, handoff: target } : state) if (process.stdout.isTTY) { const proceed = await p.confirm({ @@ -170,14 +196,20 @@ export async function planCommand(flags: Record = {}) { if (!p.isCancel(proceed) && proceed) { p.outro('Plan complete — handing off to `stash impl`.') const { implCommand } = await import('../impl/index.js') - await implCommand({}) + await implCommand({}, {}) return } + p.outro( + `Plan drafted at \`${PLAN_REL_PATH}\`. Review it, then run \`${cli} impl\` to implement.`, + ) + } else { + // Mirror init's non-TTY hint: the next command will also hit the + // agent-target picker, so name `--target` here rather than letting + // the user re-discover the flag on the next exit-cleanly hint. + p.outro( + `Plan drafted at \`${PLAN_REL_PATH}\`. Review it, then run \`${cli} impl --target \` to implement. The \`--target\` flag is required when running non-interactively.`, + ) } - - p.outro( - `Plan drafted at \`${PLAN_REL_PATH}\`. Review it, then run \`${cli} impl\` to implement.`, - ) } catch (err) { if (err instanceof CancelledError) { p.cancel('Cancelled.') diff --git a/packages/cli/tests/e2e/impl-non-tty.e2e.test.ts b/packages/cli/tests/e2e/impl-non-tty.e2e.test.ts new file mode 100644 index 00000000..ae39c824 --- /dev/null +++ b/packages/cli/tests/e2e/impl-non-tty.e2e.test.ts @@ -0,0 +1,81 @@ +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { runPiped } from '../helpers/spawn-piped.js' + +/** + * E2E coverage for the BUGS.md reproducer: `bunx stash impl < /dev/null` + * used to hang forever on the agent-target picker (clack `select` reads + * from `/dev/tty`). The fix short-circuits with a hint when there's no + * TTY and no `--target`, and accepts `--target ` as the + * non-interactive escape hatch. + * + * These tests spawn the built CLI through `runPiped` (not the PTY + * `render`) so the child actually sees `process.stdout.isTTY === false`, + * which is the precondition that triggered the original hang. + */ +describe('stash impl — non-TTY safety (BUGS.md reproducer)', () => { + let tmpDir: string + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'stash-impl-non-tty-e2e-')) + fs.mkdirSync(path.join(tmpDir, '.cipherstash')) + fs.writeFileSync( + path.join(tmpDir, '.cipherstash', 'context.json'), + JSON.stringify({ + integration: 'postgresql', + packageManager: 'npm', + schemas: [], + }), + ) + // A plan file is required to reach the agent-target picker — without + // one, impl exits earlier on the "no plan + non-TTY" guard. The + // BUGS.md repro had a plan on disk, which is how the bug surfaced. + fs.writeFileSync(path.join(tmpDir, '.cipherstash', 'plan.md'), '# Plan') + }) + + afterEach(() => { + if (tmpDir && fs.existsSync(tmpDir)) { + fs.rmSync(tmpDir, { recursive: true, force: true }) + } + }) + + it('exits 0 with a hint instead of hanging on the agent-target picker', async () => { + // 5s is well over the time the fix needs (≈100ms in practice) and + // well under what would happen on a regression (the original bug + // hung indefinitely). + const r = await runPiped(['impl'], { cwd: tmpDir, timeoutMs: 5_000 }) + + expect(r.timedOut).toBe(false) + expect(r.exitCode).toBe(0) + expect(r.stdout).toContain('No agent selected') + expect(r.stdout).toContain('--target') + expect(r.stdout).toContain('claude-code|codex|agents-md|wizard') + }) + + it('rejects an unknown --target with a clear error and exits 1', async () => { + const r = await runPiped(['impl', '--target', 'bogus'], { + cwd: tmpDir, + timeoutMs: 5_000, + }) + + expect(r.timedOut).toBe(false) + expect(r.exitCode).toBe(1) + const combined = r.stdout + r.stderr + expect(combined).toContain('Unknown --target') + expect(combined).toContain('bogus') + expect(combined).toContain('claude-code') + expect(combined).toContain('wizard') + }) + + it('--help documents the --target flag under Impl Flags', async () => { + const r = await runPiped(['--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') + }) +}) diff --git a/packages/cli/tests/helpers/spawn-piped.ts b/packages/cli/tests/helpers/spawn-piped.ts new file mode 100644 index 00000000..554c871e --- /dev/null +++ b/packages/cli/tests/helpers/spawn-piped.ts @@ -0,0 +1,75 @@ +import { spawn } from 'node:child_process' +import { STASH_BIN } from './pty.js' + +/** + * Non-PTY counterpart to `render()` in `./pty.ts`. Spawns the built CLI + * with all three stdio channels as pipes — so the child sees + * `process.stdout.isTTY === false` and `/dev/tty` is unavailable. This is + * the exact "non-TTY context" the BUGS.md reproducer described: + * `bunx stash impl < /dev/null` from CI, automation harnesses, or the + * Claude Code Bash tool. + * + * Use this — not `render()` — when the behaviour under test depends on + * non-TTY stdio. `render()` (node-pty) always gives the child a real + * terminal. + */ +export interface PipedResult { + /** `null` only when the process was SIGKILLed by the timeout. */ + exitCode: number | null + stdout: string + stderr: string + /** True when the timeout fired and we had to kill the process. */ + timedOut: boolean +} + +export interface PipedOptions { + cwd?: string + env?: Record + /** Default 10s — short enough that a regression of the hang surfaces fast. */ + timeoutMs?: number +} + +export function runPiped( + args: string[], + opts: PipedOptions = {}, +): Promise { + return new Promise((resolvePromise, reject) => { + const env: Record = { + ...(process.env as Record), + NO_COLOR: '1', + FORCE_COLOR: '0', + ...(opts.env ?? {}), + } + + const child = spawn(process.execPath, [STASH_BIN, ...args], { + cwd: opts.cwd ?? process.cwd(), + stdio: ['ignore', 'pipe', 'pipe'], + env, + }) + + let stdout = '' + let stderr = '' + let timedOut = false + + child.stdout?.on('data', (d: Buffer) => { + stdout += d.toString() + }) + child.stderr?.on('data', (d: Buffer) => { + stderr += d.toString() + }) + + const timer = setTimeout(() => { + timedOut = true + child.kill('SIGKILL') + }, opts.timeoutMs ?? 10_000) + + child.on('close', (code) => { + clearTimeout(timer) + resolvePromise({ exitCode: code, stdout, stderr, timedOut }) + }) + child.on('error', (err) => { + clearTimeout(timer) + reject(err) + }) + }) +} diff --git a/skills/stash-cli/SKILL.md b/skills/stash-cli/SKILL.md index d384d527..ce3cd078 100644 --- a/skills/stash-cli/SKILL.md +++ b/skills/stash-cli/SKILL.md @@ -42,6 +42,17 @@ The setup lifecycle is split across four explicit save-points. Each command can | `stash impl` | Executes the plan via agent handoff. Refuses cutover-step plans without a recorded `dual_writing` event; prints the deploy-gate banner after a rollout-step run. | Deploy-gate banner (rollout) or "verify state" (cutover/new) | | `stash status` | The encryption-rollout quest log — per-column "where am I" map, runs in ms | — | +### Running from automation (non-TTY) + +If you're invoking `stash plan` or `stash impl` from a non-TTY context — CI, a pipe, or an agent's Bash tool — **always pass `--target `**. Both commands present an interactive agent-target picker that reads from `/dev/tty`; without `--target` they print a "no agent selected" hint and exit 0 without performing the handoff. With `--target`, the picker is skipped and the named handoff runs non-interactively. + +```bash +stash plan --target claude-code +stash impl --target agents-md +``` + +`stash init` and `stash status` are safe to call from any context — they detect non-TTY and adapt automatically. + ### Rolling encryption out to production Two paths to a fully-encrypted column: @@ -137,10 +148,13 @@ The `--supabase` and `--drizzle` flags tailor the intro message and EQL install ```bash stash plan stash plan --complete-rollout +stash plan --target claude-code # non-interactive — skip the agent picker ``` `plan` is the **draft for review** save-point. Pre-flights `.cipherstash/context.json` (errors with a "Run `stash init` first" pointer if missing). Hands off to a coding agent — all four targets are offered: Claude Code, Codex, AGENTS.md (for Cursor/Windsurf/Cline), and the CipherStash Agent (`@cipherstash/wizard`). +`--target ` skips the interactive agent-target picker. **Required when invoking `plan` from a non-TTY context** (CI, pipes, an agent's Bash tool) — without it, `plan` prints a "no agent selected" hint and exits 0 without performing the handoff. In a TTY, `--target` is optional; it just bypasses the picker. + `plan` is **state-driven**. It reads `.cipherstash/migrations.json` and `cs_migrations` and dispatches to one of three plan templates: | Detected state | Plan written | @@ -166,18 +180,23 @@ There is no atomic way to replace a populated plaintext column with an encrypted ```bash stash impl stash impl --continue-without-plan +stash impl --target claude-code # non-interactive — skip the agent picker ``` `impl` is the **execute** save-point. Pre-flights `.cipherstash/context.json`. Behaviour branches on disk state: | State | Behaviour | |-------|-----------| -| Plan exists, TTY | Parses the summary block. Enforces the deploy gate (see below). Renders a confirm panel describing the plan scope. Default-yes confirm. | -| Plan exists, non-TTY | Logs and proceeds without confirm (CI/pipe-safe). The deploy-gate check still runs. | +| Plan exists, TTY, no `--target` | Parses the summary block. Enforces the deploy gate (see below). Renders a confirm panel describing the plan scope. Default-yes confirm, then the agent-target picker. | +| Plan exists, TTY, `--target X` | Same confirm panel, but skips the picker and runs handoff `X`. | +| Plan exists, non-TTY, `--target X` | Logs the plan path, skips both the confirm and the picker, runs handoff `X`. | +| Plan exists, non-TTY, no `--target` | Logs the plan path, prints a "No agent selected — pass `--target`" hint, and exits 0 without performing the handoff. The deploy-gate check still runs first. | | No plan, TTY | Interactive `p.select`: "Draft a plan first (recommended)" / "Continue without a plan" / cancel. "Draft" delegates to `stash plan`. "Continue" goes through a security confirm (default-no) before implementing. | | No plan, `--continue-without-plan` | Skips the picker, runs the security confirm (still default-no), then implements. | | No plan, non-TTY, no flag | Errors out with "Run `stash plan` first, or pass `--continue-without-plan` to skip planning." Forces explicit intent in CI. | +`--target ` skips the interactive agent-target picker. **Required when invoking `impl` from a non-TTY context** (CI, pipes, an agent's Bash tool); without it, `impl` exits cleanly with a hint rather than hanging on `/dev/tty`. In a TTY, `--target` is optional. + Once the user clears the gate, `impl` dispatches to a handoff target (Claude Code, Codex, AGENTS.md for Cursor/Windsurf/Cline, or `@cipherstash/wizard`) and the agent executes the plan: schema edits, migrations, `stash db push`, `stash encrypt {backfill,cutover,drop}` as appropriate. #### Deploy-gate enforcement