diff --git a/docs/product/output-conventions.md b/docs/product/output-conventions.md index 79771ae2..8f4efd1e 100644 --- a/docs/product/output-conventions.md +++ b/docs/product/output-conventions.md @@ -118,7 +118,9 @@ 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 for skills.`, `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. `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. A target directory that already holds a `SKILL.md` this CLI did not write is `unmanaged`: sync refuses to replace it, reports each refusal as a `SKILLS.UNMANAGED_DIRECTORY` diagnostic and in the `refused` array of the JSON result, and `skills list` shows `unmanaged` in its State column. An unmanaged directory does not count as out of date — the staleness notice stays silent about it — but the human summary of `skills sync` and `skills list` names it instead of over-claiming: `Agent skills are up to date; 1 directory is not managed by this CLI.` A directory that merely exists without a `SKILL.md` is treated as absent and is written by the next sync. diff --git a/packages/cli/e2e/init.e2e.ts b/packages/cli/e2e/init.e2e.ts index 2db88a7c..7886f2ef 100644 --- a/packages/cli/e2e/init.e2e.ts +++ b/packages/cli/e2e/init.e2e.ts @@ -130,7 +130,7 @@ describe("prisma init", () => { ]); // No allowlisted Prisma package is installed here, so the sync has // nothing to do and says so instead of failing. - expect(envelope.result.skills.outcome).toBe("up-to-date"); + expect(envelope.result.skills.outcome).toBe("no-packages"); expect(envelope.result.skills.sync?.packages).toEqual([]); expect(envelope.diagnostics).toEqual([]); @@ -205,7 +205,7 @@ describe("prisma init", () => { expect(envelope.result.postinstall.outcome).toBe("exists"); expect(envelope.result.postinstall.dependency).toBe("declared"); expect(envelope.result.config.outcome).toBe("created"); - expect(envelope.result.skills.outcome).toBe("up-to-date"); + expect(envelope.result.skills.outcome).toBe("no-packages"); expect(envelope.diagnostics.map((d) => d.code)).not.toContain( "INIT.CONFIG_KEPT", ); diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index 07ff6435..5aa282f4 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -40,7 +40,19 @@ export interface InitPostinstallReport { readonly dependency: InitDependencyOutcome; } -export type InitSkillsOutcome = "synced" | "up-to-date" | "failed" | "skipped"; +/** "up-to-date" is reserved for a project that has skills installed and + * current; each way the sync had nothing to work on names itself. + * "no-agents": the config records `agents: []`. "no-packages": no + * allowlisted Prisma package is installed. "no-skills": packages are + * installed but their versions ship no skills. */ +export type InitSkillsOutcome = + | "synced" + | "up-to-date" + | "no-agents" + | "no-packages" + | "no-skills" + | "failed" + | "skipped"; export interface InitSkillsReport { readonly outcome: InitSkillsOutcome; @@ -609,6 +621,22 @@ async function scaffoldConfigStep( }; } +function skillsOutcome(result: SkillsSyncResult): InitSkillsOutcome { + if (result.synced.length > 0 || result.pruned.length > 0) { + return "synced"; + } + if (result.agents.length === 0) { + return "no-agents"; + } + if (result.packages.length === 0) { + return "no-packages"; + } + if (result.skills.length === 0) { + return "no-skills"; + } + return "up-to-date"; +} + async function syncSkillsStep( cwd: string, agents: readonly AgentName[], @@ -620,6 +648,7 @@ async function syncSkillsStep( projectRoot: outcome.projectRoot, agents, packages: packageReports(outcome.packages), + skills: outcome.skills, synced: outcome.synced, pruned: outcome.pruned, refused: outcome.refused, @@ -627,10 +656,7 @@ async function syncSkillsStep( }; return { report: { - outcome: - result.synced.length > 0 || result.pruned.length > 0 - ? "synced" - : "up-to-date", + outcome: skillsOutcome(result), sync: result, }, lines: null, diff --git a/packages/cli/src/commands/skills/presentation.ts b/packages/cli/src/commands/skills/presentation.ts index 71ed7778..4ec9fced 100644 --- a/packages/cli/src/commands/skills/presentation.ts +++ b/packages/cli/src/commands/skills/presentation.ts @@ -24,11 +24,19 @@ function unmanagedClause(count: number): string { } function syncSummary(result: SkillsSyncResult): string { - if (result.agents.length === 0) { - return "No agents are configured for skills."; - } - if (result.packages.length === 0) { - return "No Prisma packages with agent skills are installed."; + // The empty-state sentences hold only when this run also removed + // nothing; a prune is work done, and its summary must match the + // Removed table rendered beneath it. + if (result.pruned.length === 0) { + if (result.agents.length === 0) { + return "No agents are configured to sync skills for."; + } + if (result.packages.length === 0) { + return "No Prisma packages with agent skills are installed."; + } + if (result.skills.length === 0) { + return "No Prisma dependencies in your project ship agent skills to sync."; + } } const refusedDirs = result.refused.reduce( (count, skill) => count + skill.dirs.length, @@ -37,6 +45,10 @@ function syncSummary(result: SkillsSyncResult): string { if (result.synced.length === 0 && result.pruned.length === 0) { return `Agent skills are up to date${unmanagedClause(refusedDirs)}.`; } + const removed = `${result.pruned.length} skill${result.pruned.length === 1 ? "" : "s"}`; + if (result.synced.length === 0 && result.pruned.length > 0) { + return `Removed ${removed}${unmanagedClause(refusedDirs)}.`; + } const synced = `${result.synced.length} skill${result.synced.length === 1 ? "" : "s"}`; const base = result.pruned.length === 0 @@ -105,10 +117,13 @@ export function syncPresentations(result: SkillsSyncResult): Presentations { function listSummary(result: SkillsListResult): string { if (result.agents.length === 0) { - return "No agents are configured for skills."; + return "No agents are configured to sync skills for."; + } + if (result.packages.length === 0) { + return "No Prisma packages with agent skills are installed."; } if (result.skills.length === 0) { - return "No Prisma agent skills are available to sync."; + return "No Prisma dependencies in your project ship agent skills."; } if (!result.upToDate) { return "Agent skills are out of date."; diff --git a/packages/cli/src/commands/skills/results.ts b/packages/cli/src/commands/skills/results.ts index 4ec051c5..031797b1 100644 --- a/packages/cli/src/commands/skills/results.ts +++ b/packages/cli/src/commands/skills/results.ts @@ -19,6 +19,10 @@ export interface SkillsSyncResult { * config records `agents: []` — no skills are wanted. */ readonly agents: readonly AgentName[]; readonly packages: readonly SkillsPackageReport[]; + /** Every skill name the installed packages ship, whether or not this + * run wrote it. Empty while `packages` is not means the installed + * versions ship no skills. */ + readonly skills: readonly string[]; readonly synced: readonly SyncedSkill[]; readonly pruned: readonly PrunedSkill[]; /** Target directories left untouched because they hold a skill this diff --git a/packages/cli/src/commands/skills/sync.ts b/packages/cli/src/commands/skills/sync.ts index 94313153..15f3953f 100644 --- a/packages/cli/src/commands/skills/sync.ts +++ b/packages/cli/src/commands/skills/sync.ts @@ -121,6 +121,7 @@ export const skillsSyncCommand = defineCommand({ projectRoot: outcome.projectRoot, agents: ctx.config.agents, packages: packageReports(outcome.packages), + skills: outcome.skills, synced: outcome.synced, pruned: outcome.pruned, refused: outcome.refused, diff --git a/packages/cli/src/lib/skills/sync.ts b/packages/cli/src/lib/skills/sync.ts index caad0315..b4e6693a 100644 --- a/packages/cli/src/lib/skills/sync.ts +++ b/packages/cli/src/lib/skills/sync.ts @@ -29,6 +29,10 @@ export interface RefusedSkill { export interface SyncOutcome { readonly projectRoot: string; readonly packages: readonly InstalledSourcePackage[]; + /** Every skill name the installed packages ship, whether or not this + * run wrote it. Empty while packages are installed means those + * versions ship no skills. */ + readonly skills: readonly string[]; readonly synced: readonly SyncedSkill[]; readonly pruned: readonly PrunedSkill[]; readonly refused: readonly RefusedSkill[]; @@ -102,6 +106,7 @@ export async function syncSkills(status: SkillsStatus): Promise { return { projectRoot: status.projectRoot, packages: status.packages, + skills: status.skills.map((skill) => skill.skill), synced, pruned, refused, diff --git a/packages/cli/tests/init.test.ts b/packages/cli/tests/init.test.ts index b34c0d7c..628da0dc 100644 --- a/packages/cli/tests/init.test.ts +++ b/packages/cli/tests/init.test.ts @@ -497,6 +497,53 @@ describe("init", () => { expect(result.skills.sync?.pruned).toEqual([]); }); + it("reports no-skills when the installed packages ship none", async () => { + const root = await makeProjectRoot("init-"); + await installPackage(root, { + name: "@prisma/orm-postgres", + version: "6.9.0", + }); + + const run = await makeCli().run(["init"], { + cwd: root, + isTty: { stdout: true, stderr: true }, + }); + const result = run.presented?.data as InitResult; + + expect(run.exitCode).toBe(0); + expect(result.skills.outcome).toBe("no-skills"); + expect(run.stderr).toContain( + "No Prisma dependencies in your project ship agent skills to sync.", + ); + expect(run.stderr).not.toContain("up to date"); + }); + + it("reports no-packages when no allowlisted package is installed", async () => { + const root = await makeProjectRoot("init-"); + + const { exitCode, result } = await runInit(root); + + expect(exitCode).toBe(0); + expect(result.skills.outcome).toBe("no-packages"); + expect(result.skills.sync?.packages).toEqual([]); + }); + + it("reports no-agents when the config records agents: []", async () => { + const root = await makeProjectRoot("init-"); + await installPackage(root, { + name: "@prisma/orm-postgres", + version: "8.1.0", + skills: ["prisma-8"], + }); + + const { exitCode, result } = await runInit(root, [], { + skills: { agents: [] }, + }); + + expect(exitCode).toBe(0); + expect(result.skills.outcome).toBe("no-agents"); + }); + it("surfaces a refused directory instead of claiming the skills are current", async () => { const root = await makeProjectRoot("init-"); await installPackage(root, { diff --git a/packages/cli/tests/skills-sync.test.ts b/packages/cli/tests/skills-sync.test.ts index fb058c88..b102dcfd 100644 --- a/packages/cli/tests/skills-sync.test.ts +++ b/packages/cli/tests/skills-sync.test.ts @@ -246,9 +246,59 @@ describe("skills sync", () => { expect(exitCode).toBe(0); expect(result.packages).toEqual([]); + expect(result.skills).toEqual([]); expect(result.synced).toEqual([]); }); + it("says the installed packages ship no skills instead of claiming up to date", async () => { + const root = await makeProjectRoot(); + await installPackage(root, { + name: "@prisma/orm-postgres", + version: "6.9.0", + }); + + const run = await makeCli().run(["skills", "sync"], { + cwd: root, + isTty: { stdout: true, stderr: true }, + }); + const result = run.presented?.data as SkillsSyncResult; + + expect(run.exitCode).toBe(0); + expect(result.packages.map((pkg) => pkg.package)).toEqual([ + "@prisma/orm-postgres", + ]); + expect(result.skills).toEqual([]); + expect(result.synced).toEqual([]); + expect(run.stderr).toContain( + "No Prisma dependencies in your project ship agent skills to sync.", + ); + expect(run.stderr).not.toContain("up to date"); + }); + + it("keeps the up-to-date summary while any installed package ships skills", async () => { + const root = await makeProjectRoot(); + await installPackage(root, { + name: "@prisma/orm-postgres", + version: "8.1.0", + skills: ["prisma-8"], + }); + await installPackage(root, { + name: "@prisma/composer", + version: "0.11.0", + }); + await runSync(root); + + const run = await makeCli().run(["skills", "sync"], { + cwd: root, + isTty: { stdout: true, stderr: true }, + }); + const result = run.presented?.data as SkillsSyncResult; + + expect(run.exitCode).toBe(0); + expect(result.skills).toEqual(["prisma-8"]); + expect(run.stderr).toContain("Agent skills are up to date."); + }); + it("removes a copy whose source package is gone, and nothing else", async () => { const root = await makeProjectRoot(); await seedSyncedSkill(root, ".claude/skills", { @@ -280,6 +330,25 @@ describe("skills sync", () => { ).toBe(true); }); + it("summarizes a prune-only run as removal, not as an empty state", async () => { + const root = await makeProjectRoot(); + await seedSyncedSkill(root, ".claude/skills", { + skill: "prisma-8", + library: "@prisma/orm-postgres", + version: "8.1.0", + }); + + const run = await makeCli().run(["skills", "sync"], { + cwd: root, + isTty: { stdout: true, stderr: true }, + }); + + expect(run.exitCode).toBe(0); + expect(run.stderr).toContain("Removed 1 skill."); + expect(run.stderr).not.toContain("are installed"); + expect(run.stderr).not.toContain("ship agent skills"); + }); + it("keeps a skill still shipped by another installed package", async () => { const root = await makeProjectRoot(); await installPackage(root, { @@ -449,7 +518,9 @@ describe("skills sync", () => { expect(result.agents).toEqual([]); expect(result.synced).toEqual([]); expect(result.pruned).toEqual([]); - expect(run.stderr).toContain("No agents are configured for skills."); + expect(run.stderr).toContain( + "No agents are configured to sync skills for.", + ); expect(run.stderr).not.toContain("up to date"); for (const dir of HARNESS_SKILL_DIRS) { expect(await exists(path.join(root, dir))).toBe(false); @@ -611,6 +682,47 @@ describe("skills list", () => { expect(result.orphaned).toEqual([]); }); + it("says no allowlisted package is installed when there are none", async () => { + const root = await makeProjectRoot(); + + const run = await makeCli().run(["skills", "list"], { + cwd: root, + isTty: { stdout: true, stderr: true }, + }); + const result = run.presented?.data as SkillsListResult; + + expect(run.exitCode).toBe(0); + expect(result.packages).toEqual([]); + expect(run.stderr).toContain( + "No Prisma packages with agent skills are installed.", + ); + expect(run.stderr).not.toContain("up to date"); + }); + + it("says the installed packages ship no skills", async () => { + const root = await makeProjectRoot(); + await installPackage(root, { + name: "@prisma/orm-postgres", + version: "6.9.0", + }); + + const run = await makeCli().run(["skills", "list"], { + cwd: root, + isTty: { stdout: true, stderr: true }, + }); + const result = run.presented?.data as SkillsListResult; + + expect(run.exitCode).toBe(0); + expect(result.packages.map((pkg) => pkg.package)).toEqual([ + "@prisma/orm-postgres", + ]); + expect(result.skills).toEqual([]); + expect(run.stderr).toContain( + "No Prisma dependencies in your project ship agent skills.", + ); + expect(run.stderr).not.toContain("up to date"); + }); + it("names copies waiting to be pruned", async () => { const root = await makeProjectRoot(); await seedSyncedSkill(root, ".agents/skills", { @@ -672,7 +784,9 @@ describe("skills list", () => { true, ); expect(result.upToDate).toBe(true); - expect(run.stderr).toContain("No agents are configured for skills."); + expect(run.stderr).toContain( + "No agents are configured to sync skills for.", + ); }); it("reads nothing and changes nothing", async () => {