From e16b282b8f77ad8f860acd1cbebeb0e8cae4c147 Mon Sep 17 00:00:00 2001 From: CJ Brewer Date: Tue, 5 May 2026 12:31:51 -0600 Subject: [PATCH] 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. --- .changeset/init-plan-or-implement.md | 5 - .changeset/stash-impl-command.md | 5 + packages/cli/src/bin/stash.ts | 11 ++ .../impl/__tests__/derive-mode.test.ts | 85 +++++++++ .../__tests__/how-to-proceed.test.ts | 4 +- packages/cli/src/commands/impl/index.ts | 170 ++++++++++++++++++ .../{init => impl}/steps/handoff-agents-md.ts | 10 +- .../{init => impl}/steps/handoff-claude.ts | 8 +- .../{init => impl}/steps/handoff-codex.ts | 12 +- .../{init => impl}/steps/handoff-wizard.ts | 10 +- .../{init => impl}/steps/how-to-proceed.ts | 2 +- packages/cli/src/commands/index.ts | 1 + packages/cli/src/commands/init/index.ts | 60 ++++++- .../src/commands/init/steps/choose-mode.ts | 45 ----- .../cli/src/commands/init/steps/next-steps.ts | 30 ---- packages/cli/src/commands/init/types.ts | 5 +- 16 files changed, 350 insertions(+), 113 deletions(-) delete mode 100644 .changeset/init-plan-or-implement.md create mode 100644 .changeset/stash-impl-command.md create mode 100644 packages/cli/src/commands/impl/__tests__/derive-mode.test.ts rename packages/cli/src/commands/{init => impl}/__tests__/how-to-proceed.test.ts (95%) create mode 100644 packages/cli/src/commands/impl/index.ts rename packages/cli/src/commands/{init => impl}/steps/handoff-agents-md.ts (85%) rename packages/cli/src/commands/{init => impl}/steps/handoff-claude.ts (89%) rename packages/cli/src/commands/{init => impl}/steps/handoff-codex.ts (87%) rename packages/cli/src/commands/{init => impl}/steps/handoff-wizard.ts (86%) rename packages/cli/src/commands/{init => impl}/steps/how-to-proceed.ts (99%) delete mode 100644 packages/cli/src/commands/init/steps/choose-mode.ts delete mode 100644 packages/cli/src/commands/init/steps/next-steps.ts diff --git a/.changeset/init-plan-or-implement.md b/.changeset/init-plan-or-implement.md deleted file mode 100644 index 38d7ca52..00000000 --- a/.changeset/init-plan-or-implement.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"stash": minor ---- - -Add a plan-or-implement choice to `stash init`. After the install/detection steps, the user picks whether the agent handoff should produce a reviewable plan at `.cipherstash/plan.md` first (the recommended default) or go straight to implementation. Plan mode currently routes only to Claude Code or Codex; implement mode preserves the existing four-target picker. The implementation prompt now reads an existing plan as the source of truth for routing rather than re-asking which path applies. diff --git a/.changeset/stash-impl-command.md b/.changeset/stash-impl-command.md new file mode 100644 index 00000000..43bc73be --- /dev/null +++ b/.changeset/stash-impl-command.md @@ -0,0 +1,5 @@ +--- +"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. diff --git a/packages/cli/src/bin/stash.ts b/packages/cli/src/bin/stash.ts index 2a46ade6..1c6b43d3 100644 --- a/packages/cli/src/bin/stash.ts +++ b/packages/cli/src/bin/stash.ts @@ -17,6 +17,7 @@ import * as p from '@clack/prompts' import { authCommand, envCommand, + implCommand, initCommand, installCommand, statusCommand, @@ -74,6 +75,7 @@ ${messages.cli.usagePrefix}${STASH} [options] Commands: init Initialize CipherStash for your project + impl Draft an encryption plan (or implement, if a plan exists) auth Authenticate with CipherStash wizard AI-guided encryption setup (reads your codebase) @@ -104,6 +106,10 @@ Init Flags: --supabase Use Supabase-specific setup flow --drizzle Use Drizzle-specific setup flow +Impl Flags: + --yolo Skip the planning checkpoint and go straight to implementation + (interactively confirms before proceeding) + DB Flags: --force (install) Reinstall / overwrite even if already installed --dry-run (install, push, upgrade) Show what would happen without making changes @@ -119,6 +125,8 @@ DB Flags: Examples: ${STASH} init ${STASH} init --supabase + ${STASH} impl + ${STASH} impl --yolo ${STASH} auth login ${STASH} wizard ${STASH} db install @@ -367,6 +375,9 @@ async function main() { case 'init': await initCommand(flags) break + case 'impl': + await implCommand(flags) + break case 'auth': { const authArgs = subcommand ? [subcommand, ...commandArgs] : commandArgs await authCommand(authArgs, flags) diff --git a/packages/cli/src/commands/impl/__tests__/derive-mode.test.ts b/packages/cli/src/commands/impl/__tests__/derive-mode.test.ts new file mode 100644 index 00000000..0dc2614e --- /dev/null +++ b/packages/cli/src/commands/impl/__tests__/derive-mode.test.ts @@ -0,0 +1,85 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { deriveMode, readContextFile } from '../index.js' + +let cwd: string + +beforeEach(() => { + cwd = mkdtempSync(join(tmpdir(), 'stash-impl-')) +}) + +afterEach(() => { + rmSync(cwd, { recursive: true, force: true }) +}) + +function writePlan(): void { + mkdirSync(join(cwd, '.cipherstash'), { recursive: true }) + writeFileSync(join(cwd, '.cipherstash', 'plan.md'), '# plan\n', 'utf-8') +} + +function writeContext(payload: Record): void { + mkdirSync(join(cwd, '.cipherstash'), { recursive: true }) + writeFileSync( + join(cwd, '.cipherstash', 'context.json'), + JSON.stringify(payload), + 'utf-8', + ) +} + +describe('deriveMode (no --yolo)', () => { + it('returns plan when no plan file exists', async () => { + expect(await deriveMode(cwd, false)).toBe('plan') + }) + + it('returns implement when plan file exists', async () => { + writePlan() + expect(await deriveMode(cwd, false)).toBe('implement') + }) +}) + +describe('deriveMode (--yolo)', () => { + it('is a no-op when a plan already exists — no prompt, returns implement', async () => { + // The interactive confirmation must NOT fire when a plan exists, since + // the safety checkpoint (the plan itself) has already happened. + writePlan() + expect(await deriveMode(cwd, true)).toBe('implement') + }) + + // The `--yolo + no plan` path is interactive (p.confirm). Covered by + // manual smoke tests; mocking @clack/prompts isn't worth the churn here. +}) + +describe('readContextFile', () => { + it('returns undefined when context.json is missing', () => { + expect(readContextFile(cwd)).toBeUndefined() + }) + + it('returns the parsed context when present', () => { + writeContext({ + cliVersion: '0.0.0', + integration: 'drizzle', + encryptionClientPath: './src/encryption/index.ts', + packageManager: 'pnpm', + installCommand: 'pnpm add @cipherstash/stack', + envKeys: [], + schemas: [], + installedSkills: [], + generatedAt: '2026-01-01T00:00:00.000Z', + }) + const ctx = readContextFile(cwd) + expect(ctx?.integration).toBe('drizzle') + expect(ctx?.packageManager).toBe('pnpm') + }) + + it('returns undefined on malformed JSON rather than throwing', () => { + mkdirSync(join(cwd, '.cipherstash'), { recursive: true }) + writeFileSync( + join(cwd, '.cipherstash', 'context.json'), + '{ not json', + 'utf-8', + ) + expect(readContextFile(cwd)).toBeUndefined() + }) +}) diff --git a/packages/cli/src/commands/init/__tests__/how-to-proceed.test.ts b/packages/cli/src/commands/impl/__tests__/how-to-proceed.test.ts similarity index 95% rename from packages/cli/src/commands/init/__tests__/how-to-proceed.test.ts rename to packages/cli/src/commands/impl/__tests__/how-to-proceed.test.ts index 4ebfa602..df9c9082 100644 --- a/packages/cli/src/commands/init/__tests__/how-to-proceed.test.ts +++ b/packages/cli/src/commands/impl/__tests__/how-to-proceed.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' -import type { AgentEnvironment } from '../detect-agents.js' +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 type { InitState } from '../types.js' function makeAgents(claudeCode: boolean, codex: boolean): AgentEnvironment { return { diff --git a/packages/cli/src/commands/impl/index.ts b/packages/cli/src/commands/impl/index.ts new file mode 100644 index 00000000..e1f26483 --- /dev/null +++ b/packages/cli/src/commands/impl/index.ts @@ -0,0 +1,170 @@ +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 { 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 { 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 { + 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, + } +} + +/** + * `stash impl` — the agent handoff phase. + * + * 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: + * + * - `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. + */ +export async function implCommand(flags: Record) { + const cwd = process.cwd() + const pm = detectPackageManager() + const cli = runnerCommand(pm, 'stash') + + const ctx = readContextFile(cwd) + if (!ctx) { + p.log.error( + `No CipherStash context found at \`${CONTEXT_REL_PATH}\`. Run \`${cli} init\` first.`, + ) + process.exit(1) + } + + p.intro('CipherStash Implementation') + + try { + const mode = await deriveMode(cwd, flags.yolo === true) + + if (mode === 'plan') { + p.log.info( + `No plan at \`${PLAN_REL_PATH}\`. The agent will draft one for you to review.`, + ) + } else { + p.log.info( + `Plan at \`${PLAN_REL_PATH}\` — the agent will execute it as the source of truth.`, + ) + } + + const agents = detectAgents(cwd, process.env) + const state = buildStateFromContext(ctx, mode, agents) + + await howToProceedStep.run(state, STUB_PROVIDER) + + 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.`, + ) + } + } catch (err) { + if (err instanceof CancelledError) { + p.cancel('Cancelled.') + process.exit(0) + } + throw err + } +} diff --git a/packages/cli/src/commands/init/steps/handoff-agents-md.ts b/packages/cli/src/commands/impl/steps/handoff-agents-md.ts similarity index 85% rename from packages/cli/src/commands/init/steps/handoff-agents-md.ts rename to packages/cli/src/commands/impl/steps/handoff-agents-md.ts index 281112c3..24091652 100644 --- a/packages/cli/src/commands/init/steps/handoff-agents-md.ts +++ b/packages/cli/src/commands/impl/steps/handoff-agents-md.ts @@ -1,14 +1,14 @@ import { existsSync, readFileSync, writeFileSync } from 'node:fs' import { resolve } from 'node:path' import * as p from '@clack/prompts' -import { buildAgentsMdBody } from '../lib/build-agents-md.js' -import { writeArtifacts } from '../lib/handoff-helpers.js' -import { upsertManagedBlock } from '../lib/sentinel-upsert.js' +import { buildAgentsMdBody } from '../../init/lib/build-agents-md.js' +import { writeArtifacts } from '../../init/lib/handoff-helpers.js' +import { upsertManagedBlock } from '../../init/lib/sentinel-upsert.js' import { CONTEXT_REL_PATH, SETUP_PROMPT_REL_PATH, -} from '../lib/write-context.js' -import type { InitProvider, InitState, InitStep } from '../types.js' +} from '../../init/lib/write-context.js' +import type { InitProvider, InitState, InitStep } from '../../init/types.js' const AGENTS_MD_REL_PATH = 'AGENTS.md' diff --git a/packages/cli/src/commands/init/steps/handoff-claude.ts b/packages/cli/src/commands/impl/steps/handoff-claude.ts similarity index 89% rename from packages/cli/src/commands/init/steps/handoff-claude.ts rename to packages/cli/src/commands/impl/steps/handoff-claude.ts index cdb5c47b..01ad6f1a 100644 --- a/packages/cli/src/commands/init/steps/handoff-claude.ts +++ b/packages/cli/src/commands/impl/steps/handoff-claude.ts @@ -1,11 +1,11 @@ import * as p from '@clack/prompts' -import { spawnAgent, writeArtifacts } from '../lib/handoff-helpers.js' -import { installSkills } from '../lib/install-skills.js' +import { spawnAgent, writeArtifacts } from '../../init/lib/handoff-helpers.js' +import { installSkills } from '../../init/lib/install-skills.js' import { CONTEXT_REL_PATH, SETUP_PROMPT_REL_PATH, -} from '../lib/write-context.js' -import type { InitProvider, InitState, InitStep } from '../types.js' +} from '../../init/lib/write-context.js' +import type { InitProvider, InitState, InitStep } from '../../init/types.js' const CLAUDE_SKILLS_DIR = '.claude/skills' diff --git a/packages/cli/src/commands/init/steps/handoff-codex.ts b/packages/cli/src/commands/impl/steps/handoff-codex.ts similarity index 87% rename from packages/cli/src/commands/init/steps/handoff-codex.ts rename to packages/cli/src/commands/impl/steps/handoff-codex.ts index 0dad13e1..3ccff834 100644 --- a/packages/cli/src/commands/init/steps/handoff-codex.ts +++ b/packages/cli/src/commands/impl/steps/handoff-codex.ts @@ -1,15 +1,15 @@ import { existsSync, readFileSync, writeFileSync } from 'node:fs' import { resolve } from 'node:path' import * as p from '@clack/prompts' -import { buildAgentsMdBody } from '../lib/build-agents-md.js' -import { spawnAgent, writeArtifacts } from '../lib/handoff-helpers.js' -import { installSkills } from '../lib/install-skills.js' -import { upsertManagedBlock } from '../lib/sentinel-upsert.js' +import { buildAgentsMdBody } from '../../init/lib/build-agents-md.js' +import { spawnAgent, writeArtifacts } from '../../init/lib/handoff-helpers.js' +import { installSkills } from '../../init/lib/install-skills.js' +import { upsertManagedBlock } from '../../init/lib/sentinel-upsert.js' import { CONTEXT_REL_PATH, SETUP_PROMPT_REL_PATH, -} from '../lib/write-context.js' -import type { InitProvider, InitState, InitStep } from '../types.js' +} from '../../init/lib/write-context.js' +import type { InitProvider, InitState, InitStep } from '../../init/types.js' const AGENTS_MD_REL_PATH = 'AGENTS.md' const CODEX_SKILLS_DIR = '.codex/skills' diff --git a/packages/cli/src/commands/init/steps/handoff-wizard.ts b/packages/cli/src/commands/impl/steps/handoff-wizard.ts similarity index 86% rename from packages/cli/src/commands/init/steps/handoff-wizard.ts rename to packages/cli/src/commands/impl/steps/handoff-wizard.ts index 160b24c5..2bfefa18 100644 --- a/packages/cli/src/commands/init/steps/handoff-wizard.ts +++ b/packages/cli/src/commands/impl/steps/handoff-wizard.ts @@ -1,12 +1,12 @@ import { resolve } from 'node:path' import * as p from '@clack/prompts' -import { runWizardSpawn } from '../../wizard/index.js' import { CONTEXT_REL_PATH, buildContextFile, writeContextFile, -} from '../lib/write-context.js' -import type { InitProvider, InitState, InitStep } from '../types.js' +} from '../../init/lib/write-context.js' +import type { InitProvider, InitState, InitStep } from '../../init/types.js' +import { runWizardSpawn } from '../../wizard/index.js' /** * Hand off to the CipherStash Agent (the in-house wizard package). @@ -14,8 +14,8 @@ import type { InitProvider, InitState, InitStep } from '../types.js' * Writes `.cipherstash/context.json` so the wizard has the same prepared * facts the other handoffs use, then spawns the wizard via `runWizardSpawn` * — the same path the top-level `stash wizard` subcommand takes, but with - * the exit code surfaced rather than `process.exit`-ed so init can finish - * its own outro and `next-steps` step. + * the exit code surfaced rather than `process.exit`-ed so `stash impl` can + * finish its own outro. * * No skills are installed here. The wizard fetches its own agent-side * prompt from the gateway and runs its own `maybeInstallSkills` flow. diff --git a/packages/cli/src/commands/init/steps/how-to-proceed.ts b/packages/cli/src/commands/impl/steps/how-to-proceed.ts similarity index 99% rename from packages/cli/src/commands/init/steps/how-to-proceed.ts rename to packages/cli/src/commands/impl/steps/how-to-proceed.ts index b184dbce..8f80f6f8 100644 --- a/packages/cli/src/commands/init/steps/how-to-proceed.ts +++ b/packages/cli/src/commands/impl/steps/how-to-proceed.ts @@ -6,7 +6,7 @@ import { type InitProvider, type InitState, type InitStep, -} from '../types.js' +} from '../../init/types.js' import { handoffAgentsMdStep } from './handoff-agents-md.js' import { handoffClaudeStep } from './handoff-claude.js' import { handoffCodexStep } from './handoff-codex.js' diff --git a/packages/cli/src/commands/index.ts b/packages/cli/src/commands/index.ts index 17154eec..0c06af10 100644 --- a/packages/cli/src/commands/index.ts +++ b/packages/cli/src/commands/index.ts @@ -3,6 +3,7 @@ export { statusCommand } from './db/status.js' export { testConnectionCommand } from './db/test-connection.js' export { upgradeCommand } from './db/upgrade.js' export { authCommand } from './auth/index.js' +export { implCommand } from './impl/index.js' export { initCommand } from './init/index.js' export { envCommand } from './env/index.js' export { wizardCommand } from './wizard/index.js' diff --git a/packages/cli/src/commands/init/index.ts b/packages/cli/src/commands/init/index.ts index 6624eae9..25ef5d4d 100644 --- a/packages/cli/src/commands/init/index.ts +++ b/packages/cli/src/commands/init/index.ts @@ -1,24 +1,34 @@ import * as p from '@clack/prompts' +import { implCommand } from '../impl/index.js' import { createBaseProvider } from './providers/base.js' import { createDrizzleProvider } from './providers/drizzle.js' import { createSupabaseProvider } from './providers/supabase.js' import { authenticateStep } from './steps/authenticate.js' import { buildSchemaStep } from './steps/build-schema.js' -import { chooseModeStep } from './steps/choose-mode.js' import { gatherContextStep } from './steps/gather-context.js' -import { howToProceedStep } from './steps/how-to-proceed.js' import { installDepsStep } from './steps/install-deps.js' import { installEqlStep } from './steps/install-eql.js' -import { nextStepsStep } from './steps/next-steps.js' import { resolveDatabaseStep } from './steps/resolve-database.js' import type { InitProvider, InitState } from './types.js' import { CancelledError } from './types.js' +import { detectPackageManager, runnerCommand } from './utils.js' const PROVIDER_MAP: Record InitProvider> = { supabase: createSupabaseProvider, drizzle: createDrizzleProvider, } +/** + * `stash init` does scaffold-once work only: auth, database connection, + * schema introspection, dep install, EQL install, context gathering. It + * exits at a clean checkpoint. The agent handoff (plan-or-implement) is + * the responsibility of `stash impl`, which reads `.cipherstash/context.json` + * and dispatches to the right handoff target. + * + * Splitting these gives the user a save-point between bootstrap and + * implementation — they can review what init produced before committing + * to the longer agent-driven phase. + */ const STEPS = [ authenticateStep, resolveDatabaseStep, @@ -26,9 +36,6 @@ const STEPS = [ installDepsStep, installEqlStep, gatherContextStep, - chooseModeStep, - howToProceedStep, - nextStepsStep, ] function resolveProvider(flags: Record): InitProvider { @@ -41,7 +48,10 @@ function resolveProvider(flags: Record): InitProvider { } // Use the first matched provider for UX (intro message, connection options, etc.) - const provider = PROVIDER_MAP[matchedKeys[0]]!() + // matchedKeys[0] is guaranteed by the length check above; the optional chain + // is just to satisfy biome's no-non-null-assertion rule. + const factory = PROVIDER_MAP[matchedKeys[0]] + const provider = factory ? factory() : createBaseProvider() // Combine all matched flag names for the referrer if (matchedKeys.length > 1) { @@ -63,7 +73,41 @@ export async function initCommand(flags: Record) { for (const step of STEPS) { state = await step.run(state, provider) } - p.outro('Setup complete!') + + const pm = detectPackageManager() + const cli = runnerCommand(pm, 'stash') + const checkmarks: string[] = [ + '✓ Authenticated to CipherStash', + '✓ Database connection verified', + '✓ Encryption client scaffolded', + ] + if (state.stackInstalled) { + checkmarks.push('✓ `@cipherstash/stack` installed') + } + if (state.cliInstalled) checkmarks.push('✓ `stash` CLI installed') + if (state.eqlInstalled) checkmarks.push('✓ EQL extension installed') + + p.note(checkmarks.join('\n'), 'Setup complete') + + // Offer to chain straight into `stash impl` so first-time users don't + // have to copy/paste the next command. Default-yes for low friction; + // answering N (or running non-interactively) preserves the explicit + // two-command flow. Only prompts in plan mode by definition — at this + // point the user has no plan yet, so impl will draft one (~1–3 min) + // rather than dropping them into the hour-long implementation phase. + if (process.stdout.isTTY) { + const proceed = await p.confirm({ + message: `Continue to \`${cli} impl\` now to draft your encryption plan?`, + initialValue: true, + }) + if (!p.isCancel(proceed) && proceed) { + p.outro('Setup complete — handing off to `stash impl`.') + await implCommand({}) + return + } + } + + p.outro(`Next: run \`${cli} impl\` to draft your encryption plan.`) } catch (err) { if (err instanceof CancelledError) { p.cancel('Setup cancelled.') diff --git a/packages/cli/src/commands/init/steps/choose-mode.ts b/packages/cli/src/commands/init/steps/choose-mode.ts deleted file mode 100644 index fd6d90e6..00000000 --- a/packages/cli/src/commands/init/steps/choose-mode.ts +++ /dev/null @@ -1,45 +0,0 @@ -import * as p from '@clack/prompts' -import { - CancelledError, - type InitMode, - type InitProvider, - type InitState, - type InitStep, -} from '../types.js' - -/** - * Ask the user whether the agent handoff should produce a plan first or - * go straight to implementation. Plan-first is the default — for - * migrate-existing-column work the wrong order of operations is hard to - * recover from, so a reviewable plan checkpoint is the safer default. - * - * Plan mode currently routes only to Claude Code or Codex. The next step - * (`how-to-proceed`) reads `state.mode` and filters its target list - * accordingly. - */ -export const chooseModeStep: InitStep = { - id: 'choose-mode', - name: 'Choose mode', - async run(state: InitState, _provider: InitProvider): Promise { - const mode = await p.select({ - message: 'Plan first, or go straight to implementation?', - options: [ - { - value: 'plan', - label: 'Write a plan first (recommended)', - hint: 'agent produces .cipherstash/plan.md for review — Claude Code or Codex only', - }, - { - value: 'implement', - label: 'Go straight to implementation', - hint: 'agent makes schema and code changes directly', - }, - ], - initialValue: 'plan', - }) - - if (p.isCancel(mode)) throw new CancelledError() - - return { ...state, mode } - }, -} diff --git a/packages/cli/src/commands/init/steps/next-steps.ts b/packages/cli/src/commands/init/steps/next-steps.ts deleted file mode 100644 index 4a3ef65d..00000000 --- a/packages/cli/src/commands/init/steps/next-steps.ts +++ /dev/null @@ -1,30 +0,0 @@ -import * as p from '@clack/prompts' -import { PLAN_REL_PATH } from '../lib/setup-prompt.js' -import type { InitProvider, InitState, InitStep } from '../types.js' -import { detectPackageManager, runnerCommand } from '../utils.js' - -export const nextStepsStep: InitStep = { - id: 'next-steps', - name: 'Next steps', - async run(state: InitState, provider: InitProvider): Promise { - const pm = detectPackageManager() - - if (state.mode === 'plan') { - const cli = runnerCommand(pm, 'stash') - p.note( - [ - `1. Review ${PLAN_REL_PATH} (the agent should have written it, or be writing it now).`, - `2. When the plan looks right, re-run \`${cli} init\` and pick "Go straight to implementation".`, - '3. Quickstart: https://cipherstash.com/docs/stack/quickstart', - ].join('\n'), - 'Next Steps — plan mode', - ) - return state - } - - const steps = provider.getNextSteps(state, pm) - const numbered = steps.map((s, i) => `${i + 1}. ${s}`).join('\n') - p.note(numbered, 'Next Steps') - return state - }, -} diff --git a/packages/cli/src/commands/init/types.ts b/packages/cli/src/commands/init/types.ts index def734f7..76e12d3f 100644 --- a/packages/cli/src/commands/init/types.ts +++ b/packages/cli/src/commands/init/types.ts @@ -58,8 +58,9 @@ export interface InitState { agents?: AgentEnvironment /** What the user picked at the "how to proceed" step. */ handoff?: HandoffChoice - /** Whether the handoff should plan first or implement directly. Set by - * choose-mode. Defaults to `plan` when the user accepts the recommendation. */ + /** Whether the handoff should plan first or implement directly. Derived + * from disk state by `stash impl`: if `.cipherstash/plan.md` exists, the + * mode is `implement`; otherwise `plan`. `--yolo` forces `implement`. */ mode?: InitMode }