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
4 changes: 3 additions & 1 deletion docs/product/output-conventions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
4 changes: 2 additions & 2 deletions packages/cli/e2e/init.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([]);

Expand Down Expand Up @@ -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",
);
Expand Down
36 changes: 31 additions & 5 deletions packages/cli/src/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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[],
Expand All @@ -620,17 +648,15 @@ async function syncSkillsStep(
projectRoot: outcome.projectRoot,
agents,
packages: packageReports(outcome.packages),
skills: outcome.skills,
synced: outcome.synced,
pruned: outcome.pruned,
refused: outcome.refused,
checkDisabled: outcome.checkDisabled || !checkEnabledByConfig,
};
return {
report: {
outcome:
result.synced.length > 0 || result.pruned.length > 0
? "synced"
: "up-to-date",
outcome: skillsOutcome(result),
sync: result,
},
lines: null,
Expand Down
29 changes: 22 additions & 7 deletions packages/cli/src/commands/skills/presentation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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.";
Expand Down
4 changes: 4 additions & 0 deletions packages/cli/src/commands/skills/results.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/commands/skills/sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
5 changes: 5 additions & 0 deletions packages/cli/src/lib/skills/sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand Down Expand Up @@ -102,6 +106,7 @@ export async function syncSkills(status: SkillsStatus): Promise<SyncOutcome> {
return {
projectRoot: status.projectRoot,
packages: status.packages,
skills: status.skills.map((skill) => skill.skill),
synced,
pruned,
refused,
Expand Down
47 changes: 47 additions & 0 deletions packages/cli/tests/init.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, {
Expand Down
118 changes: 116 additions & 2 deletions packages/cli/tests/skills-sync.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", {
Expand Down Expand Up @@ -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, {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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", {
Expand Down Expand Up @@ -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 () => {
Expand Down
Loading