Skip to content
Merged
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
2 changes: 1 addition & 1 deletion docs/product/output-conventions.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ It is silent when:

This notice covers every project whose install does not resync the skills. `skills sync` itself never edits the user's `package.json` or root `.gitignore`. The synced copies are ordinary files that git tracks like any other file in the repository. Sync removes the `*` ignore file an older CLI wrote into its copies, but leaves a `.gitignore` the user authored in place.

Which agents get skill copies is configuration, never detection: `skills: { agents: [...] }` in `prisma.config.ts` names them, each agent name mapping to its directory — `claude` (`.claude/skills`), `cursor` (`.cursor/skills`), `agents` (`.agents/skills`), `devin` (`.devin/skills`). An unknown name is a config error naming the known agents. When the field or the whole config is absent, the default is every known agent, so a harness adopted later finds the skills already in place. An empty list (`agents: []`, what `prisma init --skills=none` scaffolds) is a recorded choice, not an omission: sync writes nothing and answers `No agents are configured to sync skills for.`, `skills list` reports the same, and the staleness notice never fires. `prisma init` writes the section into a fresh `prisma.config.ts`; a config that already exists is never edited — init reports the exact snippet to add instead. init also adds `prisma` to `devDependencies` at the CLI's exact version when no dependency field declares it, so the scaffolded config's `prisma/config` import resolves after the next install. Everything anchors at the directory the command runs in: sync, list, the staleness notice, and the `.prisma/skills.json` opt-out all read from cwd (the postinstall hook runs with cwd at the package root, so the mainline never guesses). The notice reads the config only when a `prisma.config.ts` exists in cwd and the full agent set already looks out of date, and evaluates it at most once; without a config it uses the default set and the postinstall hook remains the primary resync trigger.
Which agents get skill copies is configuration, never detection: `skills: { agents: [...] }` in `prisma.config.ts` names them, each agent name mapping to its directory — `claude` (`.claude/skills`), `cursor` (`.cursor/skills`), `agents` (`.agents/skills`), `devin` (`.devin/skills`). An unknown name is a config error naming the known agents. When the field or the whole config is absent, the default is every known agent, so a harness adopted later finds the skills already in place. An empty list (`agents: []`, what `prisma init --skills=none` scaffolds) is a recorded choice, not an omission: sync writes nothing and answers `No agents are configured to sync skills for.`, `skills list` reports the same, and the staleness notice never fires. Narrowing the list also removes what an earlier sync wrote: a copy this CLI installed (its `SKILL.md` names an allowlisted package) in the directory of an agent the config no longer names is reported by `skills list` as orphaned and removed by the next sync, along with the `<agent>/skills` and `<agent>` directories when that leaves them empty; `agents: []` after a full sync therefore removes all four directories and answers `Removed 1 skill.`. A skill in those directories that this CLI did not write is never touched. `prisma init` writes the section into a fresh `prisma.config.ts`; a config that already exists is never edited — init reports the exact snippet to add instead. init also adds `prisma` to `devDependencies` at the CLI's exact version when no dependency field declares it, so the scaffolded config's `prisma/config` import resolves after the next install. Everything anchors at the directory the command runs in: sync, list, the staleness notice, and the `.prisma/skills.json` opt-out all read from cwd (the postinstall hook runs with cwd at the package root, so the mainline never guesses). The notice reads the config only when a `prisma.config.ts` exists in cwd and the full agent set already looks out of date, and evaluates it at most once; without a config it uses the default set and the postinstall hook remains the primary resync trigger.

`Agent skills are up to date.` appears only when installed skills exist and are current — a project with nothing to sync never borrows that line. The three empty states each name themselves: `agents: []` answers `No agents are configured to sync skills for.`; a project with no allowlisted package installed answers `No Prisma packages with agent skills are installed.`; installed packages whose versions ship no skills at all (older releases without a `skills/` directory) answer `No Prisma dependencies in your project ship agent skills to sync.` from sync and `No Prisma dependencies in your project ship agent skills.` from list. The sync JSON result carries a `skills` array naming every skill the installed packages ship, so machine consumers can make the same distinction. `prisma init` reports it in its JSON `skills.outcome`, whose values are `synced`, `up-to-date`, `no-agents`, `no-packages`, `no-skills`, `failed`, and `skipped`. A project where at least one installed package ships skills keeps the ordinary summaries even when another installed package ships none.

