diff --git a/docs/product/output-conventions.md b/docs/product/output-conventions.md index 27db0172..31ef993c 100644 --- a/docs/product/output-conventions.md +++ b/docs/product/output-conventions.md @@ -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 `/skills` and `` 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. diff --git a/packages/cli/src/commands/skills/results.ts b/packages/cli/src/commands/skills/results.ts index 031797b1..6d6bd090 100644 --- a/packages/cli/src/commands/skills/results.ts +++ b/packages/cli/src/commands/skills/results.ts @@ -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; diff --git a/packages/cli/src/commands/skills/sync.ts b/packages/cli/src/commands/skills/sync.ts index 1d5abbcc..58fc3599 100644 --- a/packages/cli/src/commands/skills/sync.ts +++ b/packages/cli/src/commands/skills/sync.ts @@ -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 }, diff --git a/packages/cli/src/lib/skills/status.ts b/packages/cli/src/lib/skills/status.ts index adff740f..e716bf72 100644 --- a/packages/cli/src/lib/skills/status.ts +++ b/packages/cli/src/lib/skills/status.ts @@ -8,6 +8,7 @@ import { agentSkillDirs, DEFAULT_AGENTS, isSkillSourcePackage, + KNOWN_AGENTS, PACKAGE_SKILLS_DIR, SKILL_SOURCE_PACKAGES, } from "./allowlist"; @@ -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; @@ -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. */ export async function readSkillsStatus( cwd: string, options?: SkillsStatusOptions, ): Promise { 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); @@ -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), }; } @@ -241,21 +255,28 @@ async function missingFromDisk(target: string): Promise { /** * 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, + unconfiguredDirs: readonly string[] = [], ): Promise { const orphans = new Map(); + const scan = [ + ...dirs.map((dir) => ({ dir, wanted: provided })), + ...unconfiguredDirs.map((dir) => ({ dir, wanted: new Set() })), + ]; - 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( diff --git a/packages/cli/src/lib/skills/sync.ts b/packages/cli/src/lib/skills/sync.ts index b4e6693a..d3e7c521 100644 --- a/packages/cli/src/lib/skills/sync.ts +++ b/packages/cli/src/lib/skills/sync.ts @@ -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"; @@ -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 { const synced: SyncedSkill[] = []; @@ -95,6 +103,7 @@ export async function syncSkills(status: SkillsStatus): Promise { recursive: true, force: true, }); + await removeEmptyHarnessDirs(status.projectRoot, dir); } pruned.push({ skill: orphan.skill, @@ -114,6 +123,30 @@ export async function syncSkills(status: SkillsStatus): Promise { }; } +/** + * 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 { + 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 { diff --git a/packages/cli/tests/skills-sync.test.ts b/packages/cli/tests/skills-sync.test.ts index b102dcfd..ad9f30b3 100644 --- a/packages/cli/tests/skills-sync.test.ts +++ b/packages/cli/tests/skills-sync.test.ts @@ -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({ @@ -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({ diff --git a/skills/prisma-platform-core-concepts/SKILL.md b/skills/prisma-platform-core-concepts/SKILL.md index 0adcffd1..341f4ae8 100644 --- a/skills/prisma-platform-core-concepts/SKILL.md +++ b/skills/prisma-platform-core-concepts/SKILL.md @@ -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: