diff --git a/docs/product/agent-install.md b/docs/product/agent-install.md new file mode 100644 index 00000000..1631dbef --- /dev/null +++ b/docs/product/agent-install.md @@ -0,0 +1,38 @@ +# Agent install + +`prisma agent install` adds the Prisma remote MCP connection and its enrollment +skill to the current project. It does not sign in, create an API token, or use +the CLI's human credentials. OAuth sign-in happens in the MCP client. + +The default configures Codex, Claude Code, Pi, and Cursor. `--client codex`, +`--client claude`, `--client pi`, or `--client cursor` installs only that client. Codex uses +`.codex/config.toml` and `.agents/skills`; Claude Code uses `.mcp.json` and +`.claude/skills`; Pi uses `.pi/mcp.json` and `.pi/skills`; Cursor uses `.cursor/mcp.json` and `.cursor/skills`. + +`--url` selects another HTTPS MCP endpoint, for example a preview server. +The production default is `https://mcp.prisma.io/mcp`. No authorization headers +are written. The client handles credential storage and refresh. + +The command preserves other MCP servers, comments, and unrelated configuration. +An existing Prisma connection with another URL, headers, or different transport +is refused. Invalid configuration, a symlink target, and an enrollment skill +not owned by Prisma are also refused. It checks every target before writing any +file. Repeating the command with the same endpoint is safe. Codex configurations that +use an inline `mcp_servers` table must be converted to normal TOML tables before +installation. The command validates the proposed configuration before writing it. + +Human output lists the configured clients and the next sign-in step. JSON output +returns the endpoint, clients, and changed file paths. Each file is written to a temporary file in the same directory and renamed only +after the write succeeds. Existing file permissions are preserved. Some client +files can remain unchanged if a later file fails; the error says to correct the filesystem problem and +rerun the command. The built-binary filesystem test proves the installation +without requiring a platform credential. + +New MCP connections enroll a persistent agent with access to the sponsor's +default workspace. The sponsor can change the workspace list in Console. The +skill prefers native MCP Events for approvals when the client supports them, +and otherwise uses a bounded status check every five seconds. + +Pi requires a version with native remote MCP and OAuth support. Run `pi mcp login prisma` +after trusting the project configuration, then `/reload` in an existing session. +See [Pi MCP setup](https://pi.dev/docs/latest/mcp). diff --git a/docs/reference/error-reference.md b/docs/reference/error-reference.md index efbf270c..76b7f4ba 100644 --- a/docs/reference/error-reference.md +++ b/docs/reference/error-reference.md @@ -565,3 +565,20 @@ A warn diagnostic from the skills sync (`skills sync`, and the sync step of `ini ### SKILLS.VERSION_CONFLICT A warn diagnostic from the skills sync (`skills sync`, and the sync step of `init`): workspace members install different versions of the same skill-bearing Prisma package, so the skills for the highest version were installed and the members pinning a lower version get a skill describing a version they did not install. The nextAction is to pin one version of the package across the workspace. Meta: none. + + +### CLI.AGENT_INSTALL_CONFIG + +The MCP configuration could not be parsed. Fix the named file and rerun +`prisma agent install`. + +### CLI.AGENT_INSTALL_CONFLICT + +An existing Prisma MCP connection differs from the requested endpoint, a target +is a symbolic link, or the enrollment skill belongs to the user. Review or move +the named file before rerunning the installer. Other clients remain unchanged. + +### CLI.AGENT_INSTALL_IO + +The installer could not read or write a project file. Check file permissions +and rerun `prisma agent install`. diff --git a/packages/cli/e2e/agent-install.e2e.ts b/packages/cli/e2e/agent-install.e2e.ts new file mode 100644 index 00000000..92635619 --- /dev/null +++ b/packages/cli/e2e/agent-install.e2e.ts @@ -0,0 +1,190 @@ +// biome-ignore-all lint/performance/noAwaitInLoops: each assertion checks an installed client directory. +import { execFile } from "node:child_process"; +import { + chmod, + mkdir, + mkdtemp, + readFile, + rm, + stat, + symlink, + writeFile, +} from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { promisify } from "node:util"; +import { parse as parseToml } from "smol-toml"; +import { afterEach, describe, expect, it } from "vitest"; +import { CLI_BINARY } from "./harness"; + +const run = promisify(execFile); +const roots: string[] = []; +async function project() { + const root = await mkdtemp(path.join(os.tmpdir(), "prisma-agent-install-")); + roots.push(root); + return root; +} +async function install(cwd: string, args: string[] = []) { + return run( + process.execPath, + [CLI_BINARY, "agent", "install", "--json", ...args], + { + cwd, + env: { + ...process.env, + HOME: cwd, + PRISMA_DISABLE_TELEMETRY: "1", + DO_NOT_TRACK: "1", + }, + }, + ); +} +afterEach(async () => { + await Promise.all( + roots.splice(0).map((root) => rm(root, { recursive: true, force: true })), + ); +}); + +describe("agent install through the built binary", () => { + it("installs all clients without a Prisma package or credential and is idempotent", async () => { + const cwd = await project(); + await install(cwd); + const codex = parseToml( + await readFile(path.join(cwd, ".codex/config.toml"), "utf8"), + ); + expect(codex).toEqual({ + mcp_servers: { prisma: { url: "https://mcp.prisma.io/mcp" } }, + }); + expect( + JSON.parse(await readFile(path.join(cwd, ".mcp.json"), "utf8")), + ).toEqual({ + mcpServers: { + prisma: { type: "http", url: "https://mcp.prisma.io/mcp" }, + }, + }); + for (const dir of [".agents", ".claude", ".pi", ".cursor"]) { + expect( + await readFile( + path.join(cwd, dir, "skills/prisma-agent-enrollment/SKILL.md"), + "utf8", + ), + ).toContain("prisma.approval.resolved"); + } + const before = await readFile(path.join(cwd, ".codex/config.toml"), "utf8"); + await install(cwd); + expect(await readFile(path.join(cwd, ".codex/config.toml"), "utf8")).toBe( + before, + ); + }); + it("preserves other servers and comments when installing one client", async () => { + const cwd = await project(); + await mkdir(path.join(cwd, ".codex")); + await writeFile( + path.join(cwd, ".codex/config.toml"), + '# Project settings\n[mcp_servers.docs]\nurl = "https://example.com/mcp"\n', + ); + await install(cwd, ["--client", "codex"]); + expect( + await readFile(path.join(cwd, ".codex/config.toml"), "utf8"), + ).toContain("# Project settings"); + expect( + parseToml(await readFile(path.join(cwd, ".codex/config.toml"), "utf8")), + ).toMatchObject({ + mcp_servers: { + docs: { url: "https://example.com/mcp" }, + prisma: { url: "https://mcp.prisma.io/mcp" }, + }, + }); + await expect(readFile(path.join(cwd, ".mcp.json"))).rejects.toMatchObject({ + code: "ENOENT", + }); + }); + it("refuses conflicting credentials before writing any client files", async () => { + const cwd = await project(); + const content = JSON.stringify({ + mcpServers: { + prisma: { + url: "https://mcp.prisma.io/mcp", + headers: { Authorization: "test-placeholder" }, + }, + }, + }); + await writeFile(path.join(cwd, ".mcp.json"), content); + await expect(install(cwd)).rejects.toMatchObject({ code: 2 }); + expect(await readFile(path.join(cwd, ".mcp.json"), "utf8")).toBe(content); + await expect( + readFile(path.join(cwd, ".codex/config.toml")), + ).rejects.toMatchObject({ code: "ENOENT" }); + }); + it("refuses user-owned skills and linked directories", async () => { + const cwd = await project(); + await mkdir(path.join(cwd, ".agents/skills/prisma-agent-enrollment"), { + recursive: true, + }); + await writeFile( + path.join(cwd, ".agents/skills/prisma-agent-enrollment/SKILL.md"), + "My instructions", + ); + await expect(install(cwd, ["--client", "codex"])).rejects.toMatchObject({ + code: 2, + }); + await rm(path.join(cwd, ".agents"), { recursive: true }); + const other = await project(); + await symlink(other, path.join(cwd, ".codex")); + await expect(install(cwd, ["--client", "codex"])).rejects.toMatchObject({ + code: 2, + }); + await expect( + readFile(path.join(other, "config.toml")), + ).rejects.toMatchObject({ code: "ENOENT" }); + }); +}); + +it("refuses an inline Codex MCP table without changing any file", async () => { + const cwd = await project(); + await mkdir(path.join(cwd, `.codex`)); + const content = `mcp_servers = { docs = { url = "https://example.com/mcp" } }\n`; + await writeFile(path.join(cwd, `.codex/config.toml`), content); + await expect(install(cwd)).rejects.toMatchObject({ code: 2 }); + expect(await readFile(path.join(cwd, `.codex/config.toml`), `utf8`)).toBe( + content, + ); + await expect(readFile(path.join(cwd, `.mcp.json`))).rejects.toMatchObject({ + code: `ENOENT`, + }); +}); + +it("installs Pi with a preview endpoint and preserves existing file permissions", async () => { + const cwd = await project(); + await mkdir(path.join(cwd, `.pi`)); + const target = path.join(cwd, `.pi/mcp.json`); + await writeFile( + target, + `{"mcpServers":{"docs":{"url":"https://example.com/mcp"}}}`, + ); + await chmod(target, 0o600); + await install(cwd, [ + `--client`, + `pi`, + `--url`, + `https://preview.example.com/mcp`, + ]); + expect(JSON.parse(await readFile(target, `utf8`))).toMatchObject({ + mcpServers: { + prisma: { url: `https://preview.example.com/mcp` }, + docs: { url: `https://example.com/mcp` }, + }, + }); + expect((await stat(target)).mode & 0o777).toBe(0o600); + const skill = await readFile( + path.join(cwd, `.pi/skills/prisma-agent-enrollment/SKILL.md`), + `utf8`, + ); + expect(skill).toContain(`configured Prisma MCP connection's OAuth sign-in`); + await install(cwd, [ + `--client`, + `pi`, + `--url`, + `https://preview.example.com/mcp`, + ]); +}); diff --git a/packages/cli/package.json b/packages/cli/package.json index 756db22a..db00a389 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -59,7 +59,9 @@ "cross-spawn": "^7.0.6", "dotenv": "^17.4.2", "execa": "^9.6.1", - "open": "^11.0.0" + "jsonc-parser": "^3.3.1", + "open": "^11.0.0", + "smol-toml": "^1.9.0" }, "devDependencies": { "@prisma/composer": "0.25.0", @@ -67,8 +69,8 @@ "@repo/cli-conformance": "workspace:8.0.0-rc.19", "@repo/cli-telemetry": "workspace:8.0.0-rc.19", "@repo/tsconfig": "workspace:8.0.0-rc.19", - "@types/node": "^22.19.19", "@types/cross-spawn": "^6.0.6", + "@types/node": "^22.19.19", "tsdown": "^0.21.10", "tsx": "^4.22.4", "typescript": "^6.0.3", diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 7afbb9cc..04708f94 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -10,6 +10,7 @@ import { import { createComposerFamily } from "@prisma/composer-cli/family"; import { ormCommandFamily as ormToolchainFamily } from "@prisma/orm-toolchain/cli"; import { CLI_DOCS_URL, CLI_NAME, DOCS_ERRORS_BASE_URL } from "./cli-name"; +import { agentInstallCommand } from "./commands/agent/install"; import { authLoginCommand } from "./commands/auth/login"; import { authLogoutCommand } from "./commands/auth/logout"; import { authWhoamiCommand } from "./commands/auth/whoami"; @@ -161,6 +162,11 @@ export { skillsCommandFamily }; * text that belongs to them; both halves are spread in below. */ const telemetry = telemetryCommandGroup({ docsUrl: CLI_DOCS_URL }); +export const agentCommandFamily: CommandFamily = defineCommandFamily({ + docsBaseUrl: DOCS_ERRORS_BASE_URL, + commands: { install: agentInstallCommand }, +}); + export const cliGroups: Readonly< Record< string, @@ -346,6 +352,11 @@ export const cliGroups: Readonly< "A ref is a named pointer to a contract, letting commands target a contract by a stable name. Set, list, and delete refs here.", }, orm: { brief: "Initialize a Prisma ORM project" }, + agent: { + brief: "Connect an AI agent to Prisma", + description: + "Install the Prisma MCP connection and enrollment skill in this project. The agent client handles sign-in and secure credential storage.", + }, skills: { brief: "Manage Prisma skills for AI coding agents. Sync and list the instruction files", @@ -444,6 +455,7 @@ export const mountedCommands: Readonly> = { "migration ref set": ormCommandFamily.commands["migration ref set"], // Local utilities: no owning package, no config section, no API. init: initCommand, + "agent install": agentInstallCommand, "skills sync": skillsCommandFamily.commands.sync, "skills list": skillsCommandFamily.commands.list, feedback: feedbackCommand, @@ -460,6 +472,7 @@ export function buildCli(): Cli { composerCommandFamily, ormCommandFamily, skillsCommandFamily, + agentCommandFamily, ], groups: cliGroups, commands: mountedCommands, diff --git a/packages/cli/src/commands/agent/install.ts b/packages/cli/src/commands/agent/install.ts new file mode 100644 index 00000000..46ef6806 --- /dev/null +++ b/packages/cli/src/commands/agent/install.ts @@ -0,0 +1,322 @@ +// biome-ignore-all lint/performance/noAwaitInLoops: parent checks and file writes must finish in order. +import { randomUUID } from "node:crypto"; +import { lstat, mkdir, open, readFile, rename, rm } from "node:fs/promises"; +import path from "node:path"; +import { defineCommand, flag } from "@prisma/cli-engine"; +import { CliStructuredError, notOk, ok } from "@prisma/cli-engine/protocol"; +import { Result } from "better-result"; +import { + applyEdits, + modify, + type ParseError, + parse as parseJson, +} from "jsonc-parser"; +import { parse as parseToml } from "smol-toml"; +import { parseSkillStamp } from "../../lib/skills/frontmatter"; + +const CLIENTS = { + codex: { config: ".codex/config.toml", skills: ".agents/skills" }, + claude: { config: ".mcp.json", skills: ".claude/skills" }, + pi: { config: ".pi/mcp.json", skills: ".pi/skills" }, + cursor: { config: ".cursor/mcp.json", skills: ".cursor/skills" }, +} as const; + +/** Reads only regular project files, including their parent directories. */ +async function readInstallTarget(cwd: string, relative: string) { + return Result.tryPromise({ + try: async () => { + const parts = relative.split(`/`); + for (let i = 1; i <= parts.length; i++) { + const target = path.join(cwd, ...parts.slice(0, i)); + const info = await lstat(target).catch( + (error: NodeJS.ErrnoException) => { + if (error.code === "ENOENT") return null; + throw error; + }, + ); + if (info?.isSymbolicLink()) + throw new CliStructuredError( + "CLI.AGENT_INSTALL_CONFLICT", + `${relative} contains a symbolic link. Choose a project-local file before installing.`, + ); + if (!info) return null; + } + return await readFile(path.join(cwd, relative), "utf8"); + }, + catch: (error) => + error instanceof CliStructuredError + ? error + : new CliStructuredError( + "CLI.AGENT_INSTALL_IO", + `Could not read ${relative}. Check its permissions and rerun agent install.`, + ), + }); +} + +function parseMcpConfiguration( + client: keyof typeof CLIENTS, + original: string | null, + file: string, +) { + return Result.try({ + try: () => { + const errors: ParseError[] = []; + let document: unknown = {}; + if (original) + document = + client === "codex" + ? parseToml(original) + : parseJson(original, errors); + if ( + errors.length || + !document || + typeof document !== `object` || + Array.isArray(document) + ) + throw new TypeError(`Invalid MCP configuration`); + const key = client === `codex` ? `mcp_servers` : `mcpServers`; + const servers = (document as Record)[key]; + if (servers === undefined) return undefined; + if (!servers || typeof servers !== `object` || Array.isArray(servers)) + throw new TypeError(`Invalid MCP servers`); + return (servers as Record).prisma; + }, + catch: () => + new CliStructuredError( + `CLI.AGENT_INSTALL_CONFIG`, + `Could not parse ${file}. Fix the configuration and rerun agent install.`, + ), + }); +} + +function matchesMcpConnection( + existing: unknown, + expected: Record, +): boolean { + if (!existing || typeof existing !== "object" || Array.isArray(existing)) + return false; + const server = existing as Record; + return ( + Object.keys(server).every((field) => field in expected) && + Object.entries(expected).every(([field, value]) => server[field] === value) + ); +} + +async function prepareMcpConfiguration( + cwd: string, + client: keyof typeof CLIENTS, + url: string, +) { + const changes: Array<{ file: string; content: string }> = []; + const { config } = CLIENTS[client]; + const original = await readInstallTarget(cwd, config); + if (original.isErr()) return Result.err(original.error); + const parsed = parseMcpConfiguration(client, original.value, config); + if (parsed.isErr()) return Result.err(parsed.error); + const existing = parsed.value; + const key = client === "codex" ? "mcp_servers" : "mcpServers"; + if (existing !== undefined) { + const expected = client === "claude" ? { type: "http", url } : { url }; + if (!matchesMcpConnection(existing, expected)) + return Result.err( + new CliStructuredError( + "CLI.AGENT_INSTALL_CONFLICT", + `The Prisma connection in ${config} differs from this installation. Review it before rerunning agent install.`, + ), + ); + return Result.ok(changes); + } + const server = client === "claude" ? { type: "http", url } : { url }; + const originalText = original.value ?? "{}\n"; + const content = + client === "codex" + ? `${original.value ?? ""}\n[mcp_servers.prisma]\nurl = ${JSON.stringify(url)}\n` + : applyEdits( + originalText, + modify(originalText, [key, "prisma"], server, { + formattingOptions: { + insertSpaces: true, + tabSize: 2, + eol: "\n", + }, + }), + ); + const proposed = parseMcpConfiguration(client, content, config); + if (proposed.isErr()) + return Result.err( + new CliStructuredError( + `CLI.AGENT_INSTALL_CONFIG`, + `Could not extend ${config} safely. Convert inline MCP tables to normal TOML tables and rerun agent install.`, + ), + ); + changes.push({ file: config, content }); + return Result.ok(changes); +} + +async function prepareClient( + cwd: string, + client: keyof typeof CLIENTS, + url: string, + enrollmentSkill: string, +) { + const configuration = await prepareMcpConfiguration(cwd, client, url); + if (configuration.isErr()) return Result.err(configuration.error); + const changes = configuration.value; + const skills = CLIENTS[client].skills; + const skillFile = `${skills}/prisma-agent-enrollment/SKILL.md`; + const skill = await readInstallTarget(cwd, skillFile); + if (skill.isErr()) return Result.err(skill.error); + if (skill.value !== null && parseSkillStamp(skill.value).library !== "prisma") + return Result.err( + new CliStructuredError( + "CLI.AGENT_INSTALL_CONFLICT", + `${skillFile} is not a Prisma-managed skill. Move it before rerunning agent install.`, + ), + ); + if (skill.value !== enrollmentSkill) + changes.push({ file: skillFile, content: enrollmentSkill }); + + return Result.ok(changes); +} + +export const agentInstallCommand = defineCommand({ + help: { + summary: + "Install the Prisma MCP connection and enrollment skill in this project", + description: + "Run this before connecting your AI agent to Prisma. Your MCP client handles sign-in and stores the credential. The command preserves other MCP servers and refuses conflicting Prisma configuration. Restart your client after installation, then ask it to connect to Prisma.", + examples: ["agent install", "agent install --client codex"], + }, + args: { + flags: { + client: flag.enum({ + brief: "Configure one MCP client, or all supported clients", + values: ["all", "codex", "claude", "pi", "cursor"], + default: "all", + }), + url: flag.string({ + brief: "Use another HTTPS MCP endpoint, such as a preview server", + default: "https://mcp.prisma.io/mcp", + }), + }, + }, + handler: async (args, ctx) => { + const endpoint = Result.try({ + try: () => new URL(args.flags.url ?? "https://mcp.prisma.io/mcp"), + catch: () => + new CliStructuredError( + "CLI.INVALID_ARGUMENTS", + "--url must be an HTTPS MCP URL without credentials or a fragment.", + ), + }); + if (endpoint.isErr()) return notOk(endpoint.error); + if ( + endpoint.value.protocol !== "https:" || + endpoint.value.username || + endpoint.value.password || + endpoint.value.hash + ) + return notOk( + new CliStructuredError( + "CLI.INVALID_ARGUMENTS", + "--url must be an HTTPS MCP URL without credentials or a fragment.", + ), + ); + const url = endpoint.value.href; + const clients = + args.flags.client === "all" || !args.flags.client + ? (Object.keys(CLIENTS) as Array) + : [args.flags.client]; + const enrollmentSkill = import.meta.url.endsWith(`.ts`) + ? await readFile( + new URL( + `../../../../../skills/prisma-agent-enrollment/SKILL.md`, + import.meta.url, + ), + `utf8`, + ) + : ( + await import( + `../../../../../skills/prisma-agent-enrollment/SKILL.md?raw` + ) + ).default; + const changes: Array<{ file: string; content: string }> = []; + const prepared = await Promise.all( + clients.map((client) => + prepareClient(ctx.cwd, client, url, enrollmentSkill), + ), + ); + for (const result of prepared) { + if (result.isErr()) return notOk(result.error); + changes.push(...result.value); + } + const written = await Result.tryPromise({ + try: async () => { + for (const change of changes) { + const target = path.join(ctx.cwd, change.file); + await mkdir(path.dirname(target), { recursive: true }); + const info = await lstat(target).catch( + (error: NodeJS.ErrnoException) => { + if (error.code === `ENOENT`) return null; + throw error; + }, + ); + const temporary = `${target}.${randomUUID()}.tmp`; + try { + await using file = await open(temporary, `wx`, info?.mode ?? 0o644); + await file.writeFile(change.content, `utf8`); + if (info) await file.chmod(info.mode); + await file.sync(); + await file.close(); + await rename(temporary, target); + } finally { + await rm(temporary, { force: true }); + } + } + }, + catch: () => + new CliStructuredError( + "CLI.AGENT_INSTALL_IO", + "Could not finish installing the agent connection. Check project permissions and rerun agent install.", + ), + }); + if (written.isErr()) return notOk(written.error); + const data = { + url, + clients, + changedFiles: changes.map((change) => change.file), + }; + return ok( + ctx.present( + { data }, + { + json: () => data, + stdout: () => [], + human: () => [ + { + kind: "summary", + status: "ok", + text: changes.length + ? "Installed the Prisma agent connection." + : "The Prisma agent connection is already installed.", + }, + { + kind: "fields", + rows: [ + { label: "clients", value: clients.join(", ") }, + { label: "MCP", value: url }, + ], + }, + ], + next: () => [ + { + kind: "user-choice", + label: + "Restart your MCP client, then ask your agent to connect to Prisma and finish signing in.", + }, + ], + }, + ), + ); + }, +}); diff --git a/packages/cli/src/markdown.d.ts b/packages/cli/src/markdown.d.ts new file mode 100644 index 00000000..83a0b1f3 --- /dev/null +++ b/packages/cli/src/markdown.d.ts @@ -0,0 +1,4 @@ +declare module "*.md?raw" { + const content: string; + export default content; +} diff --git a/packages/cli/tests/e2e-coverage.test.ts b/packages/cli/tests/e2e-coverage.test.ts index a7e5f58a..b1efc838 100644 --- a/packages/cli/tests/e2e-coverage.test.ts +++ b/packages/cli/tests/e2e-coverage.test.ts @@ -51,6 +51,8 @@ const ORM_FAMILY_REASON = "ORM command: no management API involved. Real e2e lives in prisma/prisma (R7); the shell proves composition in orm-mount.test.ts (R8)."; const EXCLUSIONS: Readonly> = { + "agent install": + "Writes project-local MCP configuration and a packaged enrollment skill. No management API is involved; e2e/agent-install.e2e.ts verifies the built binary without credentials.", "contract emit": ORM_FAMILY_REASON, "contract infer": ORM_FAMILY_REASON, "db init": ORM_FAMILY_REASON, diff --git a/packages/cli/tests/mount-coverage.test.ts b/packages/cli/tests/mount-coverage.test.ts index d1275329..b1e7d1bb 100644 --- a/packages/cli/tests/mount-coverage.test.ts +++ b/packages/cli/tests/mount-coverage.test.ts @@ -20,6 +20,7 @@ import { defineCommand, telemetryCommandGroup } from "@prisma/cli-engine"; import { ok } from "@prisma/cli-engine/protocol"; import { describe, expect, it } from "vitest"; import { + agentCommandFamily, cliGroups, composerCommandFamily, mountedCommands, @@ -75,6 +76,7 @@ function unownedMountPaths( * adding its path here. */ const EXPECTED_MOUNT_PATHS: readonly string[] = [ + "agent install", "auth login", "auth logout", "auth whoami", @@ -165,6 +167,7 @@ const EXPECTED_MOUNT_PATHS: readonly string[] = [ ]; const MOUNTED_FAMILIES = { + agent: agentCommandFamily, platform: platformCommandFamily, composer: composerCommandFamily, orm: ormCommandFamily, diff --git a/packages/cli/tsdown.config.ts b/packages/cli/tsdown.config.ts index 79d8d991..da53a8c6 100644 --- a/packages/cli/tsdown.config.ts +++ b/packages/cli/tsdown.config.ts @@ -1,3 +1,4 @@ +import { dirname, resolve } from "node:path"; import { defineConfig } from "tsdown"; export default defineConfig([ @@ -14,6 +15,17 @@ export default defineConfig([ }, format: ["esm"], clean: true, + inputOptions: { moduleTypes: { ".md": "text" } }, + plugins: [ + { + name: "markdown-text", + resolveId(source, importer) { + if (source.endsWith(`.md?raw`) && importer) + return resolve(dirname(importer), source.slice(0, -4)); + return null; + }, + }, + ], shims: true, fixedExtension: false, deps: { diff --git a/packages/prisma/package.json b/packages/prisma/package.json index 15d4ebc1..c84f934b 100644 --- a/packages/prisma/package.json +++ b/packages/prisma/package.json @@ -60,7 +60,9 @@ "cross-spawn": "^7.0.6", "dotenv": "^17.4.2", "execa": "^9.6.1", - "open": "^11.0.0" + "jsonc-parser": "^3.3.1", + "open": "^11.0.0", + "smol-toml": "^1.9.0" }, "devDependencies": { "@prisma/cli": "workspace:8.0.0-rc.19", diff --git a/packages/prisma/tsconfig.json b/packages/prisma/tsconfig.json index a589c6a4..aff28fe4 100644 --- a/packages/prisma/tsconfig.json +++ b/packages/prisma/tsconfig.json @@ -1,4 +1,4 @@ { "extends": "@repo/tsconfig/base.json", - "include": ["src/**/*.ts"] + "include": ["src/**/*.ts", "../cli/src/markdown.d.ts"] } diff --git a/packages/prisma/tsdown.config.ts b/packages/prisma/tsdown.config.ts index 3f7a7400..b78175af 100644 --- a/packages/prisma/tsdown.config.ts +++ b/packages/prisma/tsdown.config.ts @@ -1,3 +1,4 @@ +import { dirname, resolve } from "node:path"; import { defineConfig } from "tsdown"; export default defineConfig([ @@ -14,6 +15,17 @@ export default defineConfig([ }, format: ["esm"], clean: true, + inputOptions: { moduleTypes: { ".md": "text" } }, + plugins: [ + { + name: "markdown-text", + resolveId(source, importer) { + if (source.endsWith(`.md?raw`) && importer) + return resolve(dirname(importer), source.slice(0, -4)); + return null; + }, + }, + ], shims: true, fixedExtension: false, deps: { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6bfc84b7..0f278c72 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -56,9 +56,15 @@ importers: execa: specifier: ^9.6.1 version: 9.6.1 + jsonc-parser: + specifier: ^3.3.1 + version: 3.3.1 open: specifier: ^11.0.0 version: 11.0.0 + smol-toml: + specifier: ^1.9.0 + version: 1.9.0 devDependencies: '@prisma/composer': specifier: 0.25.0 @@ -239,9 +245,15 @@ importers: execa: specifier: ^9.6.1 version: 9.6.1 + jsonc-parser: + specifier: ^3.3.1 + version: 3.3.1 open: specifier: ^11.0.0 version: 11.0.0 + smol-toml: + specifier: ^1.9.0 + version: 1.9.0 devDependencies: '@prisma/cli': specifier: workspace:8.0.0-rc.19 @@ -2743,6 +2755,10 @@ packages: sisteransi@1.0.5: resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + smol-toml@1.9.0: + resolution: {integrity: sha512-hpd+HLON7HdZXqYchMM/+LaTTbdK0AU3NngIJ4KVyWbY9bfQqdL9cD+4yf6dUoU2Ap4VsU0JkQi6FxAI1B2mXQ==} + engines: {node: '>= 18'} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -5415,6 +5431,8 @@ snapshots: sisteransi@1.0.5: {} + smol-toml@1.9.0: {} + source-map-js@1.2.1: {} sparse-bitfield@3.0.3: diff --git a/skills/prisma-agent-enrollment/SKILL.md b/skills/prisma-agent-enrollment/SKILL.md new file mode 100644 index 00000000..0831f487 --- /dev/null +++ b/skills/prisma-agent-enrollment/SKILL.md @@ -0,0 +1,87 @@ +--- +name: prisma-agent-enrollment +metadata: + library: "prisma" + library_version: "8.0.0-rc.19" + version: 2026.9.30 +description: >- + Connect an AI agent to Prisma through the Prisma MCP server. Use for agent + enrollment, workspace access, approval requests, or any Prisma Platform + operation performed by an enrolled agent. +--- + +# Connect to Prisma + +Use the Prisma MCP connection for cloud operations. The MCP client owns OAuth +sign-in and credential storage. Do not request a person's API token, read the +CLI's human credentials, put credentials in chat, or call the Management API +directly to bypass this connection's policy. + +## Install and sign in + +1. If the Prisma MCP connection is missing, run `prisma agent install` in the + project. Use `--client codex`, `--client claude`, `--client pi`, or `--client cursor` to + configure only that client. Restart the client if it does not reload MCP + configuration automatically. +2. Use the configured Prisma MCP connection's OAuth sign-in. The installer defaults + to `https://mcp.prisma.io/mcp`; `--url` can select a preview endpoint. Keep using + that configured endpoint for sign-in and cloud operations. + Present the sign-in link when the client asks. The person signs in and + authorizes the connection; the client stores and refreshes its credential. +3. Call `get_agent_connection`. This confirms the enrolled agent identity, + allowed workspace IDs, and permission policy. Reconnecting the same OAuth + client reuses the agent. Do not create another identity to escape a pause + or a revoked connection. +4. Start with the default workspace. Use a different `workspaceId` on tools + only when it appears in the connection's allowed workspace list. The sponsor + changes this list in Console under Settings → Agents → Permissions. + +Discover the available MCP tools and use them for projects, branches, +databases, compute apps and deployments, buckets, queries, and schema changes. +Do not assume a tool exists: inspect its schema before calling it. If an +operation is unavailable, explain the missing capability instead of switching +silently to a person's CLI session or direct API requests. Local code editing, +builds, and tests do not need cloud credentials. + +## Wait for approval + +A tool can return `approval_required` with `approvalId`, `approveUrl`, and +`expiresAt`. Show the approval link and one sentence explaining the action. +Keep working on independent tasks while the decision is pending. + +When the harness supports MCP Events and the server advertises `events`, use +its native event subscription to `prisma.approval.resolved`, filtered by +`approvalId`. The harness supplies the verified HTTPS callback and signing +secret. Let the harness verify signatures and manage refresh and unsubscribe. +Do not invent a callback URL, create a public receiver, or assume that ordinary +MCP support includes Events support. After subscribing, read +`get_agent_approval` once so a decision made before subscription is not missed. + +If Events is unavailable, call `get_agent_approval` every five seconds until +`approved`, `approved_window`, `denied`, or `expired`. Stop at the returned +expiry time. Never ask the person to type "done" to signal a decision. + +After approval, retry the original tool with the exact same arguments and +`approvalId`. An approval cannot authorize changed arguments. A one-hour grant +can cover other actions of the same kind in its workspace, project, and branch +role; retry blocked actions through MCP so the server decides which are covered. +After denial, stop that action. After expiry, read the status again before any +retry; a late approval may still be valid. Do not repeatedly create new requests +without telling the person why the old request expired. + +Use `reason` for a short explanation on sensitive operations. Never include +secrets or connection strings in it. Pause and revocation take effect on the +next request. Membership removal also removes access, even if the workspace +still appears in an older tool response. + +## GitHub deployment + +Deploy from the linked repository's GitHub Actions workflow. The workflow uses +`prisma/cloud-deploy-action@v1` with `id-token: write` and GitHub OIDC. Do not +store a Prisma service token in the repository. Inspect MCP tool descriptions +for the connection and deployment steps available on this server. + +The legacy device enrollment flow remains available for clients that explicitly +support it. Use its agent credential through MCP; do not borrow a human CLI +session. A device client must store its credential securely and respect its +returned polling interval and expiry. diff --git a/skills/prisma-platform-core-concepts/SKILL.md b/skills/prisma-platform-core-concepts/SKILL.md index c24971fb..d003ec02 100644 --- a/skills/prisma-platform-core-concepts/SKILL.md +++ b/skills/prisma-platform-core-concepts/SKILL.md @@ -36,6 +36,15 @@ needs them. To learn a command surface, run `prisma --help`, or queries belong to the `prisma-orm-core-concepts` skill; declaring services and modules in code belongs to `prisma-composer-core-concepts`. +## Agent connections + +When operating as an enrolled agent, read `prisma-agent-enrollment` and use +Prisma MCP for cloud operations. Install it with `prisma agent install`. +The MCP client handles OAuth and secure credential storage. Use its allowed +workspace list and approval responses; do not use a person's CLI credentials +or direct Management API calls as a fallback. Local builds and code changes +can still use the CLI without a cloud credential. + ## The stack One CLI, `prisma`, fronts a set of products designed to be used together: