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: 4 additions & 0 deletions apps/cli-docs/src/fragments/commands/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,14 @@ Many commands take an optional `<org>/<project>` target as their first positiona
| Target | Meaning |
|--------|---------|
| _(omitted)_ | Auto-detect the org and project from config, DSNs, or the directory name |
| `https://<org>.sentry.io/...` | A Sentry web URL. Selects the org and project in the URL, or the whole organization when it names no project |
| `<org>/<project>` | That project in that organization |
| `<org>/<project>,<project>` | Several projects in the same organization (commands that accept several projects) |
| `<org>/<Project Name>` | A project in that organization whose display name matches. A target with a space is a name, not a slug |
| `<org>/` | The whole organization |
| `<name>` | A project named `<name>` in any accessible organization. If no project matches, commands that accept an organization use the organization named `<name>`. When both exist, the project wins: add the trailing slash (`<name>/`) to select the organization |
| `/<name>` | Same as `<name>` |
| `<Project Name>` | A project whose display name matches, in any accessible organization |
<!-- GENERATED:END target-syntax -->

## JSON Output
Expand Down
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@
"dev:stdio": "pnpm --filter '@sentry/mcp-server...' --if-present run build && dotenv -e .env -e .env.local -- pnpm --parallel --filter @sentry/mcp-server --filter @sentry/mcp-core --filter @sentry/mcp-server-mocks --if-present run dev",
"build": "dotenv -e .env -e .env.local -- pnpm -r --filter '!sentry' --filter '!sentry-cli-docs' --if-present run build",
"build:cli": "dotenv -e .env -e .env.local -- pnpm --filter sentry run build && pnpm --filter sentry-cli-docs run build",
"check:generated": "pnpm --filter @sentry/mcp-core generate-definitions && pnpm --filter @sentry/toolkit-core generate && git diff --exit-code -- packages/mcp-core/src/toolDefinitions.json packages/mcp-core/src/skillDefinitions.json plugins/sentry-mcp/agents/sentry-mcp.md plugins/sentry-mcp-experimental/agents/sentry-mcp.md packages/toolkit-core/src/generated",
"check:generated": "pnpm --filter @sentry/mcp-core generate-definitions && pnpm run generate:parsers && git diff --exit-code -- packages/mcp-core/src/toolDefinitions.json packages/mcp-core/src/skillDefinitions.json plugins/sentry-mcp/agents/sentry-mcp.md plugins/sentry-mcp-experimental/agents/sentry-mcp.md packages/toolkit-core/src/generated",
"generate:parsers": "node scripts/generate-parsers.mjs packages/toolkit-core",
"deploy:docs": "pnpm --filter sentry run generate:schema && pnpm --filter sentry run generate:docs && pnpm --filter sentry-cli-docs run build && pnpm --filter sentry-cli-docs run deploy",
"eval": "pnpm --filter '@sentry/mcp-server-evals...' --if-present run build && dotenv -e .env -e .env.local -- pnpm --filter @sentry/mcp-server-evals run eval",
"eval:ci": "pnpm --filter '@sentry/mcp-server-evals...' --if-present run build && CI=true dotenv -e .env -e .env.local -- pnpm --stream -r run eval:ci",
Expand Down Expand Up @@ -58,6 +59,7 @@
"oxfmt": "catalog:",
"oxlint": "catalog:",
"oxlint-tsgolint": "catalog:",
"peggy": "^5.1.0",
"simple-git-hooks": "catalog:",
"tsdown": "catalog:",
"tsx": "catalog:",
Expand Down
6 changes: 5 additions & 1 deletion packages/cli/CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,16 +16,20 @@ sentry project list [org] [--limit N] [--json]
sentry issue list [<org>/<project>] [--json]
```

**Target syntax** (generated from `src/lib/target-syntax.ts`):
**Target syntax** (generated from `grammar/org-project-target.pegjs`):

<!-- GENERATED:START target-syntax -->
| Target | Meaning |
|--------|---------|
| _(omitted)_ | Auto-detect the org and project from config, DSNs, or the directory name |
| `https://<org>.sentry.io/...` | A Sentry web URL. Selects the org and project in the URL, or the whole organization when it names no project |
| `<org>/<project>` | That project in that organization |
| `<org>/<project>,<project>` | Several projects in the same organization (commands that accept several projects) |
| `<org>/<Project Name>` | A project in that organization whose display name matches. A target with a space is a name, not a slug |
| `<org>/` | The whole organization |
| `<name>` | A project named `<name>` in any accessible organization. If no project matches, commands that accept an organization use the organization named `<name>`. When both exist, the project wins: add the trailing slash (`<name>/`) to select the organization |
| `/<name>` | Same as `<name>` |
| `<Project Name>` | A project whose display name matches, in any accessible organization |
<!-- GENERATED:END target-syntax -->

