Skip to content
Open
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
5 changes: 0 additions & 5 deletions .changeset/init-plan-or-implement.md

This file was deleted.

5 changes: 5 additions & 0 deletions .changeset/stash-impl-command.md
Original file line number Diff line number Diff line change
@@ -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. `--yolo` 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.
11 changes: 11 additions & 0 deletions packages/cli/src/bin/stash.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import * as p from '@clack/prompts'
import {
authCommand,
envCommand,
implCommand,
initCommand,
installCommand,
statusCommand,
Expand Down Expand Up @@ -74,6 +75,7 @@ ${messages.cli.usagePrefix}${STASH} <command> [options]

Commands:
init Initialize CipherStash for your project
impl Draft an encryption plan (or implement, if a plan exists)
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 think the wording is the wrong way around. The command performs implementation but will generate a plan first if one exists?

auth <subcommand> Authenticate with CipherStash
wizard AI-guided encryption setup (reads your codebase)

Expand Down Expand Up @@ -104,6 +106,10 @@ Init Flags:
--supabase Use Supabase-specific setup flow
--drizzle Use Drizzle-specific setup flow

Impl Flags:
--yolo Skip the planning checkpoint 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
Expand All @@ -119,6 +125,8 @@ DB Flags:
Examples:
${STASH} init
${STASH} init --supabase
${STASH} impl
${STASH} impl --yolo
${STASH} auth login
${STASH} wizard
${STASH} db install
Expand Down Expand Up @@ -367,6 +375,9 @@ async function main() {
case 'init':
await initCommand(flags)
break
case 'impl':
await implCommand(flags)
break
case 'auth': {
const authArgs = subcommand ? [subcommand, ...commandArgs] : commandArgs
await authCommand(authArgs, flags)
Expand Down
85 changes: 85 additions & 0 deletions packages/cli/src/commands/impl/__tests__/derive-mode.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
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 { deriveMode, readContextFile } from '../index.js'

let cwd: string

beforeEach(() => {
cwd = mkdtempSync(join(tmpdir(), 'stash-impl-'))
})

afterEach(() => {
rmSync(cwd, { recursive: true, force: true })
})

function writePlan(): void {
mkdirSync(join(cwd, '.cipherstash'), { recursive: true })
writeFileSync(join(cwd, '.cipherstash', 'plan.md'), '# plan\n', 'utf-8')
}

function writeContext(payload: Record<string, unknown>): void {
mkdirSync(join(cwd, '.cipherstash'), { recursive: true })
writeFileSync(
join(cwd, '.cipherstash', 'context.json'),
JSON.stringify(payload),
'utf-8',
)
}

describe('deriveMode (no --yolo)', () => {
it('returns plan when no plan file exists', async () => {
expect(await deriveMode(cwd, false)).toBe('plan')
})

it('returns implement when plan file exists', async () => {
writePlan()
expect(await deriveMode(cwd, false)).toBe('implement')
})
})

describe('deriveMode (--yolo)', () => {
it('is a no-op when a plan already exists — no prompt, returns implement', async () => {
// The interactive confirmation must NOT fire when a plan exists, since
// the safety checkpoint (the plan itself) has already happened.
writePlan()
expect(await deriveMode(cwd, true)).toBe('implement')
})

// The `--yolo + no plan` path is interactive (p.confirm). Covered by
// manual smoke tests; mocking @clack/prompts isn't worth the churn here.
})

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()
})
})
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest'
import type { AgentEnvironment } from '../detect-agents.js'
import type { AgentEnvironment } from '../../init/detect-agents.js'
import type { InitState } from '../../init/types.js'
import { buildOptions, defaultChoice } from '../steps/how-to-proceed.js'
import type { InitState } from '../types.js'

