Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/handle-stdin-not-tty.md
Original file line number Diff line number Diff line change
@@ -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 <claude-code|codex|agents-md|wizard>` 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.
13 changes: 11 additions & 2 deletions packages/cli/src/bin/stash.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <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)
Expand All @@ -127,6 +131,10 @@ Status Flags:
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 Flags:
--force (install) Reinstall / overwrite even if already installed
Expand All @@ -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
Expand Down Expand Up @@ -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({
Expand Down
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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()
})
})
99 changes: 99 additions & 0 deletions packages/cli/src/commands/impl/__tests__/impl.test.ts
Original file line number Diff line number Diff line change
@@ -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()
})
})
35 changes: 32 additions & 3 deletions packages/cli/src/commands/impl/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<string, boolean>) {
export async function implCommand(
flags: Record<string, boolean>,
values: Record<string, string> = {},
) {
const cwd = process.cwd()
const pm = detectPackageManager()
const cli = runnerCommand(pm, 'stash')
Expand All @@ -144,6 +151,17 @@ export async function implCommand(flags: Record<string, boolean>) {
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)
Expand Down Expand Up @@ -247,7 +265,18 @@ export async function implCommand(flags: Record<string, boolean>) {
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)
Expand Down
55 changes: 45 additions & 10 deletions packages/cli/src/commands/impl/steps/how-to-proceed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -77,18 +102,28 @@ export const howToProceedStep: HandoffStep = {
name: 'How to proceed',
async run(state: InitState): Promise<InitState> {
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<HandoffChoice>({
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<HandoffChoice>({
message,
options: buildOptions(state, mode),
initialValue: defaultChoice(state, mode),
})

if (p.isCancel(picked)) throw new CancelledError()
choice = picked
}

const next: InitState = { ...state, handoff: choice }

Expand Down
11 changes: 9 additions & 2 deletions packages/cli/src/commands/init/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,9 +105,16 @@ export async function initCommand(flags: Record<string, boolean>) {
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 <claude-code|codex|agents-md|wizard>\` 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.')
Expand Down
Loading
Loading