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
15 changes: 13 additions & 2 deletions docs/product/output-conventions.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,18 @@ The CLI prints one advisory line after normal command output when the agent skil
Prisma agent skills are out of date (installed @prisma/orm-postgres 8.1.0, synced 8.0.0). Run: prisma skills sync
```

A project that has never been synced is reported the same way, with `synced none`. Like the update notification, this is human-oriented stderr output, must never reach stdout, and must never change the command's exit code. Unlike the update notification it is **not** conditioned on a TTY: its main reader is a coding agent, which runs the CLI without one.
A project that has never been synced is reported the same way, with `synced none`. Like the update notification, this is human-oriented stderr output and must never change the command's exit code. Unlike the update notification it is **not** conditioned on a TTY: its main reader is a coding agent, which runs the CLI without one.

Under `--format markdown` the notice goes to stdout instead, because that format promises the whole run on one stream. It is the last section of the document, separated from the command's output by a blank line:

```markdown

### Notice
Prisma agent skills are out of date (installed @prisma/orm-postgres 8.1.0, synced 8.0.0).
- Sync agent skills: `prisma skills sync`
```

Under every other format the notice is stderr output and must never reach stdout.

It is silent when:

Expand Down Expand Up @@ -473,7 +484,7 @@ Rules:

## `--format markdown`

`--format markdown` renders the same blocks a command describes for human output as plain Markdown: a summary line, `label: value` rows, GFM pipe tables, bullet lists, nested bullets for trees, and fenced code for drawings, followed by `### Next` for the suggested next actions and `### Diagnostics` for any findings. It exists for an agent that reads CLI output as text rather than parsing JSON: every value is labelled, nothing is padded, wrapped, aligned, or coloured, and no tokens go to envelope keys. Every part of the run's output — blocks, next actions, diagnostics, structured errors, help, `--version`, and live events — lands on stdout, and the engine writes nothing to stderr. The format is only ever explicit: without `--format markdown` a terminal gets human output and a pipe gets JSON.
`--format markdown` renders the same blocks a command describes for human output as plain Markdown: a summary line, `label: value` rows, GFM pipe tables, bullet lists, nested bullets for trees, and fenced code for drawings, followed by `### Next` for the suggested next actions and `### Diagnostics` for any findings. It exists for an agent that reads CLI output as text rather than parsing JSON: every value is labelled, nothing is padded, wrapped, aligned, or coloured, and no tokens go to envelope keys. Every part of the run's output — blocks, next actions, diagnostics, structured errors, help, `--version`, and live events — lands on stdout, and the engine writes nothing to stderr. The out-of-date agent skills notice follows the same rule: under this format it is a trailing `### Notice` section on stdout rather than a stderr line. The format is only ever explicit: without `--format markdown` a terminal gets human output and a pipe gets JSON.

## Non-Streaming JSON Shape

Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ export async function main(
env: proc.env,
argv: proc.argv.slice(2),
cwd: proc.cwd(),
stdout: proc.stdout,
stderr: proc.stderr,
});
return exitCode;
Expand Down
58 changes: 49 additions & 9 deletions packages/cli/src/skills-check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,15 @@
* that ship inside the Prisma packages a project installs, so they go
* out of date whenever those packages move and nothing re-copies them.
* The project's postinstall normally does; this catches every way that
* can be bypassed, by naming the mismatch once on stderr.
* can be bypassed, by naming the mismatch once.
*
* It never changes the exit code, never writes to stdout, and is not
* conditioned on a TTY: agents run without one and are who this is for.
* It goes to stderr, except under `--format markdown`, which promises
* that everything the command produces arrives on stdout as one
* Markdown document; there the notice is a trailing `### Notice`
* section on stdout instead.
*
* It never changes the exit code and is not conditioned on a TTY:
* agents run without one and are who this is for.
*/
import { detectCI } from "@prisma/cli-engine";
import { readProjectSkillsConfig } from "./commands/skills/config";
Expand All @@ -23,6 +28,7 @@ export interface SkillsCheckRuntime {
readonly env: NodeJS.ProcessEnv;
readonly argv: readonly string[];
readonly cwd: string;
readonly stdout: { write(text: string): unknown };
readonly stderr: { write(text: string): unknown };
}

Expand Down Expand Up @@ -69,6 +75,13 @@ export async function maybeWriteSkillsStaleNotice(
return;
}
const dirs = agentSkillDirs(agents);
if (isFormat(flagTokens(runtime.argv), "markdown")) {
const notice = renderStaleNoticeMarkdown(status, dirs);
if (notice !== null) {
runtime.stdout.write(notice);
}
return;
}
const notice = renderStaleNotice(status, dirs);
if (notice !== null) {
runtime.stderr.write(notice);
Expand Down Expand Up @@ -97,7 +110,8 @@ function firstOutdatedSkillIn(
);
}

