Skip to content

Commit 4c9ebca

Browse files
committed
Merge branch 'main' of https://github.com/cipherstash/stack into feat/allow-claude-yolo
2 parents 440879b + 1175e86 commit 4c9ebca

24 files changed

Lines changed: 1596 additions & 91 deletions

packages/cli/CHANGELOG.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,21 @@
11
# @cipherstash/cli
22

3+
## 0.13.0
4+
5+
### Minor Changes
6+
7+
- e16b282: 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.
8+
- db163e1: `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.
9+
- 59b138b: Extract planning into its own `stash plan` command. Three commands now own the setup lifecycle:
10+
11+
- `stash init` — scaffold (auth, db, deps, EQL). Ends with a chain prompt to `stash plan`.
12+
- `stash plan` — draft a reviewable plan at `.cipherstash/plan.md`. Ends with a chain prompt to `stash impl`.
13+
- `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.
14+
15+
`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.
16+
17+
- db163e1: 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.
18+
319
## 0.12.1
420

521
### Patch Changes

packages/cli/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "stash",
3-
"version": "0.12.1",
3+
"version": "0.13.0",
44
"description": "CipherStash CLI — the one stash command for auth, init, encryption schema, database setup, and secrets.",
55
"license": "MIT",
66
"author": "CipherStash <hello@cipherstash.com>",

packages/cli/src/bin/stash.ts

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,12 @@ import * as p from '@clack/prompts'
1616
// Commands that depend on @cipherstash/stack are lazy-loaded in the switch below.
1717
import {
1818
authCommand,
19+
dbStatusCommand,
1920
envCommand,
21+
implCommand,
2022
initCommand,
2123
installCommand,
24+
planCommand,
2225
statusCommand,
2326
testConnectionCommand,
2427
upgradeCommand,
@@ -74,6 +77,9 @@ ${messages.cli.usagePrefix}${STASH} <command> [options]
7477
7578
Commands:
7679
init Initialize CipherStash for your project
80+
plan Draft a reviewable encryption plan at .cipherstash/plan.md
81+
impl Execute the plan with a local agent
82+
status Displays implementation status
7783
auth <subcommand> Authenticate with CipherStash
7884
wizard AI-guided encryption setup (reads your codebase)
7985
@@ -104,6 +110,10 @@ Init Flags:
104110
--supabase Use Supabase-specific setup flow
105111
--drizzle Use Drizzle-specific setup flow
106112
113+
Impl Flags:
114+
--continue-without-plan Skip planning and go straight to implementation
115+
(interactively confirms before proceeding)
116+
107117
DB Flags:
108118
--force (install) Reinstall / overwrite even if already installed
109119
--dry-run (install, push, upgrade) Show what would happen without making changes
@@ -119,6 +129,10 @@ DB Flags:
119129
Examples:
120130
${STASH} init
121131
${STASH} init --supabase
132+
${STASH} plan
133+
${STASH} impl
134+
${STASH} impl --continue-without-plan
135+
${STASH} status
122136
${STASH} auth login
123137
${STASH} wizard
124138
${STASH} db install
@@ -224,7 +238,7 @@ async function runDbCommand(
224238
break
225239
}
226240
case 'status':
227-
await statusCommand({ databaseUrl })
241+
await dbStatusCommand({ databaseUrl })
228242
break
229243
case 'test-connection':
230244
await testConnectionCommand({ databaseUrl })
@@ -367,6 +381,15 @@ async function main() {
367381
case 'init':
368382
await initCommand(flags)
369383
break
384+
case 'plan':
385+
await planCommand()
386+
break
387+
case 'impl':
388+
await implCommand(flags)
389+
break
390+
case 'status':
391+
await statusCommand()
392+
break
370393
case 'auth': {
371394
const authArgs = subcommand ? [subcommand, ...commandArgs] : commandArgs
372395
await authCommand(authArgs, flags)
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { describe, expect, it } from 'vitest'
2+
import type { AgentEnvironment } from '../../init/detect-agents.js'
3+
import type { InitState } from '../../init/types.js'
4+
import { buildOptions, defaultChoice } from '../steps/how-to-proceed.js'
5+
6+
function makeAgents(claudeCode: boolean, codex: boolean): AgentEnvironment {
7+
return {
8+
cli: { claudeCode, codex },
9+
project: {
10+
claudeDir: false,
11+
claudeMd: false,
12+
claudeSkillsDir: false,
13+
agentsMd: false,
14+
},
15+
editor: 'unknown',
16+
}
17+
}
18+
19+
const noAgents: InitState = { agents: makeAgents(false, false) }
20+
const claudeOnly: InitState = { agents: makeAgents(true, false) }
21+
const codexOnly: InitState = { agents: makeAgents(false, true) }
22+
23+
describe('howToProceed — buildOptions', () => {
24+
it('offers all four targets in implement mode', () => {
25+
const opts = buildOptions(noAgents, 'implement')
26+
const values = opts.map((o) => o.value)
27+
expect(values).toEqual(['claude-code', 'codex', 'wizard', 'agents-md'])
28+
})
29+
30+
it('offers only claude-code and codex in plan mode', () => {
31+
const opts = buildOptions(noAgents, 'plan')
32+
const values = opts.map((o) => o.value)
33+
expect(values).toEqual(['claude-code', 'codex'])
34+
})
35+
36+
it('reflects detection state in hints regardless of mode', () => {
37+
const implement = buildOptions(claudeOnly, 'implement')
38+
const plan = buildOptions(claudeOnly, 'plan')
39+
40+
const implementClaude = implement.find((o) => o.value === 'claude-code')
41+
const planClaude = plan.find((o) => o.value === 'claude-code')
42+
43+
expect(implementClaude?.hint).toMatch(/detected/)
44+
expect(planClaude?.hint).toMatch(/detected/)
45+
})
46+
})
47+
48+
describe('howToProceed — defaultChoice', () => {
49+
it('prefers claude-code when detected', () => {
50+
expect(defaultChoice(claudeOnly, 'implement')).toBe('claude-code')
51+
expect(defaultChoice(claudeOnly, 'plan')).toBe('claude-code')
52+
})
53+
54+
it('prefers codex when claude is absent and codex is detected', () => {
55+
expect(defaultChoice(codexOnly, 'implement')).toBe('codex')
56+
expect(defaultChoice(codexOnly, 'plan')).toBe('codex')
57+
})
58+
59+
it('falls back to agents-md in implement mode when no CLI is detected', () => {
60+
expect(defaultChoice(noAgents, 'implement')).toBe('agents-md')
61+
})
62+
63+
it('falls back to claude-code in plan mode when no CLI is detected', () => {
64+
// Plan mode never offers agents-md; claude-code is the listed default
65+
// so the picker has a valid initialValue rather than falling through
66+
// to a hidden option.
67+
expect(defaultChoice(noAgents, 'plan')).toBe('claude-code')
68+
})
69+
})
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
import { existsSync, readFileSync } from 'node:fs'
2+
import { resolve } from 'node:path'
3+
import * as p from '@clack/prompts'
4+
import { type AgentEnvironment, detectAgents } from '../init/detect-agents.js'
5+
import { parsePlanSummary, renderPlanSummary } from '../init/lib/parse-plan.js'
6+
import { readContextFile } from '../init/lib/read-context.js'
7+
import { PLAN_REL_PATH } from '../init/lib/setup-prompt.js'
8+
import {
9+
CONTEXT_REL_PATH,
10+
type ContextFile,
11+
} from '../init/lib/write-context.js'
12+
import { CancelledError, type InitState } from '../init/types.js'
13+
import { detectPackageManager, runnerCommand } from '../init/utils.js'
14+
import { howToProceedStep } from './steps/how-to-proceed.js'
15+
16+
function buildStateFromContext(
17+
ctx: ContextFile,
18+
agents: AgentEnvironment,
19+
): InitState {
20+
return {
21+
integration: ctx.integration,
22+
clientFilePath: ctx.encryptionClientPath,
23+
schemas: ctx.schemas,
24+
envKeys: ctx.envKeys,
25+
stackInstalled: true,
26+
cliInstalled: true,
27+
eqlInstalled: true,
28+
agents,
29+
mode: 'implement',
30+
}
31+
}
32+
33+
/**
34+
* Confirm before launching implementation when the user has chosen to
35+
* skip the planning checkpoint. Default-no is the security stance —
36+
* passing through this prompt by accident is the failure mode we're
37+
* guarding against.
38+
*/
39+
async function confirmContinueWithoutPlan(): Promise<void> {
40+
const confirmed = await p.confirm({
41+
message: 'Implementation can take some time. Continue?',
42+
initialValue: false,
43+
})
44+
if (p.isCancel(confirmed) || !confirmed) {
45+
throw new CancelledError()
46+
}
47+
}
48+
49+
/**
50+
* `stash impl` — execute an encryption plan.
51+
*
52+
* Always runs in implement mode. Behaviour branches on disk state and
53+
* flags:
54+
*
55+
* - **Plan exists** (TTY): parse the structured summary block, render
56+
* a confirmation panel, ask the user to proceed. Default-yes.
57+
* - **Plan exists** (non-TTY): proceed without confirmation.
58+
* - **No plan, `--continue-without-plan`**: confirm once, then implement.
59+
* - **No plan, TTY**: present a `p.select` — draft a plan first
60+
* (delegates to `planCommand`) or continue without one (confirms
61+
* once, then implements).
62+
* - **No plan, non-TTY**: error out with a clear next-action; CI must
63+
* pass `--continue-without-plan` or run `stash plan` first.
64+
*/
65+
export async function implCommand(flags: Record<string, boolean>) {
66+
const cwd = process.cwd()
67+
const pm = detectPackageManager()
68+
const cli = runnerCommand(pm, 'stash')
69+
70+
const ctx = readContextFile(cwd)
71+
if (!ctx) {
72+
p.log.error(
73+
`No CipherStash context found at \`${CONTEXT_REL_PATH}\`. Run \`${cli} init\` first.`,
74+
)
75+
process.exit(1)
76+
}
77+
78+
p.intro('CipherStash Implementation')
79+
80+
const planPath = resolve(cwd, PLAN_REL_PATH)
81+
const planExists = existsSync(planPath)
82+
const continueWithoutPlan = flags['continue-without-plan'] === true
83+
const isTTY = process.stdout.isTTY
84+
85+
try {
86+
if (planExists) {
87+
// Plan-summary checkpoint: the last save point before launching the
88+
// (potentially hour-long) implementation phase.
89+
if (isTTY) {
90+
const summary = parsePlanSummary(readFileSync(planPath, 'utf-8'))
91+
if (summary) {
92+
p.note(renderPlanSummary(summary), 'Plan summary')
93+
} else {
94+
p.note(
95+
`Plan at \`${PLAN_REL_PATH}\` doesn't include a machine-readable summary. Open it in your editor before proceeding.`,
96+
'Plan ready',
97+
)
98+
}
99+
const proceed = await p.confirm({
100+
message: 'Proceed with implementation against this plan?',
101+
initialValue: true,
102+
})
103+
if (p.isCancel(proceed) || !proceed) {
104+
throw new CancelledError()
105+
}
106+
} else {
107+
p.log.info(
108+
`Plan at \`${PLAN_REL_PATH}\` — agent will execute it as the source of truth.`,
109+
)
110+
}
111+
} else {
112+
// No plan on disk. Branch on flag / TTY / interactive.
113+
if (continueWithoutPlan) {
114+
await confirmContinueWithoutPlan()
115+
} else if (!isTTY) {
116+
p.log.error(
117+
`No plan at \`${PLAN_REL_PATH}\`. Run \`${cli} plan\` first, or pass --continue-without-plan to skip planning.`,
118+
)
119+
process.exit(1)
120+
} else {
121+
const choice = await p.select<'plan' | 'continue'>({
122+
message: 'No plan found. What would you like to do?',
123+
options: [
124+
{
125+
value: 'plan',
126+
label: 'Draft a plan first (recommended)',
127+
hint: `runs \`${cli} plan\` — usually 1–3 min`,
128+
},
129+
{
130+
value: 'continue',
131+
label: 'Continue without a plan',
132+
hint: 'skip the planning checkpoint',
133+
},
134+
],
135+
initialValue: 'plan',
136+
})
137+
if (p.isCancel(choice)) throw new CancelledError()
138+
139+
if (choice === 'plan') {
140+
// Lazy import avoids a circular module load between plan ↔ impl.
141+
const { planCommand } = await import('../plan/index.js')
142+
// Close the current intro frame before plan opens its own.
143+
p.outro('Handing off to `stash plan`.')
144+
await planCommand()
145+
return
146+
}
147+
148+
await confirmContinueWithoutPlan()
149+
}
150+
}
151+
152+
const agents = detectAgents(cwd, process.env)
153+
const state = buildStateFromContext(ctx, agents)
154+
155+
await howToProceedStep.run(state)
156+
157+
p.outro(
158+
`Implementation handoff complete. Run \`${cli} db status\` to verify state.`,
159+
)
160+
} catch (err) {
161+
if (err instanceof CancelledError) {
162+
p.cancel('Cancelled.')
163+
process.exit(0)
164+
}
165+
throw err
166+
}
167+
}