**Rationale**: Positional arguments follow `gh` CLI conventions and are more concise than flags.
Expand Down
33 changes: 33 additions & 0 deletions packages/cli/grammar/org-project-target.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
// Types of the parser generated from org-project-target.pegjs. The generator
// copies this file to src/generated/org-project-target-grammar.d.ts and
// rewrites relative imports to resolve from there.

import type { ParsedOrgProject } from "../src/lib/arg-parsing.js";

/**
* Hooks the target grammar calls for character-level validation, so its
* errors match the rest of the CLI. See `parseOrgProjectArg()`.
*/
export type OrgProjectTargetGrammarOptions = {
/** Accept `<org>/<project>,<project>` lists. */
multi?: boolean;
/** Throw when `value` is not a valid slug. */
checkSlug: (value: string, label: string) => void;
/** Throw when `value` starts with `@`. */
rejectAt: (value: string, label: string) => void;
/** Throw a validation error. */
invalid: (message: string, field?: string) => never;
startRule?: "target";
};

export declare class SyntaxError extends Error {
expected: unknown;
found: string | null;
location: unknown;
}

/** Parse a non-empty target that is not a URL. */
export declare function parse(
input: string,
options: OrgProjectTargetGrammarOptions,
): Exclude<ParsedOrgProject, { type: "auto-detect" }>;
117 changes: 117 additions & 0 deletions packages/cli/grammar/org-project-target.pegjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
// Grammar for the `[<org>/][<project>[,<project>...]]` target positional.
// `pnpm run generate:grammar` compiles it, with the types in
// org-project-target.d.ts, into the gitignored src/generated/; the build,
// typecheck, and test scripts run it.
//
// `parseOrgProjectArg()` (src/lib/arg-parsing.ts) runs this parser after it
// keeps the first non-blank line and handles the omitted and URL forms. The
// grammar owns the structure; character-level checks go through the
// `checkSlug`, `rejectAt`, and `invalid` hooks in `options`, so its errors
// match the rest of the CLI.
//
// The target syntax tables in the docs are generated from the `@form` lines
// here (script/generate-docs-sections.ts). Every `target` alternative needs
// at least one `@form` line, or `@invalid` when it only rejects input. A
// `@form` line reads `@form <syntax> — <meaning>`, both in Markdown; indented
// `//` lines continue the meaning. The two forms below never reach the
// grammar, so they are listed here:
//
// @form _(omitted)_ — Auto-detect the org and project from config, DSNs, or
// the directory name
// @form `https://<org>.sentry.io/...` — A Sentry web URL. Selects the org and
// project in the URL, or the whole organization when it names no project

{
const { multi = false, checkSlug, rejectAt, invalid } = options;

function slug(value, label) {
rejectAt(value, label);
checkSlug(value, label);
return value;
}

function orgSlug(value) {
return slug(value, "organization slug");
}

function projectSlug(value) {
return slug(value, "project slug");
}

function displayName(value, org) {
rejectAt(value, "project slug");
return {
type: "project-search",
projectSlug: value,
originalSlug: value,
...(org !== undefined && { org }),
};
}

// Slugs cannot contain commas, so `org/web,api` is unambiguous. Tokens are
// trimmed, empty ones dropped, and duplicates removed in first-seen order.
function projectList(org, raw) {
const tokens = [...new Set(raw.split(",").map((t) => t.trim()))].filter(
(t) => t !== "",
);
if (tokens.length === 0) {
invalid("Invalid project slug: comma-separated list is empty.", "project");
}
if (tokens.some((t) => t.includes(" "))) {
invalid(
"Comma-separated project targets must be slugs, not display names.",
"project",
);
}
const [first, second, ...others] = tokens.map(projectSlug);
return {
type: "explicit",
org,
project: first,
...(second !== undefined && { projects: [first, second, ...others] }),
};
}
}

