diff --git a/docs/product/command-spec.md b/docs/product/command-spec.md index 5c04fef..5ee18ce 100644 --- a/docs/product/command-spec.md +++ b/docs/product/command-spec.md @@ -393,7 +393,7 @@ Purpose: Behavior: - init is the config formalizer: `app deploy` works with zero config, and init writes down what deploy would infer so the setup is committed, reviewable, and stable for teammates and CI -- writing the config requires no auth and no network; linking is the only remote step +- writing the config requires no auth and no network; the only steps that may go remote are the accepted types install, link, and agent skill install, all optional and all after the config is written - fails with `INIT_CONFIG_EXISTS` when a compute config already exists in the invocation directory or any ancestor up to the repository or workspace root; the error names the existing file, and init never overwrites or merges — editing a committed config is the user's editor's job - `--format ` selects the config serialization; `ts` (alias `typescript`) is the default and writes `prisma.compute.ts`, `json` writes `prisma.compute.json` via the shared SDK serializer, a dependency-free static document (a `$schema` reference will be emitted once the schema is hosted; the loader already accepts and strips one); both formats pin exactly the same resolved values, and the CLI's global `--json` output flag is unrelated to the config format - with `--format json`, the types install step never runs: the JSON format exists to be dependency-free, so `--install` is a usage error; without `--install`, the result reports the step as skipped with no install hint @@ -418,6 +418,7 @@ Behavior: - interactive mode asks `Link this directory to a Prisma Project now? (Y/n)` when the directory has no project binding; accepting enters the same picker `project link` uses - `--no-link` suppresses the question; `--link` requires the step; `--project ` links to that project without prompting - link failures and cancellations after the config is written downgrade to warnings and `nextSteps`; the config write stands and init exits 0 +- agent skill step, after the link step: interactive runs prompt once to install the Prisma Compute skill for the project when `prisma-compute` is missing, the same shared prompt `app deploy` uses; accepting installs `prisma-compute` from `prisma/skills` from the config directory, equivalent to the detected package-runner command such as `pnpm dlx @prisma/cli@latest agent install --skill prisma-compute`; declining records the prompt as dismissed in local CLI state, so neither init nor deploy asks again; a failed install downgrades to a warning with the retry command, records no dismissal (a later interactive init or deploy offers again), and the config write stands; does not prompt in `--json`, `--quiet`, CI, non-interactive, or `--yes` runs - `nextSteps` includes the deploy command, plus the project link command when the directory is still unlinked - user-facing command hints in init output (next steps, link hints, error recovery commands) use the package runner detected from the project, such as `pnpm dlx @prisma/cli@latest project link` or `npx -y @prisma/cli@latest app deploy`, matching the `agent` group's convention - in `--json`, `result` includes `configPath`, `format` (`typescript` or `json`), `converted` (true only for the `--format ts` conversion path), the written `app` values (null when a conversion transported a config that does not pin a single fully-resolved app), per-value `settings` sources, and `link` state; `--json` never prompts @@ -1411,7 +1412,7 @@ Behavior: - maps user-facing framework names to deploy build strategies - does not accept `--build-command` or `--output-directory`; custom build settings live in the `build` block of `prisma.compute.ts` - deploys arbitrary framework output with `framework: "custom"` when the config provides a built output directory and entrypoint; `build.command` is optional for prebuilt artifacts -- in interactive human deploys, prompts once to install the Prisma Compute skill for the project when `prisma-compute` is missing; accepting installs `prisma-compute` from `prisma/skills` from the project directory, equivalent to the detected package-runner command such as `pnpm dlx @prisma/cli@latest agent install --skill prisma-compute`; declining records the prompt as dismissed in local CLI state +- in interactive human deploys, prompts once to install the Prisma Compute skill for the project when `prisma-compute` is missing; accepting installs `prisma-compute` from `prisma/skills` from the project directory, equivalent to the detected package-runner command such as `pnpm dlx @prisma/cli@latest agent install --skill prisma-compute`; declining records the prompt as dismissed in local CLI state, while a failed install records no dismissal, so a later interactive run offers again; the prompt and its dismissal state are shared with `init`, so whichever command runs interactively first asks and neither asks twice - does not prompt for agent setup in `--json`, `--quiet`, CI, `--no-interactive`, or `--yes` - uses `src/index.ts` as the Hono deploy entrypoint when the app has no `package.json#main` or `package.json#module` and that file exists - supports vanilla Bun apps with `--framework bun` using `package.json#main` or `package.json#module`, or with `--entry ` diff --git a/packages/cli/src/controllers/agent-setup.ts b/packages/cli/src/controllers/agent-setup.ts new file mode 100644 index 0000000..9ed2adf --- /dev/null +++ b/packages/cli/src/controllers/agent-setup.ts @@ -0,0 +1,90 @@ +import { resolvePrismaCliPackageCommand } from "../lib/agent/cli-command"; +import { + PRISMA_AGENT_INSTALL_ARGS, + PRISMA_COMPUTE_AGENT_SKILL, +} from "../lib/agent/constants"; +import { + readPrismaAgentSetupStatus, + shouldOfferPrismaAgentSetup, +} from "../lib/agent/setup-status"; +import { confirmPrompt } from "../shell/prompt"; +import { type CommandContext, canPrompt } from "../shell/runtime"; +import { renderSummaryLine } from "../shell/ui"; +import { runAgentInstall } from "./agent"; + +/** + * One-time interactive offer to install the Prisma Compute skill for the + * project. Shared by the commands that establish a project setup (init, + * deploy); the answer is remembered, so whichever command runs first asks. + * Returns warnings; a failed install must not fail the calling command. + */ +export async function maybePromptForAgentSetup( + context: CommandContext, + projectDir: string, +): Promise { + // canPrompt covers json/CI/non-TTY/--no-interactive; quiet and yes runs + // must also stay prompt-free even on a TTY. + if (!canPrompt(context) || context.flags.yes || context.flags.quiet) { + return []; + } + + const status = await readPrismaAgentSetupStatus({ + cwd: projectDir, + stateStore: context.stateStore, + signal: context.runtime.signal, + requiredSkill: PRISMA_COMPUTE_AGENT_SKILL, + }); + if (!shouldOfferPrismaAgentSetup(status)) { + return []; + } + + const shouldInstall = await confirmPrompt({ + input: context.runtime.stdin, + output: context.runtime.stderr, + signal: context.runtime.signal, + message: "Install the Prisma Compute skill for this project?", + initialValue: true, + }); + + if (!shouldInstall) { + await context.stateStore.setAgentSetupPromptDismissedAt( + new Date().toISOString(), + ); + return []; + } + + try { + await runAgentInstall( + context, + { skill: [PRISMA_COMPUTE_AGENT_SKILL] }, + "install", + { cwd: projectDir }, + ); + if (!context.flags.quiet && !context.flags.json) { + context.output.stderr.write( + `${renderSummaryLine(context.ui, "success", "Installed the Prisma Compute skill for this project.")}\n\n`, + ); + } + return []; + } catch (error) { + const message = + error instanceof Error ? error.message : "Prisma skill install failed."; + if (!context.flags.quiet && !context.flags.json) { + context.output.stderr.write( + `${renderSummaryLine(context.ui, "warning", `Skipped Prisma skill install: ${message}`)}\n\n`, + ); + } + const retryCommand = await resolvePrismaCliPackageCommand({ + cwd: projectDir, + signal: context.runtime.signal, + args: [ + ...PRISMA_AGENT_INSTALL_ARGS, + "--skill", + PRISMA_COMPUTE_AGENT_SKILL, + ], + }); + return [ + `The Prisma Compute skill was not installed. Run ${retryCommand} to try again.`, + ]; + } +} diff --git a/packages/cli/src/controllers/app.ts b/packages/cli/src/controllers/app.ts index ed8cde4..3d7a0af 100644 --- a/packages/cli/src/controllers/app.ts +++ b/packages/cli/src/controllers/app.ts @@ -18,15 +18,6 @@ import type { ManagementApiClient } from "@prisma/management-api-sdk"; import { matchError, Result } from "better-result"; import open from "open"; import { FileTokenStorage } from "../adapters/token-storage"; -import { resolvePrismaCliPackageCommand } from "../lib/agent/cli-command"; -import { - PRISMA_AGENT_INSTALL_ARGS, - PRISMA_COMPUTE_AGENT_SKILL, -} from "../lib/agent/constants"; -import { - readPrismaAgentSetupStatus, - shouldOfferPrismaAgentSetup, -} from "../lib/agent/setup-status"; import { DEFAULT_REGION } from "../lib/app/app-interaction"; import { type AppRecord, @@ -139,7 +130,7 @@ import { import { type CommandSuccess, writeJsonEvent } from "../shell/output"; import { confirmPrompt, selectPrompt, textPrompt } from "../shell/prompt"; import { type CommandContext, canPrompt } from "../shell/runtime"; -import { renderCommandHeader, renderSummaryLine } from "../shell/ui"; +import { renderCommandHeader } from "../shell/ui"; import type { AppBuildResult, AppDeployAllResult, @@ -166,7 +157,7 @@ import type { import type { AuthWorkspace } from "../types/auth"; import type { BranchKind } from "../types/branch"; import type { ProjectResolution, ProjectSummary } from "../types/project"; -import { runAgentInstall } from "./agent"; +import { maybePromptForAgentSetup } from "./agent-setup"; import { requireAuthenticatedAuthState } from "./auth"; import { listRealWorkspaceProjects } from "./project"; import { createSelectPromptPort } from "./select-prompt-port"; @@ -4321,75 +4312,6 @@ function maybeRenderProjectLinked( ); } -async function maybePromptForAgentSetup( - context: CommandContext, - projectDir: string, -): Promise { - if (!canPrompt(context) || context.flags.yes) { - return []; - } - - const status = await readPrismaAgentSetupStatus({ - cwd: projectDir, - stateStore: context.stateStore, - signal: context.runtime.signal, - requiredSkill: PRISMA_COMPUTE_AGENT_SKILL, - }); - if (!shouldOfferPrismaAgentSetup(status)) { - return []; - } - - const shouldInstall = await confirmPrompt({ - input: context.runtime.stdin, - output: context.runtime.stderr, - signal: context.runtime.signal, - message: "Install the Prisma Compute skill for this project?", - initialValue: true, - }); - - if (!shouldInstall) { - await context.stateStore.setAgentSetupPromptDismissedAt( - new Date().toISOString(), - ); - return []; - } - - try { - await runAgentInstall( - context, - { skill: [PRISMA_COMPUTE_AGENT_SKILL] }, - "install", - { cwd: projectDir }, - ); - if (!context.flags.quiet && !context.flags.json) { - context.output.stderr.write( - `${renderSummaryLine(context.ui, "success", "Installed the Prisma Compute skill for this project.")}\n\n`, - ); - } - return []; - } catch (error) { - const message = - error instanceof Error ? error.message : "Prisma skill install failed."; - if (!context.flags.quiet && !context.flags.json) { - context.output.stderr.write( - `${renderSummaryLine(context.ui, "warning", `Skipped Prisma skill install: ${message}`)}\n\n`, - ); - } - const retryCommand = await resolvePrismaCliPackageCommand({ - cwd: projectDir, - signal: context.runtime.signal, - args: [ - ...PRISMA_AGENT_INSTALL_ARGS, - "--skill", - PRISMA_COMPUTE_AGENT_SKILL, - ], - }); - return [ - `The Prisma Compute skill was not installed. Run ${retryCommand} to try again.`, - ]; - } -} - async function maybeCustomizeDeploySettings( context: CommandContext, options: { diff --git a/packages/cli/src/controllers/init.ts b/packages/cli/src/controllers/init.ts index 80c4498..d211fed 100644 --- a/packages/cli/src/controllers/init.ts +++ b/packages/cli/src/controllers/init.ts @@ -49,6 +49,7 @@ import type { InitSettingRow, InitTypesState, } from "../types/init"; +import { maybePromptForAgentSetup } from "./agent-setup"; import { detectDeployFramework } from "./app"; import { runProjectLink } from "./project"; @@ -216,6 +217,7 @@ export async function runInit( onWarning: (message) => warnings.push(message), formatCommand, }); + warnings.push(...(await maybePromptForAgentSetup(context, cwd))); const unlinked = link.status !== "linked" && link.status !== "already-linked"; const typesMissing = @@ -600,6 +602,9 @@ async function runInitConversion( onWarning: (message) => warnings.push(message), formatCommand, }); + warnings.push( + ...(await maybePromptForAgentSetup(stepContext, loaded.configDir)), + ); const unlinked = link.status !== "already-linked" && link.status !== "linked"; const typesMissing = diff --git a/packages/cli/tests/agent.test.ts b/packages/cli/tests/agent.test.ts index 5758be3..4301547 100644 --- a/packages/cli/tests/agent.test.ts +++ b/packages/cli/tests/agent.test.ts @@ -4,6 +4,7 @@ import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import { createTempCwd, executeCli } from "./helpers"; +import { writeSkillsLockWithSkill } from "./helpers/skills-lock"; function expectSkillsCommandPrefix( command: string[], @@ -348,21 +349,7 @@ describe("agent commands", () => { it("checks required Prisma skills from the skills lock", async () => { const cwd = await createTempCwd(); - await writeFile( - path.join(cwd, "skills-lock.json"), - JSON.stringify({ - version: 1, - skills: { - "prisma-client-api": { - source: "prisma/skills", - sourceType: "github", - skillPath: "prisma-client-api/SKILL.md", - computedHash: "test", - }, - }, - }), - "utf8", - ); + await writeSkillsLockWithSkill(cwd, "prisma-client-api"); const { readPrismaAgentSetupStatus } = await import( "../src/lib/agent/setup-status" @@ -380,21 +367,7 @@ describe("agent commands", () => { }), ).resolves.toMatchObject({ skillsInstalled: false }); - await writeFile( - path.join(cwd, "skills-lock.json"), - JSON.stringify({ - version: 1, - skills: { - "prisma-compute": { - source: "prisma/skills", - sourceType: "github", - skillPath: "prisma-compute/SKILL.md", - computedHash: "test", - }, - }, - }), - "utf8", - ); + await writeSkillsLockWithSkill(cwd, "prisma-compute"); await expect( readPrismaAgentSetupStatus({ diff --git a/packages/cli/tests/app-controller.test.ts b/packages/cli/tests/app-controller.test.ts index 09a2ecc..80dc1b9 100644 --- a/packages/cli/tests/app-controller.test.ts +++ b/packages/cli/tests/app-controller.test.ts @@ -10,6 +10,7 @@ import { createProjectClient, createResolveBranch, } from "./helpers/mock-factories"; +import { writeSkillsLockWithSkill } from "./helpers/skills-lock"; beforeEach(() => { process.env.PRISMA_CLI_TEST_REMEMBER_PROJECT_ID = "proj_123"; @@ -179,24 +180,6 @@ async function writeLocalPin( ); } -async function writeAgentSetupInstalled(cwd: string): Promise { - await writeFile( - path.join(cwd, "skills-lock.json"), - JSON.stringify({ - version: 1, - skills: { - "prisma-compute": { - source: "prisma/skills", - sourceType: "github", - skillPath: "prisma-compute/SKILL.md", - computedHash: "test", - }, - }, - }), - "utf8", - ); -} - async function setupAgentPromptDeployTest(options: { confirmPrompt: ReturnType; deployApp?: ReturnType; @@ -3221,7 +3204,7 @@ describe("app controller", () => { next: "15.0.0", }, }); - await writeAgentSetupInstalled(cwd); + await writeSkillsLockWithSkill(cwd); const stateDir = path.join(cwd, ".state"); const { context, stderr } = await createTestCommandContext({ cwd, @@ -3528,7 +3511,7 @@ describe("app controller", () => { await writePackageJson(cwd, { name: "suggested-name", }); - await writeAgentSetupInstalled(cwd); + await writeSkillsLockWithSkill(cwd); const stateDir = path.join(cwd, ".state"); const { context, stderr } = await createTestCommandContext({ cwd, diff --git a/packages/cli/tests/helpers/skills-lock.ts b/packages/cli/tests/helpers/skills-lock.ts new file mode 100644 index 0000000..4dd1e00 --- /dev/null +++ b/packages/cli/tests/helpers/skills-lock.ts @@ -0,0 +1,27 @@ +import { writeFile } from "node:fs/promises"; +import path from "node:path"; + +/** + * Writes a skills-lock.json recording the given skill as installed from + * prisma/skills, the shape the agent setup status check reads. + */ +export async function writeSkillsLockWithSkill( + cwd: string, + skillName = "prisma-compute", +): Promise { + await writeFile( + path.join(cwd, "skills-lock.json"), + JSON.stringify({ + version: 1, + skills: { + [skillName]: { + source: "prisma/skills", + sourceType: "github", + skillPath: `${skillName}/SKILL.md`, + computedHash: "test", + }, + }, + }), + "utf8", + ); +} diff --git a/packages/cli/tests/init-agent-setup.test.ts b/packages/cli/tests/init-agent-setup.test.ts new file mode 100644 index 0000000..dea390b --- /dev/null +++ b/packages/cli/tests/init-agent-setup.test.ts @@ -0,0 +1,163 @@ +import path from "node:path"; + +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { writeSkillsLockWithSkill } from "./helpers/skills-lock"; + +afterEach(() => { + vi.resetModules(); +}); + +const SKILL_PROMPT_MESSAGE = + "Install the Prisma Compute skill for this project?"; + +async function setupInitAgentPromptTest(options: { + skillAnswer?: boolean; + runAgentInstall?: ReturnType; + skillsInstalled?: boolean; + isTTY?: boolean; + quiet?: boolean; +}) { + const runAgentInstall = + options.runAgentInstall ?? + vi.fn().mockResolvedValue({ + command: "agent.install", + result: { + operation: "install", + skills: { status: "installed", command: [] }, + }, + warnings: [], + nextSteps: [], + }); + // Interactive init asks to adjust settings and to link before the skill + // prompt; both are declined so only the skill answer varies per test. + const confirmPrompt = vi.fn(async ({ message }: { message: string }) => { + if (message === SKILL_PROMPT_MESSAGE) { + return options.skillAnswer ?? true; + } + return false; + }); + + vi.doMock("../src/controllers/agent", () => ({ + runAgentInstall, + })); + vi.doMock("../src/shell/prompt", async () => { + const actual = await vi.importActual( + "../src/shell/prompt", + ); + return { + ...actual, + confirmPrompt, + }; + }); + + const { createTempCwd, createTestCommandContext } = await import("./helpers"); + const { runInit } = await import("../src/controllers/init"); + const cwd = await createTempCwd(); + if (options.skillsInstalled) { + await writeSkillsLockWithSkill(cwd); + } + const { context } = await createTestCommandContext({ + cwd, + stateDir: path.join(cwd, ".state"), + isTTY: options.isTTY ?? true, + flags: options.quiet ? { quiet: true } : undefined, + env: { + ...process.env, + PRISMA_CLI_MOCK_FIXTURE_PATH: undefined, + }, + }); + + return { context, cwd, confirmPrompt, runAgentInstall, runInit }; +} + +function skillPromptCalls(confirmPrompt: ReturnType) { + return confirmPrompt.mock.calls.filter( + ([options]) => options.message === SKILL_PROMPT_MESSAGE, + ); +} + +describe("init agent setup prompt", () => { + it("interactive init offers the Prisma Compute skill install and installs on accept", async () => { + const { context, cwd, confirmPrompt, runAgentInstall, runInit } = + await setupInitAgentPromptTest({ skillAnswer: true }); + + const result = await runInit(context, { framework: "hono" }); + + expect(result.command).toBe("init"); + expect(skillPromptCalls(confirmPrompt)).toHaveLength(1); + expect(runAgentInstall).toHaveBeenCalledTimes(1); + expect(runAgentInstall.mock.calls[0][0]).toBe(context); + expect(runAgentInstall.mock.calls[0][1]).toEqual({ + skill: ["prisma-compute"], + }); + expect(runAgentInstall.mock.calls[0][2]).toBe("install"); + expect(runAgentInstall.mock.calls[0][3]).toEqual({ cwd }); + expect(result.warnings).toEqual([]); + }); + + it("declining the skill prompt records dismissal and init still succeeds", async () => { + const { context, confirmPrompt, runAgentInstall, runInit } = + await setupInitAgentPromptTest({ skillAnswer: false }); + + const result = await runInit(context, { framework: "hono" }); + + expect(result.command).toBe("init"); + expect(skillPromptCalls(confirmPrompt)).toHaveLength(1); + expect(runAgentInstall).not.toHaveBeenCalled(); + await expect( + context.stateStore.readAgentSetupPromptDismissedAt(), + ).resolves.toEqual(expect.any(String)); + }); + + it("does not offer the skill install when prisma-compute is already installed", async () => { + const { context, confirmPrompt, runAgentInstall, runInit } = + await setupInitAgentPromptTest({ skillsInstalled: true }); + + const result = await runInit(context, { framework: "hono" }); + + expect(result.command).toBe("init"); + expect(skillPromptCalls(confirmPrompt)).toHaveLength(0); + expect(runAgentInstall).not.toHaveBeenCalled(); + }); + + it("downgrades a failed skill install to a warning with the retry command", async () => { + const { context, runAgentInstall, runInit } = + await setupInitAgentPromptTest({ + skillAnswer: true, + runAgentInstall: vi + .fn() + .mockRejectedValue(new Error("skills installer exploded")), + }); + + const result = await runInit(context, { framework: "hono" }); + + expect(result.command).toBe("init"); + expect(runAgentInstall).toHaveBeenCalledTimes(1); + expect(result.warnings).toEqual([ + expect.stringContaining("The Prisma Compute skill was not installed."), + ]); + }); + + it("does not offer the skill install in quiet runs", async () => { + const { context, confirmPrompt, runAgentInstall, runInit } = + await setupInitAgentPromptTest({ quiet: true }); + + const result = await runInit(context, { framework: "hono" }); + + expect(result.command).toBe("init"); + expect(skillPromptCalls(confirmPrompt)).toHaveLength(0); + expect(runAgentInstall).not.toHaveBeenCalled(); + }); + + it("does not offer the skill install in non-interactive runs", async () => { + const { context, confirmPrompt, runAgentInstall, runInit } = + await setupInitAgentPromptTest({ isTTY: false }); + + const result = await runInit(context, { framework: "hono" }); + + expect(result.command).toBe("init"); + expect(confirmPrompt).not.toHaveBeenCalled(); + expect(runAgentInstall).not.toHaveBeenCalled(); + }); +});