diff --git a/docs/product/command-principles.md b/docs/product/command-principles.md index 74c6ddf..d06b5b8 100644 --- a/docs/product/command-principles.md +++ b/docs/product/command-principles.md @@ -29,6 +29,7 @@ Use the other convention docs for adjacent concerns: The long-term command surface grows through workflow groups such as: - `init` +- `agent` - `auth` - `project` - `branch` @@ -38,7 +39,7 @@ The long-term command surface grows through workflow groups such as: - `app` - `git` -The preview implements only `auth`, `project`, `git`, `branch`, `database`, and `app`. +The preview implements only `agent`, `auth`, `project`, `git`, `branch`, `database`, and `app`. ## Stable Nouns diff --git a/docs/product/command-spec.md b/docs/product/command-spec.md index 2cd8a5b..f288897 100644 --- a/docs/product/command-spec.md +++ b/docs/product/command-spec.md @@ -10,6 +10,7 @@ This file is authoritative for command group scope during beta. The beta package includes these command groups: +- `agent` - `auth` - `project` (includes `project env` subgroup) - `git` @@ -376,6 +377,83 @@ prisma-cli --version --json `prisma-cli version` is the richer environment report; `prisma-cli --version` is the terse one-liner. Both report the same `cli.version`. Use the flag for quick checks, the subcommand for support tickets and bug reports. +## ` @prisma/cli@latest agent install --agent --all-agents --skill --global --copy` + +Purpose: + +- install Prisma context for AI coding agents + +Behavior: + +- user-facing agent install commands use the package runner detected from the project: `bunx`, `pnpm dlx`, `yarn dlx`, or `npx -y` +- installs Prisma skills from `prisma/skills` by invoking `skills@latest` through the detected project package manager: + ` skills@latest add prisma/skills --skill "*" --agent codex --agent claude-code --yes` +- detects the runner from the current directory and its ancestors: + - `bun.lock`, `bun.lockb`, or `packageManager: "bun@..."` -> `bunx` + - `pnpm-lock.yaml`, `pnpm-workspace.yaml`, or `packageManager: "pnpm@..."` -> `pnpm dlx` + - `yarn.lock` or `packageManager: "yarn@..."` -> `yarn dlx` + - `package-lock.json`, `npm-shrinkwrap.json`, `packageManager: "npm@..."`, or no package-manager signal -> `npx -y` +- defaults to the `codex` and `claude-code` agent targets because those are the primary local agent workflows during beta +- accepts repeated `--agent ` flags to target specific skills CLI agents, such as `codex`, `claude-code`, or `cursor` +- accepts `--all-agents` to pass `--agent "*"` to the skills CLI +- accepts repeated `--skill ` flags to install a subset such as `prisma-compute` +- accepts `--global` and `--copy` and forwards them to the skills CLI +- accepts `--dry-run` to report the skills CLI command without spawning the installer + +Examples: + +```bash +npx -y @prisma/cli@latest agent install +pnpm dlx @prisma/cli@latest agent install --agent codex +pnpm dlx @prisma/cli@latest agent install --agent codex --agent cursor +pnpm dlx @prisma/cli@latest agent install --all-agents +pnpm dlx @prisma/cli@latest agent install --skill prisma-compute +``` + +## ` @prisma/cli@latest agent update --agent --all-agents --skill --global --copy` + +Purpose: + +- refresh Prisma skills + +Behavior: + +- has the same flags and behavior as `agent install` +- reruns the detected package-manager runner with `skills@latest add prisma/skills ...` instead of `skills update`, so it refreshes only Prisma's skills and works for project-scoped installs + +Examples: + +```bash +npx -y @prisma/cli@latest agent update +pnpm dlx @prisma/cli@latest agent update --agent codex +pnpm dlx @prisma/cli@latest agent update --all-agents +``` + +## ` @prisma/cli@latest agent status --global` + +Purpose: + +- show whether Prisma skills are installed for the current project or globally + +Behavior: + +- resolves the Compute config directory before checking project skills, so running from an app subdirectory still checks the project root +- runs `skills@latest list --json` through the detected package runner and reports installed Prisma skills from the skills CLI by default +- accepts `--global` to run `skills@latest list -g --json` and report globally installed Prisma skills instead +- filters the list to Prisma skills and includes each skill's name, path, scope, and agent targets in JSON output +- for project status, falls back to whether `skills-lock.json` references `prisma/skills` when the skills CLI list command fails +- for global status, reports the Skills CLI list failure instead of falling back to project-local state +- reports whether the project has dismissed the interactive agent setup prompt +- does not require authentication + +Examples: + +```bash +npx -y @prisma/cli@latest agent status +pnpm dlx @prisma/cli@latest agent status --json +pnpm dlx @prisma/cli@latest agent status --global +``` + ## `prisma-cli auth login` Purpose: @@ -389,6 +467,9 @@ Behavior: - resolves active workspace when required - confirms successful browser authentication and directs the user back to the terminal - returns the current auth state after login +- in human output, when run from a project directory that does not already have Prisma skills and has not dismissed the setup prompt, suggests the detected package-runner command, such as `pnpm dlx @prisma/cli@latest agent install` +- does not install skills during auth; auth is not project-scoped and must not mutate the project +- does not show the agent setup tip in `--json`, `--quiet`, CI, non-TTY output, or directories that do not look like projects Examples: @@ -1072,6 +1153,8 @@ 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 +- 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 ` - treats `--entry ` without `--framework` as a Bun app deploy diff --git a/docs/product/error-conventions.md b/docs/product/error-conventions.md index 075c964..757bab3 100644 --- a/docs/product/error-conventions.md +++ b/docs/product/error-conventions.md @@ -143,7 +143,7 @@ Rules: - `ok` is always `false` - `command` is always present - `error.code` is stable and machine-readable -- `error.domain` is a stable logical area such as `cli`, `auth`, `project`, `branch`, `app`, or `database` +- `error.domain` is a stable logical area such as `cli`, `agent`, `auth`, `project`, `branch`, `app`, or `database` - `error.severity` is stable and machine-readable - `error.summary` is the short human-readable headline - `error.why` explains the immediate cause when known @@ -161,6 +161,7 @@ These codes are the minimum stable set for the MVP: - `USAGE_ERROR` - `AUTH_REQUIRED` - `AUTH_CONFIG_INVALID` +- `AGENT_SKILLS_INSTALL_FAILED` - `WORKSPACE_SWITCH_UNAVAILABLE` - `WORKSPACE_NOT_AUTHENTICATED` - `WORKSPACE_AMBIGUOUS` @@ -222,6 +223,7 @@ Recommended meanings: - `USAGE_ERROR`: invalid arguments or invalid command combination - `AUTH_REQUIRED`: command needs an authenticated session - `AUTH_CONFIG_INVALID`: environment auth configuration is present but unusable, such as an empty `PRISMA_SERVICE_TOKEN` +- `AGENT_SKILLS_INSTALL_FAILED`: installing Prisma skills through the external skills CLI failed; callers should inspect the command, exit code, and stderr in `error.meta` - `WORKSPACE_SWITCH_UNAVAILABLE`: `PRISMA_SERVICE_TOKEN` is the active auth source, so local OAuth workspace switching cannot apply - `WORKSPACE_NOT_AUTHENTICATED`: requested workspace is not present in the local OAuth credentials store for a switch/logout operation; callers should run `auth login` for that workspace - `WORKSPACE_AMBIGUOUS`: requested workspace name matches more than one local OAuth workspace; callers should switch by workspace id diff --git a/packages/cli/package.json b/packages/cli/package.json index adb5d40..d314e53 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -51,6 +51,7 @@ "colorette": "^2.0.20", "commander": "^14.0.3", "dotenv": "^17.4.2", + "execa": "^9.6.1", "magicast": "^0.5.3", "open": "^11.0.0", "string-width": "^8.2.1", diff --git a/packages/cli/src/adapters/local-state.ts b/packages/cli/src/adapters/local-state.ts index a885800..5d7f2bd 100644 --- a/packages/cli/src/adapters/local-state.ts +++ b/packages/cli/src/adapters/local-state.ts @@ -22,6 +22,9 @@ export interface LocalState { selectedByProject: Record; knownLiveDeploymentByProject: Record>; }; + agent: { + setupPromptDismissedAt: string | null; + }; } export interface SelectedAppState { @@ -49,6 +52,9 @@ const DEFAULT_STATE: LocalState = { selectedByProject: {}, knownLiveDeploymentByProject: {}, }, + agent: { + setupPromptDismissedAt: null, + }, }; export const DEFAULT_STATE_FILE_NAME = "state.json"; @@ -91,6 +97,9 @@ export class LocalStateStore { knownLiveDeploymentByProject: parsed.app?.knownLiveDeploymentByProject ?? {}, }, + agent: { + setupPromptDismissedAt: parsed.agent?.setupPromptDismissedAt ?? null, + }, }; } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") { @@ -253,4 +262,18 @@ export class LocalStateStore { await this.write(state); return state; } + + async readAgentSetupPromptDismissedAt(): Promise { + const state = await this.read(); + return state.agent.setupPromptDismissedAt; + } + + async setAgentSetupPromptDismissedAt( + dismissedAt: string, + ): Promise { + const state = await this.read(); + state.agent.setupPromptDismissedAt = dismissedAt; + await this.write(state); + return state; + } } diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 9e9b829..cfbe65a 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -2,6 +2,7 @@ import process from "node:process"; import { Command, CommanderError, Option } from "commander"; +import { createAgentCommand } from "./commands/agent"; import { createAppCommand } from "./commands/app"; import { createAuthCommand } from "./commands/auth"; import { createBranchCommand } from "./commands/branch"; @@ -81,6 +82,7 @@ export function createProgram(runtime: CliRuntime): Command { program.name("prisma").showSuggestionAfterError(); program.addCommand(createVersionCommand(runtime)); + program.addCommand(createAgentCommand(runtime)); program.addCommand(createAuthCommand(runtime)); program.addCommand(createProjectCommand(runtime)); program.addCommand(createGitCommand(runtime)); diff --git a/packages/cli/src/commands/agent/index.ts b/packages/cli/src/commands/agent/index.ts new file mode 100644 index 0000000..18651b3 --- /dev/null +++ b/packages/cli/src/commands/agent/index.ts @@ -0,0 +1,162 @@ +import { Command, Option } from "commander"; + +import { + type AgentInstallOptions, + type AgentStatusOptions, + runAgentInstall, + runAgentStatus, +} from "../../controllers/agent"; +import { + renderAgentInstall, + renderAgentStatus, + serializeAgentInstall, + serializeAgentStatus, +} from "../../presenters/agent"; +import { attachCommandDescriptor } from "../../shell/command-meta"; +import { runCommand } from "../../shell/command-runner"; +import { + addCompactGlobalFlags, + addGlobalFlags, +} from "../../shell/global-flags"; +import { type CliRuntime, configureRuntimeCommand } from "../../shell/runtime"; +import type { AgentInstallResult, AgentStatusResult } from "../../types/agent"; + +export function createAgentCommand(runtime: CliRuntime): Command { + const command = attachCommandDescriptor( + configureRuntimeCommand(new Command("agent"), runtime), + "agent", + ); + + addCompactGlobalFlags(command); + + command.addCommand(createAgentInstallCommand(runtime)); + command.addCommand(createAgentUpdateCommand(runtime)); + command.addCommand(createAgentStatusCommand(runtime)); + + return command; +} + +function createAgentInstallCommand(runtime: CliRuntime): Command { + const command = attachCommandDescriptor( + configureRuntimeCommand(new Command("install"), runtime), + "agent.install", + ); + + addAgentInstallOptions(command); + + addGlobalFlags(command); + + command.action(async (options) => { + await runCommand( + runtime, + "agent.install", + options as Record, + (context) => + runAgentInstall(context, options as AgentInstallOptions, "install"), + { + renderHuman: (context, descriptor, result) => + renderAgentInstall(context, descriptor, result), + renderJson: (result) => serializeAgentInstall(result), + }, + ); + }); + + return command; +} + +function createAgentUpdateCommand(runtime: CliRuntime): Command { + const command = attachCommandDescriptor( + configureRuntimeCommand(new Command("update"), runtime), + "agent.update", + ); + + addAgentInstallOptions(command); + addGlobalFlags(command); + + command.action(async (options) => { + await runCommand( + runtime, + "agent.update", + options as Record, + (context) => + runAgentInstall(context, options as AgentInstallOptions, "update"), + { + renderHuman: (context, descriptor, result) => + renderAgentInstall(context, descriptor, result), + renderJson: (result) => serializeAgentInstall(result), + }, + ); + }); + + return command; +} + +function createAgentStatusCommand(runtime: CliRuntime): Command { + const command = attachCommandDescriptor( + configureRuntimeCommand(new Command("status"), runtime), + "agent.status", + ); + + command.addOption( + new Option( + "--global", + "Check globally installed Prisma skills instead of project skills", + ), + ); + addGlobalFlags(command); + + command.action(async (options) => { + await runCommand( + runtime, + "agent.status", + options as Record, + (context) => runAgentStatus(context, options as AgentStatusOptions), + { + renderHuman: (context, descriptor, result) => + renderAgentStatus(context, descriptor, result), + renderJson: (result) => serializeAgentStatus(result), + }, + ); + }); + + return command; +} + +function addAgentInstallOptions(command: Command): void { + command + .addOption( + new Option( + "--agent ", + "Agent target for Prisma skills; repeat for multiple agents", + ).argParser(collectValues), + ) + .addOption( + new Option( + "--all-agents", + "Install Prisma skills for every agent supported by the skills CLI", + ), + ) + .addOption( + new Option( + "--skill ", + "Prisma skill to install; repeat for multiple skills", + ).argParser(collectValues), + ) + .addOption( + new Option( + "--global", + "Install skills into the user directory instead of the project", + ), + ) + .addOption( + new Option( + "--copy", + "Ask the skills CLI to copy files instead of symlinking them", + ), + ) + .addOption(new Option("--dry-run", "Show changes without writing files")); +} + +function collectValues(value: string, previous: string[] | undefined) { + return [...(previous ?? []), value]; +} diff --git a/packages/cli/src/controllers/agent.ts b/packages/cli/src/controllers/agent.ts new file mode 100644 index 0000000..b6612fb --- /dev/null +++ b/packages/cli/src/controllers/agent.ts @@ -0,0 +1,419 @@ +import { execa } from "execa"; +import { resolvePrismaCliPackageCommand } from "../lib/agent/cli-command"; +import { + DEFAULT_PRISMA_AGENT_SKILLS, + DEFAULT_PRISMA_AGENT_TARGETS, + PRISMA_AGENT_INSTALL_ARGS, + PRISMA_AGENT_STATUS_ARGS, + PRISMA_SKILLS_SOURCE, + SKILLS_CLI_PACKAGE, +} from "../lib/agent/constants"; +import { resolveSkillsPackageRunner } from "../lib/agent/package-manager"; +import { + readPrismaAgentSetupStatus, + resolvePrismaAgentSetupCwd, +} from "../lib/agent/setup-status"; +import { formatShellCommand } from "../shell/command-arguments"; +import { CliError } from "../shell/errors"; +import type { CommandContext } from "../shell/runtime"; +import type { + AgentInstalledSkill, + AgentInstallResult, + AgentSkillsResult, + AgentStatusResult, +} from "../types/agent"; + +interface SkillsListSuccess { + status: "ok"; + command: string[]; + skills: AgentInstalledSkill[]; +} + +interface SkillsListFailure { + status: "failed"; + command: string[]; + message: string; +} + +export interface AgentInstallOptions { + agent?: string[]; + skill?: string[]; + allAgents?: boolean; + copy?: boolean; + global?: boolean; + dryRun?: boolean; +} + +export interface AgentStatusOptions { + global?: boolean; +} + +interface AgentInstallRunOptions { + cwd?: string; +} + +export async function runAgentInstall( + context: CommandContext, + options: AgentInstallOptions, + operation: "install" | "update" = "install", + runOptions: AgentInstallRunOptions = {}, +) { + const dryRun = options.dryRun === true; + const cwd = await resolvePrismaAgentSetupCwd({ + cwd: runOptions.cwd ?? context.runtime.cwd, + signal: context.runtime.signal, + }); + const skillsCommand = await buildSkillsInstallCommand(context, options, cwd); + const skills = await installSkills(context, { + command: skillsCommand, + cwd, + dryRun, + }); + const nextSteps = await resolveAgentInstallNextSteps(context, { + cwd, + dryRun, + global: options.global === true, + }); + + return { + command: `agent.${operation}`, + result: { + operation, + skills, + } satisfies AgentInstallResult, + warnings: [], + nextSteps, + }; +} + +async function resolveAgentInstallNextSteps( + context: CommandContext, + options: { cwd: string; dryRun: boolean; global: boolean }, +): Promise { + if (options.dryRun) { + return []; + } + + const statusCommand = await resolvePrismaCliPackageCommand({ + cwd: options.cwd, + signal: context.runtime.signal, + args: options.global + ? [...PRISMA_AGENT_STATUS_ARGS, "--global"] + : PRISMA_AGENT_STATUS_ARGS, + }); + + return [`Run ${statusCommand} to verify the installed Prisma skills.`]; +} + +export async function runAgentStatus( + context: CommandContext, + options: AgentStatusOptions = {}, +) { + const statusScope = options.global ? "global" : "project"; + const cwd = await resolvePrismaAgentSetupCwd({ + cwd: context.runtime.cwd, + signal: context.runtime.signal, + }); + const status = await readPrismaAgentSetupStatus({ + cwd, + stateStore: context.stateStore, + signal: context.runtime.signal, + }); + const installCommand = await resolvePrismaCliPackageCommand({ + cwd, + signal: context.runtime.signal, + args: options.global + ? [...PRISMA_AGENT_INSTALL_ARGS, "--global"] + : PRISMA_AGENT_INSTALL_ARGS, + }); + const skillsList = await listInstalledPrismaSkills(context, cwd, statusScope); + const skillsInstalled = + skillsList.status === "ok" + ? skillsList.skills.length > 0 + : statusScope === "project" && status.skillsInstalled; + const warnings = + skillsList.status === "ok" + ? [] + : [ + statusScope === "project" + ? `Could not read installed skills with ${formatShellCommand(skillsList.command)}: ${skillsList.message}. Falling back to ${status.skillsLockPath}.` + : `Could not read globally installed skills with ${formatShellCommand(skillsList.command)}: ${skillsList.message}.`, + ]; + const statusSource = resolveStatusSource(skillsList, statusScope); + + return { + command: "agent.status", + result: { + skills: skillsList.status === "ok" ? skillsList.skills : [], + skillsListCommand: skillsList.command, + statusScope, + skillsLockPath: status.skillsLockPath, + skillsLockInstalled: status.skillsInstalled, + skillsInstalled, + statusSource, + promptDismissedAt: status.promptDismissedAt, + } satisfies AgentStatusResult, + warnings, + nextSteps: skillsInstalled + ? [] + : [`Run ${installCommand} to install or refresh Prisma skills.`], + }; +} + +async function buildSkillsInstallCommand( + context: CommandContext, + options: AgentInstallOptions, + cwd: string, +): Promise { + const command = [ + ...(await resolveSkillsPackageRunner({ + cwd, + signal: context.runtime.signal, + })), + SKILLS_CLI_PACKAGE, + "add", + PRISMA_SKILLS_SOURCE, + ]; + const skills = + options.skill && options.skill.length > 0 + ? options.skill + : DEFAULT_PRISMA_AGENT_SKILLS; + const agents = resolveTargetAgents(options); + + for (const skill of skills) { + command.push("--skill", skill); + } + + for (const agent of agents) { + command.push("--agent", agent); + } + + if (options.global) { + command.push("--global"); + } + + if (options.copy || process.platform === "win32") { + command.push("--copy"); + } + + command.push("--yes"); + return command; +} + +function resolveTargetAgents(options: AgentInstallOptions): string[] { + if (options.allAgents) { + return ["*"]; + } + + if (options.agent && options.agent.length > 0) { + return options.agent; + } + + return DEFAULT_PRISMA_AGENT_TARGETS; +} + +async function installSkills( + context: CommandContext, + options: { + command: string[]; + cwd: string; + dryRun: boolean; + }, +): Promise { + if (options.dryRun) { + return { status: "would-install", command: options.command }; + } + + await runChildProcess(context, options.command, options.cwd); + return { status: "installed", command: options.command }; +} + +async function listInstalledPrismaSkills( + context: CommandContext, + cwd: string, + scope: "project" | "global", +): Promise { + const command = [ + ...(await resolveSkillsPackageRunner({ + cwd, + signal: context.runtime.signal, + })), + SKILLS_CLI_PACKAGE, + "list", + ...(scope === "global" ? ["-g"] : []), + "--json", + ]; + + try { + const { stdout } = await runChildProcessCapture(context, command, cwd); + return { + status: "ok", + command, + skills: parseSkillsListOutput(stdout).filter((skill) => + isPrismaSkillName(skill.name), + ), + }; + } catch (error) { + if (isAbortError(error) || context.runtime.signal.aborted) { + throw error; + } + + return { + status: "failed", + command, + message: error instanceof Error ? error.message : String(error), + }; + } +} + +async function runChildProcess( + context: CommandContext, + command: string[], + cwd: string, +): Promise { + const [executable, args] = splitCommand(command); + + try { + await execa(executable, args, { + cwd, + env: context.runtime.env, + cancelSignal: context.runtime.signal, + stdin: "ignore", + }); + } catch (error) { + if (isAbortError(error)) { + throw error; + } + + throw skillsInstallFailed(command, exitCodeFromError(error), error); + } +} + +async function runChildProcessCapture( + context: CommandContext, + command: string[], + cwd: string, +): Promise<{ stdout: string; stderr: string }> { + const [executable, args] = splitCommand(command); + + const result = await execa(executable, args, { + cwd, + env: context.runtime.env, + cancelSignal: context.runtime.signal, + stdin: "ignore", + }); + + return { + stdout: result.stdout ?? "", + stderr: result.stderr ?? "", + }; +} + +function splitCommand(command: string[]): [executable: string, args: string[]] { + const [executable, ...args] = command; + if (!executable) { + throw new Error("Cannot run an empty command."); + } + + return [executable, args]; +} + +function isAbortError(error: unknown): boolean { + return ( + (error instanceof Error && error.name === "AbortError") || + (isObject(error) && error.isCanceled === true) + ); +} + +function exitCodeFromError(error: unknown): number | null { + if (!isObject(error) || typeof error.exitCode !== "number") { + return null; + } + + return error.exitCode; +} + +function isObject(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function resolveStatusSource( + skillsList: SkillsListSuccess | SkillsListFailure, + statusScope: "project" | "global", +): AgentStatusResult["statusSource"] { + if (skillsList.status === "ok") { + return "skills-cli"; + } + + return statusScope === "project" ? "skills-lock" : "unavailable"; +} + +function parseSkillsListOutput(output: string): AgentInstalledSkill[] { + const parsed = JSON.parse(output) as unknown; + if (!Array.isArray(parsed)) { + throw new Error("skills list did not return a JSON array"); + } + + return parsed.flatMap((item) => { + const skill = parseInstalledSkill(item); + return skill ? [skill] : []; + }); +} + +function parseInstalledSkill(value: unknown): AgentInstalledSkill | null { + if (typeof value !== "object" || value === null) { + return null; + } + + const candidate = value as Record; + if ( + typeof candidate.name !== "string" || + typeof candidate.path !== "string" || + typeof candidate.scope !== "string" || + !Array.isArray(candidate.agents) + ) { + return null; + } + + return { + name: candidate.name, + path: candidate.path, + scope: candidate.scope, + agents: candidate.agents.filter((agent) => typeof agent === "string"), + }; +} + +function isPrismaSkillName(name: string): boolean { + return name === "prisma" || name.startsWith("prisma-"); +} + +function skillsInstallFailed( + command: string[], + code: number | null, + error?: unknown, +): CliError { + const commandText = formatShellCommand(command); + + return new CliError({ + code: "AGENT_SKILLS_INSTALL_FAILED", + domain: "cli", + summary: "Prisma skills install failed", + why: `The skills installer exited with code ${code ?? "unknown"}.`, + fix: "Run the command below to retry the installer directly.", + debug: formatErrorDebug(error), + exitCode: 1, + nextSteps: [commandText], + }); +} + +function formatErrorDebug(error: unknown): string | undefined { + if (error instanceof Error) { + return error.stack ?? error.message; + } + + if (error === undefined) { + return undefined; + } + + return String(error); +} diff --git a/packages/cli/src/controllers/app.ts b/packages/cli/src/controllers/app.ts index 442cc01..4e06bdb 100644 --- a/packages/cli/src/controllers/app.ts +++ b/packages/cli/src/controllers/app.ts @@ -18,6 +18,15 @@ 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, @@ -130,7 +139,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 } from "../shell/ui"; +import { renderCommandHeader, renderSummaryLine } from "../shell/ui"; import type { AppBuildResult, AppDeployAllResult, @@ -157,6 +166,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 { requireAuthenticatedAuthState } from "./auth"; import { listRealWorkspaceProjects } from "./project"; import { createSelectPromptPort } from "./select-prompt-port"; @@ -613,6 +623,10 @@ async function runSingleAppDeploy( // The compute config marks the project root: the Project binding and other // repo-level concerns live next to the config, not wherever deploy ran. const projectDir = computeConfig.config?.configDir ?? context.runtime.cwd; + const agentSetupWarnings = await maybePromptForAgentSetup( + context, + projectDir, + ); const skipLocalPin = Boolean( envProjectId || options?.projectRef || options?.createProjectName, @@ -852,7 +866,11 @@ async function runSingleAppDeploy( durationMs: deployDurationMs, localPin: localPinResult, }, - warnings: [...legacyWarnings, ...branchDatabaseSetup.warnings], + warnings: [ + ...agentSetupWarnings, + ...legacyWarnings, + ...branchDatabaseSetup.warnings, + ], nextSteps: [ "prisma-cli app list-deploys", `prisma-cli app show-deploy ${deployResult.deployment.id}`, @@ -2330,6 +2348,7 @@ async function confirmDomainRemoval( const confirmed = await confirmPrompt({ input: context.runtime.stdin, output: context.output.stderr, + signal: context.runtime.signal, message: `Detach ${hostname} from App "${target.app.name}"?`, initialValue: false, }); @@ -3011,6 +3030,7 @@ async function confirmAppRemoval( await textPrompt({ input: context.runtime.stdin, output: context.output.stderr, + signal: context.runtime.signal, message: `Type ${app.name} to confirm app removal`, placeholder: app.name, validate: (value) => @@ -4279,6 +4299,75 @@ 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: { @@ -4315,6 +4404,7 @@ async function maybeCustomizeDeploySettings( const shouldCustomize = await confirmPrompt({ input: context.runtime.stdin, output: context.runtime.stderr, + signal: context.runtime.signal, message: "Customize build settings?", initialValue: false, }); @@ -4329,6 +4419,7 @@ async function maybeCustomizeDeploySettings( const frameworkKey = await selectPrompt({ input: context.runtime.stdin, output: context.runtime.stderr, + signal: context.runtime.signal, message: `Framework (${options.framework.displayName})`, choices: FRAMEWORKS.map((framework) => ({ label: framework.displayName, @@ -4339,6 +4430,7 @@ async function maybeCustomizeDeploySettings( const requestedPort = await textPrompt({ input: context.runtime.stdin, output: context.runtime.stderr, + signal: context.runtime.signal, message: `HTTP port (${options.runtime.port})`, placeholder: String(options.runtime.port), validate: validateDeployHttpPortText, diff --git a/packages/cli/src/controllers/auth.ts b/packages/cli/src/controllers/auth.ts index 45702d1..b557ff5 100644 --- a/packages/cli/src/controllers/auth.ts +++ b/packages/cli/src/controllers/auth.ts @@ -8,6 +8,14 @@ import { type StoredAuthWorkspace, WorkspaceSelectionError, } from "../adapters/token-storage"; +import { resolvePrismaCliPackageCommand } from "../lib/agent/cli-command"; +import { PRISMA_AGENT_INSTALL_ARGS } from "../lib/agent/constants"; +import { + isLikelyProjectDirectory, + readPrismaAgentSetupStatus, + resolvePrismaAgentSetupCwd, + shouldOfferPrismaAgentSetup, +} from "../lib/agent/setup-status"; import { performLogin, performLogout, @@ -70,9 +78,20 @@ export async function runAuthLogin( result = await loginWithSelectionFlow(context, useCases, options); } + const agentSetupTipCommand = await resolveAgentSetupTipCommand(context); + if (agentSetupTipCommand) { + result = { + ...result, + agentSetupTip: { + command: agentSetupTipCommand, + }, + }; + } + return createAuthSuccess("auth.login", result, [ "prisma-cli auth whoami", "prisma-cli project list", + ...(result.agentSetupTip ? [result.agentSetupTip.command] : []), ]); } @@ -684,3 +703,50 @@ function createAuthSuccess( nextSteps, }; } + +async function resolveAgentSetupTipCommand( + context: CommandContext, +): Promise { + if (context.flags.json || context.flags.quiet) { + return null; + } + + if (context.runtime.env.CI && context.flags.interactive !== true) { + return null; + } + + if (!context.runtime.stderr.isTTY && context.flags.interactive !== true) { + return null; + } + + const setupCwd = await resolvePrismaAgentSetupCwd({ + cwd: context.runtime.cwd, + signal: context.runtime.signal, + }); + + if ( + !(await isLikelyProjectDirectory({ + cwd: setupCwd, + signal: context.runtime.signal, + })) + ) { + return null; + } + + const shouldOffer = shouldOfferPrismaAgentSetup( + await readPrismaAgentSetupStatus({ + cwd: setupCwd, + stateStore: context.stateStore, + signal: context.runtime.signal, + }), + ); + if (!shouldOffer) { + return null; + } + + return await resolvePrismaCliPackageCommand({ + cwd: setupCwd, + signal: context.runtime.signal, + args: PRISMA_AGENT_INSTALL_ARGS, + }); +} diff --git a/packages/cli/src/controllers/select-prompt-port.ts b/packages/cli/src/controllers/select-prompt-port.ts index c3eb89b..d8b3e1a 100644 --- a/packages/cli/src/controllers/select-prompt-port.ts +++ b/packages/cli/src/controllers/select-prompt-port.ts @@ -10,6 +10,7 @@ export function createSelectPromptPort( selectPrompt({ input: context.runtime.stdin, output: context.runtime.stderr, + signal: context.runtime.signal, message, choices, }), diff --git a/packages/cli/src/lib/agent/cli-command.ts b/packages/cli/src/lib/agent/cli-command.ts new file mode 100644 index 0000000..b070ab5 --- /dev/null +++ b/packages/cli/src/lib/agent/cli-command.ts @@ -0,0 +1,49 @@ +import { formatPrismaCliCommand } from "../../shell/cli-command"; +import { + resolvePackageRunner, + resolvePackageRunnerSync, +} from "./package-manager"; + +export type PrismaCliPackageCommandFormatter = ( + args: readonly string[], +) => string; + +export async function resolvePrismaCliPackageCommandFormatter(options: { + cwd: string; + signal: AbortSignal; +}): Promise { + const packageRunner = await resolvePackageRunner(options); + return createPrismaCliPackageCommandFormatter(packageRunner); +} + +export async function resolvePrismaCliPackageCommand(options: { + cwd: string; + signal: AbortSignal; + args: readonly string[]; +}): Promise { + const formatCommand = await resolvePrismaCliPackageCommandFormatter(options); + return formatCommand(options.args); +} + +export function resolvePrismaCliPackageCommandFormatterSync( + cwd: string, +): PrismaCliPackageCommandFormatter { + return createPrismaCliPackageCommandFormatter(resolvePackageRunnerSync(cwd)); +} + +export function resolvePrismaCliPackageCommandSync( + cwd: string, + args: readonly string[], +): string { + const formatCommand = resolvePrismaCliPackageCommandFormatterSync(cwd); + return formatCommand(args); +} + +function createPrismaCliPackageCommandFormatter( + packageRunner: readonly string[], +): PrismaCliPackageCommandFormatter { + return (args) => + formatPrismaCliCommand(args, { + packageRunner, + }); +} diff --git a/packages/cli/src/lib/agent/constants.ts b/packages/cli/src/lib/agent/constants.ts new file mode 100644 index 0000000..3549e6d --- /dev/null +++ b/packages/cli/src/lib/agent/constants.ts @@ -0,0 +1,9 @@ +export const PRISMA_SKILLS_SOURCE = "prisma/skills"; +export const PRISMA_SKILLS_LOCK_FILENAME = "skills-lock.json"; +export const SKILLS_CLI_PACKAGE = "skills@latest"; +export const DEFAULT_PRISMA_AGENT_SKILLS = ["*"]; +export const PRISMA_COMPUTE_AGENT_SKILL = "prisma-compute"; +export const DEFAULT_PRISMA_AGENT_TARGETS = ["codex", "claude-code"]; +export const PRISMA_AGENT_INSTALL_ARGS = ["agent", "install"] as const; +export const PRISMA_AGENT_UPDATE_ARGS = ["agent", "update"] as const; +export const PRISMA_AGENT_STATUS_ARGS = ["agent", "status"] as const; diff --git a/packages/cli/src/lib/agent/package-manager.ts b/packages/cli/src/lib/agent/package-manager.ts new file mode 100644 index 0000000..e82d222 --- /dev/null +++ b/packages/cli/src/lib/agent/package-manager.ts @@ -0,0 +1,165 @@ +import { readFileSync, statSync } from "node:fs"; +import path from "node:path"; + +export type AgentPackageManager = "bun" | "npm" | "pnpm" | "yarn"; + +const LOCKFILE_PACKAGE_MANAGERS: Array<{ + packageManager: AgentPackageManager; + fileNames: string[]; +}> = [ + { packageManager: "bun", fileNames: ["bun.lock", "bun.lockb"] }, + { + packageManager: "pnpm", + fileNames: ["pnpm-lock.yaml", "pnpm-workspace.yaml"], + }, + { packageManager: "yarn", fileNames: ["yarn.lock"] }, + { + packageManager: "npm", + fileNames: ["package-lock.json", "npm-shrinkwrap.json"], + }, +]; + +export async function resolveSkillsPackageRunner(options: { + cwd: string; + signal: AbortSignal; +}): Promise { + return resolvePackageRunner(options); +} + +export async function resolvePackageRunner(options: { + cwd: string; + signal: AbortSignal; +}): Promise { + options.signal.throwIfAborted(); + const packageManager = + detectPackageManagerSync(options.cwd, options.signal) ?? "npm"; + options.signal.throwIfAborted(); + + return packageRunnerForPackageManager(packageManager); +} + +export function resolvePackageRunnerSync(cwd: string): string[] { + const packageManager = detectPackageManagerSync(cwd) ?? "npm"; + return packageRunnerForPackageManager(packageManager); +} + +export async function detectPackageManager( + cwd: string, + signal: AbortSignal, +): Promise { + signal.throwIfAborted(); + const packageManager = detectPackageManagerSync(cwd, signal); + signal.throwIfAborted(); + return packageManager; +} + +export function detectPackageManagerSync( + cwd: string, + signal?: AbortSignal, +): AgentPackageManager | null { + let directory = path.resolve(cwd); + + while (true) { + signal?.throwIfAborted(); + + const packageJsonManager = readPackageJsonPackageManager(directory); + if (packageJsonManager) { + return packageJsonManager; + } + + const lockfileManager = readLockfilePackageManager(directory, signal); + if (lockfileManager) { + return lockfileManager; + } + + const parent = path.dirname(directory); + if (parent === directory) { + return null; + } + directory = parent; + } +} + +function readPackageJsonPackageManager( + directory: string, +): AgentPackageManager | null { + const packageJsonPath = path.join(directory, "package.json"); + let content: string; + + try { + content = readFileSync(packageJsonPath, "utf8"); + } catch (error) { + if (isMissingFileError(error)) { + return null; + } + throw error; + } + + try { + const packageJson = JSON.parse(content) as { packageManager?: unknown }; + return parsePackageManager(packageJson.packageManager); + } catch { + return null; + } +} + +function readLockfilePackageManager( + directory: string, + signal?: AbortSignal, +): AgentPackageManager | null { + for (const candidate of LOCKFILE_PACKAGE_MANAGERS) { + for (const fileName of candidate.fileNames) { + signal?.throwIfAborted(); + if (fileExists(path.join(directory, fileName))) { + return candidate.packageManager; + } + } + } + + return null; +} + +function fileExists(filePath: string): boolean { + try { + return statSync(filePath).isFile(); + } catch (error) { + if (isMissingFileError(error)) { + return false; + } + throw error; + } +} + +function parsePackageManager(value: unknown): AgentPackageManager | null { + if (typeof value !== "string") { + return null; + } + + const normalized = value.trim().toLowerCase(); + if (normalized === "bun" || normalized.startsWith("bun@")) return "bun"; + if (normalized === "pnpm" || normalized.startsWith("pnpm@")) return "pnpm"; + if (normalized === "yarn" || normalized.startsWith("yarn@")) return "yarn"; + if (normalized === "npm" || normalized.startsWith("npm@")) return "npm"; + + return null; +} + +function packageRunnerForPackageManager( + packageManager: AgentPackageManager, +): string[] { + switch (packageManager) { + case "bun": + return ["bunx"]; + case "pnpm": + return ["pnpm", "dlx"]; + case "yarn": + return ["yarn", "dlx"]; + case "npm": + return ["npx", "-y"]; + } +} + +function isMissingFileError(error: unknown): boolean { + const code = (error as NodeJS.ErrnoException).code; + return code === "ENOENT" || code === "ENOTDIR"; +} diff --git a/packages/cli/src/lib/agent/setup-status.ts b/packages/cli/src/lib/agent/setup-status.ts new file mode 100644 index 0000000..5a7ebb3 --- /dev/null +++ b/packages/cli/src/lib/agent/setup-status.ts @@ -0,0 +1,156 @@ +import { readFile, stat } from "node:fs/promises"; +import path from "node:path"; +import { findComputeConfigDir } from "@prisma/compute-sdk/config"; + +import type { LocalStateStore } from "../../adapters/local-state"; +import { PRISMA_SKILLS_LOCK_FILENAME, PRISMA_SKILLS_SOURCE } from "./constants"; + +export interface PrismaAgentSetupStatus { + skillsLockPath: string; + skillsInstalled: boolean; + promptDismissedAt: string | null; +} + +export async function readPrismaAgentSetupStatus(options: { + cwd: string; + stateStore?: LocalStateStore; + signal: AbortSignal; + requiredSkill?: string; +}): Promise { + const setupCwd = await resolvePrismaAgentSetupCwd(options); + const skillsLockPath = path.join(setupCwd, PRISMA_SKILLS_LOCK_FILENAME); + const [skillsInstalled, promptDismissedAt] = await Promise.all([ + hasPrismaSkillsLock(skillsLockPath, options.signal, options.requiredSkill), + options.stateStore?.readAgentSetupPromptDismissedAt() ?? null, + ]); + + return { + skillsLockPath: path.basename(skillsLockPath), + skillsInstalled, + promptDismissedAt, + }; +} + +export async function resolvePrismaAgentSetupCwd(options: { + cwd: string; + signal: AbortSignal; +}): Promise { + options.signal.throwIfAborted(); + const configDir = await findComputeConfigDir(options.cwd, options.signal); + options.signal.throwIfAborted(); + return configDir ?? options.cwd; +} + +export function isPrismaAgentSetupComplete( + status: PrismaAgentSetupStatus, +): boolean { + return status.skillsInstalled; +} + +export function shouldOfferPrismaAgentSetup( + status: PrismaAgentSetupStatus, +): boolean { + return !isPrismaAgentSetupComplete(status) && !status.promptDismissedAt; +} + +export async function isLikelyProjectDirectory(options: { + cwd: string; + signal: AbortSignal; +}): Promise { + const signals = [ + "package.json", + "prisma.compute.ts", + "prisma.config.ts", + ".git", + ]; + + return ( + await Promise.all( + signals.map((fileName) => + pathExists(path.join(options.cwd, fileName), options.signal), + ), + ) + ).some(Boolean); +} + +async function hasPrismaSkillsLock( + filePath: string, + signal: AbortSignal, + requiredSkill?: string, +): Promise { + try { + const raw = await readFile(filePath, { encoding: "utf8", signal }); + return hasPrismaSkillsLockEntry(JSON.parse(raw), requiredSkill); + } catch (error) { + if (isNotFoundError(error) || error instanceof SyntaxError) { + return false; + } + throw error; + } +} + +function hasPrismaSkillsLockEntry( + value: unknown, + requiredSkill: string | undefined, +): boolean { + if (!isRecord(value)) { + return false; + } + + if (requiredSkill) { + return skillLockEntryUsesPrismaSource( + readSkillLockEntries(value)[requiredSkill], + ); + } + + if (readLegacySources(value).includes(PRISMA_SKILLS_SOURCE)) { + return true; + } + + return Object.values(readSkillLockEntries(value)).some( + skillLockEntryUsesPrismaSource, + ); +} + +function readLegacySources(value: Record): string[] { + return Array.isArray(value.sources) + ? value.sources.filter((source) => typeof source === "string") + : []; +} + +function readSkillLockEntries( + value: Record, +): Record { + return isRecord(value.skills) ? value.skills : {}; +} + +function skillLockEntryUsesPrismaSource(value: unknown): boolean { + return isRecord(value) && value.source === PRISMA_SKILLS_SOURCE; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +async function pathExists( + filePath: string, + signal: AbortSignal, +): Promise { + signal.throwIfAborted(); + try { + await stat(filePath); + signal.throwIfAborted(); + return true; + } catch (error) { + if (signal.aborted) throw error; + if (isNotFoundError(error)) { + return false; + } + throw error; + } +} + +function isNotFoundError(error: unknown): boolean { + const code = (error as NodeJS.ErrnoException).code; + return code === "ENOENT" || code === "ENOTDIR"; +} diff --git a/packages/cli/src/lib/app/app-interaction.ts b/packages/cli/src/lib/app/app-interaction.ts index 947f386..f31b625 100644 --- a/packages/cli/src/lib/app/app-interaction.ts +++ b/packages/cli/src/lib/app/app-interaction.ts @@ -26,6 +26,7 @@ export function createDeployInteraction( const selection = await selectPrompt({ input: context.runtime.stdin, output: context.runtime.stderr, + signal: context.runtime.signal, message: "Select an app", choices: [ ...sorted.map((app) => ({ @@ -45,6 +46,7 @@ export function createDeployInteraction( return textPrompt({ input: context.runtime.stdin, output: context.runtime.stderr, + signal: context.runtime.signal, message: "App name", validate: (value) => !value?.trim() ? "App name is required" : undefined, diff --git a/packages/cli/src/lib/app/branch-database-deploy.ts b/packages/cli/src/lib/app/branch-database-deploy.ts index ab69818..1bb09bc 100644 --- a/packages/cli/src/lib/app/branch-database-deploy.ts +++ b/packages/cli/src/lib/app/branch-database-deploy.ts @@ -145,6 +145,7 @@ export async function maybeSetupBranchDatabase( const shouldCreate = await confirmPrompt({ input: context.runtime.stdin, output: context.output.stderr, + signal: context.runtime.signal, message: databasePromptMessage(branch), initialValue: false, }); diff --git a/packages/cli/src/lib/app/branch-database.ts b/packages/cli/src/lib/app/branch-database.ts index 6f38518..3a9ac41 100644 --- a/packages/cli/src/lib/app/branch-database.ts +++ b/packages/cli/src/lib/app/branch-database.ts @@ -37,6 +37,7 @@ export interface BranchDatabaseSignal { const SKIPPED_DIRECTORIES = new Set([ ".git", + ".agents", ".next", ".nuxt", ".output", diff --git a/packages/cli/src/lib/app/production-deploy-gate.ts b/packages/cli/src/lib/app/production-deploy-gate.ts index 1f52b07..e729185 100644 --- a/packages/cli/src/lib/app/production-deploy-gate.ts +++ b/packages/cli/src/lib/app/production-deploy-gate.ts @@ -52,6 +52,7 @@ export async function enforceProductionDeployGate( const confirmed = await confirmPrompt({ input: context.runtime.stdin, output: context.output.stderr, + signal: context.runtime.signal, message: "Deploy to production?", initialValue: false, }); diff --git a/packages/cli/src/lib/project/interactive-setup.ts b/packages/cli/src/lib/project/interactive-setup.ts index 483b401..8c39df6 100644 --- a/packages/cli/src/lib/project/interactive-setup.ts +++ b/packages/cli/src/lib/project/interactive-setup.ts @@ -83,6 +83,7 @@ export async function promptForProjectSetupChoice(options: { const rawName = await textPrompt({ input: options.context.runtime.stdin, output: options.context.runtime.stderr, + signal: options.context.runtime.signal, message: "Project name", placeholder: suggestedName.name, validate: (value) => diff --git a/packages/cli/src/presenters/agent.ts b/packages/cli/src/presenters/agent.ts new file mode 100644 index 0000000..f62d512 --- /dev/null +++ b/packages/cli/src/presenters/agent.ts @@ -0,0 +1,130 @@ +import { formatShellCommand } from "../shell/command-arguments"; +import type { CommandDescriptor } from "../shell/command-meta"; +import { formatDescriptorLabel } from "../shell/command-meta"; +import type { CommandContext } from "../shell/runtime"; +import { renderSummaryLine } from "../shell/ui"; +import type { AgentInstallResult, AgentStatusResult } from "../types/agent"; + +export function renderAgentInstall( + context: CommandContext, + descriptor: CommandDescriptor, + result: AgentInstallResult, +): string[] { + const ui = context.ui; + const rail = ui.dim("│"); + const lines = [ + renderSummaryLine( + ui, + "success", + `${formatDescriptorLabel(descriptor)} → ${operationSummary(result)} Prisma skills.`, + ), + "", + `${rail} ${ui.accent("skills:")} ${result.skills.status.replace("-", " ")}`, + `${rail} ${ui.dim(formatShellCommand(result.skills.command))}`, + ]; + + return lines; +} + +export function renderAgentStatus( + context: CommandContext, + descriptor: CommandDescriptor, + result: AgentStatusResult, +): string[] { + const ui = context.ui; + const rail = ui.dim("│"); + const lines = [ + `${ui.strong(formatDescriptorLabel(descriptor))} ${ui.dim("→")} ${ui.dim(`Checking ${result.statusScope} Prisma skills.`)}`, + "", + `${rail} ${ui.accent("skills:")} ${result.skillsInstalled ? "installed" : "not found"}`, + ...renderInstalledSkills(context, result.skills), + `${rail}`, + `${rail} ${ui.accent("source:")} ${formatStatusSource(result)}`, + `${rail} ${ui.dim(formatShellCommand(result.skillsListCommand))}`, + ...renderProjectStatusDetails(context, result), + ]; + + return lines; +} + +export function serializeAgentInstall(result: AgentInstallResult) { + return result; +} + +export function serializeAgentStatus(result: AgentStatusResult) { + return result; +} + +function formatSetupPromptStatus(result: AgentStatusResult): string { + if (result.skillsInstalled) { + return "not needed"; + } + + if (result.promptDismissedAt) { + return `dismissed ${result.promptDismissedAt}`; + } + + return "active"; +} + +function formatStatusSource(result: AgentStatusResult): string { + if (result.statusSource === "skills-cli") { + return result.statusScope === "global" + ? "skills list -g --json" + : "skills list --json"; + } + + if (result.statusSource === "skills-lock") { + return result.skillsLockPath; + } + + return "unavailable"; +} + +function renderProjectStatusDetails( + context: CommandContext, + result: AgentStatusResult, +): string[] { + if (result.statusScope !== "project") { + return []; + } + + const ui = context.ui; + const rail = ui.dim("│"); + return [ + `${rail}`, + `${rail} ${ui.accent("skills lock:")} ${result.skillsLockInstalled ? "installed" : "not found"}`, + `${rail} ${ui.dim(result.skillsLockPath)}`, + `${rail}`, + `${rail} ${ui.accent("setup prompt:")} ${formatSetupPromptStatus(result)}`, + ]; +} + +function renderInstalledSkills( + context: CommandContext, + skills: AgentStatusResult["skills"], +): string[] { + const ui = context.ui; + const rail = ui.dim("│"); + if (skills.length === 0) { + return [`${rail} ${ui.dim("No Prisma skills reported.")}`]; + } + + return skills.map((skill) => { + const agents = + skill.agents.length > 0 ? skill.agents.join(", ") : "no agents reported"; + return `${rail} ${skill.name} ${ui.dim(`${skill.scope}; ${agents}`)}`; + }); +} + +function operationSummary(result: AgentInstallResult): string { + if (result.skills.status === "would-install") { + return "Would install"; + } + + return operationLabel(result.operation); +} + +function operationLabel(operation: AgentInstallResult["operation"]): string { + return operation === "update" ? "Updated" : "Installed"; +} diff --git a/packages/cli/src/presenters/auth.ts b/packages/cli/src/presenters/auth.ts index 0ac9472..6535ff9 100644 --- a/packages/cli/src/presenters/auth.ts +++ b/packages/cli/src/presenters/auth.ts @@ -41,7 +41,14 @@ export function renderAuthSuccess( context: rows, operationDescription: "Applying authentication session changes", operationCount: 1, - details: ["Session stored in local CLI state."], + details: [ + "Session stored in local CLI state.", + ...(result.agentSetupTip + ? [ + `Install Prisma skills for this project with ${result.agentSetupTip.command}.`, + ] + : []), + ], }, context.ui, ); diff --git a/packages/cli/src/shell/cli-command.ts b/packages/cli/src/shell/cli-command.ts new file mode 100644 index 0000000..2500b1b --- /dev/null +++ b/packages/cli/src/shell/cli-command.ts @@ -0,0 +1,29 @@ +export const PRISMA_CLI_PACKAGE_NAME = "@prisma/cli"; +export const PRISMA_CLI_PACKAGE_SPEC = `${PRISMA_CLI_PACKAGE_NAME}@latest`; +export const DEFAULT_PRISMA_CLI_PACKAGE_RUNNER = ["npx", "-y"]; +export const PRISMA_CLI_BINARY = "prisma-cli"; + +export type PrismaCliCommandInvocation = "binary" | "package"; + +export interface PrismaCliCommandFormatOptions { + invocation?: PrismaCliCommandInvocation; + packageRunner?: readonly string[]; +} + +export function formatPrismaCliCommand( + args: readonly string[], + options: PrismaCliCommandFormatOptions = {}, +): string { + return [...getPrismaCliCommandPrefix(options), ...args].join(" "); +} + +function getPrismaCliCommandPrefix({ + invocation = "package", + packageRunner = DEFAULT_PRISMA_CLI_PACKAGE_RUNNER, +}: PrismaCliCommandFormatOptions): string[] { + if (invocation === "binary") { + return [PRISMA_CLI_BINARY]; + } + + return [...packageRunner, PRISMA_CLI_PACKAGE_SPEC]; +} diff --git a/packages/cli/src/shell/command-arguments.ts b/packages/cli/src/shell/command-arguments.ts index f10c639..8f38b2a 100644 --- a/packages/cli/src/shell/command-arguments.ts +++ b/packages/cli/src/shell/command-arguments.ts @@ -4,3 +4,13 @@ export function formatCommandArgument(value: string): string { ? value : `'${value.replace(/'/g, "'\\''")}'`; } + +export function formatShellCommand(command: readonly string[]): string { + return command.map(formatShellCommandWord).join(" "); +} + +function formatShellCommandWord(value: string): string { + return /^[A-Za-z0-9_./:@=-]+$/.test(value) + ? value + : `'${value.replace(/'/g, "'\\''")}'`; +} diff --git a/packages/cli/src/shell/command-meta.ts b/packages/cli/src/shell/command-meta.ts index f41878b..9ca88a9 100644 --- a/packages/cli/src/shell/command-meta.ts +++ b/packages/cli/src/shell/command-meta.ts @@ -1,4 +1,12 @@ import type { Command } from "commander"; +import { resolvePrismaCliPackageCommandFormatterSync } from "../lib/agent/cli-command"; +import { + PRISMA_AGENT_INSTALL_ARGS, + PRISMA_AGENT_STATUS_ARGS, + PRISMA_AGENT_UPDATE_ARGS, + PRISMA_COMPUTE_AGENT_SKILL, +} from "../lib/agent/constants"; +import type { CliRuntime } from "./runtime"; const COMMAND_DESCRIPTOR_ID = Symbol("prisma.commandDescriptorId"); @@ -7,10 +15,20 @@ export interface CommandDescriptor { path: string[]; description: string; docsPath?: string; - examples?: string[]; + examples?: string[] | ((runtime: CliRuntime) => string[]); longDescription?: string; } +function agentCommandExamples( + runtime: CliRuntime, + commands: readonly (readonly string[])[], +): string[] { + const formatCommand = resolvePrismaCliPackageCommandFormatterSync( + runtime.cwd, + ); + return commands.map((command) => formatCommand(command)); +} + const DESCRIPTORS: CommandDescriptor[] = [ { id: "root", @@ -26,6 +44,51 @@ const DESCRIPTORS: CommandDescriptor[] = [ description: "Show CLI build and environment", examples: ["prisma-cli version", "prisma-cli version --json"], }, + { + id: "agent", + path: ["prisma", "agent"], + description: "Install Prisma context for AI coding agents", + examples: (runtime) => + agentCommandExamples(runtime, [ + PRISMA_AGENT_INSTALL_ARGS, + PRISMA_AGENT_UPDATE_ARGS, + PRISMA_AGENT_STATUS_ARGS, + ]), + }, + { + id: "agent.install", + path: ["prisma", "agent", "install"], + description: "Install Prisma skills for AI coding agents", + examples: (runtime) => + agentCommandExamples(runtime, [ + PRISMA_AGENT_INSTALL_ARGS, + [...PRISMA_AGENT_INSTALL_ARGS, "--agent", "codex"], + [...PRISMA_AGENT_INSTALL_ARGS, "--all-agents"], + [...PRISMA_AGENT_INSTALL_ARGS, "--skill", PRISMA_COMPUTE_AGENT_SKILL], + ]), + }, + { + id: "agent.update", + path: ["prisma", "agent", "update"], + description: "Refresh Prisma skills for AI coding agents", + examples: (runtime) => + agentCommandExamples(runtime, [ + PRISMA_AGENT_UPDATE_ARGS, + [...PRISMA_AGENT_UPDATE_ARGS, "--agent", "codex"], + [...PRISMA_AGENT_UPDATE_ARGS, "--all-agents"], + ]), + }, + { + id: "agent.status", + path: ["prisma", "agent", "status"], + description: "Show installed Prisma skills", + examples: (runtime) => + agentCommandExamples(runtime, [ + PRISMA_AGENT_STATUS_ARGS, + [...PRISMA_AGENT_STATUS_ARGS, "--json"], + [...PRISMA_AGENT_STATUS_ARGS, "--global"], + ]), + }, { id: "auth", path: ["prisma", "auth"], diff --git a/packages/cli/src/shell/errors.ts b/packages/cli/src/shell/errors.ts index 3f18561..4a45beb 100644 --- a/packages/cli/src/shell/errors.ts +++ b/packages/cli/src/shell/errors.ts @@ -82,6 +82,21 @@ export function usageError( }); } +export function isUsageError( + error: unknown, + summary?: string, +): error is CliError { + return ( + isErrorRecord(error) && + error.code === "USAGE_ERROR" && + (summary === undefined || error.summary === summary) + ); +} + +function isErrorRecord(error: unknown): error is Record { + return typeof error === "object" && error !== null; +} + export function authRequiredError( nextSteps: string[] = ["prisma-cli auth login"], options: { debug?: string | null } = {}, diff --git a/packages/cli/src/shell/help.ts b/packages/cli/src/shell/help.ts index 62249eb..55d760d 100644 --- a/packages/cli/src/shell/help.ts +++ b/packages/cli/src/shell/help.ts @@ -35,7 +35,7 @@ export function renderHelp(command: Command, runtime: CliRuntime): string { lines.push( ...renderVisibleOptions(rail, ui, visibleCommands, visibleOptions), ); - lines.push(...renderExamples(rail, descriptor.examples)); + lines.push(...renderExamples(rail, runtime, descriptor.examples)); lines.push(...renderDocsPath(rail, ui, descriptor.docsPath)); lines.push(""); @@ -93,16 +93,20 @@ function shouldLabelGlobalOptions( function renderExamples( rail: string, - examples: string[] | undefined, + runtime: CliRuntime, + examples: string[] | ((runtime: CliRuntime) => string[]) | undefined, ): string[] { - if (!examples || examples.length === 0) { + const resolvedExamples = + typeof examples === "function" ? examples(runtime) : examples; + + if (!resolvedExamples || resolvedExamples.length === 0) { return []; } return [ `${rail}`, `${rail} Examples:`, - ...examples.map((example) => `${rail} $ ${example}`), + ...resolvedExamples.map((example) => `${rail} $ ${example}`), ]; } diff --git a/packages/cli/src/shell/prompt.ts b/packages/cli/src/shell/prompt.ts index 66192d7..c6835be 100644 --- a/packages/cli/src/shell/prompt.ts +++ b/packages/cli/src/shell/prompt.ts @@ -1,7 +1,9 @@ import type { Readable, Writable } from "node:stream"; import { confirm, isCancel, select, text } from "@clack/prompts"; -import { usageError } from "./errors"; +import { isUsageError, usageError } from "./errors"; + +const PROMPT_CANCELED_SUMMARY = "Interactive prompt canceled"; export interface SelectOption { label: string; @@ -11,6 +13,7 @@ export interface SelectOption { export async function selectPrompt(options: { input: Readable; output: Writable; + signal?: AbortSignal; message: string; choices: SelectOption[]; }): Promise { @@ -22,13 +25,14 @@ export async function selectPrompt(options: { const response = await select({ input: options.input, output: options.output, + signal: options.signal, message: options.message, options: promptOptions, }); if (isCancel(response)) { throw usageError( - "Interactive prompt canceled", + PROMPT_CANCELED_SUMMARY, "The command was canceled before a selection was made.", "Re-run the command and choose an option to continue.", ); @@ -40,6 +44,7 @@ export async function selectPrompt(options: { export async function textPrompt(options: { input: Readable; output: Writable; + signal?: AbortSignal; message: string; placeholder?: string; validate?: (value: string | undefined) => string | undefined; @@ -47,6 +52,7 @@ export async function textPrompt(options: { const response = await text({ input: options.input, output: options.output, + signal: options.signal, message: options.message, placeholder: options.placeholder, validate: options.validate, @@ -54,7 +60,7 @@ export async function textPrompt(options: { if (isCancel(response)) { throw usageError( - "Interactive prompt canceled", + PROMPT_CANCELED_SUMMARY, "The command was canceled before a value was entered.", "Re-run the command and provide a value to continue.", ); @@ -66,19 +72,21 @@ export async function textPrompt(options: { export async function confirmPrompt(options: { input: Readable; output: Writable; + signal?: AbortSignal; message: string; initialValue?: boolean; }): Promise { const response = await confirm({ input: options.input, output: options.output, + signal: options.signal, message: options.message, initialValue: options.initialValue ?? false, }); if (isCancel(response)) { throw usageError( - "Interactive prompt canceled", + PROMPT_CANCELED_SUMMARY, "The command was canceled before a confirmation was made.", "Re-run the command and choose an option to continue.", ); @@ -87,6 +95,10 @@ export async function confirmPrompt(options: { return response; } +export function isPromptCancelError(error: unknown): boolean { + return isUsageError(error, PROMPT_CANCELED_SUMMARY); +} + export function disposePromptState(_input: Readable): void { // @clack/prompts manages its own input listeners for each prompt invocation. } diff --git a/packages/cli/src/types/agent.ts b/packages/cli/src/types/agent.ts new file mode 100644 index 0000000..dcf615e --- /dev/null +++ b/packages/cli/src/types/agent.ts @@ -0,0 +1,27 @@ +export interface AgentSkillsResult { + status: "installed" | "would-install"; + command: string[]; +} + +export interface AgentInstalledSkill { + name: string; + path: string; + scope: string; + agents: string[]; +} + +export interface AgentInstallResult { + operation: "install" | "update"; + skills: AgentSkillsResult; +} + +export interface AgentStatusResult { + skills: AgentInstalledSkill[]; + skillsListCommand: string[]; + statusScope: "project" | "global"; + skillsLockPath: string; + skillsLockInstalled: boolean; + skillsInstalled: boolean; + statusSource: "skills-cli" | "skills-lock" | "unavailable"; + promptDismissedAt: string | null; +} diff --git a/packages/cli/src/types/auth.ts b/packages/cli/src/types/auth.ts index 99f7baf..670e53a 100644 --- a/packages/cli/src/types/auth.ts +++ b/packages/cli/src/types/auth.ts @@ -23,6 +23,9 @@ export interface AuthStateResult { user: AuthUser | null; workspace: AuthWorkspace | null; credential: AuthCredential | null; + agentSetupTip?: { + command: string; + }; } export interface AuthWorkspaceSession { diff --git a/packages/cli/tests/agent.test.ts b/packages/cli/tests/agent.test.ts new file mode 100644 index 0000000..5758be3 --- /dev/null +++ b/packages/cli/tests/agent.test.ts @@ -0,0 +1,773 @@ +import { access, mkdir, writeFile } from "node:fs/promises"; +import path from "node:path"; + +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { createTempCwd, executeCli } from "./helpers"; + +function expectSkillsCommandPrefix( + command: string[], + binaryName: string, + args: string[], +): void { + expect(command[0]).toBe(binaryName); + expect(command.slice(1, args.length + 1)).toEqual(args); +} + +function mockSkillsExeca( + stdout: unknown, + options: { failed?: boolean; stderr?: string } = {}, +) { + const execa = vi.fn(async () => { + if (options.failed) { + throw new Error(options.stderr ?? "skills list failed"); + } + + return { + stdout: typeof stdout === "string" ? stdout : JSON.stringify(stdout), + stderr: options.stderr ?? "", + }; + }); + + vi.doMock("execa", () => ({ execa })); + return execa; +} + +afterEach(() => { + vi.doUnmock("execa"); + vi.resetModules(); + vi.restoreAllMocks(); +}); + +describe("agent commands", () => { + it("shows help for agent commands", async () => { + const cwd = await createTempCwd(); + const stateDir = path.join(cwd, ".state"); + + const rootHelp = await executeCli({ + argv: ["--help"], + cwd, + stateDir, + }); + const agentHelp = await executeCli({ + argv: ["agent", "--help"], + cwd, + stateDir, + }); + const installHelp = await executeCli({ + argv: ["agent", "install", "--help"], + cwd, + stateDir, + }); + const updateHelp = await executeCli({ + argv: ["agent", "update", "--help"], + cwd, + stateDir, + }); + const statusHelp = await executeCli({ + argv: ["agent", "status", "--help"], + cwd, + stateDir, + }); + + expect(rootHelp.exitCode).toBe(0); + expect(rootHelp.stderr).toContain("agent"); + + expect(agentHelp.exitCode).toBe(0); + expect(agentHelp.stderr).toContain( + "Install Prisma context for AI coding agents", + ); + expect(agentHelp.stderr).toContain( + "$ npx -y @prisma/cli@latest agent install", + ); + expect(agentHelp.stderr).toContain( + "$ npx -y @prisma/cli@latest agent update", + ); + expect(agentHelp.stderr).toContain( + "$ npx -y @prisma/cli@latest agent status", + ); + + expect(installHelp.exitCode).toBe(0); + expect(installHelp.stderr).toContain("--agent "); + expect(installHelp.stderr).toContain("--all-agents"); + expect(installHelp.stderr).toContain("--skill "); + expect(installHelp.stderr).not.toContain("--skip-skills"); + expect(installHelp.stderr).not.toContain("--skip-project-files"); + + expect(updateHelp.exitCode).toBe(0); + expect(updateHelp.stderr).toContain( + "Refresh Prisma skills for AI coding agents", + ); + expect(updateHelp.stderr).toContain("--all-agents"); + + expect(statusHelp.exitCode).toBe(0); + expect(statusHelp.stderr).toContain("--global"); + }); + + it("uses the detected package manager in agent help examples", async () => { + const cwd = await createTempCwd(); + const stateDir = path.join(cwd, ".state"); + await writeFile( + path.join(cwd, "package.json"), + JSON.stringify({ packageManager: "pnpm@11.0.0" }, null, 2), + "utf8", + ); + + const result = await executeCli({ + argv: ["agent", "--help"], + cwd, + stateDir, + }); + + expect(result.exitCode).toBe(0); + expect(result.stderr).toContain( + "$ pnpm dlx @prisma/cli@latest agent install", + ); + expect(result.stderr).toContain( + "$ pnpm dlx @prisma/cli@latest agent update", + ); + }); + + it("builds the skills CLI install command without writing files in dry-run mode", async () => { + const cwd = await createTempCwd(); + const stateDir = path.join(cwd, ".state"); + + const result = await executeCli({ + argv: [ + "agent", + "install", + "--dry-run", + "--agent", + "codex", + "--agent", + "cursor", + "--skill", + "prisma-compute", + "--global", + "--copy", + "--json", + ], + cwd, + stateDir, + }); + + expect(result.exitCode).toBe(0); + expect(result.stderr).toBe(""); + const payload = JSON.parse(result.stdout); + + expect(payload).toMatchObject({ + ok: true, + command: "agent.install", + result: { + operation: "install", + skills: { + status: "would-install", + }, + }, + nextSteps: [], + }); + expect(payload.result.skills.command).toEqual([ + "npx", + "-y", + "skills@latest", + "add", + "prisma/skills", + "--skill", + "prisma-compute", + "--agent", + "codex", + "--agent", + "cursor", + "--global", + "--copy", + "--yes", + ]); + }); + + it("renders agent install dry-run as a planned install", async () => { + const cwd = await createTempCwd(); + const stateDir = path.join(cwd, ".state"); + + const result = await executeCli({ + argv: ["agent", "install", "--dry-run"], + cwd, + stateDir, + }); + + expect(result.exitCode).toBe(0); + expect(result.stderr).toContain( + "agent install → Would install Prisma skills.", + ); + expect(result.stderr).toContain("skills: would install"); + expect(result.stderr).toContain("--skill '*'"); + }); + + it("uses the detected package manager for the skills installer", async () => { + const cases = [ + { + lockfile: "bun.lock", + binary: "bunx", + args: ["skills@latest", "add"], + }, + { + lockfile: "pnpm-lock.yaml", + binary: "pnpm", + args: ["dlx", "skills@latest", "add"], + }, + { + lockfile: "yarn.lock", + binary: "yarn", + args: ["dlx", "skills@latest", "add"], + }, + { + lockfile: "package-lock.json", + binary: "npx", + args: ["-y", "skills@latest", "add"], + }, + ]; + + await Promise.all( + cases.map(async (testCase) => { + const cwd = await createTempCwd(); + const stateDir = path.join(cwd, ".state"); + await writeFile(path.join(cwd, testCase.lockfile), "", "utf8"); + + const result = await executeCli({ + argv: ["agent", "install", "--dry-run", "--json"], + cwd, + stateDir, + }); + + expect(result.exitCode).toBe(0); + const payload = JSON.parse(result.stdout); + expectSkillsCommandPrefix( + payload.result.skills.command, + testCase.binary, + testCase.args, + ); + }), + ); + }); + + it("runs the skills installer through Execa without streaming output", async () => { + vi.resetModules(); + const execa = mockSkillsExeca(""); + const { createTestCommandContext } = await import("./helpers"); + const { runAgentInstall } = await import("../src/controllers/agent"); + const cwd = await createTempCwd(); + const stateDir = path.join(cwd, ".state"); + const { context } = await createTestCommandContext({ cwd, stateDir }); + + const result = await runAgentInstall(context, { + agent: ["codex"], + skill: ["prisma-compute"], + }); + + const expectedCommand = [ + "npx", + "-y", + "skills@latest", + "add", + "prisma/skills", + "--skill", + "prisma-compute", + "--agent", + "codex", + ...(process.platform === "win32" ? ["--copy"] : []), + "--yes", + ]; + expect(execa).toHaveBeenCalledWith( + "npx", + expectedCommand.slice(1), + expect.objectContaining({ + cwd, + env: context.runtime.env, + cancelSignal: context.runtime.signal, + stdin: "ignore", + }), + ); + const [, , execaOptions] = execa.mock.calls[0] as unknown as [ + string, + string[], + Record, + ]; + expect(execaOptions).not.toHaveProperty("stdout"); + expect(execaOptions).not.toHaveProperty("stderr"); + expect(result.result.skills).toEqual({ + status: "installed", + command: expectedCommand, + }); + }); + + it("prefers package.json packageManager for the skills installer", async () => { + const cwd = await createTempCwd(); + const stateDir = path.join(cwd, ".state"); + await writeFile(path.join(cwd, "package-lock.json"), "", "utf8"); + await writeFile( + path.join(cwd, "package.json"), + JSON.stringify({ packageManager: "pnpm@10.0.0" }, null, 2), + "utf8", + ); + + const result = await executeCli({ + argv: ["agent", "install", "--dry-run", "--json"], + cwd, + stateDir, + }); + + expect(result.exitCode).toBe(0); + const payload = JSON.parse(result.stdout); + expectSkillsCommandPrefix(payload.result.skills.command, "pnpm", [ + "dlx", + "skills@latest", + "add", + ]); + }); + + it("detects the package manager from a parent workspace", async () => { + const cwd = await createTempCwd(); + const appPath = path.join(cwd, "apps", "web"); + const stateDir = path.join(cwd, ".state"); + await mkdir(appPath, { recursive: true }); + await writeFile(path.join(cwd, "pnpm-lock.yaml"), "", "utf8"); + + const result = await executeCli({ + argv: ["agent", "install", "--dry-run", "--json"], + cwd: appPath, + stateDir, + }); + + expect(result.exitCode).toBe(0); + const payload = JSON.parse(result.stdout); + expectSkillsCommandPrefix(payload.result.skills.command, "pnpm", [ + "dlx", + "skills@latest", + "add", + ]); + }); + + 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", + ); + + const { readPrismaAgentSetupStatus } = await import( + "../src/lib/agent/setup-status" + ); + const signal = new AbortController().signal; + + await expect( + readPrismaAgentSetupStatus({ cwd, signal }), + ).resolves.toMatchObject({ skillsInstalled: true }); + await expect( + readPrismaAgentSetupStatus({ + cwd, + signal, + requiredSkill: "prisma-compute", + }), + ).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 expect( + readPrismaAgentSetupStatus({ + cwd, + signal, + requiredSkill: "prisma-compute", + }), + ).resolves.toMatchObject({ skillsInstalled: true }); + }); + + it("treats malformed skills lock files as not installed", async () => { + const cwd = await createTempCwd(); + await writeFile(path.join(cwd, "skills-lock.json"), "{", "utf8"); + + const { readPrismaAgentSetupStatus } = await import( + "../src/lib/agent/setup-status" + ); + + await expect( + readPrismaAgentSetupStatus({ + cwd, + signal: new AbortController().signal, + }), + ).resolves.toMatchObject({ skillsInstalled: false }); + }); + + it("keeps Windows command suffixes out of displayed agent commands", async () => { + const platform = vi + .spyOn(process, "platform", "get") + .mockReturnValue("win32"); + try { + const cwd = await createTempCwd(); + await writeFile( + path.join(cwd, "package.json"), + JSON.stringify({ packageManager: "pnpm@11.0.0" }, null, 2), + "utf8", + ); + + const { resolvePrismaCliPackageCommandSync } = await import( + "../src/lib/agent/cli-command" + ); + const { resolveSkillsPackageRunner } = await import( + "../src/lib/agent/package-manager" + ); + + expect( + resolvePrismaCliPackageCommandSync(cwd, ["agent", "install"]), + ).toBe("pnpm dlx @prisma/cli@latest agent install"); + const help = await executeCli({ + argv: ["agent", "--help"], + cwd, + stateDir: path.join(cwd, ".state"), + }); + expect(help.exitCode).toBe(0); + expect(help.stderr).toContain( + "$ pnpm dlx @prisma/cli@latest agent install", + ); + expect(help.stderr).not.toContain("pnpm.cmd"); + + const install = await executeCli({ + argv: ["agent", "install", "--dry-run", "--json"], + cwd, + stateDir: path.join(cwd, ".state"), + }); + expect(install.exitCode).toBe(0); + await expect(access(path.join(cwd, "AGENTS.md"))).rejects.toThrow(); + await expect(access(path.join(cwd, "CLAUDE.md"))).rejects.toThrow(); + expect(JSON.parse(install.stdout).result.skills.command).toEqual([ + "pnpm", + "dlx", + "skills@latest", + "add", + "prisma/skills", + "--skill", + "*", + "--agent", + "codex", + "--agent", + "claude-code", + "--copy", + "--yes", + ]); + + await expect( + resolveSkillsPackageRunner({ + cwd, + signal: new AbortController().signal, + }), + ).resolves.toEqual(["pnpm", "dlx"]); + } finally { + platform.mockRestore(); + } + }); + + it("leaves Windows command execution details to Execa", async () => { + const platform = vi + .spyOn(process, "platform", "get") + .mockReturnValue("win32"); + try { + const cwd = await createTempCwd(); + await writeFile(path.join(cwd, "bun.lock"), "", "utf8"); + + const { resolvePrismaCliPackageCommandSync } = await import( + "../src/lib/agent/cli-command" + ); + const { resolveSkillsPackageRunner } = await import( + "../src/lib/agent/package-manager" + ); + + expect( + resolvePrismaCliPackageCommandSync(cwd, ["agent", "install"]), + ).toBe("bunx @prisma/cli@latest agent install"); + await expect( + resolveSkillsPackageRunner({ + cwd, + signal: new AbortController().signal, + }), + ).resolves.toEqual(["bunx"]); + } finally { + platform.mockRestore(); + } + }); + + it("supports all agent targets in dry-run mode", async () => { + const cwd = await createTempCwd(); + const stateDir = path.join(cwd, ".state"); + + const result = await executeCli({ + argv: ["agent", "update", "--dry-run", "--all-agents", "--json"], + cwd, + stateDir, + }); + + expect(result.exitCode).toBe(0); + const payload = JSON.parse(result.stdout); + + expect(payload.command).toBe("agent.update"); + expect(payload.result.operation).toBe("update"); + expect(payload.result.skills.command).toContain("--agent"); + expect(payload.result.skills.command).toContain("*"); + }); + + it("points global installs at the global status check", async () => { + vi.resetModules(); + mockSkillsExeca(""); + const { createTestCommandContext } = await import("./helpers"); + const { runAgentInstall } = await import("../src/controllers/agent"); + const cwd = await createTempCwd(); + const stateDir = path.join(cwd, ".state"); + const { context } = await createTestCommandContext({ cwd, stateDir }); + + const result = await runAgentInstall(context, { global: true }); + + expect(result.nextSteps).toEqual([ + "Run npx -y @prisma/cli@latest agent status --global to verify the installed Prisma skills.", + ]); + }); + + it("reports installed Prisma skills from the skills CLI", async () => { + vi.resetModules(); + const execa = mockSkillsExeca([ + { + name: "prisma-compute", + path: "/repo/.agents/skills/prisma-compute", + scope: "project", + agents: ["Codex", "Cursor"], + }, + { + name: "unrelated", + path: "/repo/.agents/skills/unrelated", + scope: "project", + agents: ["Codex"], + }, + ]); + const { createTestCommandContext } = await import("./helpers"); + const { runAgentStatus } = await import("../src/controllers/agent"); + const cwd = await createTempCwd(); + const stateDir = path.join(cwd, ".state"); + await writeFile( + path.join(cwd, "package.json"), + JSON.stringify({ packageManager: "pnpm@11.0.0" }, null, 2), + "utf8", + ); + const { context } = await createTestCommandContext({ cwd, stateDir }); + + const result = await runAgentStatus(context); + + expect(execa).toHaveBeenCalledWith( + "pnpm", + ["dlx", "skills@latest", "list", "--json"], + expect.objectContaining({ cwd }), + ); + expect(result.result).toMatchObject({ + skills: [ + { + name: "prisma-compute", + path: "/repo/.agents/skills/prisma-compute", + scope: "project", + agents: ["Codex", "Cursor"], + }, + ], + skillsListCommand: ["pnpm", "dlx", "skills@latest", "list", "--json"], + statusScope: "project", + skillsLockPath: "skills-lock.json", + skillsLockInstalled: false, + skillsInstalled: true, + statusSource: "skills-cli", + promptDismissedAt: null, + }); + expect(result.warnings).toEqual([]); + expect(result.nextSteps).toEqual([]); + }); + + it("reports globally installed Prisma skills from the skills CLI", async () => { + vi.resetModules(); + const execa = mockSkillsExeca([ + { + name: "prisma-compute", + path: "/Users/aman/.agents/skills/prisma-compute", + scope: "global", + agents: ["Codex"], + }, + ]); + const { createTestCommandContext } = await import("./helpers"); + const { runAgentStatus } = await import("../src/controllers/agent"); + const cwd = await createTempCwd(); + const stateDir = path.join(cwd, ".state"); + const { context } = await createTestCommandContext({ cwd, stateDir }); + + const result = await runAgentStatus(context, { global: true }); + + expect(execa).toHaveBeenCalledWith( + "npx", + ["-y", "skills@latest", "list", "-g", "--json"], + expect.objectContaining({ cwd }), + ); + expect(result.result).toMatchObject({ + skills: [ + { + name: "prisma-compute", + path: "/Users/aman/.agents/skills/prisma-compute", + scope: "global", + agents: ["Codex"], + }, + ], + skillsListCommand: ["npx", "-y", "skills@latest", "list", "-g", "--json"], + statusScope: "global", + skillsInstalled: true, + statusSource: "skills-cli", + }); + expect(result.nextSteps).toEqual([]); + }); + + it("checks Prisma skills from the compute config root when run in a subdirectory", async () => { + vi.resetModules(); + const execa = mockSkillsExeca([ + { + name: "prisma-compute", + path: "/repo/.agents/skills/prisma-compute", + scope: "project", + agents: ["Codex"], + }, + ]); + const { createTestCommandContext } = await import("./helpers"); + const { runAgentStatus } = await import("../src/controllers/agent"); + const cwd = await createTempCwd(); + const appDir = path.join(cwd, "apps", "web"); + const stateDir = path.join(cwd, ".state"); + await mkdir(appDir, { recursive: true }); + await mkdir(path.join(cwd, ".git"), { recursive: true }); + await writeFile( + path.join(cwd, "prisma.compute.ts"), + 'export default { apps: { web: { root: "apps/web" } } };\n', + "utf8", + ); + await writeFile( + path.join(cwd, "package.json"), + JSON.stringify({ packageManager: "pnpm@11.0.0" }, null, 2), + "utf8", + ); + await writeFile( + path.join(cwd, "skills-lock.json"), + JSON.stringify({ sources: ["prisma/skills"] }), + "utf8", + ); + const { context } = await createTestCommandContext({ + cwd: appDir, + stateDir, + }); + + const result = await runAgentStatus(context); + + expect(execa).toHaveBeenCalledWith( + "pnpm", + ["dlx", "skills@latest", "list", "--json"], + expect.objectContaining({ cwd }), + ); + expect(result.result.skillsLockInstalled).toBe(true); + expect(result.result.skillsInstalled).toBe(true); + expect(result.result.statusScope).toBe("project"); + expect(result.result.skills).toEqual([ + { + name: "prisma-compute", + path: "/repo/.agents/skills/prisma-compute", + scope: "project", + agents: ["Codex"], + }, + ]); + expect(result.nextSteps).toEqual([]); + }); + + it("falls back to skills-lock status when skills CLI listing fails", async () => { + vi.resetModules(); + mockSkillsExeca("", { failed: true, stderr: "skills exploded" }); + const { createTestCommandContext } = await import("./helpers"); + const { runAgentStatus } = await import("../src/controllers/agent"); + const cwd = await createTempCwd(); + const stateDir = path.join(cwd, ".state"); + await writeFile( + path.join(cwd, "skills-lock.json"), + JSON.stringify({ sources: ["prisma/skills"] }), + "utf8", + ); + const { context } = await createTestCommandContext({ cwd, stateDir }); + + const result = await runAgentStatus(context); + + expect(result.result).toEqual({ + skills: [], + skillsListCommand: ["npx", "-y", "skills@latest", "list", "--json"], + statusScope: "project", + skillsLockPath: "skills-lock.json", + skillsLockInstalled: true, + skillsInstalled: true, + statusSource: "skills-lock", + promptDismissedAt: null, + }); + expect(result.warnings[0]).toContain("skills exploded"); + expect(result.nextSteps).toEqual([]); + }); + + it("does not fall back to project skills-lock status for global status failures", async () => { + vi.resetModules(); + mockSkillsExeca("", { + failed: true, + stderr: "global skills exploded", + }); + const { createTestCommandContext } = await import("./helpers"); + const { runAgentStatus } = await import("../src/controllers/agent"); + const cwd = await createTempCwd(); + const stateDir = path.join(cwd, ".state"); + await writeFile( + path.join(cwd, "skills-lock.json"), + JSON.stringify({ sources: ["prisma/skills"] }), + "utf8", + ); + const { context } = await createTestCommandContext({ cwd, stateDir }); + + const result = await runAgentStatus(context, { global: true }); + + expect(result.result).toEqual({ + skills: [], + skillsListCommand: ["npx", "-y", "skills@latest", "list", "-g", "--json"], + statusScope: "global", + skillsLockPath: "skills-lock.json", + skillsLockInstalled: true, + skillsInstalled: false, + statusSource: "unavailable", + promptDismissedAt: null, + }); + expect(result.warnings[0]).toContain("global skills exploded"); + expect(result.nextSteps).toEqual([ + "Run npx -y @prisma/cli@latest agent install --global to install or refresh Prisma skills.", + ]); + }); +}); diff --git a/packages/cli/tests/app-branch-database.test.ts b/packages/cli/tests/app-branch-database.test.ts index db141e1..eae974f 100644 --- a/packages/cli/tests/app-branch-database.test.ts +++ b/packages/cli/tests/app-branch-database.test.ts @@ -1,4 +1,3 @@ -import { EventEmitter } from "node:events"; import { mkdir, writeFile } from "node:fs/promises"; import path from "node:path"; @@ -48,6 +47,24 @@ afterEach(() => { vi.restoreAllMocks(); }); +async function writePrismaComputeSkillsLock(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", + ); +} + describe("app deploy branch database setup", () => { it("deploy --db creates a branch database and writes branch env overrides before deploying", async () => { const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); @@ -1221,6 +1238,7 @@ describe("app deploy branch database setup", () => { ); const { runAppDeploy } = await import("../src/controllers/app"); const cwd = await createTempCwd(); + await writePrismaComputeSkillsLock(cwd); await mkdir(path.join(cwd, "prisma"), { recursive: true }); await writeFile( path.join(cwd, "prisma/schema.prisma"), @@ -1615,6 +1633,31 @@ describe("app deploy branch database setup", () => { expect(signal.schema?.path).toBe(path.join(cwd, "prisma/schema.prisma")); }); + it("ignores installed agent skills when scanning for DATABASE_URL references", async () => { + const { createTempCwd } = await import("./helpers"); + const { hasBranchDatabaseSignal, inspectBranchDatabaseSignal } = + await import("../src/lib/app/branch-database"); + const cwd = await createTempCwd(); + await mkdir(path.join(cwd, ".agents/skills/prisma-compute/scripts"), { + recursive: true, + }); + await writeFile( + path.join( + cwd, + ".agents/skills/prisma-compute/scripts/verify-compute-surface.mjs", + ), + "console.log('DATABASE_URL')\n", + ); + + const signal = await inspectBranchDatabaseSignal( + cwd, + new AbortController().signal, + ); + + expect(signal.databaseUrlReferences).toEqual([]); + expect(hasBranchDatabaseSignal(signal)).toBe(false); + }); + it("prefers a Prisma Next config over schema.prisma when both exist", async () => { const { createTempCwd } = await import("./helpers"); const { inspectBranchDatabaseSignal } = await import( diff --git a/packages/cli/tests/app-controller.test.ts b/packages/cli/tests/app-controller.test.ts index 6baef55..17d5472 100644 --- a/packages/cli/tests/app-controller.test.ts +++ b/packages/cli/tests/app-controller.test.ts @@ -4,7 +4,7 @@ import path from "node:path"; import { Result } from "better-result"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; - +import { usageError } from "../src/shell/errors"; import { asSingleDeployResult } from "./helpers/deploy-result"; import { createProjectClient, @@ -40,6 +40,7 @@ afterEach(() => { vi.doUnmock("../src/lib/auth/auth-ops"); vi.doUnmock("../src/lib/auth/guard"); + vi.doUnmock("../src/controllers/agent"); vi.doUnmock("../src/lib/app/app-provider"); vi.doUnmock("../src/lib/app/branch-database"); vi.doUnmock("../src/lib/app/compute-config"); @@ -178,6 +179,106 @@ 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; + runAgentInstall?: ReturnType; +}) { + const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); + const runAgentInstall = options.runAgentInstall ?? vi.fn(); + const deployApp = + options.deployApp ?? + vi.fn().mockResolvedValue({ + projectId: "proj_123", + app: { + id: "app_1", + name: "hello-world", + region: "eu-central-1", + liveDeploymentId: "dep_123", + }, + deployment: { + id: "dep_123", + status: "running", + url: "https://hello-world.prisma.app", + }, + }); + + vi.doMock("../src/lib/auth/guard", () => ({ + requireComputeAuth, + })); + vi.doMock("../src/controllers/agent", () => ({ + runAgentInstall, + })); + vi.doMock("../src/shell/prompt", async () => { + const actual = await vi.importActual( + "../src/shell/prompt", + ); + return { + ...actual, + confirmPrompt: options.confirmPrompt, + }; + }); + vi.doMock("../src/lib/app/app-provider", () => ({ + createAppProvider: vi.fn(() => + withBranchDatabaseProviderDefaults({ + resolveBranch: createResolveBranch(), + createProject: vi.fn(), + listApps: vi.fn().mockResolvedValue([ + { + id: "app_1", + name: "hello-world", + region: "eu-central-1", + liveDeploymentId: null, + liveUrl: null, + }, + ]), + deployApp, + listDeployments: vi.fn(), + showDeployment: vi.fn(), + }), + ), + })); + + const { createTempCwd, createTestCommandContext } = await import("./helpers"); + const { runAppDeploy } = await import("../src/controllers/app"); + const cwd = await createTempCwd(); + const { context } = await createTestCommandContext({ + cwd, + stateDir: path.join(cwd, ".state"), + isTTY: true, + env: { + ...process.env, + PRISMA_CLI_MOCK_FIXTURE_PATH: undefined, + }, + }); + + return { + context, + deployApp, + requireComputeAuth, + runAgentInstall, + runAppDeploy, + }; +} + describe("app controller", () => { it("deploy with a multi-app config and no target deploys every target in order", async () => { const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); @@ -3042,6 +3143,9 @@ describe("app controller", () => { PRISMA_CLI_MOCK_FIXTURE_PATH: undefined, }, }); + await context.stateStore.setAgentSetupPromptDismissedAt( + "2026-06-25T00:00:00.000Z", + ); const result = await runAppDeploy(context, "hello-world", { framework: "hono", @@ -3111,6 +3215,7 @@ describe("app controller", () => { next: "15.0.0", }, }); + await writeAgentSetupInstalled(cwd); const stateDir = path.join(cwd, ".state"); const { context, stderr } = await createTestCommandContext({ cwd, @@ -3150,6 +3255,227 @@ describe("app controller", () => { expect(promptIndex).toBeGreaterThan(detectedIndex); }); + it("prompts to install the Prisma Compute skill during interactive deploy", async () => { + const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); + const confirmPrompt = vi.fn().mockResolvedValue(true); + const runAgentInstall = vi.fn().mockResolvedValue({ + command: "agent.install", + result: { + operation: "install", + skills: { status: "installed", command: [] }, + }, + warnings: [], + nextSteps: [], + }); + const deployApp = vi.fn().mockResolvedValue({ + projectId: "proj_123", + app: { + id: "app_1", + name: "hello-world", + region: "eu-central-1", + liveDeploymentId: "dep_123", + }, + deployment: { + id: "dep_123", + status: "running", + url: "https://hello-world.prisma.app", + }, + }); + + vi.doMock("../src/lib/auth/guard", () => ({ + requireComputeAuth, + })); + vi.doMock("../src/controllers/agent", () => ({ + runAgentInstall, + })); + vi.doMock("../src/shell/prompt", async () => { + const actual = await vi.importActual< + typeof import("../src/shell/prompt") + >("../src/shell/prompt"); + return { + ...actual, + confirmPrompt, + }; + }); + vi.doMock("../src/lib/app/app-provider", () => ({ + createAppProvider: vi.fn(() => + withBranchDatabaseProviderDefaults({ + resolveBranch: createResolveBranch(), + createProject: vi.fn(), + listApps: vi.fn().mockResolvedValue([ + { + id: "app_1", + name: "hello-world", + region: "eu-central-1", + liveDeploymentId: null, + liveUrl: null, + }, + ]), + deployApp, + listDeployments: vi.fn(), + showDeployment: vi.fn(), + }), + ), + })); + + const { createTempCwd, createTestCommandContext } = await import( + "./helpers" + ); + const { runAppDeploy } = await import("../src/controllers/app"); + const cwd = await createTempCwd(); + const { context } = await createTestCommandContext({ + cwd, + stateDir: path.join(cwd, ".state"), + isTTY: true, + env: { + ...process.env, + PRISMA_CLI_MOCK_FIXTURE_PATH: undefined, + }, + }); + + await runAppDeploy(context, "hello-world", { + projectRef: "proj_123", + framework: "hono", + }); + + expect(confirmPrompt).toHaveBeenCalledWith( + expect.objectContaining({ + message: "Install the Prisma Compute skill for this project?", + initialValue: true, + }), + ); + 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(deployApp).toHaveBeenCalled(); + }); + + it("prompts for Prisma skills before deploy setup failures", async () => { + const requireComputeAuth = vi + .fn() + .mockRejectedValue(new Error("auth setup failed")); + const confirmPrompt = vi.fn().mockResolvedValue(true); + const runAgentInstall = vi.fn().mockResolvedValue({ + command: "agent.install", + result: { + operation: "install", + skills: { status: "installed", command: [] }, + }, + warnings: [], + nextSteps: [], + }); + + vi.doMock("../src/lib/auth/guard", () => ({ + requireComputeAuth, + })); + vi.doMock("../src/controllers/agent", () => ({ + runAgentInstall, + })); + vi.doMock("../src/shell/prompt", async () => { + const actual = await vi.importActual< + typeof import("../src/shell/prompt") + >("../src/shell/prompt"); + return { + ...actual, + confirmPrompt, + }; + }); + + const { createTempCwd, createTestCommandContext } = await import( + "./helpers" + ); + const { runAppDeploy } = await import("../src/controllers/app"); + const cwd = await createTempCwd(); + const { context } = await createTestCommandContext({ + cwd, + stateDir: path.join(cwd, ".state"), + isTTY: true, + env: { + ...process.env, + PRISMA_CLI_MOCK_FIXTURE_PATH: undefined, + }, + }); + + await expect( + runAppDeploy(context, "hello-world", { + projectRef: "proj_123", + framework: "hono", + }), + ).rejects.toThrow("auth setup failed"); + + expect(confirmPrompt).toHaveBeenCalledWith( + expect.objectContaining({ + message: "Install the Prisma Compute skill for this project?", + }), + ); + 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(requireComputeAuth).toHaveBeenCalled(); + }); + + it("does not prompt again after agent setup is declined during deploy", async () => { + const confirmPrompt = vi.fn().mockResolvedValue(false); + const { context, deployApp, runAgentInstall, runAppDeploy } = + await setupAgentPromptDeployTest({ + confirmPrompt, + }); + + await runAppDeploy(context, "hello-world", { + projectRef: "proj_123", + framework: "hono", + }); + await runAppDeploy(context, "hello-world", { + projectRef: "proj_123", + framework: "hono", + }); + + expect(confirmPrompt).toHaveBeenCalledTimes(1); + expect(runAgentInstall).not.toHaveBeenCalled(); + expect(deployApp).toHaveBeenCalledTimes(2); + await expect( + context.stateStore.readAgentSetupPromptDismissedAt(), + ).resolves.toEqual(expect.any(String)); + }); + + it("cancels deploy when the agent setup prompt is canceled", async () => { + const confirmPrompt = vi + .fn() + .mockRejectedValue( + usageError( + "Interactive prompt canceled", + "The command was canceled before a confirmation was made.", + "Re-run the command and choose an option to continue.", + ), + ); + const { context, deployApp, runAgentInstall, runAppDeploy } = + await setupAgentPromptDeployTest({ + confirmPrompt, + }); + + await expect( + runAppDeploy(context, "hello-world", { + projectRef: "proj_123", + framework: "hono", + }), + ).rejects.toThrow("Interactive prompt canceled"); + + expect(confirmPrompt).toHaveBeenCalledTimes(1); + expect(runAgentInstall).not.toHaveBeenCalled(); + expect(deployApp).not.toHaveBeenCalled(); + await expect( + context.stateStore.readAgentSetupPromptDismissedAt(), + ).resolves.toBeNull(); + }); + it("interactive first deploy can create a new Project from an editable suggested name", async () => { const requireComputeAuth = vi.fn().mockResolvedValue(createProjectClient()); const createProject = vi.fn().mockResolvedValue({ @@ -3196,6 +3522,7 @@ describe("app controller", () => { await writePackageJson(cwd, { name: "suggested-name", }); + await writeAgentSetupInstalled(cwd); const stateDir = path.join(cwd, ".state"); const { context, stderr } = await createTestCommandContext({ cwd, @@ -3663,6 +3990,9 @@ describe("app controller", () => { PRISMA_CLI_MOCK_FIXTURE_PATH: undefined, }, }); + await context.stateStore.setAgentSetupPromptDismissedAt( + "2026-06-25T00:00:00.000Z", + ); const result = await runAppDeploy(context, undefined, { projectRef: "proj_123", diff --git a/packages/cli/tests/auth.test.ts b/packages/cli/tests/auth.test.ts index 2cee976..2f25cdd 100644 --- a/packages/cli/tests/auth.test.ts +++ b/packages/cli/tests/auth.test.ts @@ -53,6 +53,83 @@ describe("auth commands", () => { ); }); + it("suggests agent setup after login from a project directory", async () => { + const cwd = await createTempCwd(); + const stateDir = path.join(cwd, ".state"); + await writeFile( + path.join(cwd, "package.json"), + JSON.stringify({ packageManager: "pnpm@11.0.0" }, null, 2), + "utf8", + ); + + const result = await executeCli({ + argv: ["auth", "login", "--provider", "github", "--user", "usr_456"], + cwd, + stateDir, + fixturePath, + isTTY: true, + }); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe(""); + expect(result.stderr).toContain( + "Install Prisma skills for this project with pnpm dlx @prisma/cli@latest agent install.", + ); + }); + + it("suggests agent setup from a subdirectory under a compute config root", async () => { + const cwd = await createTempCwd(); + const appDir = path.join(cwd, "apps", "web"); + const stateDir = path.join(cwd, ".state"); + await mkdir(appDir, { recursive: true }); + await mkdir(path.join(cwd, ".git"), { recursive: true }); + await writeFile( + path.join(cwd, "prisma.compute.ts"), + 'export default { apps: { web: { root: "apps/web" } } };\n', + "utf8", + ); + await writeFile( + path.join(cwd, "package.json"), + JSON.stringify({ packageManager: "pnpm@11.0.0" }, null, 2), + "utf8", + ); + + const result = await executeCli({ + argv: ["auth", "login", "--provider", "github", "--user", "usr_456"], + cwd: appDir, + stateDir, + fixturePath, + isTTY: true, + }); + + expect(result.exitCode).toBe(0); + expect(result.stderr).toContain( + "Install Prisma skills for this project with pnpm dlx @prisma/cli@latest agent install.", + ); + }); + + it("does not suggest agent setup after login when Prisma skills are installed", async () => { + const cwd = await createTempCwd(); + const stateDir = path.join(cwd, ".state"); + await writeFile(path.join(cwd, "package.json"), "{}\n", "utf8"); + await writeFile( + path.join(cwd, "skills-lock.json"), + JSON.stringify({ sources: ["prisma/skills"] }), + "utf8", + ); + + const result = await executeCli({ + argv: ["auth", "login", "--provider", "github", "--user", "usr_456"], + cwd, + stateDir, + fixturePath, + isTTY: true, + }); + + expect(result.exitCode).toBe(0); + expect(result.stderr).not.toContain("@prisma/cli@latest agent install"); + }); + it("returns the stable signed-in JSON shape for whoami", async () => { const cwd = await createTempCwd(); const stateDir = path.join(cwd, ".state"); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dccd4c7..17d30b6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -44,6 +44,9 @@ importers: dotenv: specifier: ^17.4.2 version: 17.4.2 + execa: + specifier: ^9.6.1 + version: 9.6.1 magicast: specifier: ^0.5.3 version: 0.5.3 @@ -808,6 +811,13 @@ packages: cpu: [x64] os: [win32] + '@sec-ant/readable-stream@0.4.1': + resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==} + + '@sindresorhus/merge-streams@4.0.0': + resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} + engines: {node: '>=18'} + '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} @@ -1000,6 +1010,10 @@ packages: convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -1067,6 +1081,10 @@ packages: events-universal@1.0.1: resolution: {integrity: sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==} + execa@9.6.1: + resolution: {integrity: sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==} + engines: {node: ^18.19.0 || >=20.5.0} + expect-type@1.3.0: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} @@ -1092,6 +1110,10 @@ packages: picomatch: optional: true + figures@6.1.0: + resolution: {integrity: sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==} + engines: {node: '>=18'} + file-uri-to-path@1.0.0: resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==} @@ -1104,6 +1126,10 @@ packages: resolution: {integrity: sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==} engines: {node: '>=18'} + get-stream@9.0.1: + resolution: {integrity: sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==} + engines: {node: '>=18'} + get-tsconfig@4.14.0: resolution: {integrity: sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==} @@ -1121,6 +1147,10 @@ packages: resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} engines: {node: '>= 14'} + human-signals@8.0.1: + resolution: {integrity: sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==} + engines: {node: '>=18.18.0'} + import-without-cache@0.3.3: resolution: {integrity: sha512-bDxwDdF04gm550DfZHgffvlX+9kUlcz32UD0AeBTmVPFiWkrexF2XVmiuFFbDhiFuP8fQkrkvI2KdSNPYWAXkQ==} engines: {node: '>=20.19.0'} @@ -1139,10 +1169,25 @@ packages: engines: {node: '>=14.16'} hasBin: true + is-plain-obj@4.1.0: + resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} + engines: {node: '>=12'} + + is-stream@4.0.1: + resolution: {integrity: sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==} + engines: {node: '>=18'} + + is-unicode-supported@2.1.0: + resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==} + engines: {node: '>=18'} + is-wsl@3.1.1: resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==} engines: {node: '>=16'} + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + jiti@2.7.0: resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} hasBin: true @@ -1200,6 +1245,10 @@ packages: engines: {node: ^18.17.0 || >=20.5.0} hasBin: true + npm-run-path@6.0.0: + resolution: {integrity: sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==} + engines: {node: '>=18'} + obug@2.1.1: resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} @@ -1217,6 +1266,18 @@ packages: resolution: {integrity: sha512-Ux1J4NUqC6tZayBqLN1kUlDAEvLiQlli/53sSddU4IN+h+3xxnv2HmRSMpVSvr1hvJzotfMs3ERvETGK+f4OwA==} engines: {node: '>= 4.0'} + parse-ms@4.0.0: + resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==} + engines: {node: '>=18'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-key@4.0.0: + resolution: {integrity: sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==} + engines: {node: '>=12'} + path-scurry@2.0.2: resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} engines: {node: 18 || 20 || >=22} @@ -1243,6 +1304,10 @@ packages: resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==} engines: {node: '>=20'} + pretty-ms@9.3.0: + resolution: {integrity: sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==} + engines: {node: '>=18'} + quansync@1.0.0: resolution: {integrity: sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA==} @@ -1296,9 +1361,21 @@ packages: engines: {node: '>=10'} hasBin: true + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + sisteransi@1.0.5: resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} @@ -1323,6 +1400,10 @@ packages: resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} engines: {node: '>=12'} + strip-final-newline@4.0.0: + resolution: {integrity: sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==} + engines: {node: '>=18'} + tar-stream@3.2.0: resolution: {integrity: sha512-ojzvCvVaNp6aOTFmG7jaRD0meowIAuPc3cMMhSgKiVWws1GyHbGd/xvnyuRKcKlMpt3qvxx6r0hreCNITP9hIg==} @@ -1408,6 +1489,10 @@ packages: undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + unicorn-magic@0.3.0: + resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==} + engines: {node: '>=18'} + unrun@0.2.39: resolution: {integrity: sha512-h9FxYVpztY/wwq+bauLOh6Y3CWu2IVeRLq5lxzneBiIU9Tn86OGp9xiQrGhnYspAmg5dzdY0Cc8+Y70kuTARCg==} engines: {node: '>=20.19.0'} @@ -1505,6 +1590,11 @@ packages: whatwg-url@5.0.0: resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + why-is-node-running@2.3.0: resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} engines: {node: '>=8'} @@ -1547,6 +1637,10 @@ packages: engines: {node: '>= 14.6'} hasBin: true + yoctocolors@2.1.2: + resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==} + engines: {node: '>=18'} + snapshots: '@babel/generator@8.0.0-rc.3': @@ -2009,6 +2103,10 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.62.2': optional: true + '@sec-ant/readable-stream@0.4.1': {} + + '@sindresorhus/merge-streams@4.0.0': {} + '@standard-schema/spec@1.1.0': {} '@tybys/wasm-util@0.10.2': @@ -2184,6 +2282,12 @@ snapshots: convert-source-map@2.0.0: {} + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + debug@4.4.3: dependencies: ms: 2.1.3 @@ -2279,6 +2383,21 @@ snapshots: transitivePeerDependencies: - bare-abort-controller + execa@9.6.1: + dependencies: + '@sindresorhus/merge-streams': 4.0.0 + cross-spawn: 7.0.6 + figures: 6.1.0 + get-stream: 9.0.1 + human-signals: 8.0.1 + is-plain-obj: 4.1.0 + is-stream: 4.0.1 + npm-run-path: 6.0.0 + pretty-ms: 9.3.0 + signal-exit: 4.1.0 + strip-final-newline: 4.0.0 + yoctocolors: 2.1.2 + expect-type@1.3.0: {} fast-fifo@1.3.2: {} @@ -2297,6 +2416,10 @@ snapshots: optionalDependencies: picomatch: 4.0.4 + figures@6.1.0: + dependencies: + is-unicode-supported: 2.1.0 + file-uri-to-path@1.0.0: {} fsevents@2.3.3: @@ -2304,6 +2427,11 @@ snapshots: get-east-asian-width@1.6.0: {} + get-stream@9.0.1: + dependencies: + '@sec-ant/readable-stream': 0.4.1 + is-stream: 4.0.1 + get-tsconfig@4.14.0: dependencies: resolve-pkg-maps: 1.0.0 @@ -2325,6 +2453,8 @@ snapshots: transitivePeerDependencies: - supports-color + human-signals@8.0.1: {} + import-without-cache@0.3.3: {} is-docker@3.0.0: {} @@ -2335,10 +2465,18 @@ snapshots: dependencies: is-docker: 3.0.0 + is-plain-obj@4.1.0: {} + + is-stream@4.0.1: {} + + is-unicode-supported@2.1.0: {} + is-wsl@3.1.1: dependencies: is-inside-container: 1.0.0 + isexe@2.0.0: {} + jiti@2.7.0: {} jsesc@3.1.0: {} @@ -2379,6 +2517,11 @@ snapshots: dependencies: abbrev: 3.0.1 + npm-run-path@6.0.0: + dependencies: + path-key: 4.0.0 + unicorn-magic: 0.3.0 + obug@2.1.1: {} open@11.0.0: @@ -2400,6 +2543,12 @@ snapshots: optionalDependencies: fsevents: 2.3.3 + parse-ms@4.0.0: {} + + path-key@3.1.1: {} + + path-key@4.0.0: {} + path-scurry@2.0.2: dependencies: lru-cache: 11.5.1 @@ -2421,6 +2570,10 @@ snapshots: powershell-utils@0.1.0: {} + pretty-ms@9.3.0: + dependencies: + parse-ms: 4.0.0 + quansync@1.0.0: {} resolve-from@5.0.0: {} @@ -2503,8 +2656,16 @@ snapshots: semver@7.8.5: {} + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + siginfo@2.0.0: {} + signal-exit@4.1.0: {} + sisteransi@1.0.5: {} source-map-js@1.2.1: {} @@ -2531,6 +2692,8 @@ snapshots: dependencies: ansi-regex: 6.2.2 + strip-final-newline@4.0.0: {} + tar-stream@3.2.0: dependencies: b4a: 1.8.1 @@ -2625,6 +2788,8 @@ snapshots: undici-types@6.21.0: {} + unicorn-magic@0.3.0: {} + unrun@0.2.39: dependencies: rolldown: 1.0.0-rc.17 @@ -2678,6 +2843,10 @@ snapshots: tr46: 0.0.3 webidl-conversions: 3.0.1 + which@2.0.2: + dependencies: + isexe: 2.0.0 + why-is-node-running@2.3.0: dependencies: siginfo: 2.0.0 @@ -2712,3 +2881,5 @@ snapshots: yaml@2.9.0: optional: true + + yoctocolors@2.1.2: {}