Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions docs/product/agent-install.md
Original file line number Diff line number Diff line change
@@ -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).
17 changes: 17 additions & 0 deletions docs/reference/error-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
190 changes: 190 additions & 0 deletions packages/cli/e2e/agent-install.e2e.ts
Original file line number Diff line number Diff line change
@@ -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`,
]);
});
6 changes: 4 additions & 2 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -59,16 +59,18 @@
"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",
"@prisma/credentials-store": "^7.8.0",
"@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",
Expand Down
13 changes: 13 additions & 0 deletions packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -444,6 +455,7 @@ export const mountedCommands: Readonly<Record<string, AnyCommand>> = {
"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,
Expand All @@ -460,6 +472,7 @@ export function buildCli(): Cli {
composerCommandFamily,
ormCommandFamily,
skillsCommandFamily,
agentCommandFamily,
],
groups: cliGroups,
commands: mountedCommands,
Expand Down
Loading
Loading