export function renderStaleNotice(
/** The mismatch in words, without the stream's framing around it. */
function staleNoticeSentence(
status: SkillsStatus,
dirs: readonly string[],
): string | null {
Expand All @@ -111,11 +125,33 @@ export function renderStaleNotice(
)?.syncedVersion;
return (
`Prisma agent skills are out of date (installed ${outdated.library} ` +
`${outdated.version}, synced ${synced ?? "none"}). ` +
`Run: ${getCliName()} skills sync\n`
`${outdated.version}, synced ${synced ?? "none"})`
);
}

export function renderStaleNotice(
status: SkillsStatus,
dirs: readonly string[],
): string | null {
const sentence = staleNoticeSentence(status, dirs);
return sentence === null
? null
: `${sentence}. Run: ${getCliName()} skills sync\n`;
}

/** The same notice as a trailing section of the Markdown document, in
* the heading-then-bullet shape the engine renders `### Diagnostics`
* in. The leading blank line separates it from the command's output. */
export function renderStaleNoticeMarkdown(
status: SkillsStatus,
dirs: readonly string[],
): string | null {
const sentence = staleNoticeSentence(status, dirs);
return sentence === null
? null
: `\n### Notice\n${sentence}.\n- Sync agent skills: \`${getCliName()} skills sync\`\n`;
}

/** The shared flags that take a separate value, so the word after them
* is that value rather than the command being invoked. */
const FLAGS_TAKING_A_VALUE = new Set([
Expand Down Expand Up @@ -176,10 +212,14 @@ function isSuppressedByInvocation(runtime: SkillsCheckRuntime): boolean {
if (argv.includes("--version")) {
return true;
}
return argv.some(
return isFormat(argv, "json");
}

function isFormat(tokens: readonly string[], format: string): boolean {
return tokens.some(
(token, index) =>
token === "--format=json" ||
(token === "--format" && argv[index + 1] === "json"),
token === `--format=${format}` ||
(token === "--format" && tokens[index + 1] === format),
);
}

Expand Down
113 changes: 111 additions & 2 deletions packages/cli/tests/skills-check.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
// biome-ignore-all lint/performance/noAwaitInLoops: the fixture writes one harness directory after another.
/**
* The staleness check as the bin runs it: one stderr line after the
* command's own output, never touching the exit code, and silent
* The staleness check as the bin runs it: one line after the command's
* own output — on stderr, or on stdout as a Markdown section under
* `--format markdown` — never touching the exit code, and silent
* through every off switch.
*/
import { existsSync } from "node:fs";
Expand Down Expand Up @@ -95,6 +96,20 @@ function stubCli(exitCode = 0, marker?: string) {
});
}

/** What a command writes under --format markdown: its document on
* stdout and nothing on stderr. */
function stubMarkdownCli(document: string, exitCode = 0) {
return () => ({
run: async (
_argv: readonly string[],
runtime: { stdout: { write(text: string): void } },
) => {
runtime.stdout.write(document);
return exitCode;
},
});
}

/** A project whose installed package is newer than the copies in its
* harness directories. */
async function makeStaleProject(): Promise<string> {
Expand Down Expand Up @@ -229,6 +244,99 @@ describe("the skills check", () => {
});
});

describe("the skills check under --format markdown", () => {
const MARKDOWN_NOTICE =
"\n### Notice\n" +
"Prisma agent skills are out of date (installed @prisma/orm-postgres 8.1.0, synced 8.0.0).\n" +
"- Sync agent skills: `prisma skills sync`\n";

it.each([
["--format markdown", ["auth", "whoami", "--format", "markdown"]],
["--format=markdown", ["auth", "whoami", "--format=markdown"]],
])("writes the notice to stdout under %s", async (_name, argv) => {
const proc = makeProcess({ cwd: await makeStaleProject(), argv });

const exitCode = await main(
proc,
stubMarkdownCli("[ok] Signed in as ada@example.com\n"),
);

expect(exitCode).toBe(0);
expect(proc.stdoutText).toBe(
`[ok] Signed in as ada@example.com\n${MARKDOWN_NOTICE}`,
);
expect(proc.stderrText).toBe("");
});

it("reports a project that was never synced the same way", async () => {
const root = await makeProjectRoot("check-");
await installPackage(root, {
name: "@prisma/orm-postgres",
version: "8.1.0",
skills: ["prisma-8"],
});
const proc = makeProcess({
cwd: root,
argv: ["auth", "whoami", "--format", "markdown"],
});

await main(proc, stubMarkdownCli(""));

expect(proc.stdoutText).toContain(
"(installed @prisma/orm-postgres 8.1.0, synced none)",
);
expect(proc.stderrText).toBe("");
});

it("says nothing on either stream when every copy is current", async () => {
const proc = makeProcess({
cwd: await makeSyncedProject(),
argv: ["auth", "whoami", "--format", "markdown"],
});

await main(proc, stubMarkdownCli(""));

expect(proc.stdoutText).toBe("");
expect(proc.stderrText).toBe("");
});

it("leaves the exit code of a failing command alone", async () => {
const proc = makeProcess({
cwd: await makeStaleProject(),
argv: ["auth", "whoami", "--format", "markdown"],
});

const exitCode = await main(proc, stubMarkdownCli("", 2));

expect(exitCode).toBe(2);
expect(proc.stdoutText).toBe(MARKDOWN_NOTICE);
});

it("stays on stderr when --format names another value", async () => {
const proc = makeProcess({
cwd: await makeStaleProject(),
argv: ["auth", "whoami", "--format", "human"],
});

await main(proc, stubCli());

expect(proc.stdoutText).toBe("");
expect(proc.stderrText).toContain(NOTICE);
});

it("ignores a markdown format after a bare --", async () => {
const proc = makeProcess({
cwd: await makeStaleProject(),
argv: ["auth", "whoami", "--", "--format", "markdown"],
});

await main(proc, stubCli());

expect(proc.stdoutText).toBe("");
expect(proc.stderrText).toContain(NOTICE);
});
});

describe("the skills check off switches", () => {
it.each([
["--quiet", { argv: ["auth", "whoami", "--quiet"] }],
Expand All @@ -250,6 +358,7 @@ describe("the skills check off switches", () => {
await main(proc, stubCli());

expect(proc.stderrText).toBe("");
expect(proc.stdoutText).toBe("");
});

it.each([
Expand Down
Loading