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
2 changes: 1 addition & 1 deletion .changeset/stash-impl-command.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@
"stash": minor
---

Split agent handoff out of `stash init` into a new `stash impl` command. `init` now owns scaffolding only (auth, database, encryption client, EQL extension) and exits at a clean checkpoint pointing at `stash impl`. `stash impl` derives plan-vs-implement mode from disk state — if `.cipherstash/plan.md` is missing it asks the agent to draft a plan; if it exists, the agent executes the plan as the source of truth. `--yolo` skips the planning checkpoint after an interactive confirmation. The earlier in-init `Plan first / Go straight to implementation` picker is removed in favour of the new command boundary.
Split agent handoff out of `stash init` into a new `stash impl` command. `init` now owns scaffolding only (auth, database, encryption client, EQL extension) and exits at a clean checkpoint pointing at `stash impl`. `stash impl` derives plan-vs-implement mode from disk state — if `.cipherstash/plan.md` is missing it asks the agent to draft a plan; if it exists, the agent executes the plan as the source of truth. `--continue-without-plan` skips the planning checkpoint after an interactive confirmation. The earlier in-init `Plan first / Go straight to implementation` picker is removed in favour of the new command boundary.
5 changes: 5 additions & 0 deletions .changeset/stash-impl-plan-summary.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"stash": minor
---

`stash impl` now renders a plan summary panel and asks the user to confirm before launching the implementation agent. When a plan exists, the CLI parses a machine-readable `<!-- cipherstash:plan-summary {...} -->` block (the planning agent is instructed to emit one at the top of `.cipherstash/plan.md`) and prints column counts, per-column paths, and whether the work is single-deploy or staged across 4 deploys. Default-yes on the confirm so the path of least resistance is to proceed; saying No exits cleanly. Older plans without the summary block fall back to a soft "open in your editor" panel — never an error. Non-TTY runs (CI, pipes) skip the confirm and proceed.
11 changes: 11 additions & 0 deletions .changeset/stash-plan-command.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"stash": minor
---

Extract planning into its own `stash plan` command. Three commands now own the setup lifecycle:

- `stash init` — scaffold (auth, db, deps, EQL). Ends with a chain prompt to `stash plan`.
- `stash plan` — draft a reviewable plan at `.cipherstash/plan.md`. Ends with a chain prompt to `stash impl`.
- `stash impl` — execute. With a plan, shows the summary panel and confirms. Without one, presents a `Draft a plan first / Continue without a plan` picker (the second option goes through a security confirm). `--continue-without-plan` skips the picker.

`stash status` reflects the new flow — its "Plan written" stage and `Next:` line route to `stash plan` when init is done but no plan exists. Non-TTY runs of `stash impl` without a plan now error out with a clear next-action rather than guessing intent.
5 changes: 5 additions & 0 deletions .changeset/stash-status-command.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"stash": minor
---