packages/cli/src/commands/init/steps/handoff-agents-md.ts renamed to packages/cli/src/commands/impl/steps/handoff-agents-md.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
11
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
22
import { resolve } from 'node:path'
33
import * as p from '@clack/prompts'
4-
import { buildAgentsMdBody } from '../lib/build-agents-md.js'
5-
import { writeArtifacts } from '../lib/handoff-helpers.js'
6-
import { upsertManagedBlock } from '../lib/sentinel-upsert.js'
4+
import { buildAgentsMdBody } from '../../init/lib/build-agents-md.js'
5+
import { writeArtifacts } from '../../init/lib/handoff-helpers.js'
6+
import { upsertManagedBlock } from '../../init/lib/sentinel-upsert.js'
77
import {
88
CONTEXT_REL_PATH,
99
SETUP_PROMPT_REL_PATH,
10-
} from '../lib/write-context.js'
11-
import type { InitProvider, InitState, InitStep } from '../types.js'
10+
} from '../../init/lib/write-context.js'
11+
import type { HandoffStep, InitState } from '../../init/types.js'
1212

1313
const AGENTS_MD_REL_PATH = 'AGENTS.md'
1414

@@ -25,10 +25,10 @@ const AGENTS_MD_REL_PATH = 'AGENTS.md'
2525
* tools wouldn't know to look there. Re-runs replace only the sentinel
2626
* region in AGENTS.md.
2727
*/
28-
export const handoffAgentsMdStep: InitStep = {
28+
export const handoffAgentsMdStep: HandoffStep = {
2929
id: 'handoff-agents-md',
3030
name: 'Write AGENTS.md',
31-
async run(state: InitState, _provider: InitProvider): Promise<InitState> {
31+
async run(state: InitState): Promise<InitState> {
3232
const cwd = process.cwd()
3333
const integration = state.integration ?? 'postgresql'
3434

0 commit comments

Comments
 (0)