Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/wizard-plan-mode.md
Original file line number Diff line number Diff line change
@@ -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 <plan|implement>` (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.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not convinced backwards compatibility is important here, and would be fine with the default being plan.


`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.
19 changes: 8 additions & 11 deletions packages/cli/src/commands/impl/__tests__/how-to-proceed.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand All @@ -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')
})
})
8 changes: 4 additions & 4 deletions packages/cli/src/commands/impl/steps/handoff-wizard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.`,
)
}

Expand Down
54 changes: 24 additions & 30 deletions packages/cli/src/commands/impl/steps/how-to-proceed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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',
Expand All @@ -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 = {
Expand Down
77 changes: 77 additions & 0 deletions packages/wizard/src/__tests__/parse-args.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
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('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.
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)
})
})
22 changes: 12 additions & 10 deletions packages/wizard/src/agent/__tests__/interface.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
17 changes: 13 additions & 4 deletions packages/wizard/src/agent/fetch-prompt.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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<FetchedPrompt> {
const { ctx, cliVersion, runner } = options
const mode: WizardMode = options.mode ?? 'implement'

const strategy = AutoStrategy.detect()
const { token } = await strategy.getToken()

Expand All @@ -36,6 +44,7 @@ export async function fetchIntegrationPrompt(
version: 'v1',
clientVersion: cliVersion,
integration: ctx.integration,
mode,
context: {
selectedColumns: ctx.selectedColumns,
schemaFiles: ctx.schemaFiles,
Expand Down
74 changes: 63 additions & 11 deletions packages/wizard/src/agent/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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. */
Expand Down
Loading