diff --git a/docs/product/output-conventions.md b/docs/product/output-conventions.md index d10a552e..9a059653 100644 --- a/docs/product/output-conventions.md +++ b/docs/product/output-conventions.md @@ -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: @@ -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 diff --git a/packages/cli/src/main.ts b/packages/cli/src/main.ts index 4fa68c91..a8121649 100644 --- a/packages/cli/src/main.ts +++ b/packages/cli/src/main.ts @@ -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; diff --git a/packages/cli/src/skills-check.ts b/packages/cli/src/skills-check.ts index 2b57f497..b7ad239c 100644 --- a/packages/cli/src/skills-check.ts +++ b/packages/cli/src/skills-check.ts @@ -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"; @@ -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 }; } @@ -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); @@ -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 { @@ -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([ @@ -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), ); } diff --git a/packages/cli/tests/skills-check.test.ts b/packages/cli/tests/skills-check.test.ts index 90d3917f..fe9281a2 100644 --- a/packages/cli/tests/skills-check.test.ts +++ b/packages/cli/tests/skills-check.test.ts @@ -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"; @@ -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 { @@ -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"] }], @@ -250,6 +358,7 @@ describe("the skills check off switches", () => { await main(proc, stubCli()); expect(proc.stderrText).toBe(""); + expect(proc.stdoutText).toBe(""); }); it.each([