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
25 changes: 25 additions & 0 deletions .changeset/honest-noninteractive-init.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
---
'stash': minor
---

`stash init` is honest non-interactively — it no longer reports success for a
setup that didn't fully complete.

- **Fails on version skew.** A non-interactive run can't reconcile an
already-installed `@cipherstash/*` package that's *older* than this CLI
expects (it won't mutate an install without consent), so instead of warning
and proceeding — scaffolding against mismatched packages and then claiming
success — it now refuses with a non-zero exit and the exact align command.
Interactive runs still offer to align. A *newer* install stays a warn (the
install is likely fine; update the CLI instead).
- **No false "Setup complete".** If the EQL extension isn't installed at the
end (and the integration isn't Prisma Next, which installs it via
`migration apply`), the summary reads "Setup incomplete" and init exits
non-zero, pointing at `stash eql install`.
- **Honest checkmarks.** The summary no longer claims "Database connection
verified" (init resolves a URL but doesn't open a connection) — it now says
"Database URL resolved" — and only shows "Encryption client scaffolded" when
a client was actually written (skipped for Prisma Next).
- **No false "skills loaded".** The agent handoff prompt only points at the
skills directory when skills were actually copied (a stripped build installs
none), instead of telling the agent to read files that aren't there.
11 changes: 9 additions & 2 deletions packages/cli/src/commands/impl/steps/handoff-claude.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,17 @@ export const handoffClaudeStep: HandoffStep = {
writeArtifacts(cwd, state, 'claude-code', installed)

const mode = state.mode ?? 'implement'
// Only point the agent at the skills dir when skills were actually copied.
// A stripped CLI build (no bundled skills) returns [] — claiming they're
// there would send the agent to read files that don't exist.
const skillsClause =
installed.length > 0
? `The installed skills under ${CLAUDE_SKILLS_DIR}/ have the rules; `
: ''
const launchPrompt =
mode === 'plan'
? `Read ${SETUP_PROMPT_REL_PATH} and produce the planning deliverable it describes. The installed skills under ${CLAUDE_SKILLS_DIR}/ have the rules; ${CONTEXT_REL_PATH} has the project facts. Do not edit code or run mutating commands during this phase.`
: `Read ${SETUP_PROMPT_REL_PATH} and complete the setup steps. The installed skills under ${CLAUDE_SKILLS_DIR}/ have the rules; ${CONTEXT_REL_PATH} has the project facts.`
? `Read ${SETUP_PROMPT_REL_PATH} and produce the planning deliverable it describes. ${skillsClause}${CONTEXT_REL_PATH} has the project facts. Do not edit code or run mutating commands during this phase.`
: `Read ${SETUP_PROMPT_REL_PATH} and complete the setup steps. ${skillsClause}${CONTEXT_REL_PATH} has the project facts.`
Comment thread
coderabbitai[bot] marked this conversation as resolved.

if (!state.agents?.cli.claudeCode) {
p.note(
Expand Down
11 changes: 9 additions & 2 deletions packages/cli/src/commands/impl/steps/handoff-codex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,10 +54,17 @@ export const handoffCodexStep: HandoffStep = {
writeArtifacts(cwd, state, 'codex', installed)

const mode = state.mode ?? 'implement'
// Only reference the skills dir when skills were actually copied (a
// stripped build returns []); the durable rules live in AGENTS.md either
// way, so the prompt stays useful without them.
const skillsClause =
installed.length > 0
? `the skills under ${CODEX_SKILLS_DIR}/ have the API details; `
: ''
const launchPrompt =
mode === 'plan'
? `Read ${SETUP_PROMPT_REL_PATH} and produce the planning deliverable it describes. AGENTS.md has the durable rules; the skills under ${CODEX_SKILLS_DIR}/ have the API details; ${CONTEXT_REL_PATH} has the project facts. Do not edit code or run mutating commands during this phase.`
: `Read ${SETUP_PROMPT_REL_PATH} and complete the setup steps. AGENTS.md has the durable rules; the skills under ${CODEX_SKILLS_DIR}/ have the API details; ${CONTEXT_REL_PATH} has the project facts.`
? `Read ${SETUP_PROMPT_REL_PATH} and produce the planning deliverable it describes. AGENTS.md has the durable rules; ${skillsClause}${CONTEXT_REL_PATH} has the project facts. Do not edit code or run mutating commands during this phase.`
: `Read ${SETUP_PROMPT_REL_PATH} and complete the setup steps. AGENTS.md has the durable rules; ${skillsClause}${CONTEXT_REL_PATH} has the project facts.`

if (!state.agents?.cli.codex) {
p.note(
Expand Down
47 changes: 46 additions & 1 deletion packages/cli/src/commands/init/__tests__/init-command.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import * as p from '@clack/prompts'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { CliExit } from '../../../cli/exit.js'
import { messages } from '../../../messages.js'
import type { InitState } from '../types.js'

// `--region` is the non-interactive escape hatch for `stash init`; it must land
Expand All @@ -9,6 +12,10 @@ import type { InitState } from '../types.js'
// (`authenticate`, `install-deps`) out of the fast suite.
const authRun = vi.hoisted(() => vi.fn(async (state: InitState) => state))
const passthrough = { run: async (s: InitState) => s }
// Controllable so the honest-summary tests can vary whether EQL installed.
const eqlRun = vi.hoisted(() =>
vi.fn(async (s: InitState) => ({ ...s, eqlInstalled: true })),
)

vi.mock('../steps/authenticate.js', () => ({
authenticateStep: { id: 'authenticate', name: 'Authenticate', run: authRun },
Expand All @@ -26,7 +33,10 @@ vi.mock('../steps/install-deps.js', () => ({
installDepsStep: { id: 'install-deps', ...passthrough },
}))
vi.mock('../steps/install-eql.js', () => ({
installEqlStep: { id: 'install-eql', ...passthrough },
// A successful init installs EQL — the default mark keeps the honest-summary
// gate (`eqlPending` → exit 1) happy for the region-threading runs. The
// honest-summary tests override `eqlRun` per case.
installEqlStep: { id: 'install-eql', run: eqlRun },
}))
vi.mock('../steps/gather-context.js', () => ({
gatherContextStep: { id: 'gather-context', ...passthrough },
Expand Down Expand Up @@ -70,3 +80,38 @@ describe('initCommand — region threading', () => {
expect(stateArg.regionFlag).toBeUndefined()
})
})

describe('initCommand — honest summary', () => {
it('exits non-zero and reports "Setup incomplete" when EQL was not installed', async () => {
eqlRun.mockImplementationOnce(async (s: InitState) => ({
...s,
eqlInstalled: false,
}))

await expect(initCommand({}, {})).rejects.toBeInstanceOf(CliExit)
// The summary titles the run as incomplete, and the EQL fix is surfaced.
expect(vi.mocked(p.note)).toHaveBeenCalledWith(
expect.any(String),
messages.init.setupIncomplete,
)
expect(vi.mocked(p.log.error)).toHaveBeenCalledWith(
expect.stringContaining(messages.init.eqlNotInstalled),
)
})

it('completes (no throw) when EQL was not installed but the integration is prisma-next', async () => {
// Prisma Next installs EQL via `migration apply`, so eqlInstalled=false is
// expected there and must NOT be treated as an incomplete setup.
eqlRun.mockImplementationOnce(async (s: InitState) => ({
...s,
integration: 'prisma-next',
eqlInstalled: false,
}))

await expect(initCommand({}, {})).resolves.toBeUndefined()
expect(vi.mocked(p.note)).toHaveBeenCalledWith(
expect.any(String),
'Setup complete',
)
})
})
32 changes: 30 additions & 2 deletions packages/cli/src/commands/init/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import * as p from '@clack/prompts'
import { CliExit } from '../../cli/exit.js'
import { messages } from '../../messages.js'
import { HANDOFF_CHOICES } from '../impl/steps/how-to-proceed.js'
import { planCommand } from '../plan/index.js'
import { createBaseProvider } from './providers/base.js'
Expand Down Expand Up @@ -99,17 +100,44 @@ export async function initCommand(

const pm = detectPackageManager()
const cli = runnerCommand(pm, 'stash')
// Only claim what actually happened. Auth throws on failure (reaching here
// means it succeeded); the database step *resolves* a URL but never opens a
// connection, so don't claim "verified"; the client scaffold is skipped for
// Prisma Next (no `clientFilePath` on state). `schemaGenerated` is true only
// when a placeholder was actually written — when an existing client file is
// kept, `clientFilePath` is still set but nothing was scaffolded, so don't
// claim we did.
const checkmarks: string[] = [
'✓ Authenticated to CipherStash',
'✓ Database connection verified',
'✓ Encryption client scaffolded',
'✓ Database URL resolved',
]
if (state.schemaGenerated) {
checkmarks.push('✓ Encryption client scaffolded')
} else if (state.clientFilePath) {
checkmarks.push('✓ Encryption client kept (existing file)')
}
if (state.stackInstalled) {
checkmarks.push('✓ `@cipherstash/stack` installed')
}
if (state.cliInstalled) checkmarks.push('✓ `stash` CLI installed')
if (state.eqlInstalled) checkmarks.push('✓ EQL extension installed')

// EQL is required for encryption. Prisma Next installs it via `migration
// apply` (so `eqlInstalled` is false by design there); every other
// integration needs it installed here. If it's missing, setup is NOT
// complete — say so and exit non-zero so automation can't read a false
// success from a run where encryption would fail at query time.
const eqlPending =
!state.eqlInstalled && state.integration !== 'prisma-next'
if (eqlPending) {
checkmarks.push('✗ EQL extension NOT installed')
p.note(checkmarks.join('\n'), messages.init.setupIncomplete)
p.log.error(
`${messages.init.eqlNotInstalled} Run \`${cli} eql install\` before running any encryption.`,
)
throw new CliExit(1)
}

p.note(checkmarks.join('\n'), 'Setup complete')

// Offer to chain straight into `stash plan` so first-time users don't
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -409,3 +409,50 @@ describe('renderSetupPrompt — no db push recommendations', () => {
}
})
})

describe('renderSetupPrompt — honours what the handoff actually wrote', () => {
for (const mode of ['implement', 'plan'] as const) {
it(`claude-code with no skills points at neither a skills dir nor AGENTS.md (${mode})`, () => {
const out = renderSetupPrompt({
...baseCtx,
mode,
handoff: 'claude-code',
installedSkills: [],
})
// Nothing was written, so don't send the agent to files that don't exist.
expect(out).not.toContain('.claude/skills/')
expect(out).not.toContain('Read the skills')
// It may NAME AGENTS.md to say it was NOT written, but must not point the
// agent at it as a rules source (this handoff never writes one).
expect(out).not.toMatch(
/(?:rules are in|doctrine in|[Rr]ead)[^\n]*AGENTS\.md/,
)
expect(out).toContain('No skills or `AGENTS.md` were written')
expect(out).toContain('cipherstash.com/docs')
})

it(`codex with no skills points at AGENTS.md, not .codex/skills/ (${mode})`, () => {
const out = renderSetupPrompt({
...baseCtx,
mode,
handoff: 'codex',
installedSkills: [],
})
expect(out).not.toContain('.codex/skills/')
expect(out).toContain('AGENTS.md')
})

it(`claude-code with skills does not claim the doctrine is in AGENTS.md (${mode})`, () => {
// The Claude handoff never writes AGENTS.md — the doctrine is in the
// installed skills.
const out = renderSetupPrompt({
...baseCtx,
mode,
handoff: 'claude-code',
installedSkills: ['stash-encryption'],
})
expect(out).toContain('.claude/skills/')
expect(out).not.toContain('AGENTS.md')
})
}
})
59 changes: 43 additions & 16 deletions packages/cli/src/commands/init/lib/setup-prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,45 @@ function renderSkillIndex(installedSkills: string[]): string {
.join('\n')
}

/**
* The "## Skills loaded" section, honouring what the handoff actually wrote —
* so the prompt never points the agent at files that don't exist:
*
* - No skills installed (a stripped CLI build): don't reference any skill
* directory; point only at whatever durable rules the handoff wrote
* (`AGENTS.md` for codex / agents-md; nothing for claude-code, so send the
* agent to the docs).
* - claude-code: the doctrine lives in the installed skills, not `AGENTS.md`
* (this handoff never writes one) — so don't name `AGENTS.md`.
*/
function skillsLoadedLines(
handoff: HandoffChoice,
installedSkills: string[],
): string[] {
const wroteAgentsMd = handoff === 'codex' || handoff === 'agents-md'
if (installedSkills.length === 0) {
return [
'## Rules',
'',
wroteAgentsMd
? 'No skills were installed (stripped build) — the durable rules are in `AGENTS.md`; read it before answering API or pattern questions.'
: 'No skills or `AGENTS.md` were written (stripped build) — consult https://cipherstash.com/docs for the encryption API, schema rules, and the rollout/cutover lifecycle.',
]
}
const doctrine = wroteAgentsMd
? 'Read the skills before answering API or pattern questions. The doctrine in `AGENTS.md` covers the invariants that apply regardless of which flow you take — never log plaintext, never `.notNull()` on creation, etc.'
: 'Read the skills before answering API or pattern questions — they carry the invariants that apply regardless of which flow you take: never log plaintext, never `.notNull()` on creation, etc.'
return [
'## Skills loaded',
'',
`Reusable rules and worked examples live in ${rulesLocation(handoff)}:`,
'',
renderSkillIndex(installedSkills),
'',
doctrine,
]
}

/**
* Render the project-specific action prompt. Dispatches to the plan-mode or
* implement-mode renderer based on `ctx.mode`. Both produce the same shape
Expand Down Expand Up @@ -227,13 +266,7 @@ export function renderImplementPrompt(ctx: SetupPromptContext): string {
'',
...setupChecklist(ctx),
'',
'## Skills loaded',
'',
`Reusable rules and worked examples live in ${rulesLocation(ctx.handoff)}:`,
'',
renderSkillIndex(ctx.installedSkills),
'',
'Read the skills before answering API or pattern questions. The doctrine in `AGENTS.md` (or its inlined equivalent) covers the invariants that apply regardless of which flow you take — never log plaintext, never `.notNull()` on creation, etc.',
...skillsLoadedLines(ctx.handoff, ctx.installedSkills),
'',
'## The two options',
'',
Expand Down Expand Up @@ -283,7 +316,7 @@ export function renderImplementPrompt(ctx: SetupPromptContext): string {
'',
'## Your first response',
'',
`Before any edits, send the user a short orientation message. Confirm setup is complete, list the skills loaded with one-line purposes, summarise the two options in your own words, and end with a clear question — *"Which would you like to do? You can name a specific table+column or describe what you're trying to protect."* Reference concrete tables/columns from \`.cipherstash/context.json\` when it helps. Mention that they can run \`${cli} status\` at any time to see where each rollout is.`,
`Before any edits, send the user a short orientation message. Confirm setup is complete, list the skills loaded (if any) with one-line purposes, summarise the two options in your own words, and end with a clear question — *"Which would you like to do? You can name a specific table+column or describe what you're trying to protect."* Reference concrete tables/columns from \`.cipherstash/context.json\` when it helps. Mention that they can run \`${cli} status\` at any time to see where each rollout is.`,
'',
'Once the user answers, execute the relevant flow. Show diffs / generated SQL before applying. Pause for review at every database-mutating step.',
'',
Expand Down Expand Up @@ -359,13 +392,7 @@ function planSharedSetupBlock(ctx: SetupPromptContext): string[] {
'',
...setupChecklist(ctx),
'',
'## Skills loaded',
'',
`Reusable rules and worked examples live in ${rulesLocation(ctx.handoff)}:`,
'',
renderSkillIndex(ctx.installedSkills),
'',
'Read the skills before answering API or pattern questions. The doctrine in `AGENTS.md` (or its inlined equivalent) covers the invariants that apply regardless of which flow you take — never log plaintext, never `.notNull()` on creation, etc.',
...skillsLoadedLines(ctx.handoff, ctx.installedSkills),
'',
]
}
Expand Down Expand Up @@ -497,7 +524,7 @@ function renderRolloutPlanPrompt(ctx: SetupPromptContext): string {
...planSharedNotDoBlock(ctx),
'## Your first response',
'',
`Send the user a short orientation message before writing anything. Confirm setup is complete, list the skills loaded with one-line purposes, explain what an encryption rollout is in your own words, and end with a clear question — *"Which table(s) and column(s) would you like the rollout plan to cover? You can name them or describe what you're trying to protect."* Reference concrete tables/columns from \`.cipherstash/context.json\` when it helps.`,
`Send the user a short orientation message before writing anything. Confirm setup is complete, list the skills loaded (if any) with one-line purposes, explain what an encryption rollout is in your own words, and end with a clear question — *"Which table(s) and column(s) would you like the rollout plan to cover? You can name them or describe what you're trying to protect."* Reference concrete tables/columns from \`.cipherstash/context.json\` when it helps.`,
'',
`Once the user answers, write \`${PLAN_REL_PATH}\`. Show the plan in chat as well so the user can react inline. After the plan is approved, tell the user to run \`${cli} impl\` to execute it.`,
'',
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { CliExit } from '../../../../cli/exit.js'
import type { InitProvider, InitState } from '../../types.js'

const execSyncMock = vi.hoisted(() => vi.fn())
Expand Down Expand Up @@ -199,15 +200,20 @@ describe('installDepsStep', () => {
)
})

it('non-interactive: warns on skew, prints align commands, never mutates', async () => {
it('non-interactive: REFUSES on skew (exit 1), prints align commands, never mutates', async () => {
vi.mocked(isInteractive).mockReturnValue(false)
present('@cipherstash/stack', 'stash')
resolvedVersions({
'@cipherstash/stack': '0.19.0',
stash: FIXTURE_VERSIONS.stash,
})

const result = await installDepsStep.run(baseState, provider)
// M4: a non-interactive run can't reconcile a `behind` skew (won't mutate
// without consent), so it refuses with a non-zero exit rather than
// proceeding and reporting a false success.
await expect(
installDepsStep.run(baseState, provider),
).rejects.toBeInstanceOf(CliExit)

expect(p.log.warn).toHaveBeenCalledWith(
expect.stringContaining('@cipherstash/stack: installed 0.19.0'),
Expand All @@ -217,9 +223,8 @@ describe('installDepsStep', () => {
expect.stringContaining('npm install @cipherstash/stack@9.9.9-test.1'),
'Version skew',
)
// Never mutates — the #661/#666 principle survives the refusal.
expect(execSyncMock).not.toHaveBeenCalled()
expect(result.stackInstalled).toBe(true)
expect(result.cliInstalled).toBe(true)
})

it('a NEWER install gets an update-stash warning, never a downgrade command', async () => {
Expand Down Expand Up @@ -284,7 +289,11 @@ describe('installDepsStep', () => {
stash: FIXTURE_VERSIONS.stash,
})

await installDepsStep.run(baseState, provider)
// An unreadable manifest classifies as `behind` skew, so a non-interactive
// run refuses (exit 1) after warning — same as any other skew.
await expect(
installDepsStep.run(baseState, provider),
).rejects.toBeInstanceOf(CliExit)

expect(p.log.warn).toHaveBeenCalledWith(
expect.stringContaining(
Expand Down
Loading
Loading