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..72283ff2 --- /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. `--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. diff --git a/.changeset/stash-impl-plan-summary.md b/.changeset/stash-impl-plan-summary.md new file mode 100644 index 00000000..c4947d01 --- /dev/null +++ b/.changeset/stash-impl-plan-summary.md @@ -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 `` 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. diff --git a/.changeset/stash-plan-command.md b/.changeset/stash-plan-command.md new file mode 100644 index 00000000..1fe8337c --- /dev/null +++ b/.changeset/stash-plan-command.md @@ -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. diff --git a/.changeset/stash-status-command.md b/.changeset/stash-status-command.md new file mode 100644 index 00000000..d368c009 --- /dev/null +++ b/.changeset/stash-status-command.md @@ -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. diff --git a/packages/cli/src/bin/stash.ts b/packages/cli/src/bin/stash.ts index 2a46ade6..e920f218 100644 --- a/packages/cli/src/bin/stash.ts +++ b/packages/cli/src/bin/stash.ts @@ -16,9 +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, @@ -74,6 +77,9 @@ ${messages.cli.usagePrefix}${STASH} [options] Commands: init Initialize CipherStash for your project + plan Draft a reviewable encryption plan at .cipherstash/plan.md + impl Execute the plan with a local agent + status Displays implementation status auth Authenticate with CipherStash wizard AI-guided encryption setup (reads your codebase) @@ -104,6 +110,10 @@ Init Flags: --supabase Use Supabase-specific setup flow --drizzle Use Drizzle-specific setup flow +Impl Flags: + --continue-without-plan Skip planning 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 +129,10 @@ DB Flags: Examples: ${STASH} init ${STASH} init --supabase + ${STASH} plan + ${STASH} impl + ${STASH} impl --continue-without-plan + ${STASH} status ${STASH} auth login ${STASH} wizard ${STASH} db install @@ -224,7 +238,7 @@ async function runDbCommand( break } case 'status': - await statusCommand({ databaseUrl }) + await dbStatusCommand({ databaseUrl }) break case 'test-connection': await testConnectionCommand({ databaseUrl }) @@ -367,6 +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) 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..4b5ad386 --- /dev/null +++ b/packages/cli/src/commands/impl/index.ts @@ -0,0 +1,167 @@ +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 InitState } from '../init/types.js' +import { detectPackageManager, runnerCommand } from '../init/utils.js' +import { howToProceedStep } from './steps/how-to-proceed.js' + +function buildStateFromContext( + ctx: ContextFile, + agents: AgentEnvironment, +): InitState { + return { + integration: ctx.integration, + clientFilePath: ctx.encryptionClientPath, + schemas: ctx.schemas, + envKeys: ctx.envKeys, + stackInstalled: true, + cliInstalled: true, + eqlInstalled: true, + agents, + 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 { + const confirmed = await p.confirm({ + message: 'Implementation can take some time. Continue?', + initialValue: false, + }) + if (p.isCancel(confirmed) || !confirmed) { + throw new CancelledError() + } +} + +/** + * `stash impl` — execute an encryption plan. + * + * Always runs in implement mode. Behaviour branches on disk state and + * flags: + * + * - **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) { + 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') + + const planPath = resolve(cwd, PLAN_REL_PATH) + const planExists = existsSync(planPath) + const continueWithoutPlan = flags['continue-without-plan'] === true + const isTTY = process.stdout.isTTY + + 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 { + // 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, agents) + + await howToProceedStep.run(state) + + 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 81% 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..5f5178f2 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' @@ -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 { + async run(state: InitState, _provider?: InitProvider): Promise { const cwd = process.cwd() const integration = state.integration ?? 'postgresql' diff --git a/packages/cli/src/commands/init/steps/handoff-claude.ts b/packages/cli/src/commands/impl/steps/handoff-claude.ts similarity index 86% rename from packages/cli/src/commands/init/steps/handoff-claude.ts rename to packages/cli/src/commands/impl/steps/handoff-claude.ts index cdb5c47b..e270b0f7 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' @@ -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 { + async run(state: InitState, _provider?: InitProvider): Promise { const cwd = process.cwd() const integration = state.integration ?? 'postgresql' diff --git a/packages/cli/src/commands/init/steps/handoff-codex.ts b/packages/cli/src/commands/impl/steps/handoff-codex.ts similarity index 85% rename from packages/cli/src/commands/init/steps/handoff-codex.ts rename to packages/cli/src/commands/impl/steps/handoff-codex.ts index 0dad13e1..7344ae84 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' @@ -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 { + async run(state: InitState, _provider?: InitProvider): Promise { const cwd = process.cwd() const integration = state.integration ?? 'postgresql' diff --git a/packages/cli/src/commands/init/steps/handoff-wizard.ts b/packages/cli/src/commands/impl/steps/handoff-wizard.ts similarity index 82% rename from packages/cli/src/commands/init/steps/handoff-wizard.ts rename to packages/cli/src/commands/impl/steps/handoff-wizard.ts index 160b24c5..8d2d5c7f 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. @@ -23,7 +23,7 @@ import type { InitProvider, InitState, InitStep } from '../types.js' export const handoffWizardStep: InitStep = { id: 'handoff-wizard', name: 'Use the CipherStash Agent', - async run(state: InitState, _provider: InitProvider): Promise { + async run(state: InitState, _provider?: InitProvider): Promise { const cwd = process.cwd() const envKeys = state.envKeys ?? [] 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 91% 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..932952d5 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' @@ -82,7 +82,7 @@ export function buildOptions( export const howToProceedStep: InitStep = { id: 'how-to-proceed', name: 'How to proceed', - async run(state: InitState, provider: InitProvider): Promise { + async run(state: InitState, _provider?: InitProvider): Promise { const mode: InitMode = state.mode ?? 'implement' const message = mode === 'plan' @@ -101,13 +101,13 @@ export const howToProceedStep: InitStep = { switch (choice) { case 'claude-code': - return handoffClaudeStep.run(next, provider) + return handoffClaudeStep.run(next) case 'codex': - return handoffCodexStep.run(next, provider) + return handoffCodexStep.run(next) case 'agents-md': - return handoffAgentsMdStep.run(next, provider) + return handoffAgentsMdStep.run(next) case 'wizard': - return handoffWizardStep.run(next, provider) + return handoffWizardStep.run(next) } }, } diff --git a/packages/cli/src/commands/index.ts b/packages/cli/src/commands/index.ts index 17154eec..ab699d84 100644 --- a/packages/cli/src/commands/index.ts +++ b/packages/cli/src/commands/index.ts @@ -1,8 +1,11 @@ export { installCommand } from './db/install.js' -export { statusCommand } from './db/status.js' +export { statusCommand as dbStatusCommand } 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 { planCommand } from './plan/index.js' +export { statusCommand } from './status/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..ee15b3cf 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 { planCommand } from '../plan/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 plan` 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 + // multi-command flow. Drafting a plan is fast (~1–3 min of agent + // thinking) and produces a reviewable artifact — `stash impl` is the + // separate, slower verb that actually mutates code. + if (process.stdout.isTTY) { + const proceed = await p.confirm({ + message: `Continue to \`${cli} plan\` now to draft your encryption plan?`, + initialValue: true, + }) + if (!p.isCancel(proceed) && proceed) { + p.outro('Setup complete — handing off to `stash plan`.') + await planCommand() + return + } + } + + p.outro(`Next: run \`${cli} plan\` to draft your encryption plan.`) } catch (err) { if (err instanceof CancelledError) { p.cancel('Setup cancelled.') diff --git a/packages/cli/src/commands/init/lib/__tests__/parse-plan.test.ts b/packages/cli/src/commands/init/lib/__tests__/parse-plan.test.ts new file mode 100644 index 00000000..652048f4 --- /dev/null +++ b/packages/cli/src/commands/init/lib/__tests__/parse-plan.test.ts @@ -0,0 +1,169 @@ +import { describe, expect, it } from 'vitest' +import { + type PlanSummary, + parsePlanSummary, + renderPlanSummary, +} from '../parse-plan.js' + +describe('parsePlanSummary', () => { + it('returns undefined when no summary block is present', () => { + const md = '# CipherStash Encryption Plan\n\nNo summary here.\n' + expect(parsePlanSummary(md)).toBeUndefined() + }) + + it('parses a well-formed summary block', () => { + const md = ` + +# CipherStash Encryption Plan +` + const summary = parsePlanSummary(md) + expect(summary).toBeDefined() + expect(summary?.columns).toHaveLength(2) + expect(summary?.columns[0]).toEqual({ + table: 'users', + column: 'email', + path: 'new', + }) + }) + + it('returns undefined for malformed JSON inside the block', () => { + const md = `` + expect(parsePlanSummary(md)).toBeUndefined() + }) + + it('returns undefined when shape does not match (missing columns)', () => { + const md = `` + expect(parsePlanSummary(md)).toBeUndefined() + }) + + it('rejects entries with an unknown path value', () => { + const md = `` + expect(parsePlanSummary(md)).toBeUndefined() + }) + + it('rejects entries with empty table or column strings', () => { + const empty = `` + expect(parsePlanSummary(empty)).toBeUndefined() + }) + + it('tolerates extra unknown fields without dropping the parse', () => { + // Future-proofing — agents may include estimated-deploys or other + // ancillary keys. The parser should ignore them, not fail. + const md = `` + const summary = parsePlanSummary(md) + expect(summary?.columns).toHaveLength(1) + }) + + it('finds the block even with surrounding whitespace and extra newlines', () => { + const md = ` + + + +# Plan +` + expect(parsePlanSummary(md)?.columns[0]?.path).toBe('migrate') + }) +}) + +describe('renderPlanSummary', () => { + function summary(columns: PlanSummary['columns']): PlanSummary { + return { columns } + } + + it('reports column and table counts', () => { + const out = renderPlanSummary( + summary([ + { table: 'users', column: 'email', path: 'new' }, + { table: 'users', column: 'phone', path: 'migrate' }, + { table: 'orders', column: 'notes', path: 'migrate' }, + ]), + ) + expect(out).toContain('3 columns across 2 tables') + }) + + it('uses singular forms when counts are 1', () => { + const out = renderPlanSummary( + summary([{ table: 'users', column: 'email', path: 'new' }]), + ) + expect(out).toContain('1 column across 1 table') + expect(out).not.toContain('1 columns') + expect(out).not.toContain('1 tables') + }) + + it('describes each column with its path', () => { + const out = renderPlanSummary( + summary([ + { table: 'users', column: 'email', path: 'new' }, + { table: 'users', column: 'phone', path: 'migrate' }, + ]), + ) + expect(out).toContain('users.email') + expect(out).toContain('users.phone') + expect(out).toContain('add new encrypted column') + expect(out).toContain('migrate existing column') + }) + + it('mentions the staged 4-deploy lifecycle when any column is migrate-existing', () => { + const out = renderPlanSummary( + summary([ + { table: 'users', column: 'email', path: 'new' }, + { table: 'users', column: 'phone', path: 'migrate' }, + ]), + ) + expect(out).toMatch(/staged across 4 deploys/i) + expect(out).toMatch(/schema-add → backfill → cutover → drop/) + }) + + it('reports a single-deploy implementation when all columns are additive', () => { + const out = renderPlanSummary( + summary([ + { table: 'users', column: 'email', path: 'new' }, + { table: 'users', column: 'phone', path: 'new' }, + ]), + ) + expect(out).toContain('single-deploy') + expect(out).not.toMatch(/4 deploys/) + }) + + it('does not multiply deploy count by migrate-column count (deploys batch)', () => { + // 3 migrate columns is still 4 deploys — schema-add covers all twins, + // one backfill, one cutover, one drop. The renderer must not say "12 + // deploys" or anything similar. + const out = renderPlanSummary( + summary([ + { table: 'users', column: 'a', path: 'migrate' }, + { table: 'users', column: 'b', path: 'migrate' }, + { table: 'users', column: 'c', path: 'migrate' }, + ]), + ) + expect(out).toContain('4 deploys') + expect(out).not.toContain('12 deploys') + }) +}) diff --git a/packages/cli/src/commands/init/lib/__tests__/read-context.test.ts b/packages/cli/src/commands/init/lib/__tests__/read-context.test.ts new file mode 100644 index 00000000..70bd56a7 --- /dev/null +++ b/packages/cli/src/commands/init/lib/__tests__/read-context.test.ts @@ -0,0 +1,57 @@ +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 { readContextFile } from '../read-context.js' + +let cwd: string + +beforeEach(() => { + cwd = mkdtempSync(join(tmpdir(), 'stash-context-')) +}) + +afterEach(() => { + rmSync(cwd, { recursive: true, force: true }) +}) + +function writeContext(payload: Record): void { + mkdirSync(join(cwd, '.cipherstash'), { recursive: true }) + writeFileSync( + join(cwd, '.cipherstash', 'context.json'), + JSON.stringify(payload), + 'utf-8', + ) +} + +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/lib/__tests__/setup-prompt.test.ts b/packages/cli/src/commands/init/lib/__tests__/setup-prompt.test.ts index c4a9f19a..fa1509b2 100644 --- a/packages/cli/src/commands/init/lib/__tests__/setup-prompt.test.ts +++ b/packages/cli/src/commands/init/lib/__tests__/setup-prompt.test.ts @@ -207,6 +207,21 @@ describe('renderSetupPrompt — plan mode', () => { expect(out).toContain('.cipherstash/context.json') }) + it('instructs the agent to begin the plan with a machine-readable summary block', () => { + // `stash impl` parses this block to render a confirmation panel before + // launching implementation. If the agent forgets it, plan-summary + // gracefully degrades — but the prompt still has to ask for it so + // most plans get a structured summary. + const out = renderSetupPrompt(planCtx) + expect(out).toContain('cipherstash:plan-summary') + expect(out).toContain('"columns"') + // The instruction shows the union form `"new" | "migrate"`; both + // values must appear so the agent knows what to choose between. + expect(out).toContain('"new"') + expect(out).toContain('"migrate"') + expect(out).toMatch(/at the very top of the file/i) + }) + it('preserves the integration + package manager header in plan mode', () => { const out = renderSetupPrompt(planCtx) expect(out).toContain('Integration: `drizzle`') diff --git a/packages/cli/src/commands/init/lib/parse-plan.ts b/packages/cli/src/commands/init/lib/parse-plan.ts new file mode 100644 index 00000000..3328a34d --- /dev/null +++ b/packages/cli/src/commands/init/lib/parse-plan.ts @@ -0,0 +1,113 @@ +/** + * Parse and render `.cipherstash/plan.md` summary blocks. + * + * The agent is instructed (in `renderPlanPrompt`) to begin the plan file + * with an HTML-comment block carrying a structured JSON summary: + * + * + * + * `stash impl` parses this block to render a confirmation panel before + * dispatching to the implementation handoff. Plans without the block (or + * with a malformed one) fall back to a soft "open the plan in your editor" + * message — never an error. Older plans pre-dating this feature are still + * usable. + */ + +export type PlanPath = 'new' | 'migrate' + +export interface PlanColumn { + table: string + column: string + path: PlanPath +} + +export interface PlanSummary { + columns: PlanColumn[] +} + +const SUMMARY_BLOCK_RE = // + +function isPlanColumn(x: unknown): x is PlanColumn { + if (!x || typeof x !== 'object') return false + const c = x as Record + return ( + typeof c.table === 'string' && + c.table.length > 0 && + typeof c.column === 'string' && + c.column.length > 0 && + (c.path === 'new' || c.path === 'migrate') + ) +} + +function isPlanSummary(x: unknown): x is PlanSummary { + if (!x || typeof x !== 'object') return false + const obj = x as Record + return Array.isArray(obj.columns) && obj.columns.every(isPlanColumn) +} + +/** + * Extract the machine-readable plan summary, or `undefined` if the plan + * has no summary block (or one that doesn't match the schema). Never + * throws — malformed input is treated as "no summary." + */ +export function parsePlanSummary(content: string): PlanSummary | undefined { + const match = content.match(SUMMARY_BLOCK_RE) + if (!match) return undefined + try { + const parsed = JSON.parse(match[1]) as unknown + if (!isPlanSummary(parsed)) return undefined + return parsed + } catch { + return undefined + } +} + +const COLUMN_LABEL_WIDTH = 20 + +/** + * Render the plan summary as the body of a `p.note` panel. + * + * 3 columns across 2 tables + * + * ◇ users.email add new encrypted column + * ◇ users.phone migrate existing column + * ◇ orders.notes migrate existing column + * + * Includes migrate-existing columns — implementation is staged across + * 4 deploys (schema-add → backfill → cutover → drop). + * + * Deploys are reported as a flat 4 (not 4 per migrate column) because the + * lifecycle batches columns: one schema-add deploy covers every twin, one + * backfill covers every column, etc. + */ +export function renderPlanSummary(summary: PlanSummary): string { + const tables = new Set(summary.columns.map((c) => c.table)) + const migrateCount = summary.columns.filter( + (c) => c.path === 'migrate', + ).length + + const colCount = summary.columns.length + const tableCount = tables.size + + const header = `${colCount} column${colCount === 1 ? '' : 's'} across ${tableCount} table${tableCount === 1 ? '' : 's'}` + + const rows = summary.columns.map((c) => { + const desc = + c.path === 'new' ? 'add new encrypted column' : 'migrate existing column' + return `◇ ${`${c.table}.${c.column}`.padEnd(COLUMN_LABEL_WIDTH)} ${desc}` + }) + + const footer = + migrateCount > 0 + ? `Includes migrate-existing column${migrateCount === 1 ? '' : 's'} — implementation is staged across 4 deploys (schema-add → backfill → cutover → drop).` + : 'All columns are additive — single-deploy implementation.' + + return [header, '', ...rows, '', footer].join('\n') +} diff --git a/packages/cli/src/commands/init/lib/read-context.ts b/packages/cli/src/commands/init/lib/read-context.ts new file mode 100644 index 00000000..38edb61a --- /dev/null +++ b/packages/cli/src/commands/init/lib/read-context.ts @@ -0,0 +1,21 @@ +import { existsSync, readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { CONTEXT_REL_PATH, type ContextFile } from './write-context.js' + +/** + * Read the `.cipherstash/context.json` file written by `stash init`. + * Returns `undefined` when the file is missing or malformed — both `stash + * plan` and `stash impl` use that signal to point the user back at + * `stash init` rather than crashing. + * + * Never throws on bad input. Malformed JSON is treated as "no context." + */ +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 + } +} diff --git a/packages/cli/src/commands/init/lib/setup-prompt.ts b/packages/cli/src/commands/init/lib/setup-prompt.ts index 54d9057d..238dfc93 100644 --- a/packages/cli/src/commands/init/lib/setup-prompt.ts +++ b/packages/cli/src/commands/init/lib/setup-prompt.ts @@ -331,7 +331,7 @@ export function renderPlanPrompt(ctx: SetupPromptContext): string { '', `Integration: \`${ctx.integration}\` · Package manager: \`${ctx.packageManager}\``, '', - `\`stash init\` has finished its mechanical setup. The user picked **plan-first** — your job is to produce a reviewable plan at \`${PLAN_REL_PATH}\`, **not** to make schema or code changes. Read-only inspection (\`${cli} db status\`, reading schema files, grepping the codebase) is encouraged. Schema edits, migrations, \`${cli} db push\`, and any \`${cli} encrypt *\` mutations are deferred to the implementation phase that runs after the user reviews and approves the plan.`, + `\`stash plan\` runs the planning phase — your job is to produce a reviewable plan at \`${PLAN_REL_PATH}\`, **not** to make schema or code changes. Read-only inspection (\`${cli} db status\`, reading schema files, grepping the codebase) is encouraged. Schema edits, migrations, \`${cli} db push\`, and any \`${cli} encrypt *\` mutations are deferred to \`${cli} impl\`, which the user will run after reviewing and approving the plan.`, '', '## What `stash init` already did', '', @@ -358,6 +358,24 @@ export function renderPlanPrompt(ctx: SetupPromptContext): string { '', `Write \`${PLAN_REL_PATH}\` covering, for each table+column the user wants to protect:`, '', + bullet( + '**A machine-readable summary block at the very top of the file**, before any heading or prose. `stash impl` parses this to render a confirmation panel before launching implementation. Use this exact shape (valid JSON, single block, no other content inside the comment):', + ), + '', + ' ```', + ' ', + ' ```', + '', + ` \`path\` is \`"new"\` for additive columns (no plaintext predecessor) and \`"migrate"\` for columns that already exist with live data. The block must remain in sync with the prose that follows; if you revise the plan, regenerate the summary.`, + '', + 'Then, the prose plan covers:', + '', bullet( "The table and column names (extract candidates from `.cipherstash/context.json`; if the user hasn't yet said which columns matter, ask before writing the plan).", ), @@ -400,7 +418,7 @@ export function renderPlanPrompt(ctx: SetupPromptContext): string { '', `Send the user a short orientation message before writing anything. Confirm setup is complete, list the skills loaded with one-line purposes, summarise the two options in your own words, and end with a clear question — *"Which table(s) and column(s) would you like the plan to cover? You can name them or describe what you're trying to protect."* Reference concrete tables/columns from \`.cipherstash/context.json\` when it helps.`, '', - `Once the user answers, write \`${PLAN_REL_PATH}\`. Show the plan in chat as well so the user can react inline. After the plan is approved, tell the user how to proceed to implementation — re-run \`${cli} init\` and pick **Go straight to implementation**, or paste the implementation prompt manually.`, + `Once the user answers, write \`${PLAN_REL_PATH}\`. Show the plan in chat as well so the user can react inline. After the plan is approved, tell the user to run \`${cli} impl\` to execute it.`, '', '## Stop and ask the user when', '', 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..da72b3f4 100644 --- a/packages/cli/src/commands/init/types.ts +++ b/packages/cli/src/commands/init/types.ts @@ -58,15 +58,20 @@ 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 is producing a plan or executing one. Set by the + * command itself: `stash plan` always sets `'plan'`, `stash impl` always + * sets `'implement'`. */ mode?: InitMode } export interface InitStep { id: string name: string - run(state: InitState, provider: InitProvider): Promise + /** `provider` is optional. The init pipeline passes one (it owns + * intro copy and provider-specific defaults); the post-init handoff + * steps invoked by `stash plan` / `stash impl` don't have a provider + * to give and don't use one. */ + run(state: InitState, provider?: InitProvider): Promise } export interface InitProvider { diff --git a/packages/cli/src/commands/plan/index.ts b/packages/cli/src/commands/plan/index.ts new file mode 100644 index 00000000..da3f11dc --- /dev/null +++ b/packages/cli/src/commands/plan/index.ts @@ -0,0 +1,97 @@ +import { existsSync } from 'node:fs' +import { resolve } from 'node:path' +import * as p from '@clack/prompts' +import { howToProceedStep } from '../impl/steps/how-to-proceed.js' +import { type AgentEnvironment, detectAgents } from '../init/detect-agents.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 InitState } from '../init/types.js' +import { detectPackageManager, runnerCommand } from '../init/utils.js' + +function buildStateFromContext( + ctx: ContextFile, + agents: AgentEnvironment, +): InitState { + return { + integration: ctx.integration, + clientFilePath: ctx.encryptionClientPath, + schemas: ctx.schemas, + envKeys: ctx.envKeys, + stackInstalled: true, + cliInstalled: true, + eqlInstalled: true, + agents, + mode: 'plan', + } +} + +/** + * `stash plan` — draft a reviewable encryption plan. + * + * Pre-flights `.cipherstash/context.json` (errors with a `stash init` + * pointer if missing). Always sets `mode='plan'`, dispatches to a handoff + * target via `howToProceedStep`, and ends with a chain prompt offering to + * continue into `stash impl`. + * + * The deliverable is `.cipherstash/plan.md` with a machine-readable + * summary block at the top — `stash impl` parses that block to render a + * confirmation panel before launching implementation. + */ +export async function planCommand() { + 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 Plan') + + try { + if (existsSync(resolve(cwd, PLAN_REL_PATH))) { + p.log.warn( + `Plan already exists at \`${PLAN_REL_PATH}\`. The agent will be told to revise it; delete the file first if you want to start fresh.`, + ) + } + + const agents = detectAgents(cwd, process.env) + const state = buildStateFromContext(ctx, agents) + + await howToProceedStep.run(state) + + // Chain into `stash impl` so the user doesn't have to copy/paste. Lazy + // import avoids a circular module load — plan and impl both pull from + // init/lib/ and need to be importable independently. + if (process.stdout.isTTY) { + const proceed = await p.confirm({ + message: `Plan drafted at \`${PLAN_REL_PATH}\`. Continue to \`${cli} impl\` now?`, + initialValue: true, + }) + if (!p.isCancel(proceed) && proceed) { + p.outro('Plan complete — handing off to `stash impl`.') + const { implCommand } = await import('../impl/index.js') + await implCommand({}) + return + } + } + + p.outro( + `Plan drafted at \`${PLAN_REL_PATH}\`. Review it, then run \`${cli} impl\` to implement.`, + ) + } catch (err) { + if (err instanceof CancelledError) { + p.cancel('Cancelled.') + process.exit(0) + } + throw err + } +} diff --git a/packages/cli/src/commands/status/__tests__/status.test.ts b/packages/cli/src/commands/status/__tests__/status.test.ts new file mode 100644 index 00000000..b4db3ef4 --- /dev/null +++ b/packages/cli/src/commands/status/__tests__/status.test.ts @@ -0,0 +1,183 @@ +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 { buildStages, nextAction, readProjectStatus } from '../index.js' + +let cwd: string + +beforeEach(() => { + cwd = mkdtempSync(join(tmpdir(), 'stash-status-')) +}) + +afterEach(() => { + rmSync(cwd, { recursive: true, force: true }) +}) + +function writeContext(payload: Record): void { + mkdirSync(join(cwd, '.cipherstash'), { recursive: true }) + writeFileSync( + join(cwd, '.cipherstash', 'context.json'), + JSON.stringify(payload), + 'utf-8', + ) +} + +function writePlan(): void { + mkdirSync(join(cwd, '.cipherstash'), { recursive: true }) + writeFileSync(join(cwd, '.cipherstash', 'plan.md'), '# plan\n', 'utf-8') +} + +function writeSetupPrompt(): void { + mkdirSync(join(cwd, '.cipherstash'), { recursive: true }) + writeFileSync( + join(cwd, '.cipherstash', 'setup-prompt.md'), + '# prompt\n', + 'utf-8', + ) +} + +const sampleContext = { + cliVersion: '0.0.0', + integration: 'drizzle' as const, + encryptionClientPath: './src/encryption/index.ts', + packageManager: 'pnpm' as const, + installCommand: 'pnpm add @cipherstash/stack', + envKeys: [], + schemas: [ + { tableName: 'users', columns: [] }, + { tableName: 'orders', columns: [] }, + ], + installedSkills: [], + generatedAt: '2026-01-01T00:00:00.000Z', +} + +describe('readProjectStatus', () => { + it('reports a virgin project as uninitialized', () => { + const status = readProjectStatus(cwd) + expect(status.initialized).toBe(false) + expect(status.planExists).toBe(false) + expect(status.agentEngaged).toBe(false) + }) + + it('reports init-only state when only context.json exists', () => { + writeContext(sampleContext) + const status = readProjectStatus(cwd) + expect(status.initialized).toBe(true) + expect(status.context?.integration).toBe('drizzle') + expect(status.planExists).toBe(false) + expect(status.agentEngaged).toBe(false) + }) + + it('reports plan written once plan.md exists', () => { + writeContext(sampleContext) + writePlan() + const status = readProjectStatus(cwd) + expect(status.planExists).toBe(true) + }) + + it('reports agentEngaged when setup-prompt.md exists', () => { + writeContext(sampleContext) + writeSetupPrompt() + const status = readProjectStatus(cwd) + expect(status.agentEngaged).toBe(true) + }) + + it('treats malformed context.json as not-initialized rather than throwing', () => { + mkdirSync(join(cwd, '.cipherstash'), { recursive: true }) + writeFileSync( + join(cwd, '.cipherstash', 'context.json'), + '{ not json', + 'utf-8', + ) + const status = readProjectStatus(cwd) + expect(status.initialized).toBe(false) + }) +}) + +describe('buildStages', () => { + it('marks every stage pending in a virgin project', () => { + const stages = buildStages(readProjectStatus(cwd), 'pnpm dlx stash') + expect(stages.map((s) => s.status)).toEqual([ + 'pending', + 'pending', + 'pending', + ]) + // Init detail nudges the user to begin. + expect(stages[0].detail).toMatch(/init/) + }) + + it('marks Initialized done and shows integration + table count when context exists', () => { + writeContext(sampleContext) + const stages = buildStages(readProjectStatus(cwd), 'pnpm dlx stash') + expect(stages[0].status).toBe('done') + expect(stages[0].detail).toContain('drizzle') + expect(stages[0].detail).toContain('pnpm') + expect(stages[0].detail).toContain('2 tables') + }) + + it('uses singular "table" for a one-table project', () => { + writeContext({ + ...sampleContext, + schemas: [{ tableName: 'x', columns: [] }], + }) + const stages = buildStages(readProjectStatus(cwd), 'pnpm dlx stash') + expect(stages[0].detail).toContain('1 table') + expect(stages[0].detail).not.toContain('1 tables') + }) + + it('marks Plan written done and shows the plan path when plan exists', () => { + writeContext(sampleContext) + writePlan() + const stages = buildStages(readProjectStatus(cwd), 'pnpm dlx stash') + expect(stages[1].status).toBe('done') + expect(stages[1].detail).toContain('.cipherstash/plan.md') + }) + + it('points at `plan` for next-step when init done but plan missing', () => { + writeContext(sampleContext) + const stages = buildStages(readProjectStatus(cwd), 'pnpm dlx stash') + expect(stages[1].status).toBe('pending') + expect(stages[1].detail).toMatch(/plan/) + expect(stages[2].detail).toMatch(/waiting on plan/) + }) + + it('keeps Implementation pending even after agent engagement (DB state lives in encrypt status)', () => { + writeContext(sampleContext) + writePlan() + writeSetupPrompt() + const stages = buildStages(readProjectStatus(cwd), 'pnpm dlx stash') + expect(stages[2].status).toBe('pending') + expect(stages[2].detail).toContain('encrypt status') + }) +}) + +describe('nextAction', () => { + it('points at init when uninitialized', () => { + expect(nextAction(readProjectStatus(cwd), 'pnpm dlx stash')).toMatch(/init/) + }) + + it('points at `plan` when initialized but no plan exists', () => { + writeContext(sampleContext) + expect(nextAction(readProjectStatus(cwd), 'pnpm dlx stash')).toMatch( + /\bplan\b/, + ) + }) + + it('asks the user to review the plan before implementing', () => { + writeContext(sampleContext) + writePlan() + const action = nextAction(readProjectStatus(cwd), 'pnpm dlx stash') + expect(action).toMatch(/plan\.md/) + expect(action).toMatch(/impl/) + }) + + it('routes to encrypt status once the agent has been engaged', () => { + writeContext(sampleContext) + writePlan() + writeSetupPrompt() + expect(nextAction(readProjectStatus(cwd), 'pnpm dlx stash')).toMatch( + /encrypt status/, + ) + }) +}) diff --git a/packages/cli/src/commands/status/index.ts b/packages/cli/src/commands/status/index.ts new file mode 100644 index 00000000..32272eb8 --- /dev/null +++ b/packages/cli/src/commands/status/index.ts @@ -0,0 +1,144 @@ +import { existsSync, readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import * as p from '@clack/prompts' +import { PLAN_REL_PATH } from '../init/lib/setup-prompt.js' +import { + CONTEXT_REL_PATH, + type ContextFile, + SETUP_PROMPT_REL_PATH, +} from '../init/lib/write-context.js' +import { detectPackageManager, runnerCommand } from '../init/utils.js' + +export type StageStatus = 'done' | 'pending' + +export interface Stage { + label: string + status: StageStatus + detail: string +} + +export interface ProjectStatus { + initialized: boolean + context?: ContextFile + planExists: boolean + /** Setup-prompt is written by every `stash impl` run, regardless of mode. + * Its presence means the user has handed off to an agent at least once; + * it does NOT mean implementation is complete. We surface it as a softer + * "agent has been engaged" signal rather than treating it as done. */ + agentEngaged: boolean +} + +export function readProjectStatus(cwd: string): ProjectStatus { + const contextPath = resolve(cwd, CONTEXT_REL_PATH) + let context: ContextFile | undefined + if (existsSync(contextPath)) { + try { + context = JSON.parse(readFileSync(contextPath, 'utf-8')) as ContextFile + } catch { + // malformed context.json — treat as not-initialized + } + } + return { + initialized: context !== undefined, + context, + planExists: existsSync(resolve(cwd, PLAN_REL_PATH)), + agentEngaged: existsSync(resolve(cwd, SETUP_PROMPT_REL_PATH)), + } +} + +export function buildStages(status: ProjectStatus, cli: string): Stage[] { + const initDetail = status.context + ? `${status.context.integration} · ${status.context.packageManager} · ${status.context.schemas.length} table${status.context.schemas.length === 1 ? '' : 's'}` + : `run \`${cli} init\` to begin` + + const planDetail = status.planExists + ? PLAN_REL_PATH + : status.initialized + ? `run \`${cli} plan\` to draft` + : 'waiting on init' + + let implLabel = 'Implementation' + let implDetail: string + const implStatus: StageStatus = 'pending' + if (!status.initialized) { + implDetail = 'waiting on init' + } else if (!status.planExists) { + implDetail = 'waiting on plan' + } else if (!status.agentEngaged) { + implDetail = `run \`${cli} impl\` to execute the plan` + } else { + // Agent has been engaged at least once. We can't tell from disk alone + // whether the implementation is complete — that requires DB inspection + // (`stash encrypt status`). Keep status as `pending` and point there. + implLabel = 'Implementation' + implDetail = `agent engaged — see \`${cli} encrypt status\` for column-level state` + } + + return [ + { + label: 'Initialized', + status: status.initialized ? 'done' : 'pending', + detail: initDetail, + }, + { + label: 'Plan written', + status: status.planExists ? 'done' : 'pending', + detail: planDetail, + }, + { + label: implLabel, + status: implStatus, + detail: implDetail, + }, + ] +} + +export function nextAction(status: ProjectStatus, cli: string): string { + if (!status.initialized) return `Run \`${cli} init\` to begin.` + if (!status.planExists) { + return `Run \`${cli} plan\` to draft your encryption plan.` + } + if (!status.agentEngaged) { + return `Review \`${PLAN_REL_PATH}\`, then run \`${cli} impl\` to implement.` + } + return `Run \`${cli} encrypt status\` to inspect per-column migration state.` +} + +const LABEL_WIDTH = 16 + +function renderStage(stage: Stage): string { + const marker = stage.status === 'done' ? '✓' : '◯' + return `${marker} ${stage.label.padEnd(LABEL_WIDTH)} ${stage.detail}` +} + +/** + * `stash status` — the lifecycle map. Reads disk state only: + * `.cipherstash/context.json` (init done?), `.cipherstash/plan.md` (plan + * written?), `.cipherstash/setup-prompt.md` (agent engaged at least once?). + * Points at `stash db status` and `stash encrypt status` for the deeper + * state that requires database connectivity. + * + * Designed to give the user a one-shot answer to "where am I?" without + * waiting on auth, DB connection, or any network round-trip. Runs in + * milliseconds. The deeper commands stay specialised. + */ +export async function statusCommand() { + const cwd = process.cwd() + const pm = detectPackageManager() + const cli = runnerCommand(pm, 'stash') + + const status = readProjectStatus(cwd) + const stages = buildStages(status, cli) + + p.intro('CipherStash project status') + + p.note(stages.map(renderStage).join('\n'), 'Lifecycle') + + const deeper = [ + `Database state: \`${cli} db status\``, + `Per-column state: \`${cli} encrypt status\``, + ].join('\n') + p.note(deeper, 'Deeper inspection') + + p.outro(nextAction(status, cli)) +}