From eb61d9b33be6a85e429672f6127393eddef0c2c7 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Mon, 10 Aug 2026 11:20:59 +1000 Subject: [PATCH 1/3] feat(cli): add a Lovable handoff target to plan and impl MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lovable's agent runs in Lovable's cloud and only sees the repo through GitHub sync, so without repo-local guidance it answers CipherStash questions from stale training data (the pre-EQL-v3 "needs a Postgres extension and superuser" story) and talks users out of a supported Supabase setup. The new --target lovable (and picker entry) writes the same AGENTS.md as the editor-agent handoff — doctrine plus the per-integration skills inlined — with Lovable-specific next steps: commit and push the generated files, then add a Knowledge note in the Lovable project settings pointing the agent at AGENTS.md and .cipherstash/setup-prompt.md, since Lovable does not auto-load AGENTS.md the way Cursor or Windsurf do. --- .changeset/lovable-handoff-target.md | 5 ++ packages/cli/src/cli/registry.ts | 4 +- .../impl/__tests__/how-to-proceed.test.ts | 20 ++++-- .../commands/impl/steps/handoff-lovable.ts | 68 +++++++++++++++++++ .../src/commands/impl/steps/how-to-proceed.ts | 9 +++ .../cli/src/commands/init/lib/setup-prompt.ts | 5 +- packages/cli/src/commands/init/types.ts | 7 +- packages/cli/src/commands/plan/index.ts | 2 +- .../cli/tests/e2e/impl-non-tty.e2e.test.ts | 4 +- skills/stash-cli/SKILL.md | 4 +- 10 files changed, 116 insertions(+), 12 deletions(-) create mode 100644 .changeset/lovable-handoff-target.md create mode 100644 packages/cli/src/commands/impl/steps/handoff-lovable.ts diff --git a/.changeset/lovable-handoff-target.md b/.changeset/lovable-handoff-target.md new file mode 100644 index 000000000..f22bee9b4 --- /dev/null +++ b/.changeset/lovable-handoff-target.md @@ -0,0 +1,5 @@ +--- +'stash': minor +--- + +Add a `lovable` handoff target to `stash plan` and `stash impl` (`--target lovable`, plus a new agent-target picker entry). It writes the same AGENTS.md as the editor-agent handoff — doctrine plus the per-integration skills inlined — but the next-steps guidance is Lovable-specific: commit and push the generated files through Lovable's GitHub sync, then add a Knowledge note in the Lovable project settings pointing the agent at `AGENTS.md` and `.cipherstash/setup-prompt.md`. Without repo-local guidance, Lovable's agent answers CipherStash questions from stale training data (the pre-EQL-v3 "needs a Postgres extension and superuser" story) and talks users out of a supported Supabase setup. diff --git a/packages/cli/src/cli/registry.ts b/packages/cli/src/cli/registry.ts index 1c22a2a6b..4f2bf73c0 100644 --- a/packages/cli/src/cli/registry.ts +++ b/packages/cli/src/cli/registry.ts @@ -156,7 +156,7 @@ export const registry: CommandGroup[] = [ name: '--target', value: '', description: - 'Skip the agent-target picker and hand off directly to one of claude-code | codex | agents-md | wizard. Safe in non-TTY contexts.', + 'Skip the agent-target picker and hand off directly to one of claude-code | codex | agents-md | lovable | wizard. Safe in non-TTY contexts.', }, ], }, @@ -178,7 +178,7 @@ export const registry: CommandGroup[] = [ name: '--target', value: '', description: - 'Skip the agent-target picker and hand off directly to one of claude-code | codex | agents-md | wizard. Safe in non-TTY contexts.', + 'Skip the agent-target picker and hand off directly to one of claude-code | codex | agents-md | lovable | wizard. Safe in non-TTY contexts.', }, ], }, 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 c598afd48..f763ec727 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 @@ -26,16 +26,28 @@ const claudeOnly: InitState = { agents: makeAgents(true, false) } const codexOnly: InitState = { agents: makeAgents(false, true) } describe('howToProceed — buildOptions', () => { - it('offers all four targets in implement mode', () => { + it('offers all five targets in implement mode', () => { const opts = buildOptions(noAgents, 'implement') const values = opts.map((o) => o.value) - expect(values).toEqual(['claude-code', 'codex', 'agents-md', 'wizard']) + expect(values).toEqual([ + 'claude-code', + 'codex', + 'agents-md', + 'lovable', + 'wizard', + ]) }) - it('offers all four targets in plan mode', () => { + it('offers all five targets in plan mode', () => { const opts = buildOptions(noAgents, 'plan') const values = opts.map((o) => o.value) - expect(values).toEqual(['claude-code', 'codex', 'agents-md', 'wizard']) + expect(values).toEqual([ + 'claude-code', + 'codex', + 'agents-md', + 'lovable', + 'wizard', + ]) }) it('reflects detection state in hints regardless of mode', () => { diff --git a/packages/cli/src/commands/impl/steps/handoff-lovable.ts b/packages/cli/src/commands/impl/steps/handoff-lovable.ts new file mode 100644 index 000000000..c9a976985 --- /dev/null +++ b/packages/cli/src/commands/impl/steps/handoff-lovable.ts @@ -0,0 +1,68 @@ +import * as p from '@clack/prompts' +import { buildAgentsMdBody } from '../../init/lib/build-agents-md.js' +import { + AGENTS_MD_REL_PATH, + writeAgentsMd, + writeArtifacts, +} from '../../init/lib/handoff-helpers.js' +import { availableSkills } from '../../init/lib/install-skills.js' +import { + CONTEXT_REL_PATH, + SETUP_PROMPT_REL_PATH, +} from '../../init/lib/write-context.js' +import type { HandoffStep, InitState } from '../../init/types.js' + +/** + * Write `AGENTS.md`, `.cipherstash/context.json`, and + * `.cipherstash/setup-prompt.md` for a Lovable project, then stop. + * + * Same artifacts as the AGENTS.md handoff — Lovable's agent runs in + * Lovable's cloud, not on this machine, so there is nothing to launch. + * What differs is how the files reach the agent: Lovable only sees the + * repo through its GitHub sync, so the guidance tells the user to commit + * and push, then add a Knowledge pointer in the Lovable project settings. + * That pointer matters because Lovable does not auto-load AGENTS.md the + * way Cursor/Windsurf do — without it the agent answers CipherStash + * questions from stale training data (the pre-EQL-v3 "needs a Postgres + * extension and superuser" story) instead of the inlined skills. + */ +export const handoffLovableStep: HandoffStep = { + id: 'handoff-lovable', + name: 'Write AGENTS.md for Lovable', + async run(state: InitState): Promise { + const cwd = process.cwd() + const integration = state.integration ?? 'postgresql' + + const inlinable = availableSkills(integration) + const managed = buildAgentsMdBody( + integration, + 'doctrine-plus-skills', + inlinable, + ) + const written = writeAgentsMd(cwd, managed) + + writeArtifacts(cwd, state, 'lovable', { + installed: [], + inlined: written ? inlinable : [], + failed: written ? [] : inlinable, + }) + + p.note( + [ + `Rules at ${AGENTS_MD_REL_PATH}`, + `Action plan at ${SETUP_PROMPT_REL_PATH}`, + `Context at ${CONTEXT_REL_PATH}`, + '', + 'Lovable only sees these files through its GitHub sync:', + `1. Commit and push ${AGENTS_MD_REL_PATH} and .cipherstash/`, + '2. In Lovable: Settings → Knowledge, add:', + ` "Follow ${AGENTS_MD_REL_PATH} for all CipherStash work.`, + ` Start from ${SETUP_PROMPT_REL_PATH}."`, + `3. Ask the Lovable agent to read ${SETUP_PROMPT_REL_PATH} and begin.`, + ].join('\n'), + 'Drive the Lovable agent', + ) + + return state + }, +} 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 c6eb2081a..39cd9ebc5 100644 --- a/packages/cli/src/commands/impl/steps/how-to-proceed.ts +++ b/packages/cli/src/commands/impl/steps/how-to-proceed.ts @@ -9,6 +9,7 @@ import { import { handoffAgentsMdStep } from './handoff-agents-md.js' import { handoffClaudeStep } from './handoff-claude.js' import { handoffCodexStep } from './handoff-codex.js' +import { handoffLovableStep } from './handoff-lovable.js' import { handoffWizardStep } from './handoff-wizard.js' /** @@ -20,6 +21,7 @@ export const HANDOFF_CHOICES: readonly HandoffChoice[] = [ 'claude-code', 'codex', 'agents-md', + 'lovable', 'wizard', ] as const @@ -91,6 +93,11 @@ export function buildOptions( label: 'Write AGENTS.md', hint: 'works with Cursor, Windsurf, Cline, and more', }, + { + value: 'lovable', + label: 'Write AGENTS.md for Lovable', + hint: 'push via GitHub sync, then add a Knowledge pointer in Lovable', + }, { value: 'wizard', label: 'Use the CipherStash Agent', @@ -136,6 +143,8 @@ export const howToProceedStep: HandoffStep = { return handoffCodexStep.run(next) case 'agents-md': return handoffAgentsMdStep.run(next) + case 'lovable': + return handoffLovableStep.run(next) case 'wizard': return handoffWizardStep.run(next) } diff --git a/packages/cli/src/commands/init/lib/setup-prompt.ts b/packages/cli/src/commands/init/lib/setup-prompt.ts index ec89a26e1..a85f73ce3 100644 --- a/packages/cli/src/commands/init/lib/setup-prompt.ts +++ b/packages/cli/src/commands/init/lib/setup-prompt.ts @@ -173,6 +173,8 @@ function checked(line: string): string { */ function rulesLocation(handoff: HandoffChoice, skills: SkillsDelivery): string { if (handoff === 'agents-md') return '`AGENTS.md` (Cursor / Windsurf / Cline)' + if (handoff === 'lovable') + return '`AGENTS.md` (synced into Lovable via GitHub)' const dir = handoff === 'claude-code' ? '`.claude/skills/`' : '`.codex/skills/`' const inlined = 'inlined in `AGENTS.md` under "## Skill references"' @@ -249,7 +251,8 @@ function skillsLoadedLines( handoff: HandoffChoice, skills: SkillsDelivery, ): string[] { - const wroteAgentsMd = handoff === 'codex' || handoff === 'agents-md' + const wroteAgentsMd = + handoff === 'codex' || handoff === 'agents-md' || handoff === 'lovable' const delivered = [...skills.installed, ...skills.inlined] if (delivered.length === 0) { if (skills.failed.length > 0) { diff --git a/packages/cli/src/commands/init/types.ts b/packages/cli/src/commands/init/types.ts index 9abf65282..1488e13e6 100644 --- a/packages/cli/src/commands/init/types.ts +++ b/packages/cli/src/commands/init/types.ts @@ -59,7 +59,12 @@ export interface SchemaDef { columns: ColumnDef[] } -export type HandoffChoice = 'claude-code' | 'codex' | 'agents-md' | 'wizard' +export type HandoffChoice = + | 'claude-code' + | 'codex' + | 'agents-md' + | 'lovable' + | 'wizard' /** * Whether the handoff agent should produce a plan first (`plan`) or go diff --git a/packages/cli/src/commands/plan/index.ts b/packages/cli/src/commands/plan/index.ts index 18182d9a4..c303a8e03 100644 --- a/packages/cli/src/commands/plan/index.ts +++ b/packages/cli/src/commands/plan/index.ts @@ -335,7 +335,7 @@ export async function planCommand( // agent-target picker, so name `--target` here rather than letting // the user re-discover the flag on the next exit-cleanly hint. p.outro( - `${planLine}. Review it, then run \`${cli} impl --target \` to implement. The \`--target\` flag is required when running non-interactively.`, + `${planLine}. Review it, then run \`${cli} impl --target <${HANDOFF_CHOICES.join('|')}>\` to implement. The \`--target\` flag is required when running non-interactively.`, ) } } catch (err) { diff --git a/packages/cli/tests/e2e/impl-non-tty.e2e.test.ts b/packages/cli/tests/e2e/impl-non-tty.e2e.test.ts index f9ff03f49..db5ddfc26 100644 --- a/packages/cli/tests/e2e/impl-non-tty.e2e.test.ts +++ b/packages/cli/tests/e2e/impl-non-tty.e2e.test.ts @@ -81,6 +81,8 @@ describe('stash impl — non-TTY safety (BUGS.md reproducer)', () => { expect(r.timedOut).toBe(false) expect(r.exitCode).toBe(0) expect(r.stdout).toContain('--target') - expect(r.stdout).toContain('claude-code | codex | agents-md | wizard') + expect(r.stdout).toContain( + 'claude-code | codex | agents-md | lovable | wizard', + ) }) }) diff --git a/skills/stash-cli/SKILL.md b/skills/stash-cli/SKILL.md index a9c9e3e43..18453d1b2 100644 --- a/skills/stash-cli/SKILL.md +++ b/skills/stash-cli/SKILL.md @@ -131,7 +131,7 @@ There is **no global `--non-interactive` or `--json` flag** (and no global `--ye |---|---| | Region (`auth login`, `init`) | `--region ` or `STASH_REGION` | | Database URL (all `db` / `eql` / `schema` commands) | `--database-url ` or `DATABASE_URL` | -| Agent target (`plan`, `impl`) | `--target ` | +| Agent target (`plan`, `impl`) | `--target ` | | Dual-write confirmation (`encrypt backfill`) | `--confirm-dual-writes-deployed` | | Machine-readable output | `--json` on `status`, `manifest`, `auth login`, `auth regions` | @@ -267,7 +267,7 @@ Each column carries `path: "new" | "migrate"`. `stash impl` parses this to rende To re-plan, delete `.cipherstash/plan.md` first — otherwise the agent is told to revise it rather than start fresh. `--complete-rollout` is the escape hatch for databases with no deployed application (local dev, sandboxes, test DBs); it's only safe when nothing in production writes to that database. -**The outro reports what actually happened.** The plan file is written by the handed-off agent, so `plan` verifies it on disk before claiming anything: `Plan drafted at .cipherstash/plan.md` appears only when the file exists after the handoff. If a launched agent exits without writing it, `plan` errors and **exits non-zero**. Deferred handoffs — `--target agents-md`, or a Claude Code / Codex target whose CLI isn't installed — end with `No plan drafted yet` and exit 0: the plan lands later, when you drive the agent yourself. A pre-existing plan the run didn't modify is reported as `left unchanged by this run`, not drafted. Either way, check that `.cipherstash/plan.md` exists before acting on it. +**The outro reports what actually happened.** The plan file is written by the handed-off agent, so `plan` verifies it on disk before claiming anything: `Plan drafted at .cipherstash/plan.md` appears only when the file exists after the handoff. If a launched agent exits without writing it, `plan` errors and **exits non-zero**. Deferred handoffs — `--target agents-md`, `--target lovable`, or a Claude Code / Codex target whose CLI isn't installed — end with `No plan drafted yet` and exit 0: the plan lands later, when you drive the agent yourself. The `lovable` target writes the same AGENTS.md (with the skills inlined) but its next steps go through Lovable's GitHub sync: commit and push the generated files, then add a Knowledge note in the Lovable project settings pointing the agent at `AGENTS.md` and `.cipherstash/setup-prompt.md` — Lovable does not auto-load AGENTS.md the way Cursor or Windsurf do. A pre-existing plan the run didn't modify is reported as `left unchanged by this run`, not drafted. Either way, check that `.cipherstash/plan.md` exists before acting on it. ### `impl` — execute From 1269b5edcc0fee8d8121116e5ac04bc9560fd87f Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Mon, 10 Aug 2026 12:02:16 +1000 Subject: [PATCH 2/3] fix(cli): only claim AGENTS.md exists when the write succeeded MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-up on the Lovable handoff. When writeAgentsMd() fails, the handoff note now shows recovery guidance instead of telling the user to commit a file that was never written — in both the Lovable and editor-agent (agents-md) steps, which shared the flaw. skillsLoadedLines() keyed its 'durable rules are in AGENTS.md' claim on any AGENTS.md-writing handoff, but for agents-md and lovable the skills are inlined into AGENTS.md itself — all skills failing means the file was not written. Only codex writes its doctrine separately, so only codex keeps that pointer on the all-failed path. --- .../commands/impl/steps/handoff-agents-md.ts | 28 +++++++--- .../commands/impl/steps/handoff-lovable.ts | 37 ++++++++----- .../init/lib/__tests__/setup-prompt.test.ts | 53 +++++++++++++++++++ .../cli/src/commands/init/lib/setup-prompt.ts | 8 ++- 4 files changed, 105 insertions(+), 21 deletions(-) diff --git a/packages/cli/src/commands/impl/steps/handoff-agents-md.ts b/packages/cli/src/commands/impl/steps/handoff-agents-md.ts index c0aca3962..f0c51a581 100644 --- a/packages/cli/src/commands/impl/steps/handoff-agents-md.ts +++ b/packages/cli/src/commands/impl/steps/handoff-agents-md.ts @@ -46,15 +46,27 @@ export const handoffAgentsMdStep: HandoffStep = { failed: written ? [] : inlinable, }) + // Same honesty rule as the Lovable step: only claim AGENTS.md exists + // when the write succeeded (writeAgentsMd already logged the warning). p.note( - [ - `Rules at ${AGENTS_MD_REL_PATH}`, - `Action plan at ${SETUP_PROMPT_REL_PATH}`, - `Context at ${CONTEXT_REL_PATH}`, - '', - 'Cursor / Windsurf / Cline pick up AGENTS.md automatically.', - `Open your agent and point it at ${SETUP_PROMPT_REL_PATH} to start.`, - ].join('\n'), + written + ? [ + `Rules at ${AGENTS_MD_REL_PATH}`, + `Action plan at ${SETUP_PROMPT_REL_PATH}`, + `Context at ${CONTEXT_REL_PATH}`, + '', + 'Cursor / Windsurf / Cline pick up AGENTS.md automatically.', + `Open your agent and point it at ${SETUP_PROMPT_REL_PATH} to start.`, + ].join('\n') + : [ + `${AGENTS_MD_REL_PATH} could not be written (see the warning above).`, + `Action plan at ${SETUP_PROMPT_REL_PATH}`, + `Context at ${CONTEXT_REL_PATH}`, + '', + 'Fix the file permissions and re-run this command so the rules', + `land in ${AGENTS_MD_REL_PATH}, then open your agent and point it`, + `at ${SETUP_PROMPT_REL_PATH} to start.`, + ].join('\n'), 'Drive your editor agent', ) diff --git a/packages/cli/src/commands/impl/steps/handoff-lovable.ts b/packages/cli/src/commands/impl/steps/handoff-lovable.ts index c9a976985..244e9dbe2 100644 --- a/packages/cli/src/commands/impl/steps/handoff-lovable.ts +++ b/packages/cli/src/commands/impl/steps/handoff-lovable.ts @@ -47,19 +47,32 @@ export const handoffLovableStep: HandoffStep = { failed: written ? [] : inlinable, }) + // The note must reflect what was actually written: telling the user to + // commit an AGENTS.md that failed to write sends them hunting for a + // file that does not exist (writeAgentsMd already logged the warning). p.note( - [ - `Rules at ${AGENTS_MD_REL_PATH}`, - `Action plan at ${SETUP_PROMPT_REL_PATH}`, - `Context at ${CONTEXT_REL_PATH}`, - '', - 'Lovable only sees these files through its GitHub sync:', - `1. Commit and push ${AGENTS_MD_REL_PATH} and .cipherstash/`, - '2. In Lovable: Settings → Knowledge, add:', - ` "Follow ${AGENTS_MD_REL_PATH} for all CipherStash work.`, - ` Start from ${SETUP_PROMPT_REL_PATH}."`, - `3. Ask the Lovable agent to read ${SETUP_PROMPT_REL_PATH} and begin.`, - ].join('\n'), + written + ? [ + `Rules at ${AGENTS_MD_REL_PATH}`, + `Action plan at ${SETUP_PROMPT_REL_PATH}`, + `Context at ${CONTEXT_REL_PATH}`, + '', + 'Lovable only sees these files through its GitHub sync:', + `1. Commit and push ${AGENTS_MD_REL_PATH} and .cipherstash/`, + '2. In Lovable: Settings → Knowledge, add:', + ` "Follow ${AGENTS_MD_REL_PATH} for all CipherStash work.`, + ` Start from ${SETUP_PROMPT_REL_PATH}."`, + `3. Ask the Lovable agent to read ${SETUP_PROMPT_REL_PATH} and begin.`, + ].join('\n') + : [ + `${AGENTS_MD_REL_PATH} could not be written (see the warning above).`, + `Action plan at ${SETUP_PROMPT_REL_PATH}`, + `Context at ${CONTEXT_REL_PATH}`, + '', + 'Fix the file permissions and re-run this command so the rules', + `land in ${AGENTS_MD_REL_PATH}, then commit, push, and add the`, + 'Knowledge pointer in Lovable (Settings → Knowledge).', + ].join('\n'), 'Drive the Lovable agent', ) diff --git a/packages/cli/src/commands/init/lib/__tests__/setup-prompt.test.ts b/packages/cli/src/commands/init/lib/__tests__/setup-prompt.test.ts index 4003a3edf..85281d147 100644 --- a/packages/cli/src/commands/init/lib/__tests__/setup-prompt.test.ts +++ b/packages/cli/src/commands/init/lib/__tests__/setup-prompt.test.ts @@ -716,5 +716,58 @@ describe('renderSetupPrompt — honours what the handoff actually wrote', () => expect(out).toContain('cipherstash.com/docs') expect(out).not.toContain('.claude/skills/') }) + + for (const handoff of ['agents-md', 'lovable'] as const) { + it(`${handoff} with every skill failed does not point at AGENTS.md (${mode})`, () => { + // For these handoffs the skills are inlined INTO AGENTS.md, so + // all-failed means writeAgentsMd itself failed — the file does + // not exist and the prompt must not send the agent to it. + const out = renderSetupPrompt({ + ...baseCtx, + mode, + handoff, + skills: { + installed: [], + inlined: [], + failed: ['stash-encryption', 'stash-cli'], + }, + }) + expect(out).toContain('could not be installed') + expect(out).not.toContain('AGENTS.md') + expect(out).toContain('cipherstash.com/docs') + }) + } + + it(`codex with every skill failed still points at AGENTS.md (${mode})`, () => { + // Codex writes its durable rules to AGENTS.md separately from the + // skills, so the file exists even when every skill copy failed. + const out = renderSetupPrompt({ + ...baseCtx, + mode, + handoff: 'codex', + skills: { + installed: [], + inlined: [], + failed: ['stash-encryption', 'stash-cli'], + }, + }) + expect(out).toContain('could not be installed') + expect(out).toContain('the durable rules are in `AGENTS.md`') + }) + + it(`lovable with inlined skills names the GitHub-synced AGENTS.md (${mode})`, () => { + const out = renderSetupPrompt({ + ...baseCtx, + mode, + handoff: 'lovable', + skills: { + installed: [], + inlined: ['stash-encryption', 'stash-supabase'], + failed: [], + }, + }) + expect(out).toContain('synced into Lovable via GitHub') + expect(out).toContain('`stash-supabase`') + }) } }) diff --git a/packages/cli/src/commands/init/lib/setup-prompt.ts b/packages/cli/src/commands/init/lib/setup-prompt.ts index a85f73ce3..2ef268670 100644 --- a/packages/cli/src/commands/init/lib/setup-prompt.ts +++ b/packages/cli/src/commands/init/lib/setup-prompt.ts @@ -256,10 +256,16 @@ function skillsLoadedLines( const delivered = [...skills.installed, ...skills.inlined] if (delivered.length === 0) { if (skills.failed.length > 0) { + // Only the codex handoff writes its durable rules to AGENTS.md + // separately from the skills, so only there can the file exist when + // every skill failed. For agents-md and lovable the skills are + // inlined INTO AGENTS.md — all-failed means the file itself was not + // written, and pointing the agent at it would name a file that does + // not exist. return [ '## Rules', '', - wroteAgentsMd + handoff === 'codex' ? 'The skills could not be installed (the destination was not writable) — the durable rules are in `AGENTS.md`; read it, and consult https://cipherstash.com/docs for the API details the skills would have carried.' : 'The skills could not be installed (the destination was not writable) — consult https://cipherstash.com/docs for the encryption API, schema rules, and the rollout/cutover lifecycle.', ] From 52104d7a2d25a929e31dd3efcc7ac7a589cf08d2 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Mon, 10 Aug 2026 15:45:21 +1000 Subject: [PATCH 3/3] test(cli): cover the Lovable handoff step, its dispatch, and the AGENTS.md honesty branch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Coverage-bot follow-up on the Lovable handoff. Four gaps, all in new or changed code that CI would have shipped green: - handoff-lovable.test.ts — neither arm of the writeAgentsMd success/ failure split was exercised, so a regression could record skills as inlined after the write failed, or tell the user to commit a file that was never written. - handoff-agents-md.test.ts — the step had no test at all, and this branch gained the same honesty ternary. - how-to-proceed-dispatch.test.ts — buildOptions ordering was covered but not the dispatch arms. Table-driven off HANDOFF_CHOICES so a target that reaches the picker without a dispatch arm fails here. - The --help e2e now loops over impl and plan; each carries its own --target descriptor, so asserting only impl let a dropped target ship silently from plan. Mutation-checked: rerouting the lovable dispatch arm and forcing the note ternary true each fail the new tests. --- .../steps/__tests__/handoff-agents-md.test.ts | 80 +++++++++++++ .../steps/__tests__/handoff-lovable.test.ts | 109 ++++++++++++++++++ .../__tests__/how-to-proceed-dispatch.test.ts | 71 ++++++++++++ .../cli/tests/e2e/impl-non-tty.e2e.test.ts | 29 +++-- 4 files changed, 277 insertions(+), 12 deletions(-) create mode 100644 packages/cli/src/commands/impl/steps/__tests__/handoff-agents-md.test.ts create mode 100644 packages/cli/src/commands/impl/steps/__tests__/handoff-lovable.test.ts create mode 100644 packages/cli/src/commands/impl/steps/__tests__/how-to-proceed-dispatch.test.ts diff --git a/packages/cli/src/commands/impl/steps/__tests__/handoff-agents-md.test.ts b/packages/cli/src/commands/impl/steps/__tests__/handoff-agents-md.test.ts new file mode 100644 index 000000000..5b42d8f97 --- /dev/null +++ b/packages/cli/src/commands/impl/steps/__tests__/handoff-agents-md.test.ts @@ -0,0 +1,80 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { InitState } from '../../../init/types.js' + +// Same seam as the handoff-codex test. This step launches nothing — it writes +// the artifacts for an editor agent (Cursor / Windsurf / Cline) and prints the +// guidance — so the unit under test is the honesty contract between +// `writeAgentsMd`'s result, the recorded delivery, and the note. +const availableSkills = vi.hoisted(() => vi.fn()) +vi.mock('../../../init/lib/install-skills.js', () => ({ availableSkills })) +const writeAgentsMd = vi.hoisted(() => vi.fn()) +vi.mock('../../../init/lib/handoff-helpers.js', () => ({ + AGENTS_MD_REL_PATH: 'AGENTS.md', + writeAgentsMd, + writeArtifacts: vi.fn(), +})) +const buildAgentsMdBody = vi.hoisted(() => vi.fn()) +vi.mock('../../../init/lib/build-agents-md.js', () => ({ buildAgentsMdBody })) +vi.mock('@clack/prompts', () => ({ + note: vi.fn(), + log: { success: vi.fn(), info: vi.fn(), warn: vi.fn() }, +})) + +import * as p from '@clack/prompts' +import { writeArtifacts } from '../../../init/lib/handoff-helpers.js' +import { handoffAgentsMdStep } from '../handoff-agents-md.js' + +const state = { integration: 'drizzle' } as unknown as InitState + +const noteBody = () => String(vi.mocked(p.note).mock.calls[0][0]) +const agentsMdMode = () => vi.mocked(buildAgentsMdBody).mock.calls[0][1] +const delivery = () => vi.mocked(writeArtifacts).mock.calls[0][3] +const handoffRecorded = () => vi.mocked(writeArtifacts).mock.calls[0][2] + +beforeEach(() => { + vi.clearAllMocks() + writeAgentsMd.mockReturnValue(true) + availableSkills.mockReturnValue(['stash-encryption', 'stash-drizzle']) +}) + +describe('when AGENTS.md was written', () => { + it('inlines the skills — these agents do not auto-load skill directories', async () => { + await handoffAgentsMdStep.run(state) + expect(agentsMdMode()).toBe('doctrine-plus-skills') + expect(handoffRecorded()).toBe('agents-md') + expect(delivery()).toEqual({ + installed: [], + inlined: ['stash-encryption', 'stash-drizzle'], + failed: [], + }) + }) + + it('tells the user their editor agent picks the file up automatically', async () => { + await handoffAgentsMdStep.run(state) + const body = noteBody() + expect(body).toContain('pick up AGENTS.md automatically') + expect(body).toContain('.cipherstash/setup-prompt.md') + }) +}) + +describe('when AGENTS.md could not be written', () => { + beforeEach(() => { + writeAgentsMd.mockReturnValue(false) + }) + + it('records the skills as failed, not inlined', async () => { + await handoffAgentsMdStep.run(state) + expect(delivery()).toEqual({ + installed: [], + inlined: [], + failed: ['stash-encryption', 'stash-drizzle'], + }) + }) + + it('does not claim an agent will pick up a file that was never written', async () => { + await handoffAgentsMdStep.run(state) + const body = noteBody() + expect(body).toContain('could not be written') + expect(body).not.toContain('pick up AGENTS.md automatically') + }) +}) diff --git a/packages/cli/src/commands/impl/steps/__tests__/handoff-lovable.test.ts b/packages/cli/src/commands/impl/steps/__tests__/handoff-lovable.test.ts new file mode 100644 index 000000000..f5eb023fc --- /dev/null +++ b/packages/cli/src/commands/impl/steps/__tests__/handoff-lovable.test.ts @@ -0,0 +1,109 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { InitState } from '../../../init/types.js' + +// Same seam as the handoff-codex test, minus the launch: Lovable's agent runs +// in Lovable's cloud, so this step only writes files and prints guidance. The +// unit under test is the honesty contract between `writeAgentsMd`'s result, +// the delivery recorded into the artifacts, and what the note tells the user +// to do next. +const availableSkills = vi.hoisted(() => vi.fn()) +vi.mock('../../../init/lib/install-skills.js', () => ({ availableSkills })) +const writeAgentsMd = vi.hoisted(() => vi.fn()) +vi.mock('../../../init/lib/handoff-helpers.js', () => ({ + AGENTS_MD_REL_PATH: 'AGENTS.md', + writeAgentsMd, + writeArtifacts: vi.fn(), +})) +const buildAgentsMdBody = vi.hoisted(() => vi.fn()) +vi.mock('../../../init/lib/build-agents-md.js', () => ({ buildAgentsMdBody })) +vi.mock('@clack/prompts', () => ({ + note: vi.fn(), + log: { success: vi.fn(), info: vi.fn(), warn: vi.fn() }, +})) + +import * as p from '@clack/prompts' +import { writeArtifacts } from '../../../init/lib/handoff-helpers.js' +import { handoffLovableStep } from '../handoff-lovable.js' + +const state = { integration: 'supabase' } as unknown as InitState + +const noteBody = () => String(vi.mocked(p.note).mock.calls[0][0]) +const agentsMdMode = () => vi.mocked(buildAgentsMdBody).mock.calls[0][1] +const inlinedList = () => vi.mocked(buildAgentsMdBody).mock.calls[0][2] +const delivery = () => vi.mocked(writeArtifacts).mock.calls[0][3] +const handoffRecorded = () => vi.mocked(writeArtifacts).mock.calls[0][2] + +beforeEach(() => { + vi.clearAllMocks() + writeAgentsMd.mockReturnValue(true) + availableSkills.mockReturnValue(['stash-encryption', 'stash-supabase']) +}) + +describe('when AGENTS.md was written', () => { + it('inlines the per-integration skills — Lovable does not load skill directories', async () => { + await handoffLovableStep.run(state) + expect(agentsMdMode()).toBe('doctrine-plus-skills') + expect(inlinedList()).toEqual(['stash-encryption', 'stash-supabase']) + }) + + it('records the skills as inlined under the lovable handoff', async () => { + await handoffLovableStep.run(state) + expect(handoffRecorded()).toBe('lovable') + expect(delivery()).toEqual({ + installed: [], + inlined: ['stash-encryption', 'stash-supabase'], + failed: [], + }) + }) + + it('walks the user through the GitHub sync and the Knowledge pointer', async () => { + // Lovable only sees the repo through its GitHub sync and does not + // auto-load AGENTS.md, so both halves have to be in the note or the + // guidance never reaches the agent. + await handoffLovableStep.run(state) + const body = noteBody() + expect(body).toContain('Commit and push') + expect(body).toContain('Settings → Knowledge') + expect(body).toContain('.cipherstash/setup-prompt.md') + }) +}) + +// The failure arm is the whole point of the honesty contract: telling the +// user to commit a file that was never written sends them hunting for it. +describe('when AGENTS.md could not be written', () => { + beforeEach(() => { + writeAgentsMd.mockReturnValue(false) + }) + + it('records the skills as failed, not inlined', async () => { + await handoffLovableStep.run(state) + expect(delivery()).toEqual({ + installed: [], + inlined: [], + failed: ['stash-encryption', 'stash-supabase'], + }) + }) + + it('says the write failed instead of telling the user to commit it', async () => { + await handoffLovableStep.run(state) + const body = noteBody() + expect(body).toContain('could not be written') + expect(body).not.toContain('Commit and push') + }) + + it('still points at the artifacts that did land', async () => { + await handoffLovableStep.run(state) + const body = noteBody() + expect(body).toContain('.cipherstash/setup-prompt.md') + expect(body).toContain('.cipherstash/context.json') + }) +}) + +// A stripped CLI build ships no skills. AGENTS.md still carries the doctrine, +// so the guidance stands — there is just nothing to inline. +it('records an empty delivery when this build ships no skills', async () => { + availableSkills.mockReturnValue([]) + await handoffLovableStep.run(state) + expect(delivery()).toEqual({ installed: [], inlined: [], failed: [] }) + expect(noteBody()).toContain('Settings → Knowledge') +}) diff --git a/packages/cli/src/commands/impl/steps/__tests__/how-to-proceed-dispatch.test.ts b/packages/cli/src/commands/impl/steps/__tests__/how-to-proceed-dispatch.test.ts new file mode 100644 index 000000000..ca9850a95 --- /dev/null +++ b/packages/cli/src/commands/impl/steps/__tests__/how-to-proceed-dispatch.test.ts @@ -0,0 +1,71 @@ +import { beforeEach, expect, it, vi } from 'vitest' +import type { HandoffChoice, InitState } from '../../../init/types.js' + +// `buildOptions` / `defaultChoice` / `resolveTarget` are pure and covered in +// `impl/__tests__/how-to-proceed.test.ts`. What is NOT covered there is the +// dispatch arm: a pre-resolved `state.handoff` must skip the picker and run +// the matching step. Misrouting or dropping an arm would otherwise pass CI. +const runs = vi.hoisted(() => ({ + 'claude-code': vi.fn(async (s: InitState) => s), + codex: vi.fn(async (s: InitState) => s), + 'agents-md': vi.fn(async (s: InitState) => s), + lovable: vi.fn(async (s: InitState) => s), + wizard: vi.fn(async (s: InitState) => s), +})) +vi.mock('../handoff-claude.js', () => ({ + handoffClaudeStep: { run: runs['claude-code'] }, +})) +vi.mock('../handoff-codex.js', () => ({ + handoffCodexStep: { run: runs.codex }, +})) +vi.mock('../handoff-agents-md.js', () => ({ + handoffAgentsMdStep: { run: runs['agents-md'] }, +})) +vi.mock('../handoff-lovable.js', () => ({ + handoffLovableStep: { run: runs.lovable }, +})) +vi.mock('../handoff-wizard.js', () => ({ + handoffWizardStep: { run: runs.wizard }, +})) +const select = vi.hoisted(() => vi.fn()) +vi.mock('@clack/prompts', () => ({ + select, + isCancel: vi.fn(() => false), + note: vi.fn(), + log: { success: vi.fn(), info: vi.fn(), warn: vi.fn() }, +})) + +import { HANDOFF_CHOICES, howToProceedStep } from '../how-to-proceed.js' + +beforeEach(() => { + vi.clearAllMocks() +}) + +// Table-driven off HANDOFF_CHOICES so a new target that reaches the picker +// without a dispatch arm fails here rather than at runtime. +for (const choice of HANDOFF_CHOICES) { + it(`routes a pre-resolved \`${choice}\` state to its own step, without a prompt`, async () => { + await howToProceedStep.run({ handoff: choice } as InitState) + + expect(runs[choice]).toHaveBeenCalledTimes(1) + // The dispatched step must see the resolved choice on the state. + expect(runs[choice].mock.calls[0][0].handoff).toBe(choice) + // Every other arm stays untouched. + for (const other of HANDOFF_CHOICES) { + if (other !== choice) expect(runs[other]).not.toHaveBeenCalled() + } + // A pre-resolved target is what makes the command non-TTY safe. + expect(select).not.toHaveBeenCalled() + }) +} + +it('runs the picked step when the picker is used', async () => { + const picked: HandoffChoice = 'lovable' + select.mockResolvedValueOnce(picked) + + await howToProceedStep.run({ agents: undefined } as InitState) + + expect(select).toHaveBeenCalledTimes(1) + expect(runs.lovable).toHaveBeenCalledTimes(1) + expect(runs.lovable.mock.calls[0][0].handoff).toBe('lovable') +}) diff --git a/packages/cli/tests/e2e/impl-non-tty.e2e.test.ts b/packages/cli/tests/e2e/impl-non-tty.e2e.test.ts index db5ddfc26..42fe0f5d9 100644 --- a/packages/cli/tests/e2e/impl-non-tty.e2e.test.ts +++ b/packages/cli/tests/e2e/impl-non-tty.e2e.test.ts @@ -72,17 +72,22 @@ describe('stash impl — non-TTY safety (BUGS.md reproducer)', () => { expect(combined).toContain('wizard') }) - it('`impl --help` documents the --target flag and its values', async () => { - // The global banner no longer inlines per-command flags; the --target - // escape hatch is documented under `impl --help` (rendered from the - // command-descriptor registry). - const r = await runPiped(['impl', '--help'], { timeoutMs: 5_000 }) + // `plan` and `impl` each carry their own --target descriptor in the + // registry, so both need asserting — dropping a target from one of them + // would otherwise ship silently. + for (const command of ['impl', 'plan'] as const) { + it(`\`${command} --help\` documents the --target flag and its values`, async () => { + // The global banner no longer inlines per-command flags; the --target + // escape hatch is documented under the command's own --help (rendered + // from the command-descriptor registry). + const r = await runPiped([command, '--help'], { timeoutMs: 5_000 }) - expect(r.timedOut).toBe(false) - expect(r.exitCode).toBe(0) - expect(r.stdout).toContain('--target') - expect(r.stdout).toContain( - 'claude-code | codex | agents-md | lovable | wizard', - ) - }) + expect(r.timedOut).toBe(false) + expect(r.exitCode).toBe(0) + expect(r.stdout).toContain('--target') + expect(r.stdout).toContain( + 'claude-code | codex | agents-md | lovable | wizard', + ) + }) + } })