-
Notifications
You must be signed in to change notification settings - Fork 6
feat(cli): add a Lovable handoff target to plan and impl #871
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
80 changes: 80 additions & 0 deletions
80
packages/cli/src/commands/impl/steps/__tests__/handoff-agents-md.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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') | ||
| }) | ||
| }) |
109 changes: 109 additions & 0 deletions
109
packages/cli/src/commands/impl/steps/__tests__/handoff-lovable.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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') | ||
| }) |
71 changes: 71 additions & 0 deletions
71
packages/cli/src/commands/impl/steps/__tests__/how-to-proceed-dispatch.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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') | ||
| }) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.