Add `stash status` — a top-level lifecycle map for the project. Reads `.cipherstash/context.json`, `.cipherstash/plan.md`, and `.cipherstash/setup-prompt.md` from disk to render a panel showing whether init is done, whether a plan has been written, and whether an agent has been engaged. Points at `stash db status` for EQL install info and `stash encrypt status` for per-column migration phase. Runs in milliseconds — no auth, no database connection required. The existing `stash db status` is unchanged.
22 changes: 17 additions & 5 deletions packages/cli/src/bin/stash.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,12 @@ import * as p from '@clack/prompts'
// Commands that depend on @cipherstash/stack are lazy-loaded in the switch below.
import {
authCommand,
dbStatusCommand,
envCommand,
implCommand,
initCommand,
installCommand,
planCommand,
statusCommand,
testConnectionCommand,
upgradeCommand,
Expand Down Expand Up @@ -75,7 +77,9 @@ ${messages.cli.usagePrefix}${STASH} <command> [options]

Commands:
init Initialize CipherStash for your project
impl Draft an encryption plan (or implement, if a plan exists)
plan Draft a reviewable encryption plan at .cipherstash/plan.md
impl Execute the plan with a local agent
status Displays implementation status
auth <subcommand> Authenticate with CipherStash
wizard AI-guided encryption setup (reads your codebase)

Expand Down Expand Up @@ -107,8 +111,8 @@ Init Flags:
--drizzle Use Drizzle-specific setup flow

Impl Flags:
--yolo Skip the planning checkpoint and go straight to implementation
(interactively confirms before proceeding)
--continue-without-plan Skip planning and go straight to implementation
(interactively confirms before proceeding)

DB Flags:
--force (install) Reinstall / overwrite even if already installed
Expand All @@ -125,8 +129,10 @@ DB Flags:
Examples:
${STASH} init
${STASH} init --supabase
${STASH} plan
${STASH} impl
${STASH} impl --yolo
${STASH} impl --continue-without-plan
${STASH} status
${STASH} auth login
${STASH} wizard
${STASH} db install
Expand Down Expand Up @@ -232,7 +238,7 @@ async function runDbCommand(
break
}
case 'status':
await statusCommand({ databaseUrl })
await dbStatusCommand({ databaseUrl })
break
case 'test-connection':
await testConnectionCommand({ databaseUrl })
Expand Down Expand Up @@ -375,9 +381,15 @@ async function main() {
case 'init':
await initCommand(flags)
break
case 'plan':
await planCommand()
break
case 'impl':
await implCommand(flags)
break
case 'status':
await statusCommand()
break
case 'auth': {
const authArgs = subcommand ? [subcommand, ...commandArgs] : commandArgs
await authCommand(authArgs, flags)
Expand Down
213 changes: 105 additions & 108 deletions packages/cli/src/commands/impl/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,121 +2,65 @@ import { existsSync, readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import * as p from '@clack/prompts'
import { type AgentEnvironment, detectAgents } from '../init/detect-agents.js'
import { parsePlanSummary, renderPlanSummary } from '../init/lib/parse-plan.js'
import { readContextFile } from '../init/lib/read-context.js'
import { PLAN_REL_PATH } from '../init/lib/setup-prompt.js'
import {
CONTEXT_REL_PATH,
type ContextFile,
} from '../init/lib/write-context.js'
import {
CancelledError,
type InitMode,
type InitProvider,
type InitState,
} from '../init/types.js'
import { CancelledError, type InitState } from '../init/types.js'
import { detectPackageManager, runnerCommand } from '../init/utils.js'
import { howToProceedStep } from './steps/how-to-proceed.js'

/**
* The handoff steps in `impl/steps/handoff-*.ts` accept an `InitProvider`
* but ignore it (their `run` signatures take `_provider`). The provider
* abstraction belongs to the `init` flow, where it picks intro copy and
* default next-steps. `stash impl` reads everything it needs from
* `.cipherstash/context.json` instead, so a stub keeps the type signature
* happy without pretending impl has provider-specific behaviour.
*/
const STUB_PROVIDER: InitProvider = {
name: 'impl',
introMessage: '',
getNextSteps: () => [],
}

export function readContextFile(cwd: string): ContextFile | undefined {
const path = resolve(cwd, CONTEXT_REL_PATH)
if (!existsSync(path)) return undefined
try {
return JSON.parse(readFileSync(path, 'utf-8')) as ContextFile
} catch {
return undefined
}
}

/**
* Derive the impl mode from disk state and flags.
*
* no `--yolo`, plan missing → `plan` (default — the safer path)
* no `--yolo`, plan exists → `implement` (the plan is the source of truth)
* `--yolo`, plan missing → `implement` after interactive confirmation
* `--yolo`, plan exists → `implement`; `--yolo` is a no-op once a plan
* exists, since the safety checkpoint already
* fired
*
* The interactive confirmation when `--yolo` is the only thing standing
* between the user and ~45–60 min of agent-driven implementation. Cheap
* to ask, expensive to skip by accident.
*/
export async function deriveMode(
cwd: string,
yolo: boolean,
): Promise<InitMode> {
const planExists = existsSync(resolve(cwd, PLAN_REL_PATH))

if (yolo) {
if (planExists) {
p.log.info(
`Plan exists at \`${PLAN_REL_PATH}\` — \`--yolo\` is a no-op when a plan is already in place.`,
)
return 'implement'
}
const confirmed = await p.confirm({
message:
'Skip the planning checkpoint and go straight to implementation?',
initialValue: false,
})
if (p.isCancel(confirmed) || !confirmed) {
throw new CancelledError()
}
return 'implement'
}

return planExists ? 'implement' : 'plan'
}

function buildStateFromContext(
ctx: ContextFile,
mode: InitMode,
agents: AgentEnvironment,
): InitState {
return {
integration: ctx.integration,
clientFilePath: ctx.encryptionClientPath,
schemas: ctx.schemas,
envKeys: ctx.envKeys,
// After init has run, these are true. The pre-flight context.json
// check above is the gate — if init didn't complete, context.json
// wouldn't exist and we'd have already errored.
stackInstalled: true,
cliInstalled: true,
eqlInstalled: true,
agents,
mode,
mode: 'implement',
}
}

/**
* Confirm before launching implementation when the user has chosen to
* skip the planning checkpoint. Default-no is the security stance —
* passing through this prompt by accident is the failure mode we're
* guarding against.
*/
async function confirmContinueWithoutPlan(): Promise<void> {
const confirmed = await p.confirm({
message: 'Implementation can take some time. Continue?',
initialValue: false,
})
if (p.isCancel(confirmed) || !confirmed) {
throw new CancelledError()
}
}

/**
* `stash impl` — the agent handoff phase.
* `stash impl` — execute an encryption plan.
*
* Pre-flights `.cipherstash/context.json` (errors with a `stash init`
* pointer if missing). Derives plan-vs-implement mode from disk state and
* the `--yolo` flag, then dispatches to a handoff target via
* `howToProceedStep`. Modes:
* Always runs in implement mode. Behaviour branches on disk state and
* flags:
*
* - `plan` (default when no `.cipherstash/plan.md` exists): the agent
* produces a reviewable plan file. The user reads it, then re-runs
* `stash impl` to execute.
* - `implement` (default when a plan exists): the agent executes the
* plan as the source of truth.
* - `--yolo` forces `implement` even with no plan, after an interactive
* confirmation prompt.
* - **Plan exists** (TTY): parse the structured summary block, render
* a confirmation panel, ask the user to proceed. Default-yes.
* - **Plan exists** (non-TTY): proceed without confirmation.
* - **No plan, `--continue-without-plan`**: confirm once, then implement.
* - **No plan, TTY**: present a `p.select` — draft a plan first
* (delegates to `planCommand`) or continue without one (confirms
* once, then implements).
* - **No plan, non-TTY**: error out with a clear next-action; CI must
* pass `--continue-without-plan` or run `stash plan` first.
*/
export async function implCommand(flags: Record<string, boolean>) {
const cwd = process.cwd()
Expand All @@ -133,33 +77,86 @@ export async function implCommand(flags: Record<string, boolean>) {

p.intro('CipherStash Implementation')

try {
const mode = await deriveMode(cwd, flags.yolo === true)
const planPath = resolve(cwd, PLAN_REL_PATH)
const planExists = existsSync(planPath)
const continueWithoutPlan = flags['continue-without-plan'] === true
const isTTY = process.stdout.isTTY

if (mode === 'plan') {
p.log.info(
`No plan at \`${PLAN_REL_PATH}\`. The agent will draft one for you to review.`,
)
try {
if (planExists) {
// Plan-summary checkpoint: the last save point before launching the
// (potentially hour-long) implementation phase.
if (isTTY) {
const summary = parsePlanSummary(readFileSync(planPath, 'utf-8'))
if (summary) {
p.note(renderPlanSummary(summary), 'Plan summary')
} else {
p.note(
`Plan at \`${PLAN_REL_PATH}\` doesn't include a machine-readable summary. Open it in your editor before proceeding.`,
'Plan ready',
)
}
const proceed = await p.confirm({
message: 'Proceed with implementation against this plan?',
initialValue: true,
})
if (p.isCancel(proceed) || !proceed) {
throw new CancelledError()
}
} else {
p.log.info(
`Plan at \`${PLAN_REL_PATH}\` — agent will execute it as the source of truth.`,
)
}
} else {
p.log.info(
`Plan at \`${PLAN_REL_PATH}\` — the agent will execute it as the source of truth.`,
)
// No plan on disk. Branch on flag / TTY / interactive.
if (continueWithoutPlan) {
await confirmContinueWithoutPlan()
} else if (!isTTY) {
p.log.error(
`No plan at \`${PLAN_REL_PATH}\`. Run \`${cli} plan\` first, or pass --continue-without-plan to skip planning.`,
)
process.exit(1)
} else {
const choice = await p.select<'plan' | 'continue'>({
message: 'No plan found. What would you like to do?',
options: [
{
value: 'plan',
label: 'Draft a plan first (recommended)',
hint: `runs \`${cli} plan\` — usually 1–3 min`,
},
{
value: 'continue',
label: 'Continue without a plan',
hint: 'skip the planning checkpoint',
},
],
initialValue: 'plan',
})
if (p.isCancel(choice)) throw new CancelledError()

if (choice === 'plan') {
// Lazy import avoids a circular module load between plan ↔ impl.
const { planCommand } = await import('../plan/index.js')
// Close the current intro frame before plan opens its own.
p.outro('Handing off to `stash plan`.')
await planCommand()
return
}

await confirmContinueWithoutPlan()
}
}

const agents = detectAgents(cwd, process.env)
const state = buildStateFromContext(ctx, mode, agents)
const state = buildStateFromContext(ctx, agents)

await howToProceedStep.run(state, STUB_PROVIDER)
await howToProceedStep.run(state)

if (mode === 'plan') {
p.outro(
`Plan drafted at \`${PLAN_REL_PATH}\`. Review it, then run \`${cli} impl\` again to implement.`,
)
} else {
p.outro(
`Implementation handoff complete. Run \`${cli} db status\` to verify state.`,
)
}
p.outro(
`Implementation handoff complete. Run \`${cli} db status\` to verify state.`,
)
} catch (err) {
if (err instanceof CancelledError) {
p.cancel('Cancelled.')
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/commands/impl/steps/handoff-agents-md.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ const AGENTS_MD_REL_PATH = 'AGENTS.md'
export const handoffAgentsMdStep: InitStep = {
id: 'handoff-agents-md',
name: 'Write AGENTS.md',
async run(state: InitState, _provider: InitProvider): Promise<InitState> {
async run(state: InitState, _provider?: InitProvider): Promise<InitState> {
const cwd = process.cwd()
const integration = state.integration ?? 'postgresql'

Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/commands/impl/steps/handoff-claude.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ const CLAUDE_INSTALL_URL = 'https://code.claude.com/docs/en/quickstart'
export const handoffClaudeStep: InitStep = {
id: 'handoff-claude',
name: 'Hand off to Claude Code',
async run(state: InitState, _provider: InitProvider): Promise<InitState> {
async run(state: InitState, _provider?: InitProvider): Promise<InitState> {
const cwd = process.cwd()
const integration = state.integration ?? 'postgresql'

Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/commands/impl/steps/handoff-codex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ const CODEX_INSTALL_URL = 'https://github.com/openai/codex'
export const handoffCodexStep: InitStep = {
id: 'handoff-codex',
name: 'Hand off to Codex',
async run(state: InitState, _provider: InitProvider): Promise<InitState> {
async run(state: InitState, _provider?: InitProvider): Promise<InitState> {
const cwd = process.cwd()
const integration = state.integration ?? 'postgresql'

Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/commands/impl/steps/handoff-wizard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import { runWizardSpawn } from '../../wizard/index.js'
export const handoffWizardStep: InitStep = {
id: 'handoff-wizard',
name: 'Use the CipherStash Agent',
async run(state: InitState, _provider: InitProvider): Promise<InitState> {
async run(state: InitState, _provider?: InitProvider): Promise<InitState> {
const cwd = process.cwd()
const envKeys = state.envKeys ?? []

Expand Down
Loading
Loading