Expand Down
5 changes: 3 additions & 2 deletions packages/cli/src/commands/skills/results.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,9 @@ export interface SkillsListResult {
readonly agents: readonly AgentName[];
readonly packages: readonly SkillsPackageReport[];
readonly skills: readonly SkillsListEntry[];
/** Copies from an allowlisted package that nothing installed still
* provides; the next sync removes them. */
/** Copies from an allowlisted package that nothing wants any more —
* no installed package provides them, or their agent is no longer
* named in skills.agents; the next sync removes them. */
readonly orphaned: readonly {
readonly skill: string;
readonly library: string | null;
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/commands/skills/sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ export const skillsSyncCommand = defineCommand({
summary:
"Copy the AI-agent instruction files (skills) from installed Prisma packages into this project",
description:
"Agent skills are instruction files that teach AI coding agents (Claude Code, Cursor, and others) how to use the installed Prisma packages. They ship inside the packages themselves, so they always describe the version in use. Sync copies them into the skill directories the agent harnesses read, and removes copies whose package is gone. It does nothing, and exits 0, when everything is already current.",
"Agent skills are instruction files that teach AI coding agents (Claude Code, Cursor, and others) how to use the installed Prisma packages. They ship inside the packages themselves, so they always describe the version in use. Sync copies them into the skill directories of the agents that skills.agents in prisma.config.ts names (every known agent when unset), and removes copies whose package is gone or whose agent is no longer named there — agents: [] removes them all. It does nothing, and exits 0, when everything is already current.",
examples: ["skills sync", "skills sync --disable"],
},
needs: { config: skillsConfigSection },
Expand Down
39 changes: 30 additions & 9 deletions packages/cli/src/lib/skills/status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
agentSkillDirs,
DEFAULT_AGENTS,
isSkillSourcePackage,
KNOWN_AGENTS,
PACKAGE_SKILLS_DIR,
SKILL_SOURCE_PACKAGES,
} from "./allowlist";
Expand Down Expand Up @@ -48,8 +49,9 @@ export interface SkillStatus {
readonly upToDate: boolean;
}

/** A copy this CLI installed whose source package is no longer
* installed, or which the source package no longer ships. */
/** A copy this CLI installed that nothing wants any more: its source
* package is no longer installed, the package no longer ships it, or
* it sits in the directory of an agent the config no longer names. */
export interface OrphanedSkill {
readonly skill: string;
readonly library: string | null;
Expand All @@ -76,12 +78,19 @@ export interface SkillsStatusOptions {
readonly checkDisabled?: boolean;
}

/** Reads every skill the installed source packages ship and the state
* of each copy in the configured agents' directories, plus the copies
* the next sync will remove. */
Comment thread
coderabbitai[bot] marked this conversation as resolved.
export async function readSkillsStatus(
cwd: string,
options?: SkillsStatusOptions,
): Promise<SkillsStatus> {
const projectRoot = path.resolve(cwd);
const dirs = agentSkillDirs(options?.agents ?? DEFAULT_AGENTS);
const agents = options?.agents ?? DEFAULT_AGENTS;
const dirs = agentSkillDirs(agents);
const unconfiguredDirs = agentSkillDirs(
KNOWN_AGENTS.filter((agent) => !agents.includes(agent)),
);
const checkDisabled =
options?.checkDisabled ?? (await readSkillsCheckDisabled(projectRoot));
const packages = await findInstalledSourcePackages(projectRoot);
Expand All @@ -100,7 +109,12 @@ export async function readSkillsStatus(
orphans:
options?.orphans === false
? []
: await findOrphanedSkills(projectRoot, dirs, new Set(sources.keys())),
: await findOrphanedSkills(
projectRoot,
dirs,
new Set(sources.keys()),
unconfiguredDirs,
),
upToDate: skills.every((skill) => skill.upToDate),
};
}
Expand Down Expand Up @@ -241,21 +255,28 @@ async function missingFromDisk(target: string): Promise<boolean> {

/**
* Copies in the harness directories that this CLI installed — their
* SKILL.md names an allowlisted package as its `library` — and that no
* installed package still provides. A skill from anywhere else is
* someone else's file and is never touched.
* SKILL.md names an allowlisted package as its `library` — and that
* nothing wants any more: in a configured directory, one no installed
* package still provides; in the directory of an agent the config no
* longer names, every one. A skill from anywhere else is someone
* else's file and is never touched.
*/
export async function findOrphanedSkills(
projectRoot: string,
dirs: readonly string[],
provided: ReadonlySet<string>,
unconfiguredDirs: readonly string[] = [],
): Promise<OrphanedSkill[]> {
const orphans = new Map<string, { library: string | null; dirs: string[] }>();
const scan = [
...dirs.map((dir) => ({ dir, wanted: provided })),
...unconfiguredDirs.map((dir) => ({ dir, wanted: new Set<string>() })),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
];

for (const dir of dirs) {
for (const { dir, wanted } of scan) {
const harnessDir = path.join(projectRoot, dir);
for (const skill of await skillDirectories(harnessDir)) {
if (provided.has(skill)) {
if (wanted.has(skill)) {
continue;
}
const stamp = await readSkillStamp(
Expand Down
45 changes: 39 additions & 6 deletions packages/cli/src/lib/skills/sync.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
// biome-ignore-all lint/performance/noAwaitInLoops: one skill tree is written at a time; an interrupted copy can leave one partial tree, which reads as absent (no SKILL.md yet) and is repaired by the next sync.
import { mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises";
import {
mkdir,
readdir,
readFile,
rm,
rmdir,
writeFile,
} from "node:fs/promises";
import path from "node:path";

import type { InstalledSourcePackage, SkillsStatus } from "./status";
Expand Down Expand Up @@ -41,11 +48,12 @@ export interface SyncOutcome {

/**
* Brings the harness skill directories in line with the installed
* source packages: copies each skill tree whose stamp does not match
* the package it came from, and removes copies whose source package is
* gone. A target directory that exists but is not this CLI's copy is
* refused, never replaced. Doing nothing is the normal outcome and is
* not an error.
* source packages and the configured agents: copies each skill tree
* whose stamp does not match the package it came from, and removes
* copies whose source package is gone or whose agent the config no
* longer names. A target directory that exists but is not this CLI's
* copy is refused, never replaced. Doing nothing is the normal outcome
* and is not an error.
*/
export async function syncSkills(status: SkillsStatus): Promise<SyncOutcome> {
const synced: SyncedSkill[] = [];
Expand Down Expand Up @@ -95,6 +103,7 @@ export async function syncSkills(status: SkillsStatus): Promise<SyncOutcome> {
recursive: true,
force: true,
});
await removeEmptyHarnessDirs(status.projectRoot, dir);
}
pruned.push({
skill: orphan.skill,
Expand All @@ -114,6 +123,30 @@ export async function syncSkills(status: SkillsStatus): Promise<SyncOutcome> {
};
}

/**
* A harness directory this CLI emptied by removing its last copy is
* removed too, and so is its parent (`.claude/skills`, then `.claude`),
* so opting an agent out leaves nothing behind. `rmdir` refuses a
* directory holding anything else, which is exactly the user's file
* that must stay.
*/
async function removeEmptyHarnessDirs(
projectRoot: string,
dir: string,
): Promise<void> {
for (
let current = dir;
current !== "." && current !== "";
current = path.dirname(current)
) {
try {
await rmdir(path.join(projectRoot, current));
} catch {
return;
}
}
}

const OLD_CLI_GITIGNORE = /^\*\r?\n?$/;

async function removeOldCliGitignore(file: string): Promise<void> {
Expand Down
144 changes: 144 additions & 0 deletions packages/cli/tests/skills-sync.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -527,6 +527,121 @@ describe("skills sync", () => {
}
});

it("removes the copies in directories the config no longer names", async () => {
const root = await makeProjectRoot();
await installPackage(root, {
name: "@prisma/orm-postgres",
version: "8.1.0",
skills: ["prisma-8"],
});
await runSync(root);
const cli = createTestCli({
commandFamilies: [skillsCommandFamily],
commands: SKILLS_COMMANDS,
groups: { skills: { brief: "Keep Prisma agent skills current" } },
config: { skills: { agents: ["claude"] } },
now: () => new Date(0),
});

const run = await cli.run(["skills", "sync"], { cwd: root });
const result = run.presented?.data as SkillsSyncResult;

expect(run.exitCode).toBe(0);
expect(result.synced).toEqual([]);
expect(result.pruned).toEqual([
{
skill: "prisma-8",
library: "@prisma/orm-postgres",
dirs: [".cursor/skills", ".agents/skills", ".devin/skills"],
},
]);
expect(await stampOf(root, ".claude/skills", "prisma-8")).toBe("8.1.0");
for (const dir of [".cursor", ".agents", ".devin"]) {
expect(await exists(path.join(root, dir))).toBe(false);
}
});

it("removes every copy under agents: [] and reports the removal", async () => {
const root = await makeProjectRoot();
await installPackage(root, {
name: "@prisma/orm-postgres",
version: "8.1.0",
skills: ["prisma-8"],
});
await runSync(root);
const cli = createTestCli({
commandFamilies: [skillsCommandFamily],
commands: SKILLS_COMMANDS,
groups: { skills: { brief: "Keep Prisma agent skills current" } },
config: { skills: { agents: [] } },
now: () => new Date(0),
});

const run = await cli.run(["skills", "sync"], {
cwd: root,
isTty: { stdout: true, stderr: true },
});
const result = run.presented?.data as SkillsSyncResult;

expect(run.exitCode).toBe(0);
expect(result.agents).toEqual([]);
expect(result.pruned).toEqual([
{
skill: "prisma-8",
library: "@prisma/orm-postgres",
dirs: [...HARNESS_SKILL_DIRS],
},
]);
expect(run.stderr).toContain("Removed 1 skill.");
for (const dir of HARNESS_SKILL_DIRS) {
expect(await exists(path.join(root, path.dirname(dir)))).toBe(false);
}

const again = await cli.run(["skills", "sync"], {
cwd: root,
isTty: { stdout: true, stderr: true },
});
expect((again.presented?.data as SkillsSyncResult).pruned).toEqual([]);
expect(again.stderr).toContain(
"No agents are configured to sync skills for.",
);
});

it("leaves other files in a directory the config no longer names", async () => {
const root = await makeProjectRoot();
await installPackage(root, {
name: "@prisma/orm-postgres",
version: "8.1.0",
skills: ["prisma-8"],
});
await runSync(root);
await writeSkillTree(path.join(root, ".cursor/skills", "team-skill"), {
skill: "team-skill",
library: "@acme/toolkit",
version: "1.0.0",
});
await writeFile(path.join(root, ".agents", "notes.md"), "# mine\n", "utf8");
const cli = createTestCli({
commandFamilies: [skillsCommandFamily],
commands: SKILLS_COMMANDS,
groups: { skills: { brief: "Keep Prisma agent skills current" } },
config: { skills: { agents: [] } },
now: () => new Date(0),
});

const { exitCode } = await cli.run(["skills", "sync"], { cwd: root });

expect(exitCode).toBe(0);
expect(await exists(path.join(root, ".cursor/skills", "prisma-8"))).toBe(
false,
);
expect(
await exists(path.join(root, ".cursor/skills", "team-skill", "SKILL.md")),
).toBe(true);
expect(await exists(path.join(root, ".agents/skills"))).toBe(false);
expect(await exists(path.join(root, ".agents", "notes.md"))).toBe(true);
});

it("refuses a config naming an agent this CLI does not know", async () => {
const root = await makeProjectRoot();
const cli = createTestCli({
Expand Down Expand Up @@ -742,6 +857,35 @@ describe("skills list", () => {
]);
});

it("names copies in directories the config no longer names as orphaned", async () => {
const root = await makeProjectRoot();
await installPackage(root, {
name: "@prisma/orm-postgres",
version: "8.1.0",
skills: ["prisma-8"],
});
await runSync(root);
const cli = createTestCli({
commandFamilies: [skillsCommandFamily],
commands: SKILLS_COMMANDS,
groups: { skills: { brief: "Keep Prisma agent skills current" } },
config: { skills: { agents: ["claude", "cursor"] } },
now: () => new Date(0),
});

const run = await cli.run(["skills", "list"], { cwd: root });
const result = run.presented?.data as SkillsListResult;

expect(result.orphaned).toEqual([
{
skill: "prisma-8",
library: "@prisma/orm-postgres",
dirs: [".agents/skills", ".devin/skills"],
},
]);
expect(result.upToDate).toBe(true);
});

it("reports the check as disabled when prisma.config.ts turns it off", async () => {
const root = await makeProjectRoot();
const cli = createTestCli({
Expand Down
2 changes: 1 addition & 1 deletion skills/prisma-platform-core-concepts/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
name: prisma-platform-core-concepts
metadata:
library: "prisma"
library_version: "8.0.0-rc.12"
library_version: "8.0.0-rc.13"
version: 2026.9.1
description: >-
Use when hosting, deploying, or operating an app on the Prisma Platform:
Expand Down
Loading