Skip to content
Open
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: 4 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@ Unit tests may still mock, and should — error paths and edge cases belong ther

Why this rule exists: `prisma project list` reported "No projects found." and exited 0 for a workspace holding 15 projects, and every project-scoped command was broken with it. The unit suite covered that command thoroughly and passed throughout, because its fixtures supplied both sides of every comparison — the credential's workspace id and the API's were the same hand-written string, while the real API returns a `wksp_` prefix that the credential does not carry. A test that writes both sides of a comparison can only confirm what its author already believed. Mocks are still the right tool for error paths and edge cases; they cannot tell you what the API actually returns.

## Help Text

Hold every new or changed command's help (summary, description, flag briefs, group brief) to `docs/product/cli-help-standard.md`. Write for a reader who has never used Prisma: each summary stands alone, defines any Prisma term in place (usually by stating the consequence), and never uses internal vocabulary such as "binding", "resolved", or "pinned". Card structure and formatting rules are in `docs/product/cli-style-guide.md`.

## Pre-Commit Verification

- `pnpm typecheck`
Expand Down
37 changes: 37 additions & 0 deletions docs/product/cli-help-standard.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# CLI Help Standard

## Purpose

This document defines the standard every command's `--help` text must meet, and why the bar is set where it is. The formatting and card-structure rules live in `cli-style-guide.md`; this document is the contract for the words.

## Why Help Is a Manual, Not a Summary

The CLI's help is the one interface every consumer is guaranteed to have. Documentation sites go stale or unread, and agent skills may not be installed. `--help` ships with the binary, matches the installed version exactly, and is the first thing both a new user and an AI agent reach for. So the CLI must be self-documenting: help text carries enough of the product model that a reader can act correctly without any other source.

The reader to write for is an AI coding agent that has never heard of Prisma. That reader is also the strictest proxy for a new human user. It does not know what a workspace, Project, Branch, contract, or skill is. It cannot infer that "linking" stores a local pointer, or that a "version" is immutable and one serves traffic. Every term it cannot resolve from the card in front of it costs a round trip: a doc lookup, a wrong invocation, a retried command. That churn is the thing this standard exists to eliminate. When help defines its own terms, an agent reads one card and issues the right command; when it does not, the agent guesses.

The same rule protects against a quieter failure: help that paraphrases the command name. "prisma deploy — Deploy" tells the reader nothing the invocation did not. Text earns its place by adding intent (when to run this, what it operates on, what happens next), never by restating the grammar.

## The Standard

Definitions of each surface: a group's *brief* is the one-line text on its parent card; its *description* is the prose on its own card. A command's *summary* is its row on the group card; its *description* is the prose on its own card.

1. Every row stands alone. A summary must be understandable by a reader who will never open the leaf card. If it depends on a term the reader cannot know, define the term in place, usually by stating the consequence: "Link this directory to a Project: commands run here target it by default".
2. Group briefs are a short lead, then scope. Name what lives beneath: "Manage S3-compatible object-store buckets for a project. CRUD operations and access keys". "CRUD" may stand in for the common verbs; operations a reader would not guess (link, transfer, promote) are named.
3. Group descriptions define every term their rows and flags rely on: what the resource is, what it belongs to, and the lifecycle words the subcommands use. If a row says "linked", "version", or "scope", the group card says what that means.
4. Commands that are self-describing stay short. "project list — List all projects in your workspace" needs no description. Add prose only where it carries intent the invocation does not.
5. Flag briefs state what the flag does and, when it exists for a distinct situation, when to reach for it. Defaults are spelled out in plain language: "(default: the project this directory is linked to)", never "(default: the resolved project)".
6. Internal vocabulary never leaks. "Binding", "resolved", "pinned", and "active" are implementation terms; help uses the plain phrase or defines the word in the same card.
7. Concepts are defined at first use, in one clause. A Project groups one product or codebase inside a workspace. A Branch maps to a Git branch and is an isolated environment with its own services, databases, and buckets. A service version is one immutable deploy; one serves traffic at a time.
8. Groups with a common multi-command path declare a Workflow section: ordered, copy-pastable steps whose purpose column states action and consequence, standing alone like any row.
9. Current product names only. Prisma ORM is called Prisma ORM; retired names such as "Prisma Next" never appear.

## What This Buys

