Skip to content

Commit e16b282

Browse files
committed
feat: introduce stash impl
We split and added to gamify the encryption setup with a save-point between scaffolding and the agent handoff, plus an opt-in prompt at end of init to chain straight into plan mode.
1 parent d43f996 commit e16b282

16 files changed

Lines changed: 350 additions & 113 deletions

.changeset/init-plan-or-implement.md

Lines changed: 0 additions & 5 deletions
This file was deleted.

.changeset/stash-impl-command.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"stash": minor
3+
---
4+
5+
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.

packages/cli/src/bin/stash.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import * as p from '@clack/prompts'
1717
import {
1818
authCommand,
1919
envCommand,
20+
implCommand,
2021
initCommand,
2122
installCommand,
2223
statusCommand,
@@ -74,6 +75,7 @@ ${messages.cli.usagePrefix}${STASH} <command> [options]
7475
7576
Commands:
7677
init Initialize CipherStash for your project
78+
impl Draft an encryption plan (or implement, if a plan exists)
7779
auth <subcommand> Authenticate with CipherStash
7880
wizard AI-guided encryption setup (reads your codebase)
7981
@@ -104,6 +106,10 @@ Init Flags:
104106
--supabase Use Supabase-specific setup flow
105107
--drizzle Use Drizzle-specific setup flow
106108
109+
Impl Flags:
110+
--yolo Skip the planning checkpoint and go straight to implementation
111+
(interactively confirms before proceeding)
112+
107113
DB Flags:
108114
--force (install) Reinstall / overwrite even if already installed
109115
--dry-run (install, push, upgrade) Show what would happen without making changes
@@ -119,6 +125,8 @@ DB Flags:
119125
Examples:
120126
${STASH} init
121127
${STASH} init --supabase
128+
${STASH} impl
129+
${STASH} impl --yolo
122130
${STASH} auth login
123131
${STASH} wizard
124132
${STASH} db install
@@ -367,6 +375,9 @@ async function main() {
367375
case 'init':
368376
await initCommand(flags)
369377
break
378+
case 'impl':
379+
await implCommand(flags)
380+
break
370381
case 'auth': {
371382
const authArgs = subcommand ? [subcommand, ...commandArgs] : commandArgs
372383
await authCommand(authArgs, flags)
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
2+
import { tmpdir } from 'node:os'
3+
import { join } from 'node:path'
4+
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
5+
import { deriveMode, readContextFile } from '../index.js'
6+
7+
let cwd: string
8+
9+
beforeEach(() => {
10+
cwd = mkdtempSync(join(tmpdir(), 'stash-impl-'))
11+
})
12+
13+
afterEach(() => {
14+
rmSync(cwd, { recursive: true, force: true })
15+
})
16+
17+
function writePlan(): void {
18+
mkdirSync(join(cwd, '.cipherstash'), { recursive: true })
19+
writeFileSync(join(cwd, '.cipherstash', 'plan.md'), '# plan\n', 'utf-8')
20+
}
21+
22+
function writeContext(payload: Record<string, unknown>): void {
23+
mkdirSync(join(cwd, '.cipherstash'), { recursive: true })
24+
writeFileSync(
25+
join(cwd, '.cipherstash', 'context.json'),
26+
JSON.stringify(payload),
27+
'utf-8',
28+
)
29+
}
30+
31+
describe('deriveMode (no --yolo)', () => {
32+
it('returns plan when no plan file exists', async () => {
33+
expect(await deriveMode(cwd, false)).toBe('plan')
34+
})
35+
36+
it('returns implement when plan file exists', async () => {
37+
writePlan()
38+
expect(await deriveMode(cwd, false)).toBe('implement')
39+
})
40+
})
41+
42+
describe('deriveMode (--yolo)', () => {
43+
it('is a no-op when a plan already exists — no prompt, returns implement', async () => {
44+
// The interactive confirmation must NOT fire when a plan exists, since
45+
// the safety checkpoint (the plan itself) has already happened.
46+
writePlan()
47+
expect(await deriveMode(cwd, true)).toBe('implement')
48+
})
49+
50+
// The `--yolo + no plan` path is interactive (p.confirm). Covered by
51+
// manual smoke tests; mocking @clack/prompts isn't worth the churn here.
52+
})
53+
54+
describe('readContextFile', () => {
55+
it('returns undefined when context.json is missing', () => {
56+
expect(readContextFile(cwd)).toBeUndefined()
57+
})
58+
59+
it('returns the parsed context when present', () => {
60+
writeContext({
61+
cliVersion: '0.0.0',
62+
integration: 'drizzle',
63+
encryptionClientPath: './src/encryption/index.ts',
64+
packageManager: 'pnpm',
65+
installCommand: 'pnpm add @cipherstash/stack',
66+
envKeys: [],
67+
schemas: [],
68+
installedSkills: [],
69+
generatedAt: '2026-01-01T00:00:00.000Z',
70+
})
71+
const ctx = readContextFile(cwd)
72+
expect(ctx?.integration).toBe('drizzle')
73+
expect(ctx?.packageManager).toBe('pnpm')
74+
})
75+
76+
it('returns undefined on malformed JSON rather than throwing', () => {
77+
mkdirSync(join(cwd, '.cipherstash'), { recursive: true })
78+
writeFileSync(
79+
join(cwd, '.cipherstash', 'context.json'),
80+
'{ not json',
81+
'utf-8',
82+
)
83+
expect(readContextFile(cwd)).toBeUndefined()
84+
})
85+
})

packages/cli/src/commands/init/__tests__/how-to-proceed.test.ts renamed to packages/cli/src/commands/impl/__tests__/how-to-proceed.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { describe, expect, it } from 'vitest'
2-
import type { AgentEnvironment } from '../detect-agents.js'
2+
import type { AgentEnvironment } from '../../init/detect-agents.js'
3+
import type { InitState } from '../../init/types.js'
34
import { buildOptions, defaultChoice } from '../steps/how-to-proceed.js'
4-
import type { InitState } from '../types.js'
55

66
function makeAgents(claudeCode: boolean, codex: boolean): AgentEnvironment {
77
return {
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
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 { PLAN_REL_PATH } from '../init/lib/setup-prompt.js'
6+
import {
7+
CONTEXT_REL_PATH,
8+
type ContextFile,
9+
} from '../init/lib/write-context.js'
10+
import {
11+
CancelledError,
12+
type InitMode,
13+
type InitProvider,
14+
type InitState,
15+
} from '../init/types.js'
16+
import { detectPackageManager, runnerCommand } from '../init/utils.js'
17+
import { howToProceedStep } from './steps/how-to-proceed.js'
18+
19+
/**
20+
* The handoff steps in `impl/steps/handoff-*.ts` accept an `InitProvider`
21+
* but ignore it (their `run` signatures take `_provider`). The provider
22+
* abstraction belongs to the `init` flow, where it picks intro copy and
23+
* default next-steps. `stash impl` reads everything it needs from
24+
* `.cipherstash/context.json` instead, so a stub keeps the type signature
25+
* happy without pretending impl has provider-specific behaviour.
26+
*/
27+
const STUB_PROVIDER: InitProvider = {
28+
name: 'impl',
29+
introMessage: '',
30+
getNextSteps: () => [],
31+
}
32+
33+
export function readContextFile(cwd: string): ContextFile | undefined {
34+
const path = resolve(cwd, CONTEXT_REL_PATH)
35+
if (!existsSync(path)) return undefined
36+
try {
37+
return JSON.parse(readFileSync(path, 'utf-8')) as ContextFile
38+
} catch {
39+
return undefined
40+
}
41+
}
42+
43+
/**
44+
* Derive the impl mode from disk state and flags.
45+
*
46+
* no `--yolo`, plan missing → `plan` (default — the safer path)
47+
* no `--yolo`, plan exists → `implement` (the plan is the source of truth)
48+
* `--yolo`, plan missing → `implement` after interactive confirmation
49+
* `--yolo`, plan exists → `implement`; `--yolo` is a no-op once a plan
50+
* exists, since the safety checkpoint already
51+
* fired
52+
*
53+
* The interactive confirmation when `--yolo` is the only thing standing
54+
* between the user and ~45–60 min of agent-driven implementation. Cheap
55+
* to ask, expensive to skip by accident.
56+
*/
57+
export async function deriveMode(
58+
cwd: string,
59+
yolo: boolean,
60+
): Promise<InitMode> {
61+
const planExists = existsSync(resolve(cwd, PLAN_REL_PATH))
62+
63+
if (yolo) {
64+
if (planExists) {
65+
p.log.info(
66+
`Plan exists at \`${PLAN_REL_PATH}\` — \`--yolo\` is a no-op when a plan is already in place.`,
67+
)
68+
return 'implement'
69+
}
70+
const confirmed = await p.confirm({
71+
message:
72+
'Skip the planning checkpoint and go straight to implementation?',
73+
initialValue: false,
74+
})
75+
if (p.isCancel(confirmed) || !confirmed) {
76+
throw new CancelledError()
77+
}
78+
return 'implement'
79+
}
80+
81+
return planExists ? 'implement' : 'plan'
82+
}
83+
84+
function buildStateFromContext(
85+
ctx: ContextFile,
86+
mode: InitMode,
87+
agents: AgentEnvironment,
88+
): InitState {
89+
return {
90+
integration: ctx.integration,
91+
clientFilePath: ctx.encryptionClientPath,
92+
schemas: ctx.schemas,
93+
envKeys: ctx.envKeys,
94+
// After init has run, these are true. The pre-flight context.json
95+
// check above is the gate — if init didn't complete, context.json
96+
// wouldn't exist and we'd have already errored.
97+
stackInstalled: true,
98+
cliInstalled: true,
99+
eqlInstalled: true,
100+
agents,
101+
mode,
102+
}
103+
}
104+
105+
/**
106+
* `stash impl` — the agent handoff phase.
107+
*
108+
* Pre-flights `.cipherstash/context.json` (errors with a `stash init`
109+
* pointer if missing). Derives plan-vs-implement mode from disk state and
110+
* the `--yolo` flag, then dispatches to a handoff target via
111+
* `howToProceedStep`. Modes:
112+
*
113+
* - `plan` (default when no `.cipherstash/plan.md` exists): the agent
114+
* produces a reviewable plan file. The user reads it, then re-runs
115+
* `stash impl` to execute.
116+
* - `implement` (default when a plan exists): the agent executes the
117+
* plan as the source of truth.
118+
* - `--yolo` forces `implement` even with no plan, after an interactive
119+
* confirmation prompt.
120+
*/
121+
export async function implCommand(flags: Record<string, boolean>) {
122+
const cwd = process.cwd()
123+
const pm = detectPackageManager()
124+
const cli = runnerCommand(pm, 'stash')
125+
126+
const ctx = readContextFile(cwd)
127+
if (!ctx) {
128+
p.log.error(
129+
`No CipherStash context found at \`${CONTEXT_REL_PATH}\`. Run \`${cli} init\` first.`,
130+
)
131+
process.exit(1)
132+
}
133+
134+
p.intro('CipherStash Implementation')
135+
136+
try {
137+
const mode = await deriveMode(cwd, flags.yolo === true)
138+
139+
if (mode === 'plan') {
140+
p.log.info(
141+
`No plan at \`${PLAN_REL_PATH}\`. The agent will draft one for you to review.`,
142+
)
143+
} else {
144+
p.log.info(
145+
`Plan at \`${PLAN_REL_PATH}\` — the agent will execute it as the source of truth.`,
146+
)
147+
}
148+
149+
const agents = detectAgents(cwd, process.env)
150+
const state = buildStateFromContext(ctx, mode, agents)
151+
152+
await howToProceedStep.run(state, STUB_PROVIDER)
153+
154+
if (mode === 'plan') {
155+
p.outro(
156+
`Plan drafted at \`${PLAN_REL_PATH}\`. Review it, then run \`${cli} impl\` again to implement.`,
157+
)
158+
} else {
159+
p.outro(
160+
`Implementation handoff complete. Run \`${cli} db status\` to verify state.`,
161+
)
162+
}
163+
} catch (err) {
164+
if (err instanceof CancelledError) {
165+
p.cancel('Cancelled.')
166+
process.exit(0)
167+
}
168+
throw err
169+
}
170+
}

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

Lines changed: 5 additions & 5 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 { InitProvider, InitState, InitStep } from '../../init/types.js'
1212

1313
const AGENTS_MD_REL_PATH = 'AGENTS.md'
1414

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

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
import * as p from '@clack/prompts'
2-
import { spawnAgent, writeArtifacts } from '../lib/handoff-helpers.js'
3-
import { installSkills } from '../lib/install-skills.js'
2+
import { spawnAgent, writeArtifacts } from '../../init/lib/handoff-helpers.js'
3+
import { installSkills } from '../../init/lib/install-skills.js'
44
import {
55
CONTEXT_REL_PATH,
66
SETUP_PROMPT_REL_PATH,
7-
} from '../lib/write-context.js'
8-
import type { InitProvider, InitState, InitStep } from '../types.js'
7+
} from '../../init/lib/write-context.js'
8+
import type { InitProvider, InitState, InitStep } from '../../init/types.js'
99

1010
const CLAUDE_SKILLS_DIR = '.claude/skills'
1111

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

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,15 @@
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 { spawnAgent, writeArtifacts } from '../lib/handoff-helpers.js'
6-
import { installSkills } from '../lib/install-skills.js'
7-
import { upsertManagedBlock } from '../lib/sentinel-upsert.js'
4+
import { buildAgentsMdBody } from '../../init/lib/build-agents-md.js'
5+
import { spawnAgent, writeArtifacts } from '../../init/lib/handoff-helpers.js'
6+
import { installSkills } from '../../init/lib/install-skills.js'
7+
import { upsertManagedBlock } from '../../init/lib/sentinel-upsert.js'
88
import {
99
CONTEXT_REL_PATH,
1010
SETUP_PROMPT_REL_PATH,
11-
} from '../lib/write-context.js'
12-
import type { InitProvider, InitState, InitStep } from '../types.js'
11+
} from '../../init/lib/write-context.js'
12+
import type { InitProvider, InitState, InitStep } from '../../init/types.js'
1313

1414
const AGENTS_MD_REL_PATH = 'AGENTS.md'
1515
const CODEX_SKILLS_DIR = '.codex/skills'

0 commit comments

Comments
 (0)