// Peggy runs a rule's action as soon as that rule matches, even when the
// enclosing alternative fails later and the parser backtracks. The token
// rules below therefore only capture text, and validation happens in the
// action of the `target` alternative that wins. Otherwise `@latest` would be
// rejected as an organization slug before it is parsed as a project (covered
// by "@latest throws with redirect to issue view" in arg-parsing.test.ts).
target
// @form `<org>/<project>` — That project in that organization
= o:org "/" p:slug !{ return multi && p.includes(","); }
{ const org = orgSlug(o); return { type: "explicit", org, project: projectSlug(p) }; }
// @form `<org>/<project>,<project>` — Several projects in the same
// organization (commands that accept several projects)
/ o:org "/" &{ return multi; } l:list { return projectList(orgSlug(o), l); }
// @form `<org>/<Project Name>` — A project in that organization whose
// display name matches. A target with a space is a name, not a slug
/ o:org "/" p:name { const org = orgSlug(o); return displayName(p, org); }
// @form `<org>/` — The whole organization
/ o:org "/" !. { return { type: "org-all", org: orgSlug(o) }; }
// @form `<name>` — A project named `<name>` in any accessible organization.
// If no project matches, commands that accept an organization use the
// organization named `<name>`. When both exist, the project wins: add the
// trailing slash (`<name>/`) to select the organization
// @form `/<name>` — Same as `<name>`
/ "/"? p:slug { return { type: "project-search", projectSlug: projectSlug(p) }; }
// @form `<Project Name>` — A project whose display name matches, in any
// accessible organization
/ "/"? p:name { return displayName(p); }
// @invalid
/ "/" !. { invalid('Invalid format: "/" requires a project slug (e.g., "/cli")'); }

// Everything before the first slash.
org = $[^/]+

// The rest of the input when it has no spaces. Slashes, commas, and other
// characters are kept so `checkSlug` reports them.
slug = $([^ ]+ !.)

// The rest of the input when it has a space: a display name.
name = $([^ ]* " " .*)

// The rest of the input when it has a comma: a project list.
list = $([^,]* "," .*)
4 changes: 3 additions & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,8 @@
"generate:sdk": "pnpm tsx script/generate-sdk.ts",
"generate:skill": "pnpm tsx script/generate-skill.ts",
"generate:banner": "pnpm tsx script/generate-banner-sixel.ts",
"generate:docs": "node script/ensure-generated.mjs schema && pnpm run generate:banner && pnpm run generate:completions && pnpm run generate:command-docs && pnpm run generate:docs-sections && pnpm run generate:skill",
"generate:docs": "node script/ensure-generated.mjs schema && pnpm run generate:grammar && pnpm run generate:banner && pnpm run generate:completions && pnpm run generate:command-docs && pnpm run generate:docs-sections && pnpm run generate:skill",
"generate:grammar": "node ../../scripts/generate-parsers.mjs .",
"generate:completions": "pnpm tsx script/generate-completion-targets.ts",
"generate:docs-sections": "pnpm tsx script/generate-docs-sections.ts",
"generate:schema": "pnpm tsx script/generate-api-schema.ts",
Expand Down Expand Up @@ -136,6 +137,7 @@
"marked": "^15.0.12",
"node-sqlite3-wasm": "0.8.59",
"p-limit": "^7.3.0",
"peggy": "^5.1.0",
"picomatch": "^4.0.4",
"pixelmatch": "^7.2.0",
"pngjs": "^7.0.0",
Expand Down
17 changes: 13 additions & 4 deletions packages/cli/script/ensure-generated.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,11 @@
/**
* Generate the gitignored `src/generated/` files when they are missing.
*
* A fresh checkout has no API schema, skill content, or completion targets, so
* `pnpm run cli`, `typecheck`, and the tests used to fail until contributors
* ran `generate:schema` and `generate:docs` by hand. Scripts that need those
* files call this first, so there is no bootstrap step to document or forget.
* A fresh checkout has no API schema, target parser, skill content, or
* completion targets, so `pnpm run cli`, `typecheck`, and the tests used to
* fail until contributors ran `generate:schema` and `generate:docs` by hand.
* Scripts that need those files call this first, so there is no bootstrap
* step to document or forget.
*
* Usage:
* node script/ensure-generated.mjs # everything the CLI imports
Expand All @@ -18,6 +19,11 @@ import { execSync } from "node:child_process";
import { existsSync } from "node:fs";

const SCHEMA = "src/generated/api-schema.json";
/** Produced by `generate:grammar`; imported by `src/lib/arg-parsing.ts`. */
const GRAMMAR_OUTPUTS = [
"src/generated/org-project-target-grammar.js",
"src/generated/org-project-target-grammar.d.ts",
];
/** Produced by `generate:docs`; imported at runtime by `src/`. */
const DOCS_OUTPUTS = [
"src/generated/skill-content.ts",
Expand All @@ -32,6 +38,9 @@ function run(script) {
if (!existsSync(SCHEMA)) {
run("generate:schema");
}
if (process.argv[2] !== "schema" && !GRAMMAR_OUTPUTS.every(existsSync)) {
run("generate:grammar");
}
if (process.argv[2] !== "schema" && !DOCS_OUTPUTS.every(existsSync)) {
run("generate:docs");
}
Loading
Loading