1. Self-documenting: the installed binary is the reference for its own version; no doc site round trip.
2. Agent-ready without skills: an agent with no Prisma skill installed can go from `prisma --help` to a correct, safe invocation by reading cards, because each card teaches the model it needs.
3. Less churn for everyone: fewer wrong invocations, fewer retries, fewer support questions that are really vocabulary questions.

## Enforcement

New and changed commands must meet this standard before they merge; review help text against it the way behavior is reviewed against the product docs. The repository `AGENTS.md` binds agents working in this repo to the same rule.
24 changes: 24 additions & 0 deletions docs/product/cli-style-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,10 @@ Rules:

## Help and Usage

The wording contract for help text, and the reasoning behind it, is
`cli-help-standard.md`. This section covers the card structure and
formatting.

Help should feel like the rest of the CLI:

- concise
Expand All @@ -140,6 +144,26 @@ Help output should:

Unknown commands should show "Did you mean ..." suggestions when there is a clear close match.

### Descriptions Describe Intent

Help text is read by people and by agents that have never seen the platform's resource model. Help is a manual, not a summary. Write for both:

- The summary states what the command does. When the command name already says it, the summary stays a plain restatement (`project list` → "List the projects in your workspace") and no description is added.
- A description earns its place by adding intent: when to run the command, what it operates on, and what happens next. It must not paraphrase the command name.
- A group's brief is a short lead sentence, then the scope of what lives beneath it ("Manage S3-compatible object-store buckets for a project. CRUD operations and access keys"). "CRUD" may stand in for the common verbs; operations a reader would not guess (link, transfer, promote) are named.
- A group's description defines every term its command rows rely on. If a row says "linked", the group card says what linking is before the reader opens the leaf.
- Do not assume the reader knows Prisma nouns. The first time a group or command depends on one, define it in one clause: a Project groups one product or codebase inside a workspace; a Branch maps to a Git branch and is an isolated environment with its own services, databases, and buckets.
- Internal resolution terms stay out of help: no "binding", "resolved", "pinned", or "active" without a plain-language definition in the same card. Prefer the plain phrase outright ("the project this directory is linked to" over "the resolved project").
- Refer to Prisma ORM by that name. Do not use retired product names such as "Prisma Next" in help text.

### Flag Briefs Say When

A flag brief states what the flag does; when the flag exists for a distinct situation, it also says when to reach for it ("--branch: target a preview branch instead of the default branch"). Defaults render as an automatic suffix, so briefs do not repeat them.

### Workflow Sections

A group card may declare a workflow: the ordered commands of that group's common path, each with a short purpose column. The engine renders it as a `Workflow` section, before any examples, with each step `$`-prefixed and copy-pastable. Declare a workflow only where a real multi-command path exists; a group of independent commands has no workflow.

## Flags

