Skip to content

Commit 1a97d40

Browse files
committed
feat(cli, wizard): support plan mode in wizard
1 parent 1175e86 commit 1a97d40

18 files changed

Lines changed: 681 additions & 222 deletions

File tree

.changeset/wizard-plan-mode.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
"@cipherstash/wizard": minor
3+
"stash": minor
4+
---
5+
6+
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.
7+
8+
`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.

packages/cli/src/commands/impl/__tests__/how-to-proceed.test.ts

Lines changed: 8 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -24,13 +24,13 @@ describe('howToProceed — buildOptions', () => {
2424
it('offers all four targets in implement mode', () => {
2525
const opts = buildOptions(noAgents, 'implement')
2626
const values = opts.map((o) => o.value)
27-
expect(values).toEqual(['claude-code', 'codex', 'wizard', 'agents-md'])
27+
expect(values).toEqual(['claude-code', 'codex', 'agents-md', 'wizard'])
2828
})
2929

30-
it('offers only claude-code and codex in plan mode', () => {
30+
it('offers all four targets in plan mode', () => {
3131
const opts = buildOptions(noAgents, 'plan')
3232
const values = opts.map((o) => o.value)
33-
expect(values).toEqual(['claude-code', 'codex'])
33+
expect(values).toEqual(['claude-code', 'codex', 'agents-md', 'wizard'])
3434
})
3535

3636
it('reflects detection state in hints regardless of mode', () => {
@@ -56,14 +56,11 @@ describe('howToProceed — defaultChoice', () => {
5656
expect(defaultChoice(codexOnly, 'plan')).toBe('codex')
5757
})
5858

59-
it('falls back to agents-md in implement mode when no CLI is detected', () => {
59+
it('falls back to agents-md in both modes when no CLI is detected', () => {
60+
// AGENTS.md is the broadest "works without anything else installed"
61+
// option, so it's the right default in either mode when no agent CLI
62+
// is on PATH.
6063
expect(defaultChoice(noAgents, 'implement')).toBe('agents-md')
61-
})
62-
63-
it('falls back to claude-code in plan mode when no CLI is detected', () => {
64-
// Plan mode never offers agents-md; claude-code is the listed default
65-
// so the picker has a valid initialValue rather than falling through
66-
// to a hidden option.
67-
expect(defaultChoice(noAgents, 'plan')).toBe('claude-code')
64+
expect(defaultChoice(noAgents, 'plan')).toBe('agents-md')
6865
})
6966
})

packages/cli/src/commands/impl/steps/handoff-wizard.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,12 +33,12 @@ export const handoffWizardStep: HandoffStep = {
3333
writeContextFile(contextAbs, ctx)
3434
p.log.success(`Wrote ${CONTEXT_REL_PATH}`)
3535

36-
// Pass through no extra flags. If a user wants to debug the wizard, they
37-
// can re-run `stash wizard --debug` directly afterwards.
38-
const exitCode = await runWizardSpawn([])
36+
const mode = state.mode ?? 'implement'
37+
const exitCode = await runWizardSpawn(['--mode', mode])
3938
if (exitCode !== 0) {
39+
const resume = mode === 'plan' ? 'stash plan' : 'stash impl'
4040
p.log.warn(
41-
`Wizard exited with code ${exitCode}. Re-run \`stash wizard\` to resume.`,
41+
`Wizard exited with code ${exitCode}. Re-run \`${resume}\` to resume.`,
4242
)
4343
}
4444

packages/cli/src/commands/impl/steps/how-to-proceed.ts

