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/impl/__tests__/how-to-proceed.test.ts b/packages/cli/src/commands/impl/__tests__/how-to-proceed.test.ts new file mode 100644 index 00000000..df9c9082 --- /dev/null +++ b/packages/cli/src/commands/impl/__tests__/how-to-proceed.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from 'vitest' +import type { AgentEnvironment } from '../../init/detect-agents.js' +import type { InitState } from '../../init/types.js' +import { buildOptions, defaultChoice } from '../steps/how-to-proceed.js' + +function makeAgents(claudeCode: boolean, codex: boolean): AgentEnvironment { + return { + cli: { claudeCode, codex }, + project: { + claudeDir: false, + claudeMd: false, + claudeSkillsDir: false, + agentsMd: false, + }, + editor: 'unknown', + } +} + +const noAgents: InitState = { agents: makeAgents(false, false) } +const claudeOnly: InitState = { agents: makeAgents(true, false) } +const codexOnly: InitState = { agents: makeAgents(false, true) } + +describe('howToProceed — buildOptions', () => { + it('offers all four targets in implement mode', () => { + const opts = buildOptions(noAgents, 'implement') + const values = opts.map((o) => o.value) + expect(values).toEqual(['claude-code', 'codex', 'wizard', 'agents-md']) + }) + + it('offers only claude-code and codex in plan mode', () => { + const opts = buildOptions(noAgents, 'plan') + const values = opts.map((o) => o.value) + expect(values).toEqual(['claude-code', 'codex']) + }) + + it('reflects detection state in hints regardless of mode', () => { + const implement = buildOptions(claudeOnly, 'implement') + const plan = buildOptions(claudeOnly, 'plan') + + const implementClaude = implement.find((o) => o.value === 'claude-code') + const planClaude = plan.find((o) => o.value === 'claude-code') + + expect(implementClaude?.hint).toMatch(/detected/) + expect(planClaude?.hint).toMatch(/detected/) + }) +}) + +describe('howToProceed — defaultChoice', () => { + it('prefers claude-code when detected', () => { + expect(defaultChoice(claudeOnly, 'implement')).toBe('claude-code') + expect(defaultChoice(claudeOnly, 'plan')).toBe('claude-code') + }) + + it('prefers codex when claude is absent and codex is detected', () => { + expect(defaultChoice(codexOnly, 'implement')).toBe('codex') + expect(defaultChoice(codexOnly, 'plan')).toBe('codex') + }) + + it('falls back to agents-md in implement mode when no CLI is detected', () => { + expect(defaultChoice(noAgents, 'implement')).toBe('agents-md') + }) + + it('falls back to claude-code in plan mode when no CLI is detected', () => { + // Plan mode never offers agents-md; claude-code is the listed default + // so the picker has a valid initialValue rather than falling through + // to a hidden option. + expect(defaultChoice(noAgents, 'plan')).toBe('claude-code') + }) +}) 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 80% 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..958eadb3 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 { HandoffStep, InitState } from '../../init/types.js' const AGENTS_MD_REL_PATH = 'AGENTS.md' @@ -25,10 +25,10 @@ const AGENTS_MD_REL_PATH = 'AGENTS.md' * tools wouldn't know to look there. Re-runs replace only the sentinel * region in AGENTS.md. */ -export const handoffAgentsMdStep: InitStep = { +export const handoffAgentsMdStep: HandoffStep = { id: 'handoff-agents-md', name: 'Write AGENTS.md', - async run(state: InitState, _provider: InitProvider): Promise { + async run(state: InitState): 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 65% rename from packages/cli/src/commands/init/steps/handoff-claude.ts rename to packages/cli/src/commands/impl/steps/handoff-claude.ts index ce018e32..bfb5f610 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 { HandoffStep, InitState } from '../../init/types.js' const CLAUDE_SKILLS_DIR = '.claude/skills' @@ -18,10 +18,10 @@ const CLAUDE_INSTALL_URL = 'https://code.claude.com/docs/en/quickstart' * on PATH we still write the artifacts and print install + manual-launch * instructions. */ -export const handoffClaudeStep: InitStep = { +export const handoffClaudeStep: HandoffStep = { id: 'handoff-claude', name: 'Hand off to Claude Code', - async run(state: InitState, _provider: InitProvider): Promise { + async run(state: InitState): Promise { const cwd = process.cwd() const integration = state.integration ?? 'postgresql' @@ -34,7 +34,11 @@ export const handoffClaudeStep: InitStep = { writeArtifacts(cwd, state, 'claude-code', installed) - const launchPrompt = `Read ${SETUP_PROMPT_REL_PATH} and complete the setup steps. The installed skills under ${CLAUDE_SKILLS_DIR}/ have the rules; ${CONTEXT_REL_PATH} has the project facts.` + const mode = state.mode ?? 'implement' + const launchPrompt = + mode === 'plan' + ? `Read ${SETUP_PROMPT_REL_PATH} and produce the planning deliverable it describes. The installed skills under ${CLAUDE_SKILLS_DIR}/ have the rules; ${CONTEXT_REL_PATH} has the project facts. Do not edit code or run mutating commands during this phase.` + : `Read ${SETUP_PROMPT_REL_PATH} and complete the setup steps. The installed skills under ${CLAUDE_SKILLS_DIR}/ have the rules; ${CONTEXT_REL_PATH} has the project facts.` if (!state.agents?.cli.claudeCode) { p.note( diff --git a/packages/cli/src/commands/init/steps/handoff-codex.ts b/packages/cli/src/commands/impl/steps/handoff-codex.ts similarity index 67% rename from packages/cli/src/commands/init/steps/handoff-codex.ts rename to packages/cli/src/commands/impl/steps/handoff-codex.ts index dd243d40..662fa7b2 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 { HandoffStep, InitState } from '../../init/types.js' const AGENTS_MD_REL_PATH = 'AGENTS.md' const CODEX_SKILLS_DIR = '.codex/skills' @@ -25,10 +25,10 @@ const CODEX_INSTALL_URL = 'https://github.com/openai/codex' * AGENTS.md is sentinel-upserted so re-runs replace only our region and * any user content outside it survives. */ -export const handoffCodexStep: InitStep = { +export const handoffCodexStep: HandoffStep = { id: 'handoff-codex', name: 'Hand off to Codex', - async run(state: InitState, _provider: InitProvider): Promise { + async run(state: InitState): Promise { const cwd = process.cwd() const integration = state.integration ?? 'postgresql' @@ -53,7 +53,11 @@ export const handoffCodexStep: InitStep = { writeArtifacts(cwd, state, 'codex', installed) - const launchPrompt = `Read ${SETUP_PROMPT_REL_PATH} and complete the setup steps. AGENTS.md has the durable rules; the skills under ${CODEX_SKILLS_DIR}/ have the API details; ${CONTEXT_REL_PATH} has the project facts.` + const mode = state.mode ?? 'implement' + const launchPrompt = + mode === 'plan' + ? `Read ${SETUP_PROMPT_REL_PATH} and produce the planning deliverable it describes. AGENTS.md has the durable rules; the skills under ${CODEX_SKILLS_DIR}/ have the API details; ${CONTEXT_REL_PATH} has the project facts. Do not edit code or run mutating commands during this phase.` + : `Read ${SETUP_PROMPT_REL_PATH} and complete the setup steps. AGENTS.md has the durable rules; the skills under ${CODEX_SKILLS_DIR}/ have the API details; ${CONTEXT_REL_PATH} has the project facts.` if (!state.agents?.cli.codex) { p.note( diff --git a/packages/cli/src/commands/init/steps/handoff-wizard.ts b/packages/cli/src/commands/impl/steps/handoff-wizard.ts similarity index 79% rename from packages/cli/src/commands/init/steps/handoff-wizard.ts rename to packages/cli/src/commands/impl/steps/handoff-wizard.ts index 160b24c5..0d2d57d3 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 { HandoffStep, InitState } from '../../init/types.js' +import { runWizardSpawn } from '../../wizard/index.js' /** * Hand off to the CipherStash Agent (the in-house wizard package). @@ -14,16 +14,16 @@ 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. */ -export const handoffWizardStep: InitStep = { +export const handoffWizardStep: HandoffStep = { id: 'handoff-wizard', name: 'Use the CipherStash Agent', - async run(state: InitState, _provider: InitProvider): Promise { + async run(state: InitState): 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 50% 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 46d85499..d5263974 100644 --- a/packages/cli/src/commands/init/steps/how-to-proceed.ts +++ b/packages/cli/src/commands/impl/steps/how-to-proceed.ts @@ -2,37 +2,43 @@ import * as p from '@clack/prompts' import { CancelledError, type HandoffChoice, - type InitProvider, + type HandoffStep, + type InitMode, 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' import { handoffWizardStep } from './handoff-wizard.js' /** - * Pick the default option in the four-way menu. + * Pick the default option in the menu. * * Detected CLIs win — Claude Code first, then Codex. Otherwise we default to * the AGENTS.md path because that's the broadest "works without anything else * installed" option. The CipherStash Agent option is positioned as a fallback * (slow first run, requires the wizard package on top of the CLI) and is - * never selected by default. + * never selected by default. In plan mode, AGENTS.md and wizard aren't + * offered — the default falls back to `claude-code`. */ -function defaultChoice(state: InitState): HandoffChoice { +export function defaultChoice(state: InitState, mode: InitMode): HandoffChoice { if (state.agents?.cli.claudeCode) return 'claude-code' if (state.agents?.cli.codex) return 'codex' - return 'agents-md' + return mode === 'plan' ? 'claude-code' : 'agents-md' } /** - * Build the option list for the four-way menu. Hints reflect detection state - * — a missing CLI doesn't hide the option (handoff steps still write the + * Build the option list for the menu. Hints reflect detection state — a + * missing CLI doesn't hide the option (handoff steps still write the * rules files and print install instructions), it just nudges the user. + * + * In plan mode we only offer Claude Code and Codex. AGENTS.md and the + * wizard don't yet have planning prompt templates, so suppress them + * entirely rather than degrading silently. */ -function buildOptions( +export function buildOptions( state: InitState, + mode: InitMode, ): { value: HandoffChoice; label: string; hint?: string }[] { const claudeHint = state.agents?.cli.claudeCode ? 'claude detected — will launch interactively' @@ -41,7 +47,7 @@ function buildOptions( ? 'codex detected — will launch interactively' : 'codex not on PATH — files will be written, install link shown' - return [ + const options: { value: HandoffChoice; label: string; hint?: string }[] = [ { value: 'claude-code', label: 'Hand off to Claude Code', @@ -52,27 +58,40 @@ function buildOptions( label: 'Hand off to Codex', hint: codexHint, }, - { - value: 'wizard', - label: 'Use the CipherStash Agent', - hint: 'our hosted setup wizard (runs `stash wizard`)', - }, - { - value: 'agents-md', - label: 'Write AGENTS.md', - hint: 'works with Cursor, Windsurf, Cline, and more', - }, ] + + if (mode === 'implement') { + options.push( + { + value: 'wizard', + label: 'Use the CipherStash Agent', + hint: 'our hosted setup wizard (runs `stash wizard`)', + }, + { + value: 'agents-md', + label: 'Write AGENTS.md', + hint: 'works with Cursor, Windsurf, Cline, and more', + }, + ) + } + + return options } -export const howToProceedStep: InitStep = { +export const howToProceedStep: HandoffStep = { id: 'how-to-proceed', name: 'How to proceed', - async run(state: InitState, provider: InitProvider): Promise { + async run(state: InitState): Promise { + const mode: InitMode = state.mode ?? 'implement' + const message = + mode === 'plan' + ? 'Which agent should write the plan?' + : 'How would you like to finish setup?' + const choice = await p.select({ - message: 'How would you like to finish setup?', - options: buildOptions(state), - initialValue: defaultChoice(state), + message, + options: buildOptions(state, mode), + initialValue: defaultChoice(state, mode), }) if (p.isCancel(choice)) throw new CancelledError() @@ -81,13 +100,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 20c79696..ee15b3cf 100644 --- a/packages/cli/src/commands/init/index.ts +++ b/packages/cli/src/commands/init/index.ts @@ -1,23 +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 { 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, @@ -25,8 +36,6 @@ const STEPS = [ installDepsStep, installEqlStep, gatherContextStep, - howToProceedStep, - nextStepsStep, ] function resolveProvider(flags: Record): InitProvider { @@ -39,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) { @@ -61,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..63f0ef1a --- /dev/null +++ b/packages/cli/src/commands/init/lib/__tests__/parse-plan.test.ts @@ -0,0 +1,180 @@ +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('rejects an empty columns array — falls back to soft summary', () => { + // An agent that writes `{"columns": []}` (genuinely empty plan, + // truncated write, or chat cutoff) would otherwise render as + // "0 columns across 0 tables — single-deploy", which is misleading. + // `stash impl` falls back to the "open in your editor" panel instead. + const md = `` + expect(parsePlanSummary(md)).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..eaced0e3 --- /dev/null +++ b/packages/cli/src/commands/init/lib/__tests__/read-context.test.ts @@ -0,0 +1,93 @@ +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() + }) + + it('returns undefined for an empty object — wrong shape, not "initialized"', () => { + // A `{}` file parses fine but doesn't carry `schemas`/`integration`/ + // `packageManager`. Downstream code (status, plan summary, etc.) + // dereferences those without guarding, so accepting `{}` would mean + // a hand-edited or partial-write file crashes the CLI. + writeContext({}) + expect(readContextFile(cwd)).toBeUndefined() + }) + + it('returns undefined when schemas is missing', () => { + writeContext({ + integration: 'drizzle', + packageManager: 'pnpm', + // schemas absent + }) + expect(readContextFile(cwd)).toBeUndefined() + }) + + it('returns undefined when integration is the wrong type', () => { + writeContext({ + integration: 42, + packageManager: 'pnpm', + schemas: [], + }) + expect(readContextFile(cwd)).toBeUndefined() + }) + + it('returns undefined when schemas is not an array', () => { + writeContext({ + integration: 'drizzle', + packageManager: 'pnpm', + schemas: 'oops', + }) + 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 418a4cce..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 @@ -10,10 +10,11 @@ const baseCtx: SetupPromptContext = { stackInstalled: true, cliInstalled: true, handoff: 'claude-code', + mode: 'implement', installedSkills: ['stash-encryption', 'stash-drizzle', 'stash-cli'], } -describe('renderSetupPrompt — orient + route', () => { +describe('renderSetupPrompt — orient + route (implement mode)', () => { it('emits integration + package manager in the header', () => { const out = renderSetupPrompt(baseCtx) expect(out).toContain('Integration: `drizzle`') @@ -139,4 +140,100 @@ describe('renderSetupPrompt — orient + route', () => { expect(out).toContain('serverExternalPackages') expect(out).toContain('@cipherstash/protect-ffi') }) + + it('directs the agent to read .cipherstash/plan.md first if it exists', () => { + // Plan mode produces .cipherstash/plan.md; if the user later runs init + // again in implement mode, the plan must be the source of truth — not + // a re-asked routing question. + const out = renderSetupPrompt(baseCtx) + expect(out).toContain('.cipherstash/plan.md') + expect(out).toMatch(/source of truth/i) + }) +}) + +describe('renderSetupPrompt — plan mode', () => { + const planCtx: SetupPromptContext = { ...baseCtx, mode: 'plan' } + + it('frames the deliverable as a plan file, not code changes', () => { + const out = renderSetupPrompt(planCtx) + expect(out).toContain('# CipherStash setup — write a plan') + expect(out).toContain('.cipherstash/plan.md') + expect(out).toMatch(/produce a plan file/i) + }) + + it('explicitly forbids mutating commands during planning', () => { + const out = renderSetupPrompt(planCtx) + expect(out).toContain('## What you must NOT do') + expect(out).toMatch(/db push/) + expect(out).toMatch(/encrypt backfill/) + expect(out).toMatch(/encrypt cutover/) + expect(out).toMatch(/encrypt drop/) + }) + + it('allows read-only inspection commands', () => { + const out = renderSetupPrompt(planCtx) + expect(out).toMatch(/db status/) + expect(out).toMatch(/Read-only/i) + }) + + it('tells the agent to offer copying the plan into docs/plans when it exists', () => { + const out = renderSetupPrompt(planCtx) + expect(out).toContain('docs/plans/') + expect(out).toMatch(/offer to copy/i) + }) + + it('lists project-specific risk classes the plan must cover', () => { + const out = renderSetupPrompt(planCtx) + expect(out).toMatch(/bundler exclusion/i) + expect(out).toMatch(/top-level-await/i) + expect(out).toMatch(/partial CipherStash/i) + }) + + it('requires the plan to identify which lifecycle path applies per column', () => { + const out = renderSetupPrompt(planCtx) + expect(out).toMatch(/path 1/i) + expect(out).toMatch(/path 3/i) + expect(out).toMatch(/four-deploy sequence/i) + }) + + it('still tells the agent its first response is an orientation message, not action', () => { + const out = renderSetupPrompt(planCtx) + expect(out).toContain('## Your first response') + expect(out).toMatch(/orientation message/i) + }) + + it('references concrete table/column names from .cipherstash/context.json', () => { + const out = renderSetupPrompt(planCtx) + 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`') + expect(out).toContain('Package manager: `pnpm`') + }) + + it('does not emit the implement-mode flow walkthroughs verbatim', () => { + // Plan mode summarises the two options in one line each rather than + // restating the full numbered walkthroughs; the walkthroughs live in + // the implement prompt. + const out = renderSetupPrompt(planCtx) + expect(out).not.toContain('### Add a new encrypted column') + expect(out).not.toContain('### Migrate an existing column to encrypted') + }) }) 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..9e810daa --- /dev/null +++ b/packages/cli/src/commands/init/lib/parse-plan.ts @@ -0,0 +1,121 @@ +/** + * 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 + // Empty `columns` is rejected: downstream `renderPlanSummary` would + // produce "0 columns across 0 tables — single-deploy", which is + // misleading. Treating empty as invalid lets `stash impl` fall back + // to the soft "open it in your editor" panel. + return ( + Array.isArray(obj.columns) && + obj.columns.length > 0 && + 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..bee0d042 --- /dev/null +++ b/packages/cli/src/commands/init/lib/read-context.ts @@ -0,0 +1,44 @@ +import { existsSync, readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { CONTEXT_REL_PATH, type ContextFile } from './write-context.js' + +/** + * Validate that a parsed JSON value has the minimum shape callers rely + * on. We only check the fields downstream code dereferences without + * a guard — `integration`, `packageManager`, and `schemas`. Other + * fields (cliVersion, generatedAt, etc.) are informational and absent + * values won't crash anything. + * + * A wider schema check would belong in a runtime validator (zod, etc.); + * this is the minimum to keep `stash status`, `stash plan`, and `stash + * impl` from hard-failing on a hand-edited or partial-write file. + */ +function isContextFile(x: unknown): x is ContextFile { + if (!x || typeof x !== 'object') return false + const obj = x as Record + return ( + typeof obj.integration === 'string' && + typeof obj.packageManager === 'string' && + Array.isArray(obj.schemas) + ) +} + +/** + * Read the `.cipherstash/context.json` file written by `stash init`. + * Returns `undefined` when the file is missing, unparseable, or doesn't + * have the expected shape — 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 and wrong-shape objects are + * both treated as "no context." + */ +export function readContextFile(cwd: string): ContextFile | undefined { + const path = resolve(cwd, CONTEXT_REL_PATH) + if (!existsSync(path)) return undefined + try { + const parsed = JSON.parse(readFileSync(path, 'utf-8')) as unknown + return isContextFile(parsed) ? parsed : undefined + } 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 3225d908..238dfc93 100644 --- a/packages/cli/src/commands/init/lib/setup-prompt.ts +++ b/packages/cli/src/commands/init/lib/setup-prompt.ts @@ -1,6 +1,8 @@ -import type { HandoffChoice, Integration } from '../types.js' +import type { HandoffChoice, InitMode, Integration } from '../types.js' import { type PackageManager, runnerCommand } from '../utils.js' +export const PLAN_REL_PATH = '.cipherstash/plan.md' + export interface SetupPromptContext { integration: Integration encryptionClientPath: string @@ -12,6 +14,11 @@ export interface SetupPromptContext { /** Which handoff option the user picked. Lets us tailor wording (e.g. the * Codex prompt names AGENTS.md, Claude names the skill). */ handoff: HandoffChoice + /** Whether the agent should produce a plan first or implement directly. + * Drives the entire prompt body — plan-mode tells the agent its task is + * to produce `.cipherstash/plan.md`; implement-mode is the original + * orient-and-route action prompt. */ + mode: InitMode /** Names of skills `stash init` copied into the project (e.g. * `stash-encryption`, `stash-drizzle`, `stash-cli`). The action prompt * names them so the agent knows which references to consult. Empty for @@ -133,11 +140,24 @@ function renderSkillIndex(installedSkills: string[]): string { } /** - * Render the project-specific action prompt. + * Render the project-specific action prompt. Dispatches to the plan-mode or + * implement-mode renderer based on `ctx.mode`. Both produce the same shape + * (orient → describe options → tell the agent what its first response is) + * but the *deliverable* differs: plan-mode writes `.cipherstash/plan.md`, + * implement-mode edits code and runs lifecycle commands. + */ +export function renderSetupPrompt(ctx: SetupPromptContext): string { + return ctx.mode === 'plan' + ? renderPlanPrompt(ctx) + : renderImplementPrompt(ctx) +} + +/** + * Render the implementation action prompt. * - * This is the file the agent reads first after `stash init` hands off. It - * does NOT prescribe a fixed sequence of edits — the agent doesn't yet know - * what the user wants. Instead the prompt: + * This is the file the agent reads first after `stash init` hands off in + * implement mode. It does NOT prescribe a fixed sequence of edits — the + * agent doesn't yet know what the user wants. Instead the prompt: * * 1. Confirms what setup is complete. * 2. Names the skills loaded and what each is for. @@ -148,8 +168,13 @@ function renderSkillIndex(installedSkills: string[]): string { * 4. Tells the agent its FIRST response should be a routing question, not * an action. * 5. Lists the "stop and ask" rules that override flow mechanics. + * + * If `.cipherstash/plan.md` exists (a previous `stash init --plan` run), + * the prompt directs the agent to read it first and treat it as the + * source of truth for routing — the user has already done the + * orientation pass. */ -export function renderSetupPrompt(ctx: SetupPromptContext): string { +export function renderImplementPrompt(ctx: SetupPromptContext): string { const cli = runnerCommand(ctx.packageManager, 'stash') const migration = migrationCommands(ctx.integration, ctx.packageManager) @@ -181,6 +206,12 @@ export function renderSetupPrompt(ctx: SetupPromptContext): string { '', '`stash init` has finished its mechanical setup. Your job is **not** to start editing schema or running migrations immediately. Your job is to **orient the user with the two real options for encrypting a column, then ask which one they want before touching anything**. Pick concrete table/column names from `.cipherstash/context.json` when describing the options so the user can recognise their own data.', '', + '## Existing plan', + '', + `Before anything else, check whether \`${PLAN_REL_PATH}\` exists. If it does, the user has already done a planning pass with you (or another agent). Read it as the source of truth for which path applies, which tables/columns are in scope, and the deploy ordering — do not re-ask the routing question. Confirm with the user that the plan is still current, then execute it. If the plan looks stale (the schema or context has moved on), say so and propose specific updates rather than starting fresh.`, + '', + `If \`${PLAN_REL_PATH}\` does **not** exist, proceed with the orient-and-route flow below.`, + '', '## What `stash init` already did', '', ...done, @@ -256,3 +287,155 @@ export function renderSetupPrompt(ctx: SetupPromptContext): string { return sections.join('\n') } + +/** + * Render the planning action prompt. + * + * Plan-mode tells the agent its task is to produce a reviewable plan file + * at `.cipherstash/plan.md` — no schema edits, no migrations, no `db push`, + * no `encrypt *` mutations during this phase. Read-only inspection + * (`stash db status`, schema grep, file reads) is fine. + * + * The plan covers: which table(s) and column(s) to protect, which + * lifecycle path applies per column (path 1 = new column / path 3 = + * migrate existing), the deploy ordering for path-3 columns, any + * project-specific risks, and the exact CLI sequence to execute when the + * user is ready to implement. + */ +export function renderPlanPrompt(ctx: SetupPromptContext): string { + const cli = runnerCommand(ctx.packageManager, 'stash') + + const done: string[] = [ + checked('Authenticated to CipherStash and selected a workspace'), + checked(`Detected integration: \`${ctx.integration}\``), + checked( + `Wrote a placeholder encryption client at \`${ctx.encryptionClientPath}\` (a small file showing the encryption-client patterns; the user's real Drizzle/Supabase schema files remain authoritative)`, + ), + ] + if (ctx.stackInstalled) { + done.push(checked('Installed `@cipherstash/stack` (runtime)')) + } + if (ctx.cliInstalled) { + done.push(checked('Installed `stash` (CLI, dev dep)')) + } + if (ctx.eqlInstalled) { + done.push( + checked( + 'Installed the EQL extension and `cipherstash.cs_migrations` into the database', + ), + ) + } + + const sections: string[] = [ + '# CipherStash setup — write a plan', + '', + `Integration: \`${ctx.integration}\` · Package manager: \`${ctx.packageManager}\``, + '', + `\`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', + '', + ...done, + '', + '## Skills loaded', + '', + `Reusable rules and worked examples live in ${rulesLocation(ctx.handoff)}:`, + '', + renderSkillIndex(ctx.installedSkills), + '', + 'Read the skills before answering API or pattern questions. The doctrine in `AGENTS.md` (or its inlined equivalent) covers the invariants that apply regardless of which flow you take — never log plaintext, never `.notNull()` on creation, etc.', + '', + '## The two options', + '', + 'There are exactly two supported ways to encrypt a column. The plan must identify which one applies per column:', + '', + '- **Add a new encrypted column** — the column does not yet exist; no plaintext predecessor to preserve. Single-deploy.', + '- **Migrate an existing column** — the column already exists with live data. Staged across four deploys: schema-add + dual-write → backfill run → cutover + read-from-encrypted → drop. The lifecycle is irreducible; the plan must spell out the deploy ordering so reviewers can sequence PRs correctly.', + '', + 'Converting a populated column in place is **not** supported — any "just swap the type" approach corrupts data. If the user asks for that, the plan must explain why and route them to the migrate-existing flow.', + '', + '## Your task: produce a plan file', + '', + `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).", + ), + bullet( + 'Which lifecycle path applies (path 1 = add new / path 3 = migrate existing). Justify briefly — the user should be able to verify the choice without reading the skill.', + ), + bullet( + 'For path-3 columns: the four-deploy sequence with one line per deploy on what changes (schema, app code, lifecycle command). Call out which step the deploy gates on (e.g. "deploy 2 must wait until deploy 1 is live in production before backfill is safe").', + ), + bullet( + `Project-specific risks. Common ones: bundler exclusion not yet configured (Next.js / webpack / Vite), top-level-await in the placeholder encryption client breaks non-Next contexts, existing partial CipherStash state (run \`${cli} db status\` and note any pre-existing encrypted columns or pending configs).`, + ), + bullet( + `The exact CLI sequence to execute when the user is ready to implement (the \`${cli} encrypt {backfill,cutover,drop}\` invocations with concrete \`--table\` / \`--column\` values).`, + ), + bullet( + "Open questions for the user — anything you can't determine from the schema, context.json, or the skills. Better to surface than guess.", + ), + '', + `After writing the plan, also offer to copy it into \`docs/plans/cipherstash-encryption.md\` if the project has a \`docs/plans/\` directory — many teams version their plans alongside the code. Don't copy without asking. If \`docs/plans/\` does not exist, leave the plan at \`${PLAN_REL_PATH}\` and don't create the directory.`, + '', + '## What you must NOT do', + '', + bullet( + 'Edit schema files, application code, or migration files. The plan describes future changes — it does not perform them.', + ), + bullet( + `Run \`${cli} db push\`, \`${cli} encrypt backfill\`, \`${cli} encrypt cutover\`, \`${cli} encrypt drop\`, \`${cli} db activate\`, or any other state-mutating command.`, + ), + bullet( + 'Run schema migrations (`drizzle-kit migrate`, `supabase migration up`, `prisma migrate`, etc.).', + ), + bullet( + 'Modify the placeholder encryption client beyond what is required to read it.', + ), + '', + `Read-only commands (\`${cli} db status\`, file reads, greps, \`${cli} doctor\` if available) are fine and encouraged — the plan is more useful when grounded in the actual current state.`, + '', + '## Your first response', + '', + `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 to run \`${cli} impl\` to execute it.`, + '', + '## Stop and ask the user when', + '', + bullet( + "The user asks to convert a populated column in place. Explain why it doesn't work and offer the migrate-existing-column flow instead.", + ), + bullet( + "A column the user names is already encrypted (`eql_v2_encrypted` udt) but with a different EQL config than they've described. This is the post-cutover re-encryption case (`stash encrypt update`, not yet shipped) — surface it in the plan as a flagged risk.", + ), + bullet( + 'You discover existing partial CipherStash setup that disagrees with what the user is describing — someone else may have run `stash init` earlier with different choices. Note this in the plan and ask the user to clarify before writing prescriptive steps.', + ), + bullet( + "The user names columns that don't appear in `.cipherstash/context.json` or in the schema files you can see. Confirm the names rather than guessing.", + ), + '', + ] + + return sections.join('\n') +} diff --git a/packages/cli/src/commands/init/lib/write-context.ts b/packages/cli/src/commands/init/lib/write-context.ts index 74d85006..1bffbdff 100644 --- a/packages/cli/src/commands/init/lib/write-context.ts +++ b/packages/cli/src/commands/init/lib/write-context.ts @@ -154,6 +154,7 @@ export function buildSetupPromptContext( stackInstalled: state.stackInstalled ?? false, cliInstalled: state.cliInstalled ?? false, handoff, + mode: state.mode ?? 'implement', installedSkills, } } 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 bf8e290f..00000000 --- a/packages/cli/src/commands/init/steps/next-steps.ts +++ /dev/null @@ -1,15 +0,0 @@ -import * as p from '@clack/prompts' -import type { InitProvider, InitState, InitStep } from '../types.js' -import { detectPackageManager } from '../utils.js' - -export const nextStepsStep: InitStep = { - id: 'next-steps', - name: 'Next steps', - async run(state: InitState, provider: InitProvider): Promise { - const pm = detectPackageManager() - 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 6e957fc8..438a6be7 100644 --- a/packages/cli/src/commands/init/types.ts +++ b/packages/cli/src/commands/init/types.ts @@ -20,6 +20,14 @@ export interface SchemaDef { export type HandoffChoice = 'claude-code' | 'codex' | 'agents-md' | 'wizard' +/** + * Whether the handoff agent should produce a plan first (`plan`) or go + * straight to implementation (`implement`). `plan` is the default — it + * gives the user a reviewable plan file at `.cipherstash/plan.md` before + * any code or schema changes happen. + */ +export type InitMode = 'plan' | 'implement' + export interface InitState { authenticated?: boolean /** Resolved DATABASE_URL. Set by resolve-database; threaded into every @@ -50,14 +58,40 @@ export interface InitState { agents?: AgentEnvironment /** What the user picked at the "how to proceed" step. */ handoff?: HandoffChoice + /** 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 } +/** + * A step that runs as part of the `stash init` pipeline. The init + * pipeline owns the `InitProvider` (intro copy, provider-specific + * defaults) and threads it into every step. Some init steps consult it + * (e.g. `authenticateStep` reads `provider.name` for telemetry) so the + * argument is required at the type level — calling + * `authenticateStep.run(state)` without a provider would crash. + */ export interface InitStep { id: string name: string run(state: InitState, provider: InitProvider): Promise } +/** + * A step that runs after init has finished — invoked by `stash plan` and + * `stash impl` to drive the agent handoff. These steps don't have an + * `InitProvider` available (init owns that abstraction) and don't need + * one, so the type intentionally omits it. Keeping `InitStep` and + * `HandoffStep` distinct prevents callers from accidentally invoking + * init-only steps from the post-init flow. + */ +export interface HandoffStep { + id: string + name: string + run(state: InitState): Promise +} + export interface InitProvider { name: string introMessage: string 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..3c8e5013 --- /dev/null +++ b/packages/cli/src/commands/status/index.ts @@ -0,0 +1,136 @@ +import { existsSync } from 'node:fs' +import { resolve } from 'node:path' +import * as p from '@clack/prompts' +import { readContextFile } from '../init/lib/read-context.js' +import { PLAN_REL_PATH } from '../init/lib/setup-prompt.js' +import { + 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 context = readContextFile(cwd) + 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)) +}