Shared flag rules:
Expand Down
13 changes: 12 additions & 1 deletion packages/cli-engine/src/cli.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { CommandFamily, MountedTree } from "./command-family";
import type { WorkflowStep } from "./commands";
import { buildEngine } from "./execution/engine";
import type { RunSummary } from "./run-summary";
import type { Runtime } from "./runtime";
Expand Down Expand Up @@ -41,13 +42,23 @@ export function createCli(spec: {
readonly version: string;
readonly commandFamilies: readonly CommandFamily[];
readonly groups: Readonly<
Record<string, { readonly brief: string; readonly description?: string }>
Record<
string,
{
readonly brief: string;
readonly description?: string;
/** The group's common path, rendered as a `Workflow` section. */
readonly workflow?: readonly WorkflowStep[];
}
>
>;
readonly commands: MountedTree;
/** Words for the root help card; the engine formats. */
readonly help?: {
readonly tagline?: string;
readonly description?: string;
/** The CLI's common path, rendered as a `Workflow` section. */
readonly workflow?: readonly WorkflowStep[];
readonly examples?: readonly string[];
readonly docsUrl?: string;
};
Expand Down
11 changes: 11 additions & 0 deletions packages/cli-engine/src/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,17 @@ import type { ChildStatusSettlement } from "./spawn";
* undefined instead of conditional properties.
*/

/**
* One step of a group's common path, rendered under a `Workflow`
* heading on the group's help card. `run` follows the example
* convention: no binary name, `{bin}` substituted at render time.
*/
export interface WorkflowStep {
readonly run: string;
/** Short purpose column shown beside the invocation. */
readonly brief: string;
}

/** The help SPI: words only — the engine formats. */
export interface HelpSpec {
/** One line, imperative, shown in listings. */
Expand Down
14 changes: 12 additions & 2 deletions packages/cli-engine/src/execution/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import type {
CommandRedirect,
MountedTree,
} from "../command-family";
import type { AnyCommand } from "../commands";
import type { AnyCommand, WorkflowStep } from "../commands";
import type { CommandContext } from "../context";
import type { ActiveCredential } from "../credential-manager";
import type { EngineEvent, Severity, StreamEvent } from "../events";
Expand Down Expand Up @@ -83,7 +83,15 @@ export interface EngineSpec {
readonly version: string;
readonly commandFamilies: readonly CommandFamily[];
readonly groups: Readonly<
Record<string, { readonly brief: string; readonly description?: string }>
Record<
string,
{
readonly brief: string;
readonly description?: string;
/** The group's common path, rendered as a `Workflow` section. */
readonly workflow?: readonly WorkflowStep[];
}
>
>;
readonly commands: MountedTree;
/** Words for the root help card; the engine formats. */
Expand All @@ -92,6 +100,8 @@ export interface EngineSpec {
readonly tagline?: string;
/** A sentence or two under the command list. */
readonly description?: string;
/** The CLI's common path, rendered as a `Workflow` section. */
readonly workflow?: readonly WorkflowStep[];
/** Same {bin} substitution rule as command examples. */
readonly examples?: readonly string[];
readonly docsUrl?: string;
Expand Down
36 changes: 35 additions & 1 deletion packages/cli-engine/src/execution/help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {
type PositionalSpec,
positionalRuntime,
} from "../args";
import type { AnyCommand } from "../commands";
import type { AnyCommand, WorkflowStep } from "../commands";
import type { CommandTreeEntry, CommandTreeNode } from "./command-tree";
import type { EngineSpec } from "./engine";
import { makePaint, type Paint, textWidth } from "./palette";
Expand Down Expand Up @@ -243,6 +243,35 @@ function exampleLines(
}
}

/** The group's common path: `$`-prefixed copy-pastable steps in mount
* order, purpose column muted, aligned like every other row block. */
function workflowLines(
workflow: readonly WorkflowStep[] | undefined,
cliName: string,
paint: Paint,
lines: string[],
): void {
if (workflow === undefined || workflow.length === 0) {
return;
}
lines.push(rail(paint));
lines.push(sectionLabel(paint, "Workflow"));
const steps = workflow.map((step) => ({
run: resolveExample(step.run, cliName),
brief: step.brief,
}));
const width = Math.max(...steps.map((step) => textWidth(step.run)));
for (const step of steps) {
const pad = " ".repeat(width - textWidth(step.run));
lines.push(
rail(
paint,
`${GAP}${paint("muted", "$")} ${step.run}${pad}${GAP}${paint("muted", step.brief)}`,
),
);
}
}

function docsLine(
url: string | undefined,
paint: Paint,
Expand Down Expand Up @@ -429,6 +458,11 @@ function renderNodeHelp(
proseLines(description, paint, lines);
}

const workflow = atRoot
? spec.help?.workflow
: spec.groups[groupPath]?.workflow;
workflowLines(workflow, spec.name, paint, lines);

if (atRoot) {
lines.push(rail(paint));
lines.push(sectionLabel(paint, "Global options"));
Expand Down
1 change: 1 addition & 0 deletions packages/cli-engine/src/exports/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export {
type ServerCommandDefinition,
type SessionCommandDefinition,
type SpawnDeclarations,
type WorkflowStep,
} from "../commands";
export {
defineConfig,
Expand Down
11 changes: 3 additions & 8 deletions packages/cli-engine/src/telemetry/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,10 +118,7 @@ export const telemetryStatusCommand = defineCommand({
help: {
summary: "Show whether anonymous CLI telemetry is enabled and why",
description:
"Reports whether telemetry is currently enabled or disabled and the reason\n" +
"(default-on, stored opt-out, environment opt-out, or CI), the path to your\n" +
"user-level config file, and whether an installation ID has been stored.\n" +
"Read-only: never sends an event, never mints an ID, never writes anything.",
"Reports whether telemetry is currently enabled or disabled and the reason (default-on, stored opt-out, environment opt-out, or CI), the path to your user-level config file, and whether an installation ID has been stored. Read-only: never sends an event, never mints an ID, never writes anything.",
examples: ["telemetry status", "telemetry status --json"],
},
handler: async (_args, ctx) => {
Expand All @@ -142,8 +139,7 @@ export const telemetryEnableCommand = defineCommand({
help: {
summary: "Enable anonymous CLI telemetry",
description:
'Stores "enableTelemetry": true in your user-level config and mints an\n' +
"installation ID if one is not already stored.",
'Stores "enableTelemetry": true in your user-level config and mints an installation ID if one is not already stored.',
examples: ["telemetry enable"],
},
handler: async (_args, ctx) => {
Expand All @@ -169,8 +165,7 @@ export const telemetryDisableCommand = defineCommand({
help: {
summary: "Disable anonymous CLI telemetry",
description:
'Stores "enableTelemetry": false in your user-level config. No installation\n' +
"ID is minted and no event is sent.",
'Stores "enableTelemetry": false in your user-level config. No installation ID is minted and no event is sent.',
examples: ["telemetry disable"],
},
handler: async (_args, ctx) => {
Expand Down
21 changes: 20 additions & 1 deletion packages/cli-engine/src/testing.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { CommandFamily, MountedTree } from "./command-family";
import type { WorkflowStep } from "./commands";
import { CONFIG_FILE_NAME } from "./config-loader";
import type { Credential } from "./credential-manager";
import type { EngineEvent, StreamEvent } from "./events";
Expand Down Expand Up @@ -206,7 +207,24 @@ function inputStreamFromString(text: string) {
export function createTestCli(spec: {
readonly commandFamilies?: readonly CommandFamily[];
readonly commands: MountedTree;
readonly groups?: Readonly<Record<string, { readonly brief: string }>>;
readonly groups?: Readonly<
Record<
string,
{
readonly brief: string;
readonly description?: string;
readonly workflow?: readonly WorkflowStep[];
}
>
>;
/** Words for the root help card, exactly as `createCli` takes them. */
readonly help?: {
readonly tagline?: string;
readonly description?: string;
readonly workflow?: readonly WorkflowStep[];
readonly examples?: readonly string[];
readonly docsUrl?: string;
};
/** Seeds the sections the config file would have held. The engine
* asks for them only when the command declares a config section, and
* checks them as it would a real file's: a section name no mounted
Expand Down Expand Up @@ -313,6 +331,7 @@ export function createTestCli(spec: {
commandFamilies: spec.commandFamilies ?? [],
groups: spec.groups ?? {},
commands: spec.commands,
help: spec.help,
telemetry: spec.telemetry,
},
/** Waiting is instant under test: browserWait's polling is driven
Expand Down
60 changes: 60 additions & 0 deletions packages/cli-engine/tests/execution.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1264,6 +1264,66 @@ describe("help examples", () => {
});
});

describe("help workflow sections", () => {
test("a group's workflow renders as $-prefixed steps with briefs, {bin} substituted", async () => {
const cli = createTestCli({
commands: { "auth login": greet },
groups: {
auth: {
brief: "Authentication",
workflow: [
{ run: "auth login", brief: "Sign in once" },
{ run: "{bin} auth whoami | cat", brief: "Check the session" },
],
},
},
now: EPOCH,
});
const result = await cli.run(["auth", "--help"], {
isTty: { stdout: true },
});

expect(result.exitCode).toBe(0);
expect(result.stdout).toContain("Workflow");
expect(result.stdout).toContain("prisma-test auth login");
expect(result.stdout).toContain("Sign in once");
expect(result.stdout).toContain("prisma-test auth whoami | cat");
expect(result.stdout).not.toContain("{bin}");
});

test("the root workflow renders before the root examples", async () => {
const cli = createTestCli({
commands: { greet },
help: {
workflow: [{ run: "greet", brief: "Say hello" }],
examples: ["greet --loud"],
},
now: EPOCH,
});
const result = await cli.run([], { isTty: { stdout: true } });

expect(result.exitCode).toBe(0);
expect(result.stdout).toContain("Workflow");
expect(result.stdout.indexOf("Workflow")).toBeLessThan(
result.stdout.indexOf("Examples"),
);
});

test("a group without a workflow renders no Workflow section", async () => {
const cli = createTestCli({
commands: { "auth login": greet },
groups: { auth: { brief: "Authentication" } },
now: EPOCH,
});
const result = await cli.run(["auth", "--help"], {
isTty: { stdout: true },
});

expect(result.exitCode).toBe(0);
expect(result.stdout).not.toContain("Workflow");
});
});

describe("--version", () => {
test("prints the version and exits 0 in human mode", async () => {
const cli = createTestCli({ commands: { greet: greet }, now: EPOCH });
Expand Down
Loading
Loading