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
5 changes: 5 additions & 0 deletions .changeset/lovable-handoff-target.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 2 additions & 2 deletions packages/cli/src/cli/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,7 @@ export const registry: CommandGroup[] = [
name: '--target',
value: '<name>',
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.',
Comment thread
coderdan marked this conversation as resolved.
},
],
},
Expand All @@ -178,7 +178,7 @@ export const registry: CommandGroup[] = [
name: '--target',
value: '<name>',
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.',
},
],
},
Expand Down
20 changes: 16 additions & 4 deletions packages/cli/src/commands/impl/__tests__/how-to-proceed.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -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')
})
})
Original file line number Diff line number Diff line change
@@ -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')
})
Original file line number Diff line number Diff line change
@@ -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')
})
28 changes: 20 additions & 8 deletions packages/cli/src/commands/impl/steps/handoff-agents-md.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).`,
Comment thread
coderdan marked this conversation as resolved.
`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',
)

Expand Down
Loading
Loading