Lines changed: 24 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -18,27 +18,28 @@ import { handoffWizardStep } from './handoff-wizard.js'
1818
* the AGENTS.md path because that's the broadest "works without anything else
1919
* installed" option. The CipherStash Agent option is positioned as a fallback
2020
* (slow first run, requires the wizard package on top of the CLI) and is
21-
* never selected by default. In plan mode, AGENTS.md and wizard aren't
22-
* offered — the default falls back to `claude-code`.
21+
* never selected by default. The same defaulting applies in both `plan` and
22+
* `implement` modes; `mode` is plumbed in so future asymmetries can be added
23+
* without a wider refactor.
2324
*/
24-
export function defaultChoice(state: InitState, mode: InitMode): HandoffChoice {
25+
export function defaultChoice(
26+
state: InitState,
27+
_mode: InitMode,
28+
): HandoffChoice {
2529
if (state.agents?.cli.claudeCode) return 'claude-code'
2630
if (state.agents?.cli.codex) return 'codex'
27-
return mode === 'plan' ? 'claude-code' : 'agents-md'
31+
return 'agents-md'
2832
}
2933

3034
/**
31-
* Build the option list for the menu. Hints reflect detection state — a
32-
* missing CLI doesn't hide the option (handoff steps still write the
33-
* rules files and print install instructions), it just nudges the user.
34-
*
35-
* In plan mode we only offer Claude Code and Codex. AGENTS.md and the
36-
* wizard don't yet have planning prompt templates, so suppress them
37-
* entirely rather than degrading silently.
35+
* Build the option list for the menu. Hints reflect detection state, not
36+
* availability — a missing CLI doesn't hide the option (handoff steps
37+
* still write the rules files and print install instructions), it just
38+
* nudges the user toward what's already on PATH.
3839
*/
3940
export function buildOptions(
4041
state: InitState,
41-
mode: InitMode,
42+
_mode: InitMode,
4243
): { value: HandoffChoice; label: string; hint?: string }[] {
4344
const claudeHint = state.agents?.cli.claudeCode
4445
? 'claude detected — will launch interactively'
@@ -47,7 +48,7 @@ export function buildOptions(
4748
? 'codex detected — will launch interactively'
4849
: 'codex not on PATH — files will be written, install link shown'
4950

50-
const options: { value: HandoffChoice; label: string; hint?: string }[] = [
51+
return [
5152
{
5253
value: 'claude-code',
5354
label: 'Hand off to Claude Code',
@@ -58,24 +59,17 @@ export function buildOptions(
5859
label: 'Hand off to Codex',
5960
hint: codexHint,
6061
},
62+
{
63+
value: 'agents-md',
64+
label: 'Write AGENTS.md',
65+
hint: 'works with Cursor, Windsurf, Cline, and more',
66+
},
67+
{
68+
value: 'wizard',
69+
label: 'Use the CipherStash Agent',
70+
hint: 'our hosted setup wizard (runs `stash wizard`)',
71+
},
6172
]
62-
63-
if (mode === 'implement') {
64-
options.push(
65-
{
66-
value: 'wizard',
67-
label: 'Use the CipherStash Agent',
68-
hint: 'our hosted setup wizard (runs `stash wizard`)',
69-
},
70-
{
71-
value: 'agents-md',
72-
label: 'Write AGENTS.md',
73-
hint: 'works with Cursor, Windsurf, Cline, and more',
74-
},
75-
)
76-
}
77-
78-
return options
7973
}
8074

8175
export const howToProceedStep: HandoffStep = {
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { parseArgs } from '../bin/parse-args.js'
3+
4+
// `parseArgs` takes the full process.argv (node + script + args), so the
5+
// test shapes its inputs the same way: ['node', 'wizard.js', ...flags].
6+
function argv(...flags: string[]): string[] {
7+
return ['node', 'wizard.js', ...flags]
8+
}
9+
10+
describe('wizard parseArgs — mode resolution', () => {
11+
it('defaults to implement when no mode flag is passed', () => {
12+
expect(parseArgs(argv()).mode).toBe('implement')
13+
// --debug alone shouldn't change mode
14+
expect(parseArgs(argv('--debug')).mode).toBe('implement')
15+
})
16+
17+
it('accepts the --plan shortcut', () => {
18+
expect(parseArgs(argv('--plan')).mode).toBe('plan')
19+
})
20+
21+
it('accepts the --implement shortcut (no-op against the default)', () => {
22+
expect(parseArgs(argv('--implement')).mode).toBe('implement')
23+
})
24+
25+
it('accepts --mode plan (space-separated long form)', () => {
26+
expect(parseArgs(argv('--mode', 'plan')).mode).toBe('plan')
27+
expect(parseArgs(argv('--mode', 'implement')).mode).toBe('implement')
28+
})
29+
30+
it('accepts --mode=plan (equals-separated long form)', () => {
31+
expect(parseArgs(argv('--mode=plan')).mode).toBe('plan')
32+
expect(parseArgs(argv('--mode=implement')).mode).toBe('implement')
33+
})
34+
35+
it('rejects unknown --mode values with a clear error', () => {
36+
const result = parseArgs(argv('--mode', 'yolo'))
37+
expect(result.modeError).toMatch(/Unknown --mode value/)
38+
expect(result.modeError).toMatch(/yolo/)
39+
})
40+
41+
it('rejects unknown --mode= values with a clear error', () => {
42+
const result = parseArgs(argv('--mode=yolo'))
43+
expect(result.modeError).toMatch(/Unknown --mode value/)
44+
})
45+
46+
it('lets the last mode flag win when multiple are passed', () => {
47+
// Useful for wrappers that always append a mode flag — they don't have
48+
// to detect and remove an earlier one.
49+
expect(parseArgs(argv('--plan', '--implement')).mode).toBe('implement')
50+
expect(parseArgs(argv('--implement', '--plan')).mode).toBe('plan')
51+
expect(parseArgs(argv('--mode', 'plan', '--implement')).mode).toBe(
52+
'implement',
53+
)
54+
expect(parseArgs(argv('--implement', '--mode=plan')).mode).toBe('plan')
55+
})
56+
57+
it('threads --debug independently of mode flags', () => {
58+
expect(parseArgs(argv('--plan', '--debug')).debug).toBe(true)
59+
expect(parseArgs(argv('--debug', '--plan')).mode).toBe('plan')
60+
})
61+
62+
it('exposes --help and --version flags independently', () => {
63+
expect(parseArgs(argv('--help')).help).toBe(true)
64+
expect(parseArgs(argv('-h')).help).toBe(true)
65+
expect(parseArgs(argv('--version')).version).toBe(true)
66+
expect(parseArgs(argv('-v')).version).toBe(true)
67+
})
68+
})

packages/wizard/src/agent/__tests__/interface.test.ts

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -57,27 +57,29 @@ describe('wizardCanUseTool — DLX command allowlist', () => {
5757
})
5858

5959
it('allows pnpm add', () => {
60-
expect(wizardCanUseTool('Bash', { command: 'pnpm add some-package' })).toBe(
61-
true,
62-
)
60+
expect(
61+
wizardCanUseTool('Bash', { command: 'pnpm add some-package' }),
62+
).toBe(true)
6363
})
6464

6565
it('allows yarn add', () => {
66-
expect(wizardCanUseTool('Bash', { command: 'yarn add some-package' })).toBe(
67-
true,
68-
)
66+
expect(
67+
wizardCanUseTool('Bash', { command: 'yarn add some-package' }),
68+
).toBe(true)
6969
})
7070

7171
it('allows bun add', () => {
72-
expect(wizardCanUseTool('Bash', { command: 'bun add some-package' })).toBe(
73-
true,
74-
)
72+
expect(
73+
wizardCanUseTool('Bash', { command: 'bun add some-package' }),
74+
).toBe(true)
7575
})
7676
})
7777

7878
describe('allows stash db commands', () => {
7979
it('allows stash db install', () => {
80-
expect(wizardCanUseTool('Bash', { command: 'stash db install' })).toBe(true)
80+
expect(wizardCanUseTool('Bash', { command: 'stash db install' })).toBe(
81+
true,
82+
)
8183
})
8284

8385
it('allows stash db push', () => {

packages/wizard/src/agent/fetch-prompt.ts

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import auth from '@cipherstash/auth'
22
import { GATEWAY_URL } from '../lib/constants.js'
3-
import type { GatheredContext } from '../lib/gather.js'
3+
import type { GatheredContext, WizardMode } from '../lib/gather.js'
44
import { classifyHttpError, formatWizardError } from './errors.js'
55

66
const { AutoStrategy } = auth
@@ -16,11 +16,19 @@ interface GatewayErrorBody {
1616
error?: { type?: string; message?: string }
1717
}
1818

19+
export interface FetchIntegrationPromptOptions {
20+
ctx: GatheredContext
21+
cliVersion: string
22+
runner: string
23+
mode?: WizardMode
24+
}
25+
1926
export async function fetchIntegrationPrompt(
20-
ctx: GatheredContext,
21-
cliVersion: string,
22-
runner: string,
27+
options: FetchIntegrationPromptOptions,
2328
): Promise<FetchedPrompt> {
29+
const { ctx, cliVersion, runner } = options
30+
const mode: WizardMode = options.mode ?? 'implement'
31+
2432
const strategy = AutoStrategy.detect()
2533
const { token } = await strategy.getToken()
2634

@@ -36,6 +44,7 @@ export async function fetchIntegrationPrompt(
3644
version: 'v1',
3745
clientVersion: cliVersion,
3846
integration: ctx.integration,
47+
mode,
3948
context: {
4049
selectedColumns: ctx.selectedColumns,
4150
schemaFiles: ctx.schemaFiles,

packages/wizard/src/agent/hooks.ts

Lines changed: 63 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -13,14 +13,46 @@ interface ScanResult {
1313

1414
// --- Pre-execution rules ---
1515

16-
export const DANGEROUS_BASH_OPERATORS = [';', '`', '$', '(', ')', '|', '&&', '||', '>', '>>', '<']
16+
export const DANGEROUS_BASH_OPERATORS = [
17+
';',
18+
'`',
19+
'$',
20+
'(',
21+
')',
22+
'|',
23+
'&&',
24+
'||',
25+
'>',
26+
'>>',
27+
'<',
28+
]
1729

1830
const BLOCKED_BASH_PATTERNS = [
19-
{ pattern: /rm\s+-rf/i, rule: 'destructive_rm', reason: 'Recursive force delete blocked' },
20-
{ pattern: /git\s+push\s+--force/i, rule: 'git_force_push', reason: 'Force push blocked' },
21-
{ pattern: /git\s+reset\s+--hard/i, rule: 'git_reset_hard', reason: 'Hard reset blocked' },
22-
{ pattern: /curl.*\$.*KEY/i, rule: 'secret_exfiltration', reason: 'Potential secret exfiltration via curl' },
23-
{ pattern: /cat.*\.env/i, rule: 'env_file_read', reason: 'Direct .env file read blocked — use wizard-tools MCP' },
31+
{
32+
pattern: /rm\s+-rf/i,
33+
rule: 'destructive_rm',
34+
reason: 'Recursive force delete blocked',
35+
},
36+
{
37+
pattern: /git\s+push\s+--force/i,
38+
rule: 'git_force_push',
39+
reason: 'Force push blocked',
40+
},
41+
{
42+
pattern: /git\s+reset\s+--hard/i,
43+
rule: 'git_reset_hard',
44+
reason: 'Hard reset blocked',
45+
},
46+
{
47+
pattern: /curl.*\$.*KEY/i,
48+
rule: 'secret_exfiltration',
49+
reason: 'Potential secret exfiltration via curl',
50+
},
51+
{
52+
pattern: /cat.*\.env/i,
53+
rule: 'env_file_read',
54+
reason: 'Direct .env file read blocked — use wizard-tools MCP',
55+
},
2456
]
2557

2658
/** Scan a Bash command before execution. */
@@ -51,14 +83,34 @@ export function scanPreToolUse(toolName: string, input: string): ScanResult {
5183
// --- Post-execution rules ---
5284

5385
const PROMPT_INJECTION_PATTERNS = [
54-
{ pattern: /ignore\s+previous\s+instructions/i, rule: 'prompt_injection_override', severity: 'critical' as const },
55-
{ pattern: /you\s+are\s+now\s+a\s+different/i, rule: 'prompt_injection_identity', severity: 'medium' as const },
86+
{
87+
pattern: /ignore\s+previous\s+instructions/i,
88+
rule: 'prompt_injection_override',
89+
severity: 'critical' as const,
90+
},
91+
{
92+
pattern: /you\s+are\s+now\s+a\s+different/i,
93+
rule: 'prompt_injection_identity',
94+
severity: 'medium' as const,
95+
},
5696
]
5797

5898
const SECRET_PATTERNS = [
59-
{ pattern: /phc_[a-zA-Z0-9]{20,}/, rule: 'hardcoded_posthog_key', reason: 'PostHog API key in code' },
60-
{ pattern: /sk_live_[a-zA-Z0-9]+/, rule: 'hardcoded_stripe_key', reason: 'Stripe live key in code' },
61-
{ pattern: /password\s*=\s*['"][^'"]+['"]/i, rule: 'hardcoded_password', reason: 'Hardcoded password detected' },
99+
{
100+
pattern: /phc_[a-zA-Z0-9]{20,}/,
101+
rule: 'hardcoded_posthog_key',
102+
reason: 'PostHog API key in code',
103+
},
104+
{
105+
pattern: /sk_live_[a-zA-Z0-9]+/,
106+
rule: 'hardcoded_stripe_key',
107+
reason: 'Stripe live key in code',
108+
},
109+
{
110+
pattern: /password\s*=\s*['"][^'"]+['"]/i,
111+
rule: 'hardcoded_password',
112+
reason: 'Hardcoded password detected',
113+
},
62114
]
63115

64116
/** Scan file content after a write/edit operation. */

0 commit comments

Comments
 (0)