From 1a97d40be88d196c14f8a3a3c395a48bd88be388 Mon Sep 17 00:00:00 2001 From: CJ Brewer Date: Wed, 6 May 2026 16:29:12 -0600 Subject: [PATCH 1/2] feat(cli, wizard): support plan mode in wizard --- .changeset/wizard-plan-mode.md | 8 + .../impl/__tests__/how-to-proceed.test.ts | 19 +- .../src/commands/impl/steps/handoff-wizard.ts | 8 +- .../src/commands/impl/steps/how-to-proceed.ts | 54 +++-- .../wizard/src/__tests__/parse-args.test.ts | 68 +++++++ .../src/agent/__tests__/interface.test.ts | 22 +- packages/wizard/src/agent/fetch-prompt.ts | 17 +- packages/wizard/src/agent/hooks.ts | 74 ++++++- packages/wizard/src/agent/interface.ts | 10 +- packages/wizard/src/bin/parse-args.ts | 65 ++++++ packages/wizard/src/bin/wizard.ts | 61 +++--- packages/wizard/src/lib/detect.ts | 28 ++- packages/wizard/src/lib/format.ts | 27 ++- packages/wizard/src/lib/gather.ts | 129 +++++++++--- packages/wizard/src/lib/prerequisites.ts | 4 +- packages/wizard/src/lib/types.ts | 17 +- packages/wizard/src/run.ts | 192 ++++++++++++------ skills/stash-cli/SKILL.md | 100 ++++++++- 18 files changed, 681 insertions(+), 222 deletions(-) create mode 100644 .changeset/wizard-plan-mode.md create mode 100644 packages/wizard/src/__tests__/parse-args.test.ts create mode 100644 packages/wizard/src/bin/parse-args.ts diff --git a/.changeset/wizard-plan-mode.md b/.changeset/wizard-plan-mode.md new file mode 100644 index 00000000..266b6cf1 --- /dev/null +++ b/.changeset/wizard-plan-mode.md @@ -0,0 +1,8 @@ +--- +"@cipherstash/wizard": minor +"stash": minor +--- + +Add plan-mode support to the wizard so `stash plan` can hand off to the CipherStash Agent. The wizard now accepts `--mode ` (default `implement` for back-compat). In plan mode it skips the column-selection TUI, forwards `mode: 'plan'` to the gateway (which returns a planning prompt whose deliverable is `.cipherstash/plan.md`), and skips the post-agent install/push/migrate and call-site-scan steps. Implement mode is unchanged. + +`stash plan`'s handoff picker now offers all four targets (Claude Code, Codex, AGENTS.md, CipherStash Agent) — the wizard is no longer gated out of plan mode. `stash impl`'s picker is unchanged. 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 index df9c9082..5f1e375e 100644 --- a/packages/cli/src/commands/impl/__tests__/how-to-proceed.test.ts +++ b/packages/cli/src/commands/impl/__tests__/how-to-proceed.test.ts @@ -24,13 +24,13 @@ 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']) + expect(values).toEqual(['claude-code', 'codex', 'agents-md', 'wizard']) }) - it('offers only claude-code and codex in plan mode', () => { + it('offers all four targets in plan mode', () => { const opts = buildOptions(noAgents, 'plan') const values = opts.map((o) => o.value) - expect(values).toEqual(['claude-code', 'codex']) + expect(values).toEqual(['claude-code', 'codex', 'agents-md', 'wizard']) }) it('reflects detection state in hints regardless of mode', () => { @@ -56,14 +56,11 @@ describe('howToProceed — defaultChoice', () => { expect(defaultChoice(codexOnly, 'plan')).toBe('codex') }) - it('falls back to agents-md in implement mode when no CLI is detected', () => { + it('falls back to agents-md in both modes when no CLI is detected', () => { + // AGENTS.md is the broadest "works without anything else installed" + // option, so it's the right default in either mode when no agent CLI + // is on PATH. 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') + expect(defaultChoice(noAgents, 'plan')).toBe('agents-md') }) }) diff --git a/packages/cli/src/commands/impl/steps/handoff-wizard.ts b/packages/cli/src/commands/impl/steps/handoff-wizard.ts index 0d2d57d3..0642dcb5 100644 --- a/packages/cli/src/commands/impl/steps/handoff-wizard.ts +++ b/packages/cli/src/commands/impl/steps/handoff-wizard.ts @@ -33,12 +33,12 @@ export const handoffWizardStep: HandoffStep = { writeContextFile(contextAbs, ctx) p.log.success(`Wrote ${CONTEXT_REL_PATH}`) - // Pass through no extra flags. If a user wants to debug the wizard, they - // can re-run `stash wizard --debug` directly afterwards. - const exitCode = await runWizardSpawn([]) + const mode = state.mode ?? 'implement' + const exitCode = await runWizardSpawn(['--mode', mode]) if (exitCode !== 0) { + const resume = mode === 'plan' ? 'stash plan' : 'stash impl' p.log.warn( - `Wizard exited with code ${exitCode}. Re-run \`stash wizard\` to resume.`, + `Wizard exited with code ${exitCode}. Re-run \`${resume}\` to resume.`, ) } diff --git a/packages/cli/src/commands/impl/steps/how-to-proceed.ts b/packages/cli/src/commands/impl/steps/how-to-proceed.ts index d5263974..ab1233b0 100644 --- a/packages/cli/src/commands/impl/steps/how-to-proceed.ts +++ b/packages/cli/src/commands/impl/steps/how-to-proceed.ts @@ -18,27 +18,28 @@ import { handoffWizardStep } from './handoff-wizard.js' * 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. In plan mode, AGENTS.md and wizard aren't - * offered — the default falls back to `claude-code`. + * never selected by default. The same defaulting applies in both `plan` and + * `implement` modes; `mode` is plumbed in so future asymmetries can be added + * without a wider refactor. */ -export function defaultChoice(state: InitState, mode: InitMode): 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 mode === 'plan' ? 'claude-code' : 'agents-md' + return 'agents-md' } /** - * 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. + * Build the option list for the menu. Hints reflect detection state, not + * availability — a missing CLI doesn't hide the option (handoff steps + * still write the rules files and print install instructions), it just + * nudges the user toward what's already on PATH. */ export function buildOptions( state: InitState, - mode: InitMode, + _mode: InitMode, ): { value: HandoffChoice; label: string; hint?: string }[] { const claudeHint = state.agents?.cli.claudeCode ? 'claude detected — will launch interactively' @@ -47,7 +48,7 @@ export function buildOptions( ? 'codex detected — will launch interactively' : 'codex not on PATH — files will be written, install link shown' - const options: { value: HandoffChoice; label: string; hint?: string }[] = [ + return [ { value: 'claude-code', label: 'Hand off to Claude Code', @@ -58,24 +59,17 @@ export function buildOptions( label: 'Hand off to Codex', hint: codexHint, }, + { + value: 'agents-md', + label: 'Write AGENTS.md', + hint: 'works with Cursor, Windsurf, Cline, and more', + }, + { + value: 'wizard', + label: 'Use the CipherStash Agent', + hint: 'our hosted setup wizard (runs `stash wizard`)', + }, ] - - 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: HandoffStep = { diff --git a/packages/wizard/src/__tests__/parse-args.test.ts b/packages/wizard/src/__tests__/parse-args.test.ts new file mode 100644 index 00000000..604b06f4 --- /dev/null +++ b/packages/wizard/src/__tests__/parse-args.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from 'vitest' +import { parseArgs } from '../bin/parse-args.js' + +// `parseArgs` takes the full process.argv (node + script + args), so the +// test shapes its inputs the same way: ['node', 'wizard.js', ...flags]. +function argv(...flags: string[]): string[] { + return ['node', 'wizard.js', ...flags] +} + +describe('wizard parseArgs — mode resolution', () => { + it('defaults to implement when no mode flag is passed', () => { + expect(parseArgs(argv()).mode).toBe('implement') + // --debug alone shouldn't change mode + expect(parseArgs(argv('--debug')).mode).toBe('implement') + }) + + it('accepts the --plan shortcut', () => { + expect(parseArgs(argv('--plan')).mode).toBe('plan') + }) + + it('accepts the --implement shortcut (no-op against the default)', () => { + expect(parseArgs(argv('--implement')).mode).toBe('implement') + }) + + it('accepts --mode plan (space-separated long form)', () => { + expect(parseArgs(argv('--mode', 'plan')).mode).toBe('plan') + expect(parseArgs(argv('--mode', 'implement')).mode).toBe('implement') + }) + + it('accepts --mode=plan (equals-separated long form)', () => { + expect(parseArgs(argv('--mode=plan')).mode).toBe('plan') + expect(parseArgs(argv('--mode=implement')).mode).toBe('implement') + }) + + it('rejects unknown --mode values with a clear error', () => { + const result = parseArgs(argv('--mode', 'yolo')) + expect(result.modeError).toMatch(/Unknown --mode value/) + expect(result.modeError).toMatch(/yolo/) + }) + + it('rejects unknown --mode= values with a clear error', () => { + const result = parseArgs(argv('--mode=yolo')) + expect(result.modeError).toMatch(/Unknown --mode value/) + }) + + it('lets the last mode flag win when multiple are passed', () => { + // Useful for wrappers that always append a mode flag — they don't have + // to detect and remove an earlier one. + expect(parseArgs(argv('--plan', '--implement')).mode).toBe('implement') + expect(parseArgs(argv('--implement', '--plan')).mode).toBe('plan') + expect(parseArgs(argv('--mode', 'plan', '--implement')).mode).toBe( + 'implement', + ) + expect(parseArgs(argv('--implement', '--mode=plan')).mode).toBe('plan') + }) + + it('threads --debug independently of mode flags', () => { + expect(parseArgs(argv('--plan', '--debug')).debug).toBe(true) + expect(parseArgs(argv('--debug', '--plan')).mode).toBe('plan') + }) + + it('exposes --help and --version flags independently', () => { + expect(parseArgs(argv('--help')).help).toBe(true) + expect(parseArgs(argv('-h')).help).toBe(true) + expect(parseArgs(argv('--version')).version).toBe(true) + expect(parseArgs(argv('-v')).version).toBe(true) + }) +}) diff --git a/packages/wizard/src/agent/__tests__/interface.test.ts b/packages/wizard/src/agent/__tests__/interface.test.ts index 4350ede2..755c463b 100644 --- a/packages/wizard/src/agent/__tests__/interface.test.ts +++ b/packages/wizard/src/agent/__tests__/interface.test.ts @@ -57,27 +57,29 @@ describe('wizardCanUseTool — DLX command allowlist', () => { }) it('allows pnpm add', () => { - expect(wizardCanUseTool('Bash', { command: 'pnpm add some-package' })).toBe( - true, - ) + expect( + wizardCanUseTool('Bash', { command: 'pnpm add some-package' }), + ).toBe(true) }) it('allows yarn add', () => { - expect(wizardCanUseTool('Bash', { command: 'yarn add some-package' })).toBe( - true, - ) + expect( + wizardCanUseTool('Bash', { command: 'yarn add some-package' }), + ).toBe(true) }) it('allows bun add', () => { - expect(wizardCanUseTool('Bash', { command: 'bun add some-package' })).toBe( - true, - ) + expect( + wizardCanUseTool('Bash', { command: 'bun add some-package' }), + ).toBe(true) }) }) describe('allows stash db commands', () => { it('allows stash db install', () => { - expect(wizardCanUseTool('Bash', { command: 'stash db install' })).toBe(true) + expect(wizardCanUseTool('Bash', { command: 'stash db install' })).toBe( + true, + ) }) it('allows stash db push', () => { diff --git a/packages/wizard/src/agent/fetch-prompt.ts b/packages/wizard/src/agent/fetch-prompt.ts index 01078c38..4d38ee56 100644 --- a/packages/wizard/src/agent/fetch-prompt.ts +++ b/packages/wizard/src/agent/fetch-prompt.ts @@ -1,6 +1,6 @@ import auth from '@cipherstash/auth' import { GATEWAY_URL } from '../lib/constants.js' -import type { GatheredContext } from '../lib/gather.js' +import type { GatheredContext, WizardMode } from '../lib/gather.js' import { classifyHttpError, formatWizardError } from './errors.js' const { AutoStrategy } = auth @@ -16,11 +16,19 @@ interface GatewayErrorBody { error?: { type?: string; message?: string } } +export interface FetchIntegrationPromptOptions { + ctx: GatheredContext + cliVersion: string + runner: string + mode?: WizardMode +} + export async function fetchIntegrationPrompt( - ctx: GatheredContext, - cliVersion: string, - runner: string, + options: FetchIntegrationPromptOptions, ): Promise { + const { ctx, cliVersion, runner } = options + const mode: WizardMode = options.mode ?? 'implement' + const strategy = AutoStrategy.detect() const { token } = await strategy.getToken() @@ -36,6 +44,7 @@ export async function fetchIntegrationPrompt( version: 'v1', clientVersion: cliVersion, integration: ctx.integration, + mode, context: { selectedColumns: ctx.selectedColumns, schemaFiles: ctx.schemaFiles, diff --git a/packages/wizard/src/agent/hooks.ts b/packages/wizard/src/agent/hooks.ts index 1762c987..89500e28 100644 --- a/packages/wizard/src/agent/hooks.ts +++ b/packages/wizard/src/agent/hooks.ts @@ -13,14 +13,46 @@ interface ScanResult { // --- Pre-execution rules --- -export const DANGEROUS_BASH_OPERATORS = [';', '`', '$', '(', ')', '|', '&&', '||', '>', '>>', '<'] +export const DANGEROUS_BASH_OPERATORS = [ + ';', + '`', + '$', + '(', + ')', + '|', + '&&', + '||', + '>', + '>>', + '<', +] const BLOCKED_BASH_PATTERNS = [ - { pattern: /rm\s+-rf/i, rule: 'destructive_rm', reason: 'Recursive force delete blocked' }, - { pattern: /git\s+push\s+--force/i, rule: 'git_force_push', reason: 'Force push blocked' }, - { pattern: /git\s+reset\s+--hard/i, rule: 'git_reset_hard', reason: 'Hard reset blocked' }, - { pattern: /curl.*\$.*KEY/i, rule: 'secret_exfiltration', reason: 'Potential secret exfiltration via curl' }, - { pattern: /cat.*\.env/i, rule: 'env_file_read', reason: 'Direct .env file read blocked — use wizard-tools MCP' }, + { + pattern: /rm\s+-rf/i, + rule: 'destructive_rm', + reason: 'Recursive force delete blocked', + }, + { + pattern: /git\s+push\s+--force/i, + rule: 'git_force_push', + reason: 'Force push blocked', + }, + { + pattern: /git\s+reset\s+--hard/i, + rule: 'git_reset_hard', + reason: 'Hard reset blocked', + }, + { + pattern: /curl.*\$.*KEY/i, + rule: 'secret_exfiltration', + reason: 'Potential secret exfiltration via curl', + }, + { + pattern: /cat.*\.env/i, + rule: 'env_file_read', + reason: 'Direct .env file read blocked — use wizard-tools MCP', + }, ] /** Scan a Bash command before execution. */ @@ -51,14 +83,34 @@ export function scanPreToolUse(toolName: string, input: string): ScanResult { // --- Post-execution rules --- const PROMPT_INJECTION_PATTERNS = [ - { pattern: /ignore\s+previous\s+instructions/i, rule: 'prompt_injection_override', severity: 'critical' as const }, - { pattern: /you\s+are\s+now\s+a\s+different/i, rule: 'prompt_injection_identity', severity: 'medium' as const }, + { + pattern: /ignore\s+previous\s+instructions/i, + rule: 'prompt_injection_override', + severity: 'critical' as const, + }, + { + pattern: /you\s+are\s+now\s+a\s+different/i, + rule: 'prompt_injection_identity', + severity: 'medium' as const, + }, ] const SECRET_PATTERNS = [ - { pattern: /phc_[a-zA-Z0-9]{20,}/, rule: 'hardcoded_posthog_key', reason: 'PostHog API key in code' }, - { pattern: /sk_live_[a-zA-Z0-9]+/, rule: 'hardcoded_stripe_key', reason: 'Stripe live key in code' }, - { pattern: /password\s*=\s*['"][^'"]+['"]/i, rule: 'hardcoded_password', reason: 'Hardcoded password detected' }, + { + pattern: /phc_[a-zA-Z0-9]{20,}/, + rule: 'hardcoded_posthog_key', + reason: 'PostHog API key in code', + }, + { + pattern: /sk_live_[a-zA-Z0-9]+/, + rule: 'hardcoded_stripe_key', + reason: 'Stripe live key in code', + }, + { + pattern: /password\s*=\s*['"][^'"]+['"]/i, + rule: 'hardcoded_password', + reason: 'Hardcoded password detected', + }, ] /** Scan file content after a write/edit operation. */ diff --git a/packages/wizard/src/agent/interface.ts b/packages/wizard/src/agent/interface.ts index 59934d32..d97df7be 100644 --- a/packages/wizard/src/agent/interface.ts +++ b/packages/wizard/src/agent/interface.ts @@ -13,9 +13,9 @@ import type { PermissionResult } from '@anthropic-ai/claude-agent-sdk' import auth from '@cipherstash/auth' import * as p from '@clack/prompts' import { GATEWAY_URL } from '../lib/constants.js' +import { PACKAGE_MANAGERS } from '../lib/detect.js' import { formatAgentOutput } from '../lib/format.js' import type { WizardSession } from '../lib/types.js' -import { PACKAGE_MANAGERS } from '../lib/detect.js' import { classifyError, formatWizardError } from './errors.js' import { scanPreToolUse } from './hooks.js' @@ -47,7 +47,9 @@ export interface WizardAgentResult { } /** Package manager DLX runner prefixes (tools run via runner dlx). */ -const RUNNER_PREFIXES = Object.values(PACKAGE_MANAGERS).map((pm) => pm.execCommand) +const RUNNER_PREFIXES = Object.values(PACKAGE_MANAGERS).map( + (pm) => pm.execCommand, +) /** Tools allowed to run via any DLX runner. */ const ALLOWED_DLX_TOOLS = ['drizzle-kit', 'tsc', 'stash db'] as const @@ -87,7 +89,9 @@ function isAllowedDlxCommand(cmd: string): boolean { // Token-boundary match: the tool name must be the entire remainder, or // the tool name followed by a space (then args). A bare `startsWith` // would let `drizzle-kit-malicious` slip through `drizzle-kit`. - return ALLOWED_DLX_TOOLS.some((t) => rest === t || rest.startsWith(`${t} `)) + return ALLOWED_DLX_TOOLS.some( + (t) => rest === t || rest.startsWith(`${t} `), + ) } } return false diff --git a/packages/wizard/src/bin/parse-args.ts b/packages/wizard/src/bin/parse-args.ts new file mode 100644 index 00000000..f497a950 --- /dev/null +++ b/packages/wizard/src/bin/parse-args.ts @@ -0,0 +1,65 @@ +/** + * Pure CLI argument parser for the wizard binary. Lives in its own module + * so tests can import it without triggering the binary's top-level + * `main()` side effect. + */ + +import type { WizardMode } from '../lib/types.js' + +export interface ParsedArgs { + help: boolean + version: boolean + debug: boolean + mode: WizardMode + modeError?: string +} + +export function parseArgs(argv: string[]): ParsedArgs { + const args = argv.slice(2) + const flags = new Set(args) + + let mode: WizardMode = 'implement' + let modeError: string | undefined + + // Last mode-setting flag wins so wrappers can append `--plan` / + // `--implement` without first scrubbing an earlier value. + for (let i = 0; i < args.length; i++) { + const arg = args[i] + if (arg === '--plan') { + mode = 'plan' + continue + } + if (arg === '--implement') { + mode = 'implement' + continue + } + if (arg === '--mode' && i + 1 < args.length) { + const next = args[i + 1] ?? '' + if (next === 'plan' || next === 'implement') { + mode = next + i++ + } else { + modeError = `Unknown --mode value: ${next}. Expected 'plan' or 'implement'.` + break + } + continue + } + if (arg.startsWith('--mode=')) { + const value = arg.slice('--mode='.length) + if (value === 'plan' || value === 'implement') { + mode = value + } else { + modeError = `Unknown --mode value: ${value}. Expected 'plan' or 'implement'.` + break + } + } + } + + return { + help: flags.has('--help') || flags.has('-h'), + version: flags.has('--version') || flags.has('-v'), + debug: flags.has('--debug'), + mode, + modeError, + } +} diff --git a/packages/wizard/src/bin/wizard.ts b/packages/wizard/src/bin/wizard.ts index abb393ea..9452261e 100644 --- a/packages/wizard/src/bin/wizard.ts +++ b/packages/wizard/src/bin/wizard.ts @@ -1,20 +1,24 @@ -import { config } from 'dotenv' - -// Load env files in Next.js precedence order. dotenv's default behavior is to -// not overwrite vars that are already set, so loading .env.local first means -// its values win over .env for the same keys. Users can still set anything in -// the real environment to override both. -config({ path: '.env.local' }) -config({ path: '.env.development.local' }) -config({ path: '.env.development' }) -config({ path: '.env' }) - import { readFileSync } from 'node:fs' import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' import * as p from '@clack/prompts' +import { config } from 'dotenv' import { detectPackageManager } from '../lib/detect.js' import { run } from '../run.js' +import { parseArgs } from './parse-args.js' + +/** + * Load env files in Next.js precedence order. dotenv's default behaviour + * is to not overwrite vars already set, so loading `.env.local` first + * means its values win over `.env` for the same keys. Users can still + * set anything in the real environment to override both. + */ +function loadDotenv(): void { + config({ path: '.env.local' }) + config({ path: '.env.development.local' }) + config({ path: '.env.development' }) + config({ path: '.env' }) +} const __dirname = dirname(fileURLToPath(import.meta.url)) const pkg = JSON.parse( @@ -35,26 +39,16 @@ Options: --help, -h Show help --version, -v Show version --debug Print extra diagnostics from the agent + --plan Drafts \`.cipherstash/plan.md\` for review. + No code or schema changes, no db pushes. + --implement Full setup flow (the default). + --mode + Long form of \`--plan\` / \`--implement\`. Last mode + flag wins if multiple are passed. `.trim() -interface ParsedArgs { - help: boolean - version: boolean - debug: boolean -} - -function parseArgs(argv: string[]): ParsedArgs { - const args = argv.slice(2) - const flags = new Set(args) - return { - help: flags.has('--help') || flags.has('-h'), - version: flags.has('--version') || flags.has('-v'), - debug: flags.has('--debug'), - } -} - async function main() { - const { help, version, debug } = parseArgs(process.argv) + const { help, version, debug, mode, modeError } = parseArgs(process.argv) if (help) { console.log(HELP) @@ -66,10 +60,21 @@ async function main() { return } + if (modeError) { + p.log.error(modeError) + process.exit(1) + } + + // Defer env loading until we're actually running — `--help` / `--version` + // shouldn't pay for it, and a malformed `--mode` should exit before it + // touches disk. + loadDotenv() + await run({ cwd: process.cwd(), debug, cliVersion: pkg.version, + mode, }) } diff --git a/packages/wizard/src/lib/detect.ts b/packages/wizard/src/lib/detect.ts index 01392722..e7231cff 100644 --- a/packages/wizard/src/lib/detect.ts +++ b/packages/wizard/src/lib/detect.ts @@ -49,10 +49,30 @@ export const PACKAGE_MANAGERS: Record< 'bun' | 'pnpm' | 'yarn' | 'npm', DetectedPackageManager > = { - bun: { name: 'bun', installCommand: 'bun add', runCommand: 'bun run', execCommand: 'bunx' }, - pnpm: { name: 'pnpm', installCommand: 'pnpm add', runCommand: 'pnpm run', execCommand: 'pnpm dlx' }, - yarn: { name: 'yarn', installCommand: 'yarn add', runCommand: 'yarn run', execCommand: 'yarn dlx' }, - npm: { name: 'npm', installCommand: 'npm install', runCommand: 'npm run', execCommand: 'npx' }, + bun: { + name: 'bun', + installCommand: 'bun add', + runCommand: 'bun run', + execCommand: 'bunx', + }, + pnpm: { + name: 'pnpm', + installCommand: 'pnpm add', + runCommand: 'pnpm run', + execCommand: 'pnpm dlx', + }, + yarn: { + name: 'yarn', + installCommand: 'yarn add', + runCommand: 'yarn run', + execCommand: 'yarn dlx', + }, + npm: { + name: 'npm', + installCommand: 'npm install', + runCommand: 'npm run', + execCommand: 'npx', + }, } /** diff --git a/packages/wizard/src/lib/format.ts b/packages/wizard/src/lib/format.ts index d7bc9304..2dc7dce1 100644 --- a/packages/wizard/src/lib/format.ts +++ b/packages/wizard/src/lib/format.ts @@ -59,10 +59,14 @@ export function formatAgentOutput(text: string): string { } // Bullet points with bold label: - **label** — rest - const bulletBoldMatch = line.match(/^\s*[-*]\s+\*\*(.+?)\*\*\s*[-—:]?\s*(.*)/) + const bulletBoldMatch = line.match( + /^\s*[-*]\s+\*\*(.+?)\*\*\s*[-—:]?\s*(.*)/, + ) if (bulletBoldMatch) { const [, label, rest] = bulletBoldMatch - result.push(` ${pc.dim('•')} ${pc.bold(label)}${rest ? pc.dim(' — ') + rest : ''}`) + result.push( + ` ${pc.dim('•')} ${pc.bold(label)}${rest ? pc.dim(' — ') + rest : ''}`, + ) continue } @@ -97,11 +101,16 @@ export function formatAgentOutput(text: string): string { * Format inline markdown: **bold**, `code`, and links. */ function formatInline(text: string): string { - return text - // Bold - .replace(/\*\*(.+?)\*\*/g, (_, content) => pc.bold(content)) - // Inline code - .replace(/`([^`]+)`/g, (_, content) => pc.cyan(content)) - // Links [text](url) — show text, dim the URL - .replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, linkText, url) => `${pc.underline(linkText)} ${pc.dim(`(${url})`)}`) + return ( + text + // Bold + .replace(/\*\*(.+?)\*\*/g, (_, content) => pc.bold(content)) + // Inline code + .replace(/`([^`]+)`/g, (_, content) => pc.cyan(content)) + // Links [text](url) — show text, dim the URL + .replace( + /\[([^\]]+)\]\(([^)]+)\)/g, + (_, linkText, url) => `${pc.underline(linkText)} ${pc.dim(`(${url})`)}`, + ) + ) } diff --git a/packages/wizard/src/lib/gather.ts b/packages/wizard/src/lib/gather.ts index 75b2026b..9c1838de 100644 --- a/packages/wizard/src/lib/gather.ts +++ b/packages/wizard/src/lib/gather.ts @@ -6,12 +6,25 @@ * This eliminates the majority of API round trips. */ -import { existsSync, readFileSync, readdirSync } from 'node:fs' -import { resolve, join } from 'node:path' +import { + existsSync, + openSync, + readFileSync, + readSync, + readdirSync, +} from 'node:fs' +import { closeSync } from 'node:fs' +import { join, resolve } from 'node:path' import * as p from '@clack/prompts' import { introspectDatabase } from '../tools/wizard-tools.js' import { checkEnvKeys } from '../tools/wizard-tools.js' -import type { Integration, DetectedPackageManager } from './types.js' +import type { + DetectedPackageManager, + Integration, + WizardMode, +} from './types.js' + +export type { WizardMode } from './types.js' export interface ColumnSelection { tableName: string @@ -23,7 +36,8 @@ export interface ColumnSelection { export interface GatheredContext { /** The integration type. */ integration: Integration - /** Tables and columns the user selected for encryption. */ + /** Tables and columns the user selected for encryption. Empty in plan + * mode — the planning agent proposes scope. */ selectedColumns: ColumnSelection[] /** Drizzle schema file paths and their contents (drizzle only). */ schemaFiles: Array<{ path: string; content: string }> @@ -35,15 +49,28 @@ export interface GatheredContext { hasStashConfig: boolean } +export interface GatherContextOptions { + cwd: string + integration: Integration + packageManager: DetectedPackageManager | undefined + mode?: WizardMode +} + /** * Gather all context needed for the agent via CLI prompts and local I/O. * No AI calls are made here — this is pure CLI interaction. + * + * Plan mode skips the column-selection TUI because the planning agent's + * job is to *propose* scope, not act on a pre-committed list. Schema + * files are still collected so the agent has something to ground its + * proposals in. */ export async function gatherContext( - cwd: string, - integration: Integration, - packageManager: DetectedPackageManager | undefined, + options: GatherContextOptions, ): Promise { + const { cwd, integration, packageManager } = options + const mode: WizardMode = options.mode ?? 'implement' + const installCmd = packageManager ? `${packageManager.installCommand} @cipherstash/stack` : 'npm install @cipherstash/stack' @@ -52,24 +79,27 @@ export async function gatherContext( existsSync(resolve(cwd, 'stash.config.ts')) || existsSync(resolve(cwd, 'stash.config.js')) - // Try DB introspection first - const tables = await tryIntrospect(cwd) - - // Get column selections from user let selectedColumns: ColumnSelection[] - if (tables && tables.length > 0) { - selectedColumns = await selectColumnsFromDb(tables) + if (mode === 'plan') { + selectedColumns = [] } else { - selectedColumns = await selectColumnsManually() - } + const tables = await tryIntrospect(cwd) + if (tables && tables.length > 0) { + selectedColumns = await selectColumnsFromDb(tables) + } else { + selectedColumns = await selectColumnsManually() + } - if (selectedColumns.length === 0) { - p.log.warn('No columns selected for encryption.') - p.cancel('Nothing to do.') - process.exit(0) + if (selectedColumns.length === 0) { + p.log.warn('No columns selected for encryption.') + p.cancel('Nothing to do.') + process.exit(0) + } } - // For Drizzle, find schema files + // Schema files are collected in both modes — the planning agent grounds + // its proposals in real schema content; the implementation agent edits + // it directly. let schemaFiles: Array<{ path: string; content: string }> = [] if (integration === 'drizzle') { schemaFiles = findDrizzleSchemaFiles(cwd) @@ -123,7 +153,8 @@ async function tryIntrospect(cwd: string): Promise { if (!dbUrl) { // Ask user for DATABASE_URL const urlInput = await p.text({ - message: 'Enter your DATABASE_URL (or press Enter to skip and enter tables manually):', + message: + 'Enter your DATABASE_URL (or press Enter to skip and enter tables manually):', placeholder: 'postgresql://user:pass@host:5432/dbname', }) @@ -186,7 +217,9 @@ async function selectColumnsFromDb( ) if (encryptableColumns.length === 0) { - p.log.info(`No encryptable columns found in ${tableName} (IDs, timestamps, and already-encrypted columns are excluded).`) + p.log.info( + `No encryptable columns found in ${tableName} (IDs, timestamps, and already-encrypted columns are excluded).`, + ) continue } @@ -243,11 +276,18 @@ async function selectColumnsManually(): Promise { if (p.isCancel(columnNames) || !columnNames?.trim()) break - for (const col of columnNames.split(',').map((c) => c.trim()).filter(Boolean)) { + for (const col of columnNames + .split(',') + .map((c) => c.trim()) + .filter(Boolean)) { const dataType = await p.select({ message: `Data type for "${tableName}.${col}":`, options: [ - { value: 'text', label: 'Text / String', hint: 'varchar, text, char, uuid' }, + { + value: 'text', + label: 'Text / String', + hint: 'varchar, text, char, uuid', + }, { value: 'number', label: 'Number', hint: 'integer, float, numeric' }, { value: 'boolean', label: 'Boolean' }, { value: 'date', label: 'Date / Timestamp' }, @@ -320,6 +360,35 @@ function findDrizzleSchemaFiles( return results } +/** + * `pgTable` is a Drizzle import that, when present, is in the file's + * import block — typically within the first kilobyte. Probing the head of + * each candidate before reading the full content keeps the recursive + * scan from loading every TypeScript file in the project into memory on + * large monorepos. + */ +const PROBE_BYTES = 8192 + +function fileContainsPgTable(fullPath: string): boolean { + let fd: number | undefined + try { + fd = openSync(fullPath, 'r') + const buf = Buffer.alloc(PROBE_BYTES) + const bytesRead = readSync(fd, buf, 0, PROBE_BYTES, 0) + return buf.subarray(0, bytesRead).includes('pgTable') + } catch { + return false + } finally { + if (fd !== undefined) { + try { + closeSync(fd) + } catch { + // already closed / not openable — nothing to do + } + } + } +} + function scanForPgTable( dir: string, cwd: string, @@ -334,12 +403,14 @@ function scanForPgTable( const fullPath = join(dir, entry.name) if (entry.isDirectory()) { scanForPgTable(fullPath, cwd, results, depth + 1) - } else if (entry.name.endsWith('.ts') && !entry.name.endsWith('.d.ts')) { + } else if ( + entry.name.endsWith('.ts') && + !entry.name.endsWith('.d.ts') && + fileContainsPgTable(fullPath) + ) { const content = readFileSync(fullPath, 'utf-8') - if (content.includes('pgTable')) { - const relativePath = fullPath.slice(cwd.length + 1) - results.push({ path: relativePath, content }) - } + const relativePath = fullPath.slice(cwd.length + 1) + results.push({ path: relativePath, content }) } } } catch { diff --git a/packages/wizard/src/lib/prerequisites.ts b/packages/wizard/src/lib/prerequisites.ts index ab240bd0..f92e7f60 100644 --- a/packages/wizard/src/lib/prerequisites.ts +++ b/packages/wizard/src/lib/prerequisites.ts @@ -26,9 +26,7 @@ export async function checkPrerequisites( } if (!findStashConfig(cwd)) { - missing.push( - `No stash.config.ts found. Run: ${runner} stash db install`, - ) + missing.push(`No stash.config.ts found. Run: ${runner} stash db install`) } return { ok: missing.length === 0, missing } diff --git a/packages/wizard/src/lib/types.ts b/packages/wizard/src/lib/types.ts index 7644652f..83980880 100644 --- a/packages/wizard/src/lib/types.ts +++ b/packages/wizard/src/lib/types.ts @@ -1,6 +1,21 @@ export type Integration = 'drizzle' | 'supabase' | 'prisma' | 'generic' -export type RunPhase = 'idle' | 'detecting' | 'gathering' | 'running' | 'completed' | 'error' +/** + * Setup-lifecycle phase the wizard runs in. `implement` (default) drives + * the full setup flow; `plan` produces a reviewable `.cipherstash/plan.md` + * with no code mutations. The CLI plumbs this through `--mode plan` / + * `--mode implement` (or the `--plan` / `--implement` shortcuts), and the + * gateway's `Mode` type is the public-API mirror of this same union. + */ +export type WizardMode = 'plan' | 'implement' + +export type RunPhase = + | 'idle' + | 'detecting' + | 'gathering' + | 'running' + | 'completed' + | 'error' export interface WizardSession { // CLI arguments diff --git a/packages/wizard/src/run.ts b/packages/wizard/src/run.ts index c27f076b..9daf37bb 100644 --- a/packages/wizard/src/run.ts +++ b/packages/wizard/src/run.ts @@ -20,7 +20,7 @@ import { detectPackageManager, detectTypeScript, } from './lib/detect.js' -import { gatherContext } from './lib/gather.js' +import { type WizardMode, gatherContext } from './lib/gather.js' import { maybeInstallSkills } from './lib/install-skills.js' import { runPostAgentSteps } from './lib/post-agent.js' import { checkPrerequisites } from './lib/prerequisites.js' @@ -31,16 +31,24 @@ interface RunOptions { cwd: string debug: boolean cliVersion: string + /** Setup-lifecycle phase. `implement` (default) runs the original full + * flow (column selection → agent edits code → post-agent install/push + * /migrate → call-site scan). `plan` skips column selection and the + * post-agent steps; the agent's deliverable is `.cipherstash/plan.md`. */ + mode?: WizardMode } export async function run(options: RunOptions) { - p.intro('CipherStash Wizard') + const mode: WizardMode = options.mode ?? 'implement' + p.intro( + mode === 'plan' ? 'CipherStash Wizard — plan mode' : 'CipherStash Wizard', + ) const startTime = Date.now() const changelog = new WizardChangelog(options.cwd) changelog.phase( 'Session start', - `cwd: \`${options.cwd}\`\ncli version: \`${options.cliVersion}\``, + `cwd: \`${options.cwd}\`\ncli version: \`${options.cliVersion}\`\nmode: \`${mode}\``, ) // Phase 1: Prerequisites @@ -119,21 +127,32 @@ export async function run(options: RunOptions) { `\`${selectedIntegration}\` (detected: ${detectedIntegration ?? 'none'})`, ) - // Phase 5: Gather context — DB introspection, column selection, schema files - // All done via CLI prompts BEFORE the agent starts. No AI tokens spent on discovery. - const gathered = await gatherContext( - options.cwd, - selectedIntegration, + // Phase 5: Gather context — DB introspection, column selection, schema + // files. All done via CLI prompts BEFORE the agent starts; no AI tokens + // spent on discovery. Plan mode skips column selection. + const gathered = await gatherContext({ + cwd: options.cwd, + integration: selectedIntegration, packageManager, - ) + mode, + }) const encryptedTables = Array.from( new Set(gathered.selectedColumns.map((c) => c.tableName)), ) - changelog.phase( - 'Columns selected', - `${gathered.selectedColumns.length} column(s) across ${encryptedTables.length} table(s): ${encryptedTables.map((t) => `\`${t}\``).join(', ')}`, - ) + if (mode === 'plan') { + changelog.phase( + 'Plan-mode scope', + gathered.selectedColumns.length > 0 + ? `${gathered.selectedColumns.length} pre-selected column(s); agent may revise.` + : 'No columns pre-selected; agent will propose scope from the schema.', + ) + } else { + changelog.phase( + 'Columns selected', + `${gathered.selectedColumns.length} column(s) across ${encryptedTables.length} table(s): ${encryptedTables.map((t) => `\`${t}\``).join(', ')}`, + ) + } // Phase 6: Build session const session: WizardSession = { @@ -157,15 +176,15 @@ export async function run(options: RunOptions) { try { trackAgentStarted(selectedIntegration) - // Run prompt fetch and agent SDK init concurrently — both are network/IO - // and they don't depend on each other. + // Network/IO operations that don't depend on each other run in parallel. const [agent, fetched] = await Promise.all([ initializeAgent(session), - fetchIntegrationPrompt( - gathered, - options.cliVersion, - packageManager?.execCommand ?? 'npx', - ), + fetchIntegrationPrompt({ + ctx: gathered, + cliVersion: options.cliVersion, + runner: packageManager?.execCommand ?? 'npx', + mode, + }), ]) if (session.debug) { @@ -176,60 +195,72 @@ export async function run(options: RunOptions) { const result = await agent.run(fetched.prompt) if (result.success) { - changelog.phase( - 'Agent completed', - 'Encryption client and schema wiring generated successfully.', - ) - - // Phase 8: Run deterministic post-agent steps (install, push, migrate) - await runPostAgentSteps({ - cwd: options.cwd, - integration: selectedIntegration, - gathered, - packageManager, - }) - changelog.phase( - 'Post-agent steps complete', - 'Package install, `db install`, `db push`, and migrations finished.', - ) - - // Phase 9: Report call sites that still need encryptModel/decryptModel - // wiring. Report-only — we don't mutate these files (CIP-2995). - try { - const matches = await scanCallSites( + if (mode === 'plan') { + changelog.phase( + 'Plan drafted', + '`.cipherstash/plan.md` written. Review and run `stash impl` to execute.', + ) + await finalize({ + selectedIntegration, + cwd: options.cwd, + changelog, + startTime, + // Skills install is offered in plan mode too — same agent rules + // apply if the user re-engages an editor agent later to refine + // the plan. + outro: + 'Plan drafted at `.cipherstash/plan.md`. Review it, then run `stash impl` to implement.', + }) + } else { + // Kick the call-site scan off in the background; it's read-only + // and independent of the post-agent install/push/migrate, so + // overlapping the two cuts wall-time on large projects. + const scanPromise = scanCallSites( options.cwd, encryptedTables, selectedIntegration, + ).then( + (matches) => ({ ok: true as const, matches }), + (err: unknown) => ({ + ok: false as const, + message: err instanceof Error ? err.message : String(err), + }), ) - const report = renderCallSiteReport(matches) - p.note(report, 'Server action & page call sites') - changelog.phase('Call-site scan', report) - } catch (err) { - p.log.warn( - `Could not scan for call sites: ${err instanceof Error ? err.message : String(err)}`, + + changelog.phase( + 'Agent completed', + 'Encryption client and schema wiring generated successfully.', ) - } - // Phase 10: Offer to install Claude skills into .claude/skills (CIP-2992). - const installedSkills = await maybeInstallSkills( - options.cwd, - selectedIntegration, - ) - if (installedSkills.length > 0) { - changelog.action( - `Installed ${installedSkills.length} Claude skill(s).`, - installedSkills.map((name) => `.claude/skills/${name}`), + await runPostAgentSteps({ + cwd: options.cwd, + integration: selectedIntegration, + gathered, + packageManager, + }) + changelog.phase( + 'Post-agent steps complete', + 'Package install, `db install`, `db push`, and migrations finished.', ) - } - trackWizardCompleted(selectedIntegration, Date.now() - startTime) - const logPath = await changelog.flush() - if (logPath) { - p.log.info(`Wizard log written to ${logPath}`) + const scanResult = await scanPromise + if (scanResult.ok) { + const report = renderCallSiteReport(scanResult.matches) + p.note(report, 'Server action & page call sites') + changelog.phase('Call-site scan', report) + } else { + p.log.warn(`Could not scan for call sites: ${scanResult.message}`) + } + + await finalize({ + selectedIntegration, + cwd: options.cwd, + changelog, + startTime, + outro: + 'Encryption is set up! Your data is now protected by CipherStash.', + }) } - p.outro( - 'Encryption is set up! Your data is now protected by CipherStash.', - ) } else { trackWizardError(result.error ?? 'unknown', selectedIntegration) changelog.note(`Agent failed: ${result.error ?? 'unknown error'}`) @@ -253,6 +284,37 @@ export async function run(options: RunOptions) { await shutdownAnalytics() } +/** + * Shared post-agent tail: skills install, completion telemetry, log flush, + * and outro. Both `plan` and `implement` flows fall through to this so + * future tweaks to skills/analytics/changelog stay in one place. + */ +async function finalize(opts: { + selectedIntegration: Integration + cwd: string + changelog: WizardChangelog + startTime: number + outro: string +}): Promise { + const installedSkills = await maybeInstallSkills( + opts.cwd, + opts.selectedIntegration, + ) + if (installedSkills.length > 0) { + opts.changelog.action( + `Installed ${installedSkills.length} Claude skill(s).`, + installedSkills.map((name) => `.claude/skills/${name}`), + ) + } + + trackWizardCompleted(opts.selectedIntegration, Date.now() - opts.startTime) + const logPath = await opts.changelog.flush() + if (logPath) { + p.log.info(`Wizard log written to ${logPath}`) + } + p.outro(opts.outro) +} + async function selectIntegration(): Promise { const selected = await p.select({ message: 'Which integration are you using?', diff --git a/skills/stash-cli/SKILL.md b/skills/stash-cli/SKILL.md index 05ec6b9e..7ae4c6a3 100644 --- a/skills/stash-cli/SKILL.md +++ b/skills/stash-cli/SKILL.md @@ -14,7 +14,9 @@ Use this skill when: - Code imports `stash` (or legacy `@cipherstash/stack-forge`) - A `stash.config.ts` file exists or needs to be created - The user wants to install, configure, or manage the EQL extension in PostgreSQL +- The user is using any of the setup-lifecycle commands: `init`, `plan`, `impl`, `status` - The user mentions "stash CLI", "stash db", "stack-forge", "stash-forge", "EQL install", or "encryption schema" +- The user has a `.cipherstash/` directory with `context.json`, `plan.md`, or `setup-prompt.md` Do NOT trigger when: - The user is working with `@cipherstash/stack` (the runtime SDK) without needing database setup @@ -27,7 +29,20 @@ Do NOT trigger when: Think of it like Prisma Migrate or Drizzle Kit: a dev-time tool that prepares your database while the runtime SDK handles queries. -The binary is named `stash`. Top-level commands: `init`, `auth`, `db`, `schema`, `env`. +The binary is named `stash`. Top-level commands: `init`, `plan`, `impl`, `status`, `auth`, `db`, `schema`, `env`. + +### Setup lifecycle (the recommended flow) + +The setup lifecycle is split across four explicit save-points. Each command can be run standalone, but the chain prompts make first-time setup a single flow: + +| Command | Owns | Ends with | +|---------|------|-----------| +| `stash init` | Auth, database, dep install, EQL install, encryption client scaffold, `.cipherstash/context.json` | Default-yes prompt → chains to `stash plan` | +| `stash plan` | Drafts `.cipherstash/plan.md` via agent handoff (Claude Code or Codex) | Default-yes prompt → chains to `stash impl` | +| `stash impl` | Executes the plan via agent handoff (any of four targets) | Outro pointing at `stash db status` to verify | +| `stash status` | Disk-only "where am I?" map, runs in ms | — | + +Use `stash status` at any time to see which save-points are complete. ## Configuration @@ -66,7 +81,7 @@ The primary interface is the `stash` package, run via `npx` (or your package man npx stash [options] ``` -### `init` — Initialize CipherStash for your project +### `init` — Scaffold a CipherStash project ```bash npx stash init @@ -74,14 +89,77 @@ npx stash init --supabase npx stash init --drizzle ``` -Init runs nearly silently, with prompts only when it can't make a sensible default choice: +Init is the **scaffold** save-point. It does mechanical setup only — no agent handoff. Six phases, prompts only when it can't make a sensible default: 1. **Authenticate** — only prompts when not already logged in (otherwise logs `Using workspace X (region)` and proceeds). -2. **Generate encryption client** — auto-detects your framework (Drizzle from `drizzle.config.*` / `drizzle-orm` / `drizzle-kit` in `package.json`; Supabase from the `DATABASE_URL` host) and silently writes a placeholder client to `./src/encryption/index.ts`. Only prompts you if a file already exists at that path. -3. **Install dependencies** — single combined prompt for `@cipherstash/stack` and `stash`. Skipped entirely when both are already in `node_modules`. -4. **Print next steps** — points you at `db install` and the optional `@cipherstash/wizard` for AI-guided setup. +2. **Resolve database** — picks up `DATABASE_URL` from `.env`/`.env.local` or prompts for it. Verifies the connection. +3. **Build schema** — auto-detects your framework (Drizzle from `drizzle.config.*` / `drizzle-orm` / `drizzle-kit` in `package.json`; Supabase from the `DATABASE_URL` host) and silently writes a placeholder client to `./src/encryption/index.ts`. Only prompts you if a file already exists at that path. +4. **Install dependencies** — single combined prompt for `@cipherstash/stack` and `stash`. Skipped entirely when both are already in `node_modules`. +5. **Install EQL** — runs the equivalent of `stash db install` against the resolved database (Drizzle migration, Supabase migration, or direct, per detection). Skipped if EQL is already installed. +6. **Gather context** — detects available coding agents (Claude Code, Codex, Cursor, Windsurf, Cline) and writes `.cipherstash/context.json` with integration, package manager, schemas, env keys, and detected agents. + +When init finishes, it prints a checkmark panel of completed phases and an interactive **chain prompt** (default-yes): *"Continue to `stash plan` now to draft your encryption plan?"* Yes auto-launches `stash plan`. No prints "Next: run `stash plan` to draft your encryption plan." Non-TTY (CI, pipes) skips the prompt and prints the hint. + +The `--supabase` and `--drizzle` flags tailor the intro message and EQL install variant. File scaffolding uses the same auto-detection regardless. + +#### Generated files + +| File | Purpose | +|------|---------| +| `./src/encryption/index.ts` | Placeholder encryption client — edit to declare encrypted columns (or let `stash plan`/`stash impl` do it for you). | +| `.cipherstash/context.json` | Detected facts about the project (integration, pm, schemas, env keys, agents). Read by `plan`, `impl`, and `status`. Never hand-edit. | +| `stash.config.ts` | Scaffolded if missing — points the CLI at `databaseUrl` and the encryption client. | + +### `plan` — Draft a reviewable encryption plan + +```bash +npx stash plan +``` + +`plan` is the **draft for review** save-point. Pre-flights `.cipherstash/context.json` (errors with a "Run `stash init` first" pointer if missing). Hands off to a coding agent — all four targets are offered: Claude Code, Codex, AGENTS.md (for Cursor/Windsurf/Cline), and the CipherStash Agent (`@cipherstash/wizard`). Claude Code, Codex, and AGENTS.md consume the local mode-aware `setup-prompt.md`. The wizard receives `--mode plan` and forwards it to the CipherStash gateway, which returns a planning prompt; the wizard runtime skips its post-agent install/push/migrate steps when `mode === 'plan'`. Every target produces a valid plan-mode artifact at `.cipherstash/plan.md`. + +The agent produces `.cipherstash/plan.md` with a machine-readable header `` listing each table/column the user wants to protect and whether it's a `"new"` (additive — the column doesn't yet exist) or `"migrate"` (existing column with live data) lifecycle. The plan also covers prose detail: deploy ordering for migrate columns, project-specific risks (bundler exclusion, partial CipherStash state), the exact CLI sequence to execute when ready. + +Ends with a default-yes prompt: *"Continue to `stash impl` now?"* Yes auto-launches `stash impl`. + +To re-plan, delete `.cipherstash/plan.md` first — `stash plan` will warn (non-blocking) if a plan already exists, since the agent will be told to revise it rather than start fresh. -The `--supabase` and `--drizzle` flags tailor the intro message and next-steps output. They don't drive prompts — file scaffolding uses the same auto-detection regardless. +### `impl` — Execute the plan + +```bash +npx stash impl +npx stash impl --continue-without-plan +``` + +`impl` is the **execute** save-point. Pre-flights `.cipherstash/context.json`. Behaviour branches on disk state: + +| State | Behaviour | +|-------|-----------| +| Plan exists, TTY | Parses the summary block. Renders a confirm panel: "3 columns across 2 tables · staged across 4 deploys (schema-add → backfill → cutover → drop)". Default-yes confirm. | +| Plan exists, non-TTY | Logs and proceeds without confirm (CI/pipe-safe). | +| No plan, TTY | Interactive `p.select`: "Draft a plan first (recommended)" / "Continue without a plan" / cancel. "Draft" delegates to `stash plan`. "Continue" goes through a security confirm (default-no) before implementing. | +| No plan, `--continue-without-plan` | Skips the picker, runs the security confirm (still default-no), then implements. | +| No plan, non-TTY, no flag | Errors out with "Run `stash plan` first, or pass `--continue-without-plan` to skip planning." Forces explicit intent in CI. | + +Once the user clears the gate, `impl` dispatches to a handoff target (Claude Code, Codex, AGENTS.md for Cursor/Windsurf/Cline, or `@cipherstash/wizard`) and the agent executes the plan: schema edits, migrations, `stash db push`, `stash encrypt {backfill,cutover,drop}` as appropriate. + +`--continue-without-plan` exists to support scripts and one-off implementations where planning isn't needed. It is **not** a way to bypass safety — the security confirm still fires when interactive. + +### `status` — Show project lifecycle state + +```bash +npx stash status +``` + +`status` is the **map**. Disk-only — no auth, no DB connection, runs in milliseconds. Reads three files: + +- `.cipherstash/context.json` → was init run? +- `.cipherstash/plan.md` → has a plan been drafted? +- `.cipherstash/setup-prompt.md` → has the agent been engaged at least once? + +Renders a lifecycle panel with three stages (Initialized, Plan written, Implementation), each marked `✓` or `◯`. Prints a context-aware "Next:" line that always names exactly one command to run. + +Points at `stash db status` for EQL/database state and `stash encrypt status` for per-column migration phase — those are the deeper, network-touching status commands. Use them when you need to inspect what's actually installed in the database or where each column is in the encryption lifecycle. ### `auth login` — Authenticate with CipherStash @@ -102,6 +180,8 @@ npx stash db install --drizzle npx stash db install --force ``` +`stash init` runs `db install` automatically as part of its EQL install phase. Run `db install` directly when you skipped init, when you need flags init doesn't expose (`--migration`, `--migrations-dir`, `--exclude-operator-family`), or when re-installing/upgrading EQL on its own. + `db install` is the single command that gets a project from zero to installed EQL: 1. Scaffolds `stash.config.ts` if missing (auto-detects an existing client file at common locations, otherwise prompts). @@ -358,7 +438,7 @@ npx stash schema build --supabase Connects to your database, lets you select tables and columns to encrypt, asks about searchable indexes, and generates a typed encryption client file. Reads `databaseUrl` from `stash.config.ts`. -For AI-guided schema integration that edits your existing schema files in place, run `npx @cipherstash/wizard` instead — it's a separate package designed for that workflow. +For AI-guided schema integration that edits your existing schema files in place, the recommended path is `npx stash plan` followed by `npx stash impl` — these add a planning save-point and can hand off to Claude Code, Codex, an AGENTS.md-driven editor, or the in-house `@cipherstash/wizard` package. `npx @cipherstash/wizard` standalone is still available for users who want to skip the plan checkpoint. ### `env` — Print production env vars for deployment @@ -468,7 +548,7 @@ if (await installer.isInstalled()) { - Node.js >= 22 - PostgreSQL database with sufficient permissions (see `checkPermissions()`) -- A `stash.config.ts` file with a valid `databaseUrl` (or run `db install` to scaffold it) +- A `stash.config.ts` file with a valid `databaseUrl` (or run `stash init` / `stash db install` to scaffold it) - Peer dependency: `@cipherstash/stack` >= 0.6.0 ## Common issues @@ -479,7 +559,7 @@ The database role needs `CREATE` privileges on the database and public schema, o ### Config not found -`stash.config.ts` must be in the project root or a parent directory. The file must `export default defineConfig(...)`. Or run `npx stash db install` to scaffold it. +`stash.config.ts` must be in the project root or a parent directory. The file must `export default defineConfig(...)`. The fastest fix is `npx stash init`, which scaffolds the config (and authenticates, installs deps, installs EQL, and writes `.cipherstash/context.json` in the same run). For a CLI-only setup, `npx stash db install` also scaffolds the config. ### Supabase environments From 11f0e8f110e3fde80c4d5df169bb4ebf24f23a82 Mon Sep 17 00:00:00 2001 From: CJ Brewer Date: Thu, 7 May 2026 08:23:38 -0600 Subject: [PATCH 2/2] chore: apply PR feedback --- packages/wizard/src/__tests__/parse-args.test.ts | 9 +++++++++ packages/wizard/src/bin/parse-args.ts | 8 ++++++-- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/packages/wizard/src/__tests__/parse-args.test.ts b/packages/wizard/src/__tests__/parse-args.test.ts index 604b06f4..c0eca88d 100644 --- a/packages/wizard/src/__tests__/parse-args.test.ts +++ b/packages/wizard/src/__tests__/parse-args.test.ts @@ -43,6 +43,15 @@ describe('wizard parseArgs — mode resolution', () => { expect(result.modeError).toMatch(/Unknown --mode value/) }) + it('rejects --mode at the end of argv with a clear error (regression: silent fall-through)', () => { + // Without the trailing-value guard, `--mode` as the final arg falls + // through to the default `implement` with no diagnostic — strictly + // worse than `--mode=` (which already errors). Both forms now surface + // the same "requires a value" error. + const result = parseArgs(argv('--mode')) + expect(result.modeError).toMatch(/--mode requires a value/) + }) + it('lets the last mode flag win when multiple are passed', () => { // Useful for wrappers that always append a mode flag — they don't have // to detect and remove an earlier one. diff --git a/packages/wizard/src/bin/parse-args.ts b/packages/wizard/src/bin/parse-args.ts index f497a950..9bb0e747 100644 --- a/packages/wizard/src/bin/parse-args.ts +++ b/packages/wizard/src/bin/parse-args.ts @@ -33,8 +33,12 @@ export function parseArgs(argv: string[]): ParsedArgs { mode = 'implement' continue } - if (arg === '--mode' && i + 1 < args.length) { - const next = args[i + 1] ?? '' + if (arg === '--mode') { + if (i + 1 >= args.length) { + modeError = "--mode requires a value. Expected 'plan' or 'implement'." + break + } + const next = args[i + 1] if (next === 'plan' || next === 'implement') { mode = next i++