function makeAgents(claudeCode: boolean, codex: boolean): AgentEnvironment {
return {
Expand Down
170 changes: 170 additions & 0 deletions packages/cli/src/commands/impl/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
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 { PLAN_REL_PATH } from '../init/lib/setup-prompt.js'
import {
CONTEXT_REL_PATH,
type ContextFile,
} from '../init/lib/write-context.js'
import {
CancelledError,
type InitMode,
type InitProvider,
type InitState,
} from '../init/types.js'
import { detectPackageManager, runnerCommand } from '../init/utils.js'
import { howToProceedStep } from './steps/how-to-proceed.js'

/**
* The handoff steps in `impl/steps/handoff-*.ts` accept an `InitProvider`
* but ignore it (their `run` signatures take `_provider`). The provider
* abstraction belongs to the `init` flow, where it picks intro copy and
* default next-steps. `stash impl` reads everything it needs from
* `.cipherstash/context.json` instead, so a stub keeps the type signature
* happy without pretending impl has provider-specific behaviour.
*/
const STUB_PROVIDER: InitProvider = {
name: 'impl',
introMessage: '',
getNextSteps: () => [],
}

export function readContextFile(cwd: string): ContextFile | undefined {
const path = resolve(cwd, CONTEXT_REL_PATH)
if (!existsSync(path)) return undefined
try {
return JSON.parse(readFileSync(path, 'utf-8')) as ContextFile
} catch {
return undefined
}
}

/**
* Derive the impl mode from disk state and flags.
*
* no `--yolo`, plan missing → `plan` (default — the safer path)
* no `--yolo`, plan exists → `implement` (the plan is the source of truth)
* `--yolo`, plan missing → `implement` after interactive confirmation
* `--yolo`, plan exists → `implement`; `--yolo` is a no-op once a plan
* exists, since the safety checkpoint already
* fired
*
* The interactive confirmation when `--yolo` is the only thing standing
* between the user and ~45–60 min of agent-driven implementation. Cheap
* to ask, expensive to skip by accident.
*/
export async function deriveMode(
cwd: string,
yolo: boolean,
): Promise<InitMode> {
const planExists = existsSync(resolve(cwd, PLAN_REL_PATH))

if (yolo) {
if (planExists) {
p.log.info(
`Plan exists at \`${PLAN_REL_PATH}\` — \`--yolo\` is a no-op when a plan is already in place.`,
)
return 'implement'
}
const confirmed = await p.confirm({
message:
'Skip the planning checkpoint and go straight to implementation?',
initialValue: false,
})
if (p.isCancel(confirmed) || !confirmed) {
throw new CancelledError()
}
return 'implement'
}

return planExists ? 'implement' : 'plan'
}

function buildStateFromContext(
ctx: ContextFile,
mode: InitMode,
agents: AgentEnvironment,
): InitState {
return {
integration: ctx.integration,
clientFilePath: ctx.encryptionClientPath,
schemas: ctx.schemas,
envKeys: ctx.envKeys,
// After init has run, these are true. The pre-flight context.json
// check above is the gate — if init didn't complete, context.json
// wouldn't exist and we'd have already errored.
stackInstalled: true,
cliInstalled: true,
eqlInstalled: true,
agents,
mode,
}
}

/**
* `stash impl` — the agent handoff phase.
*
* Pre-flights `.cipherstash/context.json` (errors with a `stash init`
* pointer if missing). Derives plan-vs-implement mode from disk state and
* the `--yolo` flag, then dispatches to a handoff target via
* `howToProceedStep`. Modes:
*
* - `plan` (default when no `.cipherstash/plan.md` exists): the agent
* produces a reviewable plan file. The user reads it, then re-runs
* `stash impl` to execute.
* - `implement` (default when a plan exists): the agent executes the
* plan as the source of truth.
* - `--yolo` forces `implement` even with no plan, after an interactive
* confirmation prompt.
*/
export async function implCommand(flags: Record<string, boolean>) {
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')

try {
const mode = await deriveMode(cwd, flags.yolo === true)

if (mode === 'plan') {
p.log.info(
`No plan at \`${PLAN_REL_PATH}\`. The agent will draft one for you to review.`,
)
} else {
p.log.info(
`Plan at \`${PLAN_REL_PATH}\` — the agent will execute it as the source of truth.`,
)
}

const agents = detectAgents(cwd, process.env)
const state = buildStateFromContext(ctx, mode, agents)

await howToProceedStep.run(state, STUB_PROVIDER)

if (mode === 'plan') {
p.outro(
`Plan drafted at \`${PLAN_REL_PATH}\`. Review it, then run \`${cli} impl\` again to implement.`,
)
} else {
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
}
}
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
import { resolve } from 'node:path'
import * as p from '@clack/prompts'
import { buildAgentsMdBody } from '../lib/build-agents-md.js'
import { writeArtifacts } from '../lib/handoff-helpers.js'
import { upsertManagedBlock } from '../lib/sentinel-upsert.js'
import { buildAgentsMdBody } from '../../init/lib/build-agents-md.js'
import { writeArtifacts } from '../../init/lib/handoff-helpers.js'
import { upsertManagedBlock } from '../../init/lib/sentinel-upsert.js'
import {
CONTEXT_REL_PATH,
SETUP_PROMPT_REL_PATH,
} from '../lib/write-context.js'
import type { InitProvider, InitState, InitStep } from '../types.js'
} from '../../init/lib/write-context.js'
import type { InitProvider, InitState, InitStep } from '../../init/types.js'

const AGENTS_MD_REL_PATH = 'AGENTS.md'

Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import * as p from '@clack/prompts'
import { spawnAgent, writeArtifacts } from '../lib/handoff-helpers.js'
import { installSkills } from '../lib/install-skills.js'
import { spawnAgent, writeArtifacts } from '../../init/lib/handoff-helpers.js'
import { installSkills } from '../../init/lib/install-skills.js'
import {
CONTEXT_REL_PATH,
SETUP_PROMPT_REL_PATH,
} from '../lib/write-context.js'
import type { InitProvider, InitState, InitStep } from '../types.js'
} from '../../init/lib/write-context.js'
import type { InitProvider, InitState, InitStep } from '../../init/types.js'

const CLAUDE_SKILLS_DIR = '.claude/skills'

Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
import { resolve } from 'node:path'
import * as p from '@clack/prompts'
import { buildAgentsMdBody } from '../lib/build-agents-md.js'
import { spawnAgent, writeArtifacts } from '../lib/handoff-helpers.js'
import { installSkills } from '../lib/install-skills.js'
import { upsertManagedBlock } from '../lib/sentinel-upsert.js'
import { buildAgentsMdBody } from '../../init/lib/build-agents-md.js'
import { spawnAgent, writeArtifacts } from '../../init/lib/handoff-helpers.js'
import { installSkills } from '../../init/lib/install-skills.js'
import { upsertManagedBlock } from '../../init/lib/sentinel-upsert.js'
import {
CONTEXT_REL_PATH,
SETUP_PROMPT_REL_PATH,
} from '../lib/write-context.js'
import type { InitProvider, InitState, InitStep } from '../types.js'
} from '../../init/lib/write-context.js'
import type { InitProvider, InitState, InitStep } from '../../init/types.js'

const AGENTS_MD_REL_PATH = 'AGENTS.md'
const CODEX_SKILLS_DIR = '.codex/skills'
Expand Down
Loading
Loading