From 0c914dd0d0d35e216ff3acbb152e775ac34a289d Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Fri, 23 Oct 2026 12:31:43 +0000 Subject: [PATCH 1/4] docs(cli): Generate and check docs that mirror CLI behavior Lists that copied code (destructive commands, detected agents, target syntax, skill file layout) are now generated from the code. New checks fail CI when a `sentry ...` example names a missing command or flag, passes a bare org slug as a project-first target, or when an installer option is missing from its help text or the install guide. `pnpm run cli` and `generate:docs` now create missing build-time files, and check:stale-refs flags `pnpm run cli -- `, which pnpm forwards as a literal `--`. Co-Authored-By: Claude Opus Co-Authored-By: BYK --- .oxfmtrc.json | 1 + .../src/content/docs/agent-guidance.md | 6 +- .../src/content/docs/agentic-usage.md | 10 +- .../cli-docs/src/content/docs/contributing.md | 2 +- .../src/content/docs/getting-started.mdx | 3 + apps/cli-docs/src/fragments/commands/alert.md | 4 +- .../src/fragments/commands/dashboard.md | 4 +- apps/cli-docs/src/fragments/commands/index.md | 14 + apps/cli-docs/src/fragments/commands/init.md | 2 +- packages/cli/AGENTS.md | 2 +- packages/cli/CONTRIBUTING.md | 18 +- packages/cli/DEVELOPMENT.md | 4 +- packages/cli/README.md | 10 +- packages/cli/install | 1 + packages/cli/package.json | 8 +- packages/cli/plugins/README.md | 33 +- .../sentry-cli/skills/sentry-cli/SKILL.md | 4 +- .../skills/sentry-cli/references/alert.md | 4 +- .../skills/sentry-cli/references/cli.md | 2 +- .../skills/sentry-cli/references/dashboard.md | 4 +- packages/cli/script/check-doc-examples.ts | 347 ++++++++++++++++++ packages/cli/script/check-installer-docs.ts | 114 ++++++ packages/cli/script/check-stale-references.ts | 52 ++- packages/cli/script/ensure-generated.mjs | 37 ++ packages/cli/script/generate-docs-sections.ts | 127 ++++++- packages/cli/script/generate-skill.ts | 7 +- packages/cli/src/commands/cli/uninstall.ts | 10 +- packages/cli/src/commands/dashboard/list.ts | 1 - .../cli/src/commands/dashboard/restore.ts | 2 +- .../cli/src/commands/dashboard/revisions.ts | 6 +- packages/cli/src/commands/dashboard/view.ts | 4 +- packages/cli/src/commands/issue/merge.ts | 1 + packages/cli/src/commands/trial/start.ts | 1 + packages/cli/src/lib/command.ts | 12 + packages/cli/src/lib/db/index.ts | 3 + packages/cli/src/lib/detect-agent.ts | 28 ++ packages/cli/src/lib/introspect.ts | 5 + packages/cli/src/lib/mutate-command.ts | 3 + packages/cli/src/lib/target-syntax.ts | 65 ++++ packages/cli/test/lib/detect-agent.test.ts | 27 ++ packages/cli/test/lib/target-syntax.test.ts | 29 ++ 41 files changed, 949 insertions(+), 68 deletions(-) create mode 100644 packages/cli/script/check-doc-examples.ts create mode 100644 packages/cli/script/check-installer-docs.ts create mode 100644 packages/cli/script/ensure-generated.mjs create mode 100644 packages/cli/src/lib/target-syntax.ts create mode 100644 packages/cli/test/lib/target-syntax.test.ts diff --git a/.oxfmtrc.json b/.oxfmtrc.json index 7bf3ff175..4e98fd9c6 100644 --- a/.oxfmtrc.json +++ b/.oxfmtrc.json @@ -12,6 +12,7 @@ "packages/cli/plugins/**", "packages/cli/README.md", "packages/cli/DEVELOPMENT.md", + "packages/cli/CONTRIBUTING.md", "packages/cli/CHANGELOG.md", "packages/cli/AGENTS.md", "packages/mcp-server-mocks/src/fixtures/**", diff --git a/apps/cli-docs/src/content/docs/agent-guidance.md b/apps/cli-docs/src/content/docs/agent-guidance.md index 7dc9d82e9..2c7b2c504 100644 --- a/apps/cli-docs/src/content/docs/agent-guidance.md +++ b/apps/cli-docs/src/content/docs/agent-guidance.md @@ -34,7 +34,9 @@ The `sentry` CLI follows conventions from well-known tools — if you're familia ## Safety Rules -- Always confirm with the user before running destructive commands: `project delete`, `trial start` + +- Always confirm with the user before running destructive commands: `alert issues delete`, `alert metrics delete`, `cli uninstall`, `dashboard widget delete`, `integration delete`, `issue merge`, `issue unlink`, `project delete`, `release delete`, `trial start` + - For mutations, verify the org/project context looks correct in the command output before proceeding with further changes - Never store or log authentication tokens — the CLI manages credentials automatically - If the CLI reports the wrong org/project, override with explicit `/` arguments @@ -210,7 +212,7 @@ sentry release deploy my-org/1.0.0 production sentry agent-conversation list # Explicit org, last 24 hours -sentry agent-conversation list my-org --period 24h +sentry agent-conversation list my-org/ --period 24h # View a conversation transcript sentry agent-conversation view my-org/conv-123 diff --git a/apps/cli-docs/src/content/docs/agentic-usage.md b/apps/cli-docs/src/content/docs/agentic-usage.md index 2f6e6ff27..b688e81ad 100644 --- a/apps/cli-docs/src/content/docs/agentic-usage.md +++ b/apps/cli-docs/src/content/docs/agentic-usage.md @@ -3,7 +3,11 @@ title: Agentic Usage description: Enable AI coding agents to use the Sentry CLI --- -AI coding agents can use the Sentry CLI through the skill system. The CLI detects and supports Claude Code (including Cowork), Cursor, Windsurf, GitHub Copilot, Gemini CLI, OpenAI Codex, Goose, Amp, Augment, OpenCode, Cline, Grok, Kimi, Junie, OpenClaw, and any agent that reads skills from `~/.agents`. This allows agents to interact with Sentry directly from your development environment. +AI coding agents can use the Sentry CLI through the skill system, which lets them interact with Sentry directly from your development environment. Any agent that reads skills from `~/.agents` can use the skill. + + +The CLI detects Claude Code, Claude Cowork, Cursor, Windsurf, GitHub Copilot, Gemini CLI, OpenAI Codex, Antigravity, Goose, Amp, Augment, OpenCode, Cline, Grok, Kimi, Junie, and OpenClaw. + ## Automatic Installation @@ -66,7 +70,7 @@ When you ask your agent about Sentry errors or want to investigate an issue, the - "Show me the latest issues in my project" → `sentry issue list` - "What's the stack trace for ISSUE-123?" → `sentry issue view ISSUE-123` -- "List all projects in my organization" → `sentry project list my-org` +- "List all projects in my organization" → `sentry project list my-org/` - "What API endpoints exist for releases?" → `sentry schema releases` - "How do I set up source maps for Next.js?" → `sentry docs "source maps Next.js"` - "What is Sentry's status right now?" → `sentry status` @@ -79,4 +83,4 @@ The skill uses your existing CLI authentication, so you'll need to run `sentry a ## Requirements - An authenticated Sentry CLI installation (`sentry auth login`) -- An AI coding agent that supports the skills system (e.g., Claude Code, Cursor, Windsurf, GitHub Copilot, Gemini CLI, Codex, Goose, Amp, Augment, OpenCode, Cline, Grok, Kimi, Junie, OpenClaw, or any agent that reads from `~/.agents`) +- An AI coding agent that supports the skills system, such as one of the agents listed at the top of this page or any agent that reads from `~/.agents` diff --git a/apps/cli-docs/src/content/docs/contributing.md b/apps/cli-docs/src/content/docs/contributing.md index 86c950e94..473d573e7 100644 --- a/apps/cli-docs/src/content/docs/contributing.md +++ b/apps/cli-docs/src/content/docs/contributing.md @@ -27,7 +27,7 @@ pnpm install cd packages/cli # Run CLI in development mode -pnpm run cli -- --help +pnpm run cli --help # Run tests pnpm run test diff --git a/apps/cli-docs/src/content/docs/getting-started.mdx b/apps/cli-docs/src/content/docs/getting-started.mdx index ee2b01822..02efb7ae4 100644 --- a/apps/cli-docs/src/content/docs/getting-started.mdx +++ b/apps/cli-docs/src/content/docs/getting-started.mdx @@ -61,6 +61,9 @@ Set `SENTRY_INIT=1` to run [`sentry init`](../commands/init/) right after instal curl https://cli.sentry.dev/install -fsS | SENTRY_INIT=1 bash ``` +The installer reports its own errors to Sentry. Set `SENTRY_CLI_NO_TELEMETRY=1` +to turn this off; the CLI honors the same variable. + ### Windows (PowerShell) On Windows, install the latest stable release from PowerShell: diff --git a/apps/cli-docs/src/fragments/commands/alert.md b/apps/cli-docs/src/fragments/commands/alert.md index 9b6d80c1d..fb881d799 100644 --- a/apps/cli-docs/src/fragments/commands/alert.md +++ b/apps/cli-docs/src/fragments/commands/alert.md @@ -50,7 +50,7 @@ sentry alert issues delete my-org/my-project/12345 --dry-run ```bash # Alert when P95 span duration goes above 500ms -sentry alert metrics create my-org \ +sentry alert metrics create my-org/ \ --name "P95 Latency" \ --project my-app \ --query "environment:prod" \ @@ -60,7 +60,7 @@ sentry alert metrics create my-org \ --trigger '{"alertThreshold":500}' # Alert on any matching log line and notify through an existing alert -sentry alert metrics create my-org \ +sentry alert metrics create my-org/ \ --name "AI chat failures" \ --project my-app \ --query "action:ai.chat result:error" \ diff --git a/apps/cli-docs/src/fragments/commands/dashboard.md b/apps/cli-docs/src/fragments/commands/dashboard.md index 9d8a1f2e1..b0d141065 100644 --- a/apps/cli-docs/src/fragments/commands/dashboard.md +++ b/apps/cli-docs/src/fragments/commands/dashboard.md @@ -136,7 +136,7 @@ sentry dashboard revisions 'Frontend Performance' sentry dashboard revisions 12345 # With explicit org -sentry dashboard revisions my-org 12345 +sentry dashboard revisions my-org/ 12345 ``` ### Restore a previous revision @@ -149,7 +149,7 @@ sentry dashboard restore 'Frontend Performance' --revision 3 sentry dashboard restore 12345 --revision 1 # With explicit org -sentry dashboard restore my-org 12345 --revision 1 +sentry dashboard restore my-org/ 12345 --revision 1 ``` :::tip diff --git a/apps/cli-docs/src/fragments/commands/index.md b/apps/cli-docs/src/fragments/commands/index.md index d1ab7faf7..9748a71f0 100644 --- a/apps/cli-docs/src/fragments/commands/index.md +++ b/apps/cli-docs/src/fragments/commands/index.md @@ -9,6 +9,20 @@ All commands support the following global options: - `--log-level ` - Set log verbosity (`error`, `warn`, `log`, `info`, `debug`, `trace`). Overrides `SENTRY_LOG_LEVEL` - `--verbose` - Shorthand for `--log-level debug` +## Targeting Organizations and Projects + +Many commands take an optional `/` target as their first positional argument, or as a prefix of an ID (e.g. `my-org/my-project/`). + + +| Target | Meaning | +|--------|---------| +| _(omitted)_ | Auto-detect the org and project from config, DSNs, or the directory name | +| `/` | That project in that organization | +| `/,` | Several projects in the same organization (commands that accept several projects) | +| `/` | The whole organization | +| `` | A project named `` in any accessible organization. If no project matches, commands that accept an organization use the organization named ``. When both exist, the project wins: add the trailing slash (`/`) to select the organization | + + ## JSON Output Most list and view commands support `--json` flag for JSON output, making it easy to integrate with other tools: diff --git a/apps/cli-docs/src/fragments/commands/init.md b/apps/cli-docs/src/fragments/commands/init.md index 0cc4aa942..cef0a5cc2 100644 --- a/apps/cli-docs/src/fragments/commands/init.md +++ b/apps/cli-docs/src/fragments/commands/init.md @@ -41,7 +41,7 @@ sentry init --features profiling,replay | _(omitted)_ | Auto-detect org and project | | `acme/` | Use org `acme`, auto-detect or create project | | `acme/my-app` | Use org `acme` and project `my-app` | -| `my-app` | Search for project `my-app` across all accessible orgs | +| `my-app` | Use the existing project `my-app` from any accessible org. If there is none, use org `my-app` when one exists; otherwise create a new project named `my-app` | Path-like arguments (starting with `.`, `/`, or `~`) are always treated as the directory. The order of target and directory can be swapped — the CLI will auto-correct with a warning. diff --git a/packages/cli/AGENTS.md b/packages/cli/AGENTS.md index 2e8d0c163..4d5d489e5 100644 --- a/packages/cli/AGENTS.md +++ b/packages/cli/AGENTS.md @@ -39,7 +39,7 @@ Before working on this codebase, read the Cursor rules: # Development pnpm install # Install dependencies (from repo root) pnpm run dev # Run CLI in dev mode -pnpm run cli -- # Run the CLI with arguments +pnpm run cli # Run the CLI with arguments # Build pnpm run build # Build for current platform diff --git a/packages/cli/CONTRIBUTING.md b/packages/cli/CONTRIBUTING.md index 4fb3df99a..524dca951 100644 --- a/packages/cli/CONTRIBUTING.md +++ b/packages/cli/CONTRIBUTING.md @@ -16,13 +16,17 @@ sentry project list [org] [--limit N] [--json] sentry issue list [/] [--json] ``` -**Target syntax**: - -- `/` - Explicit organization and project (e.g., `my-org/frontend`) -- `/,` - Several projects in the same org (e.g., `my-org/web,api`) -- `/` - All projects in the specified organization -- `` - Search for project by name across all accessible organizations -- _(omit)_ - Auto-detect from DSN or config +**Target syntax** (generated from `src/lib/target-syntax.ts`): + + +| Target | Meaning | +|--------|---------| +| _(omitted)_ | Auto-detect the org and project from config, DSNs, or the directory name | +| `/` | That project in that organization | +| `/,` | Several projects in the same organization (commands that accept several projects) | +| `/` | The whole organization | +| `` | A project named `` in any accessible organization. If no project matches, commands that accept an organization use the organization named ``. When both exist, the project wins: add the trailing slash (`/`) to select the organization | + **Rationale**: Positional arguments follow `gh` CLI conventions and are more concise than flags. diff --git a/packages/cli/DEVELOPMENT.md b/packages/cli/DEVELOPMENT.md index 0287298ce..54102fc0e 100644 --- a/packages/cli/DEVELOPMENT.md +++ b/packages/cli/DEVELOPMENT.md @@ -31,7 +31,7 @@ Dev mode (`pnpm run cli`) falls back to the CLI's public sentry.io client ID whe `pnpm run cli` loads `.env.local` automatically when it exists: ```bash -pnpm run cli -- auth login +pnpm run cli auth login ``` ## Testing the Device Flow @@ -39,7 +39,7 @@ pnpm run cli -- auth login 1. Run the CLI login command: ```bash -pnpm run cli -- auth login +pnpm run cli auth login ``` 2. You'll see output like: diff --git a/packages/cli/README.md b/packages/cli/README.md index 030e115b2..f7c97e2a2 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -45,6 +45,8 @@ bun add -g sentry > The npm/pnpm/yarn packages require Node.js 20+. On Node.js 22.15+ the CLI uses the built-in `node:sqlite`; on Node.js 20–22.14 it transparently falls back to a bundled WASM SQLite driver. +Homebrew and package manager installs don't add agent skills (package managers also skip shell completions). Run `sentry cli setup` once afterwards; see the [installation guide](https://cli.sentry.dev/getting-started/). + ### Run Without Installing ```bash @@ -149,11 +151,11 @@ cd packages/cli ### Running Locally ```bash -# Run CLI in development mode -pnpm run cli -- --help +# Run CLI in development mode (generates missing build-time files on first run) +pnpm run cli --help -# With environment variables (create .env.local first, see DEVELOPMENT.md) -pnpm run cli -- --help +# Loads packages/cli/.env.local automatically when present (see DEVELOPMENT.md) +pnpm run cli auth status ``` ### Scripts diff --git a/packages/cli/install b/packages/cli/install index 4a140d673..186231448 100755 --- a/packages/cli/install +++ b/packages/cli/install @@ -90,6 +90,7 @@ Environment Variables: SENTRY_INSTALL_DIR Override the installation directory SENTRY_VERSION Install a specific version (e.g., 0.42.2, nightly) SENTRY_INIT Set to 1 to run \`sentry init\` after installing + SENTRY_CLI_NO_TELEMETRY Set to 1 to disable installer error reporting Examples: curl -fsSL https://cli.sentry.dev/install | bash diff --git a/packages/cli/package.json b/packages/cli/package.json index 48e245c05..e86de3061 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -56,7 +56,7 @@ }, "scripts": { "tsx": "tsx --env-file-if-exists=.env.local --import ./script/require-shim.mjs", - "cli": "tsx --env-file-if-exists=.env.local --import ./script/require-shim.mjs src/bin.ts", + "cli": "node script/ensure-generated.mjs && tsx --env-file-if-exists=.env.local --import ./script/require-shim.mjs src/bin.ts", "dev": "pnpm run generate:schema && pnpm run generate:docs && pnpm run generate:sdk && tsx --import ./script/require-shim.mjs src/bin.ts", "build": "pnpm --filter @sentry/mcp-core run build && pnpm --filter @sentry/mcp-server run build && pnpm run generate:schema && pnpm run generate:docs && pnpm run generate:sdk && pnpm tsx script/build.ts --single", "build:all": "pnpm --filter @sentry/mcp-core run build && pnpm --filter @sentry/mcp-server run build && pnpm run generate:schema && pnpm run generate:docs && pnpm run generate:sdk && pnpm tsx script/build.ts", @@ -72,7 +72,7 @@ "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": "pnpm run generate:banner && pnpm run generate:completions && pnpm run generate:command-docs && pnpm run generate:skill && pnpm run generate:docs-sections", + "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: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", @@ -89,7 +89,9 @@ "check:docs-sections": "pnpm tsx script/generate-docs-sections.ts --check", "check:env-coverage": "pnpm tsx script/check-env-coverage.ts", "check:stale-refs": "pnpm tsx script/check-stale-references.ts", - "ci:policy": "pnpm run check:patches && pnpm run check:deps && pnpm run check:errors && pnpm run check:fragments && pnpm run check:docs-sections && pnpm run check:env-coverage && pnpm run check:stale-refs" + "check:doc-examples": "pnpm tsx script/check-doc-examples.ts", + "check:installer-docs": "pnpm tsx script/check-installer-docs.ts", + "ci:policy": "pnpm run check:patches && pnpm run check:deps && pnpm run check:errors && pnpm run check:fragments && pnpm run check:docs-sections && pnpm run check:env-coverage && pnpm run check:stale-refs && pnpm run check:doc-examples && pnpm run check:installer-docs" }, "devDependencies": { "@anthropic-ai/sdk": "^0.39.0", diff --git a/packages/cli/plugins/README.md b/packages/cli/plugins/README.md index f7848b78f..4acc7e69b 100644 --- a/packages/cli/plugins/README.md +++ b/packages/cli/plugins/README.md @@ -22,7 +22,8 @@ npx @sentry/dotagents add https://cli.sentry.dev sentry-cli ### Cursor -Skills are automatically available in `.cursor/skills/` for Cursor users. +`packages/cli/.cursor/skills/sentry-cli/` symlinks to the skill files, so +Cursor picks them up when you open this repository. ### Other Agents @@ -48,25 +49,21 @@ The skill will guide the assistant to provide accurate CLI commands. ## Repository Structure + ``` -cli/ # Repository root -├── .claude-plugin/ -│ └── marketplace.json # Marketplace manifest -├── .cursor/ -│ └── skills/ -│ └── sentry-cli/ -│ └── SKILL.md # Symlink to plugins location -├── plugins/ -│ ├── README.md # This file -│ └── sentry-cli/ -│ ├── .claude-plugin/ -│ │ └── plugin.json # Plugin manifest -│ └── skills/ -│ └── sentry-cli/ -│ └── SKILL.md # CLI usage skill (auto-generated) -└── script/ - └── generate-skill.ts # Generates SKILL.md from CLI commands +packages/cli/.claude-plugin/marketplace.json # Marketplace manifest +packages/cli/.cursor/skills/sentry-cli/SKILL.md # Symlink to the plugin skill +packages/cli/.cursor/skills/sentry-cli/references # Symlink to the plugin references +packages/cli/plugins/README.md # This file +packages/cli/plugins/sentry-cli/.claude-plugin/plugin.json # Plugin manifest +packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md # CLI usage skill (generated) +packages/cli/plugins/sentry-cli/skills/sentry-cli/references # One file per command group (generated) +packages/cli/script/generate-skill.ts # Generates SKILL.md and references/ ``` + + +The docs site also serves these files, plus a generated `index.json` discovery +manifest, from `apps/cli-docs/public/.well-known/skills/`. ## Updating SKILL.md diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md index 933993124..64b1e7d1f 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md @@ -46,7 +46,7 @@ The `sentry` CLI follows conventions from well-known tools — if you're familia ### Safety Rules -- Always confirm with the user before running destructive commands: `project delete`, `trial start` +- Always confirm with the user before running destructive commands: `alert issues delete`, `alert metrics delete`, `cli uninstall`, `dashboard widget delete`, `integration delete`, `issue merge`, `issue unlink`, `project delete`, `release delete`, `trial start` - For mutations, verify the org/project context looks correct in the command output before proceeding with further changes - Never store or log authentication tokens — the CLI manages credentials automatically - If the CLI reports the wrong org/project, override with explicit `/` arguments @@ -222,7 +222,7 @@ sentry release deploy my-org/1.0.0 production sentry agent-conversation list # Explicit org, last 24 hours -sentry agent-conversation list my-org --period 24h +sentry agent-conversation list my-org/ --period 24h # View a conversation transcript sentry agent-conversation view my-org/conv-123 diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/alert.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/alert.md index b7b150e57..dc3b17d39 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/alert.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/alert.md @@ -168,7 +168,7 @@ Create a metric alert rule ```bash # Alert when P95 span duration goes above 500ms -sentry alert metrics create my-org \ +sentry alert metrics create my-org/ \ --name "P95 Latency" \ --project my-app \ --query "environment:prod" \ @@ -178,7 +178,7 @@ sentry alert metrics create my-org \ --trigger '{"alertThreshold":500}' # Alert on any matching log line and notify through an existing alert -sentry alert metrics create my-org \ +sentry alert metrics create my-org/ \ --name "AI chat failures" \ --project my-app \ --query "action:ai.chat result:error" \ diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/cli.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/cli.md index 15a3e3767..86be3ee98 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/cli.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/cli.md @@ -157,7 +157,7 @@ sentry cli setup --no-modify-path --no-completions Uninstall Sentry CLI **Flags:** -- `--keep-config - Keep the config directory (~/.sentry) and auth tokens` +- `--keep-config - Keep the config directory (default ~/.config/sentry) and auth tokens` - `-y, --yes - Skip confirmation prompt` - `-f, --force - Force the operation without confirmation` - `-n, --dry-run - Show what would happen without making changes` diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/dashboard.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/dashboard.md index 60ee3c5ae..38e9c543e 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/dashboard.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/dashboard.md @@ -197,7 +197,7 @@ sentry dashboard revisions 'Frontend Performance' sentry dashboard revisions 12345 # With explicit org -sentry dashboard revisions my-org 12345 +sentry dashboard revisions my-org/ 12345 ``` ### `sentry dashboard restore ` @@ -217,7 +217,7 @@ sentry dashboard restore 'Frontend Performance' --revision 3 sentry dashboard restore 12345 --revision 1 # With explicit org -sentry dashboard restore my-org 12345 --revision 1 +sentry dashboard restore my-org/ 12345 --revision 1 ``` All commands also support `--json`, `--fields`, `--help`, `--log-level`, and `--verbose` flags. diff --git a/packages/cli/script/check-doc-examples.ts b/packages/cli/script/check-doc-examples.ts new file mode 100644 index 000000000..4bd88315f --- /dev/null +++ b/packages/cli/script/check-doc-examples.ts @@ -0,0 +1,347 @@ +#!/usr/bin/env tsx +/** + * Check `sentry …` examples in hand-written docs against the live route tree. + * + * Every `sentry` invocation in a fenced shell block or inline code span, and + * every example embedded in a command (`docs.examples` and `fullDescription`, + * which feed `--help` and the generated command pages), must: + * 1. Name a command that exists (e.g. `sentry upgrade` → `sentry cli upgrade`) + * 2. Use only flags (and single-letter aliases) that command defines + * 3. Not pass a bare org-looking slug (`my-org`) as a target. A bare name + * matches a project first, so examples that mean the org must use the + * trailing-slash form (`my-org/`) + * + * Placeholders (`/`, `[args]`, `...`) are skipped. Generated + * command pages are not scanned: their examples are checked at the source. + * + * Usage: + * tsx script/check-doc-examples.ts + * + * Exit codes: + * 0 - All examples are valid + * 1 - At least one example names a missing command, flag, or ambiguous target + */ + +import { mkdirSync, readdirSync } from "node:fs"; +import { access, readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import type { + Command, + PositionalParam, + RouteMap, + RouteTarget, +} from "../src/lib/introspect.js"; +import { DOCS_CONTENT, DOCS_FRAGMENTS } from "./paths.js"; + +// Bootstrap: skill-content stub (same pattern as generate-docs-sections.ts) +const SKILL_CONTENT_PATH = "src/generated/skill-content.ts"; +const skillContentExists = await access(SKILL_CONTENT_PATH).then( + () => true, + () => false, +); +if (!skillContentExists) { + mkdirSync("src/generated", { recursive: true }); + await writeFile( + SKILL_CONTENT_PATH, + "export const SKILL_FILES: [string, string][] = [];\n", + ); +} + +const { routes } = await import("../src/app.js"); +const { isCommand, isRouteMap } = await import("../src/lib/introspect.js"); +const { parseOrgProjectTarget } = + await import("../src/lib/completion-target.js"); + +/** Generated from the route tree, so not worth re-checking. */ +const GENERATED_DOCS_DIR = `${DOCS_CONTENT}/commands`; + +/** Hand-written Markdown outside the docs site that shows CLI usage. */ +const EXTRA_DOC_FILES = [ + "README.md", + "CONTRIBUTING.md", + "DEVELOPMENT.md", + "plugins/README.md", +]; + +/** Flags Stricli handles before command dispatch. */ +const BUILTIN_FLAGS = new Set(["help", "help-all", "version"]); +const BUILTIN_ALIASES = new Set(["h", "H"]); + +/** + * Slugs that docs use for an organization. As a bare target they resolve to + * a project first, so an example meaning "the org" needs `my-org/`. + */ +const ORG_PLACEHOLDER_RE = /^(?:my-org|acme)$/; + +/** Placeholders of positionals parsed with `parseOrgProjectArg()`. */ +const TARGET_PLACEHOLDER_RE = /^(?:target|org\/.+)$/; + +const FENCE_RE = /^(`{3,}|~{3,})\s*([\w-]*)[^\n]*\n([\s\S]*?)\n\1\s*$/gm; +const SHELL_LANGS = new Set(["", "bash", "sh", "shell", "zsh", "console"]); +const INLINE_CODE_RE = /(?[\]{}]|\.\.\.|…|^\$/; +const SHORT_ALIAS_RE = /^-([a-zA-Z])$/; + +type Finding = { file: string; example: string; problem: string }; + +/** Stricli's alias-aware lookup, not part of the simplified RouteMap type. */ +type RoutingMap = RouteMap & { + getRoutingTargetForInput(input: string): RouteTarget | undefined; +}; + +/** Split a shell command into words, honoring single and double quotes. */ +function tokenize(command: string): string[] { + const tokens: string[] = []; + let current = ""; + let quote: string | null = null; + let hasToken = false; + for (const ch of command) { + if (quote) { + if (ch === quote) { + quote = null; + } else { + current += ch; + } + continue; + } + if (ch === "'" || ch === '"') { + quote = ch; + hasToken = true; + continue; + } + if (ch === "#" && !hasToken) { + break; // comment + } + if (/\s/.test(ch)) { + if (hasToken) { + tokens.push(current); + current = ""; + hasToken = false; + } + continue; + } + current += ch; + hasToken = true; + } + if (hasToken) { + tokens.push(current); + } + return tokens; +} + +/** Collect `sentry …` invocations from one Markdown file. */ +function extractInvocations(markdown: string): string[] { + const invocations: string[] = []; + for (const [, , lang = "", body = ""] of markdown.matchAll(FENCE_RE)) { + if (!SHELL_LANGS.has(lang)) { + continue; + } + for (const rawLine of body.replace(CONTINUATION_RE, " ").split("\n")) { + const line = rawLine.trim().replace(PROMPT_RE, ""); + if (line.startsWith("#")) { + continue; + } + for (const segment of line.split(COMMAND_SPLIT_RE)) { + if (segment.startsWith("sentry ")) { + invocations.push(segment); + } + } + } + } + const prose = markdown.replace(FENCE_RE, ""); + for (const [, code = ""] of prose.matchAll(INLINE_CODE_RE)) { + invocations.push(code); + } + return invocations; +} + +/** Whether `--name` (or the boolean negation `--no-name`) is accepted. */ +function hasFlag(cmd: Command, name: string): boolean { + const flags = cmd.parameters.flags ?? {}; + const negated = name.startsWith("no-") ? name.slice(3) : undefined; + return ( + BUILTIN_FLAGS.has(name) || + Object.hasOwn(flags, name) || + (negated !== undefined && Object.hasOwn(flags, negated)) + ); +} + +/** Walk the route tree; returns the command and the remaining tokens. */ +function resolve( + tokens: string[], +): { cmd: Command; rest: string[] } | { error: string } | null { + let target: RouteTarget = routes as unknown as RouteMap; + let i = 0; + while (isRouteMap(target)) { + const word = tokens[i]; + if (word === undefined || word.startsWith("-")) { + const fallback = target.getDefaultCommand?.(); + if (fallback && isCommand(fallback)) { + return { cmd: fallback, rest: tokens.slice(i) }; + } + // `sentry issue` alone names a group; `--help` on a group is fine. + return word === undefined || word === "--help" || word === "-h" + ? null + : { error: `\`${tokens.slice(0, i).join(" ")}\` is a group` }; + } + if (PLACEHOLDER_RE.test(word)) { + return null; + } + // Resolves aliases too (`issue ignore` → `issue archive`, `new` → `create`) + const next = (target as RoutingMap).getRoutingTargetForInput(word); + if (!next) { + const fallback = target.getDefaultCommand?.(); + if (i > 0 && fallback && isCommand(fallback)) { + return { cmd: fallback, rest: tokens.slice(i) }; + } + const path = ["sentry", ...tokens.slice(0, i + 1)].join(" "); + return { error: `unknown command \`${path}\`` }; + } + target = next; + i += 1; + } + return isCommand(target) ? { cmd: target, rest: tokens.slice(i) } : null; +} + +/** + * Whether a positional takes an `/` target, where a bare name + * is a project search. Raw org slugs (placeholder `org`) are not targets. + */ +function isTargetParam(param: PositionalParam | undefined): boolean { + if (!param) { + return false; + } + return ( + param.parse === parseOrgProjectTarget || + TARGET_PLACEHOLDER_RE.test(param.placeholder ?? "") + ); +} + +/** Problems with one `sentry …` invocation. */ +function checkInvocation(invocation: string): string[] { + const tokens = tokenize(invocation).slice(1); + const resolved = resolve(tokens); + if (!resolved) { + return []; + } + if ("error" in resolved) { + return [resolved.error]; + } + const { cmd, rest } = resolved; + const problems: string[] = []; + const aliases = cmd.parameters.aliases ?? {}; + const positionals: string[] = []; + for (const token of rest) { + if (token === "--") { + break; + } + if (token.startsWith("--")) { + const name = token.slice(2).split("=")[0] ?? ""; + if (!(PLACEHOLDER_RE.test(name) || hasFlag(cmd, name))) { + problems.push(`unknown flag \`--${name}\``); + } + continue; + } + const alias = token.match(SHORT_ALIAS_RE)?.[1]; + if (alias) { + if (!(BUILTIN_ALIASES.has(alias) || Object.hasOwn(aliases, alias))) { + problems.push(`unknown flag \`-${alias}\``); + } + continue; + } + positionals.push(token); + } + + const firstParam = + cmd.parameters.positional?.kind === "tuple" + ? cmd.parameters.positional.parameters[0] + : cmd.parameters.positional?.parameter; + const first = positionals[0]; + if (first && ORG_PLACEHOLDER_RE.test(first) && isTargetParam(firstParam)) { + problems.push( + `bare \`${first}\` resolves to a project first; use \`${first}/\` for the organization`, + ); + } + return problems; +} + +function listMarkdown(dir: string): string[] { + return readdirSync(dir, { recursive: true, encoding: "utf-8" }) + .filter((f) => f.endsWith(".md") || f.endsWith(".mdx")) + .map((f) => join(dir, f)) + .filter((f) => !f.startsWith(GENERATED_DOCS_DIR)); +} + +const files = [ + ...listMarkdown(DOCS_CONTENT), + ...listMarkdown(DOCS_FRAGMENTS), + ...EXTRA_DOC_FILES, +]; + +/** + * Examples embedded in commands: `docs.examples` and `sentry …` lines in + * `fullDescription`. These feed `--help` and the generated command pages. + */ +function commandExamples( + target: RouteTarget, + path: string[], +): { source: string; invocation: string }[] { + if (isRouteMap(target)) { + return target + .getAllEntries() + .filter((e) => !e.hidden) // hidden entries are plural aliases + .flatMap((e) => commandExamples(e.target, [...path, e.name.original])); + } + if (!isCommand(target)) { + return []; + } + const source = `\`${["sentry", ...path].join(" ")}\` help`; + const fromDocs = (target.__examples ?? []).map((e) => e.command); + const fromDescription = (target.fullDescription ?? "") + .replace(CONTINUATION_RE, " ") + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.startsWith("sentry ")); + return [...fromDocs, ...fromDescription].map((invocation) => ({ + source, + invocation, + })); +} + +const findings: Finding[] = []; +let checked = 0; +for (const { source, invocation } of commandExamples( + routes as unknown as RouteMap, + [], +)) { + checked += 1; + for (const problem of checkInvocation(invocation)) { + findings.push({ file: source, example: invocation, problem }); + } +} +for (const file of files) { + const content = await readFile(file, "utf-8"); + for (const invocation of extractInvocations(content)) { + checked += 1; + for (const problem of checkInvocation(invocation)) { + findings.push({ file, example: invocation, problem }); + } + } +} + +if (findings.length === 0) { + console.log( + `✓ ${checked} sentry examples in ${files.length} files match the CLI`, + ); + process.exit(0); +} + +console.error(`✗ ${findings.length} doc example problem(s):\n`); +for (const f of findings) { + console.error(` ${f.file}: ${f.example}`); + console.error(` → ${f.problem}\n`); +} +process.exit(1); diff --git a/packages/cli/script/check-installer-docs.ts b/packages/cli/script/check-installer-docs.ts new file mode 100644 index 000000000..d01defadf --- /dev/null +++ b/packages/cli/script/check-installer-docs.ts @@ -0,0 +1,114 @@ +#!/usr/bin/env tsx +/** + * Installer documentation coverage. + * + * The install scripts (`install` for bash, `install.ps1` for PowerShell) read + * environment variables and flags that the CLI's ENV_VAR_REGISTRY never sees, + * so check-env-coverage.ts cannot catch them. This check closes that gap: + * + * 1. Every `SENTRY_*` variable an installer reads, and every flag the bash + * installer parses, appears in that installer's own help text. + * 2. Every variable and flag in either installer's help text appears in the + * installation guide (getting-started.mdx). + * + * Usage: + * tsx script/check-installer-docs.ts + * + * Exit codes: + * 0 - Installer options are documented + * 1 - An option is missing from installer help or the installation guide + */ + +import { readFile } from "node:fs/promises"; +import { DOCS_CONTENT } from "./paths.js"; + +const GUIDE = `${DOCS_CONTENT}/getting-started.mdx`; + +/** `${SENTRY_FOO:-}` / `$SENTRY_FOO` reads in bash. */ +const BASH_ENV_READ_RE = /\$\{?(SENTRY_[A-Z0-9_]+)/g; +/** `SENTRY_FOO="..."` script-local assignments (constants, not inputs). */ +const BASH_ASSIGN_RE = /^\s*(?:local\s+)?(SENTRY_[A-Z0-9_]+)=/gm; +/** `--flag)` or `-f|--flag)` case arms in the bash argument parser. */ +const BASH_FLAG_ARM_RE = /^\s*(?:-[a-z]\|)?(--[a-z][a-z-]*)\)/gm; +/** The `usage()` heredoc body in the bash installer. */ +const BASH_USAGE_RE = /usage\(\) \{\n\s*cat <` comment-based help block in PowerShell. */ +const PS_HELP_RE = /^<#([\s\S]*?)#>/; +/** Variable names listed in a help block. */ +const HELP_VAR_RE = /\b(SENTRY_[A-Z0-9_]+)\b/g; + +/** Flags handled by every CLI; not worth documenting in the guide. */ +const UNDOCUMENTED_FLAGS = new Set(["--help"]); + +type Installer = { + file: string; + reads: Set; + flags: Set; + help: string; +}; + +function matchAll(re: RegExp, text: string): Set { + return new Set([...text.matchAll(re)].map((m) => m[1] ?? "")); +} + +async function loadBash(): Promise { + const file = "install"; + const source = await readFile(file, "utf-8"); + const help = source.match(BASH_USAGE_RE)?.[1]; + if (!help) { + throw new Error(`${file}: usage() heredoc not found`); + } + const assigned = matchAll(BASH_ASSIGN_RE, source); + const reads = new Set( + [...matchAll(BASH_ENV_READ_RE, source)].filter((v) => !assigned.has(v)), + ); + return { file, reads, flags: matchAll(BASH_FLAG_ARM_RE, source), help }; +} + +async function loadPowerShell(): Promise { + const file = "install.ps1"; + const source = await readFile(file, "utf-8"); + const help = source.match(PS_HELP_RE)?.[1]; + if (!help) { + throw new Error(`${file}: <# ... #> help block not found`); + } + return { + file, + reads: matchAll(PS_ENV_READ_RE, source), + flags: new Set(), + help, + }; +} + +const installers = [await loadBash(), await loadPowerShell()]; +const guide = await readFile(GUIDE, "utf-8"); +const problems: string[] = []; + +for (const { file, reads, flags, help } of installers) { + for (const name of [...reads, ...flags]) { + if (!(UNDOCUMENTED_FLAGS.has(name) || help.includes(name))) { + problems.push(`${file} reads ${name} but its help text omits it`); + } + } + const documented = [...matchAll(HELP_VAR_RE, help), ...flags]; + for (const name of documented) { + if (!(UNDOCUMENTED_FLAGS.has(name) || guide.includes(name))) { + problems.push(`${file} supports ${name} but ${GUIDE} omits it`); + } + } +} + +if (problems.length === 0) { + console.log( + `✓ Installer options are documented (${installers.map((i) => i.file).join(", ")})`, + ); + process.exit(0); +} + +console.error(`✗ ${problems.length} undocumented installer option(s):\n`); +for (const problem of problems) { + console.error(` ${problem}`); +} +process.exit(1); diff --git a/packages/cli/script/check-stale-references.ts b/packages/cli/script/check-stale-references.ts index 4fda955b2..cfb77ba17 100644 --- a/packages/cli/script/check-stale-references.ts +++ b/packages/cli/script/check-stale-references.ts @@ -13,6 +13,9 @@ * `packageManager` field in package.json will automatically make this * check flag every `pnpm run` / `pnpm add` reference in dev docs. * + * It also flags `pnpm run cli -- ` in dev docs: pnpm passes the `--` + * through, so the CLI sees it as the end of options and fails. + * * Usage: * tsx script/check-stale-references.ts * @@ -175,15 +178,60 @@ function findOriginalLineNumber( return 0; } +// --------------------------------------------------------------------------- +// CLI scripts: `pnpm run cli -- --help` passes a literal `--` to the CLI, +// which then treats `--help` as a command name. Checked inside code blocks +// too, since that is where these commands live. +// --------------------------------------------------------------------------- + +/** package.json scripts that run the CLI entry point directly. */ +const cliScripts = Object.entries(pkg.scripts as Record) + .filter(([, command]) => command.includes("src/bin.ts")) + .map(([name]) => name); + +const CLI_SEPARATOR_RE = new RegExp( + `\\bpnpm(?:\\s+--filter\\s+\\S+)?\\s+(?:run\\s+)?(?:${cliScripts.join("|")})\\s+--(?=\\s|$)`, +); + +/** Docs that show how to run the CLI from a checkout. */ +const CLI_USAGE_FILES = [ + ...DOC_FILES, + "README.md", + "AGENTS.md", + "CONTRIBUTING.md", +]; + +for (const filePath of CLI_USAGE_FILES) { + let content: string; + try { + content = await readFile(filePath, "utf-8"); + } catch { + continue; + } + const lines = content.split("\n"); + for (let i = 0; i < lines.length; i++) { + const line = lines[i] ?? ""; + if (CLI_SEPARATOR_RE.test(line)) { + findings.push({ + file: filePath, + line: i + 1, + text: line.trim(), + reason: + "pnpm forwards a literal `--` to the CLI; pass arguments directly (`pnpm run cli --help`)", + }); + } + } +} + if (findings.length === 0) { console.log( - `✓ No stale package manager references found (current: ${currentPM}, checked: ${FILES_TO_SCAN.length} files)`, + `✓ No stale references found (current PM: ${currentPM}, checked: ${FILES_TO_SCAN.length + CLI_USAGE_FILES.length} files)`, ); process.exit(0); } console.error( - `✗ Found ${findings.length} stale package manager reference(s) (current PM: ${currentPM}):\n`, + `✗ Found ${findings.length} stale reference(s) (current PM: ${currentPM}):\n`, ); for (const f of findings) { console.error(` ${f.file}:${f.line}: ${f.text}`); diff --git a/packages/cli/script/ensure-generated.mjs b/packages/cli/script/ensure-generated.mjs new file mode 100644 index 000000000..92370db0e --- /dev/null +++ b/packages/cli/script/ensure-generated.mjs @@ -0,0 +1,37 @@ +#!/usr/bin/env node +/** + * 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. + * + * Usage: + * node script/ensure-generated.mjs # everything the CLI imports + * node script/ensure-generated.mjs schema # only the API schema + * + * Plain Node (no tsx) so the check adds no noticeable startup time. + */ + +import { execSync } from "node:child_process"; +import { existsSync } from "node:fs"; + +const SCHEMA = "src/generated/api-schema.json"; +/** Produced by `generate:docs`; imported at runtime by `src/`. */ +const DOCS_OUTPUTS = [ + "src/generated/skill-content.ts", + "src/generated/completion-targets.json", +]; + +function run(script) { + console.error(`Missing generated files; running \`pnpm run ${script}\`...`); + execSync(`pnpm run ${script}`, { stdio: "inherit" }); +} + +if (!existsSync(SCHEMA)) { + run("generate:schema"); +} +if (process.argv[2] !== "schema" && !DOCS_OUTPUTS.every(existsSync)) { + run("generate:docs"); +} diff --git a/packages/cli/script/generate-docs-sections.ts b/packages/cli/script/generate-docs-sections.ts index dabba3ad5..ad2abcd13 100644 --- a/packages/cli/script/generate-docs-sections.ts +++ b/packages/cli/script/generate-docs-sections.ts @@ -11,6 +11,10 @@ * - self-hosted.md: OAuth scopes, env var table * - README.md: dev prerequisites, library prerequisites, dev scripts * - getting-started.mdx: platform support table + * - agent-guidance.md: destructive commands (from `destructive` commands) + * - agentic-usage.md: detected agents (from AGENT_DISPLAY_NAMES) + * - commands/index.md fragment, CONTRIBUTING.md: target syntax (TARGET_FORMS) + * - plugins/README.md: skill file layout (paths checked on disk) * * Unlike generate-command-docs.ts (which produces gitignored files from scratch), * this script edits committed files in-place between marker pairs. @@ -20,9 +24,9 @@ * tsx script/generate-docs-sections.ts --check # Dry-run, exit 1 if stale */ -import { mkdirSync } from "node:fs"; +import { existsSync, mkdirSync } from "node:fs"; import { access, readFile, writeFile } from "node:fs/promises"; -import { DOCS_CONTENT } from "./paths.js"; +import { DOCS_CONTENT, DOCS_FRAGMENTS } from "./paths.js"; // Bootstrap: skill-content stub (same pattern as generate-command-docs.ts) const SKILL_CONTENT_PATH = "src/generated/skill-content.ts"; @@ -44,6 +48,8 @@ const { routes } = await import("../src/app.js"); const { extractAllRoutes } = await import("../src/lib/introspect.js"); const { OAUTH_SCOPES } = await import("../src/lib/oauth.js"); const { ENV_VAR_REGISTRY } = await import("../src/lib/env-registry.js"); +const { AGENT_DISPLAY_NAMES } = await import("../src/lib/detect-agent.js"); +const { TARGET_FORMS } = await import("../src/lib/target-syntax.js"); const pkg = JSON.parse(await readFile("package.json", "utf-8")); const workspace = JSON.parse(await readFile("../../package.json", "utf-8")); @@ -556,6 +562,97 @@ function generatePlatformSupport(): string { return lines.join("\n"); } +// --------------------------------------------------------------------------- +// Section: Destructive Commands (agent-guidance.md) +// --------------------------------------------------------------------------- + +const SENTRY_PREFIX_RE = /^sentry /; + +/** + * Generate the Safety Rules bullet that lists destructive commands. + * + * Commands opt in with `buildCommand({ destructive: true })`; + * `buildDeleteCommand` sets it automatically, so new delete commands are + * listed without touching the docs. + */ +function generateDestructiveCommands(allRoutes: RouteInfo[]): string { + const names = allRoutes + .flatMap((route) => route.commands) + .filter((cmd) => cmd.destructive) + .map((cmd) => cmd.path.replace(SENTRY_PREFIX_RE, "")) + .sort((a, b) => a.localeCompare(b)); + if (names.length === 0) { + throw new Error("No destructive commands found in the route tree"); + } + const list = names.map((name) => `\`${name}\``).join(", "); + return `- Always confirm with the user before running destructive commands: ${list}`; +} + +// --------------------------------------------------------------------------- +// Section: Detected Agents (agentic-usage.md) +// --------------------------------------------------------------------------- + +/** Generate the list of agents the CLI detects, from AGENT_DISPLAY_NAMES. */ +function generateDetectedAgents(): string { + const names = [...AGENT_DISPLAY_NAMES.values()]; + const last = names.pop(); + return `The CLI detects ${names.join(", ")}, and ${last}.`; +} + +// --------------------------------------------------------------------------- +// Section: Target Syntax (commands/index.md fragment, CONTRIBUTING.md) +// --------------------------------------------------------------------------- + +/** Generate the target syntax table from TARGET_FORMS. */ +function generateTargetSyntax(): string { + const lines = ["| Target | Meaning |", "|--------|---------|"]; + for (const form of TARGET_FORMS) { + lines.push(`| ${form.syntax} | ${form.meaning} |`); + } + return lines.join("\n"); +} + +// --------------------------------------------------------------------------- +// Section: Skill File Layout (plugins/README.md) +// --------------------------------------------------------------------------- + +/** + * Skill-related paths under `packages/cli/`, in tree order. Generation fails + * when one no longer exists, so the README cannot describe a stale layout. + */ +const SKILL_LAYOUT: readonly [path: string, note: string][] = [ + [".claude-plugin/marketplace.json", "Marketplace manifest"], + [".cursor/skills/sentry-cli/SKILL.md", "Symlink to the plugin skill"], + [".cursor/skills/sentry-cli/references", "Symlink to the plugin references"], + ["plugins/README.md", "This file"], + ["plugins/sentry-cli/.claude-plugin/plugin.json", "Plugin manifest"], + [ + "plugins/sentry-cli/skills/sentry-cli/SKILL.md", + "CLI usage skill (generated)", + ], + [ + "plugins/sentry-cli/skills/sentry-cli/references", + "One file per command group (generated)", + ], + ["script/generate-skill.ts", "Generates SKILL.md and references/"], +]; + +/** Render {@link SKILL_LAYOUT} as an annotated path list. */ +function generateSkillLayout(): string { + const missing = SKILL_LAYOUT.filter(([path]) => !existsSync(path)); + if (missing.length > 0) { + throw new Error( + `plugins/README.md layout lists missing paths: ${missing.map(([p]) => p).join(", ")}`, + ); + } + const paths = SKILL_LAYOUT.map(([path]) => `packages/cli/${path}`); + const width = Math.max(...paths.map((p) => p.length)); + const rows = SKILL_LAYOUT.map( + ([, note], i) => `${paths[i]?.padEnd(width)} # ${note}`, + ); + return ["```", ...rows, "```"].join("\n"); +} + // --------------------------------------------------------------------------- // Section Definitions // --------------------------------------------------------------------------- @@ -647,6 +744,32 @@ const sections: SectionDef[] = [ generate: generatePlatformSupport, markerStyle: "mdx", }, + // -- Lists that mirror code -- + { + filePath: `${DOCS_CONTENT}/agent-guidance.md`, + sectionName: "destructive-commands", + generate: () => generateDestructiveCommands(routeInfos), + }, + { + filePath: `${DOCS_CONTENT}/agentic-usage.md`, + sectionName: "detected-agents", + generate: generateDetectedAgents, + }, + { + filePath: `${DOCS_FRAGMENTS}/commands/index.md`, + sectionName: "target-syntax", + generate: generateTargetSyntax, + }, + { + filePath: "CONTRIBUTING.md", + sectionName: "target-syntax", + generate: generateTargetSyntax, + }, + { + filePath: "plugins/README.md", + sectionName: "skill-layout", + generate: generateSkillLayout, + }, ]; let staleCount = 0; diff --git a/packages/cli/script/generate-skill.ts b/packages/cli/script/generate-skill.ts index 2ebe69b2b..1265973f6 100644 --- a/packages/cli/script/generate-skill.ts +++ b/packages/cli/script/generate-skill.ts @@ -209,6 +209,9 @@ function escapeRegex(str: string): string { // Documentation Loading // --------------------------------------------------------------------------- +/** `` / `END` lines from generate-docs-sections.ts */ +const GENERATED_MARKER_LINE_RE = /^\n/gm; + /** Load and parse a documentation file */ async function loadDoc(relativePath: string): Promise { const fullPath = `${DOCS_PATH}/${relativePath}`; @@ -220,7 +223,9 @@ async function loadDoc(relativePath: string): Promise { return null; } const content = await readFile(fullPath, "utf-8"); - return stripMdxComponents(stripFrontmatter(content)); + return stripMdxComponents( + stripFrontmatter(content).replace(GENERATED_MARKER_LINE_RE, ""), + ); } /** Extract npm command from PackageManagerCode Astro component */ diff --git a/packages/cli/src/commands/cli/uninstall.ts b/packages/cli/src/commands/cli/uninstall.ts index ed6bfe27a..91635084c 100644 --- a/packages/cli/src/commands/cli/uninstall.ts +++ b/packages/cli/src/commands/cli/uninstall.ts @@ -30,7 +30,11 @@ import type { SentryContext } from "../../context.js"; import { getSkillInstallPath } from "../../lib/agent-skills.js"; import { isHomebrewInstall } from "../../lib/binary.js"; import { getCompletionPath } from "../../lib/completions.js"; -import { closeDatabase, getConfigDir } from "../../lib/db/index.js"; +import { + closeDatabase, + DEFAULT_CONFIG_DIR_DISPLAY, + getConfigDir, +} from "../../lib/db/index.js"; import { getInstallInfo } from "../../lib/db/install-info.js"; import { CommandOutput } from "../../lib/formatters/output.js"; import { logger } from "../../lib/logger.js"; @@ -328,7 +332,7 @@ function gatherArtifacts(home: string): UninstallArtifact[] { } } - // 5. Config directory (~/.sentry/) + // 5. Config directory (SENTRY_CONFIG_DIR, XDG, or legacy ~/.sentry) const configDir = getConfigDir(); artifacts.push({ label: "config directory", @@ -438,7 +442,7 @@ export const uninstallCommand = buildDeleteCommand({ flags: { "keep-config": { kind: "boolean", - brief: "Keep the config directory (~/.sentry) and auth tokens", + brief: `Keep the config directory (default ${DEFAULT_CONFIG_DIR_DISPLAY}) and auth tokens`, default: false, optional: true, }, diff --git a/packages/cli/src/commands/dashboard/list.ts b/packages/cli/src/commands/dashboard/list.ts index 6dfd52213..d37bf817f 100644 --- a/packages/cli/src/commands/dashboard/list.ts +++ b/packages/cli/src/commands/dashboard/list.ts @@ -376,7 +376,6 @@ export const listCommand = buildListCommand("dashboard", { " sentry dashboard list my-org/ # explicit org\n" + " sentry dashboard list my-org/my-project # org from explicit project\n" + " sentry dashboard list 'Error*' # filter by title glob\n" + - " sentry dashboard list my-org '*API*' # bare org + filter\n" + " sentry dashboard list my-org/ '*API*' # org/ + filter\n" + " sentry dashboard list -c next # next page\n" + " sentry dashboard list -c prev # previous page\n" + diff --git a/packages/cli/src/commands/dashboard/restore.ts b/packages/cli/src/commands/dashboard/restore.ts index 8bd9d5345..78f43874d 100644 --- a/packages/cli/src/commands/dashboard/restore.ts +++ b/packages/cli/src/commands/dashboard/restore.ts @@ -61,7 +61,7 @@ export const restoreCommand = buildCommand({ "Use `sentry dashboard revisions` to list available revisions first.\n\n" + "Examples:\n" + " sentry dashboard restore 12345 --revision 42\n" + - " sentry dashboard restore my-org 12345 --revision 42\n" + + " sentry dashboard restore my-org/ 12345 --revision 42\n" + " sentry dashboard restore 'My Dashboard' --revision 42\n" + " sentry dashboard restore 12345 --revision 42 --json", }, diff --git a/packages/cli/src/commands/dashboard/revisions.ts b/packages/cli/src/commands/dashboard/revisions.ts index 7cc551bac..bf4176bcd 100644 --- a/packages/cli/src/commands/dashboard/revisions.ts +++ b/packages/cli/src/commands/dashboard/revisions.ts @@ -123,9 +123,9 @@ export const revisionsCommand = buildCommand({ "Examples:\n" + " sentry dashboard revisions 12345\n" + " sentry dashboard revisions 'My Dashboard'\n" + - " sentry dashboard revisions my-org 12345\n" + - " sentry dashboard revisions my-org 12345 --json\n" + - " sentry dashboard revisions my-org 12345 -c next", + " sentry dashboard revisions my-org/ 12345\n" + + " sentry dashboard revisions my-org/ 12345 --json\n" + + " sentry dashboard revisions my-org/ 12345 -c next", }, output: { human: formatRevisionsHuman, diff --git a/packages/cli/src/commands/dashboard/view.ts b/packages/cli/src/commands/dashboard/view.ts index 36b53b633..778bfac79 100644 --- a/packages/cli/src/commands/dashboard/view.ts +++ b/packages/cli/src/commands/dashboard/view.ts @@ -172,8 +172,8 @@ export const viewCommand = buildCommand({ "Examples:\n" + " sentry dashboard view 12345\n" + " sentry dashboard view 'My Dashboard'\n" + - " sentry dashboard view my-org 12345\n" + - " sentry dashboard view my-org 'My Dashboard'\n" + + " sentry dashboard view my-org/ 12345\n" + + " sentry dashboard view my-org/ 'My Dashboard'\n" + " sentry dashboard view my-org/my-project 12345\n" + " sentry dashboard view 12345 --json\n" + " sentry dashboard view 12345 --period 7d\n" + diff --git a/packages/cli/src/commands/issue/merge.ts b/packages/cli/src/commands/issue/merge.ts index d8ced7e9d..d8fc8bf46 100644 --- a/packages/cli/src/commands/issue/merge.ts +++ b/packages/cli/src/commands/issue/merge.ts @@ -251,6 +251,7 @@ async function orderForMerge( } export const mergeCommand = buildCommand({ + destructive: true, docs: { brief: "Merge 2+ issues into a single canonical group", fullDescription: diff --git a/packages/cli/src/commands/trial/start.ts b/packages/cli/src/commands/trial/start.ts index 05a0506d7..072119e04 100644 --- a/packages/cli/src/commands/trial/start.ts +++ b/packages/cli/src/commands/trial/start.ts @@ -77,6 +77,7 @@ function parseTrialStartArgs( } export const startCommand = buildCommand({ + destructive: true, docs: { brief: "Start a product trial", fullDescription: diff --git a/packages/cli/src/lib/command.ts b/packages/cli/src/lib/command.ts index 33d6de8f7..fd040f0ba 100644 --- a/packages/cli/src/lib/command.ts +++ b/packages/cli/src/lib/command.ts @@ -192,6 +192,15 @@ type LocalCommandBuilderArguments< * repo-local `.sentryclirc` URL mismatches the current token. */ readonly skipRcUrlCheck?: boolean; + /** + * Mark the command as destructive or irreversible. Defaults to `false`. + * + * Destructive commands are listed in the agent Safety Rules (generated from + * this flag), so agents ask the user before running them. + * `buildDeleteCommand` sets this automatically; set it explicitly for other + * irreversible operations (e.g. `issue merge`, `trial start`). + */ + readonly destructive?: boolean; }; // --------------------------------------------------------------------------- @@ -882,6 +891,9 @@ export function buildCommand< (cmd as unknown as Record).__examples = builderArgs.docs.examples; } + if (builderArgs.destructive) { + (cmd as unknown as Record).__destructive = true; + } // Attach the JSON schema to the built command as a non-standard property. // introspect.ts reads this to populate CommandInfo.jsonFields for help diff --git a/packages/cli/src/lib/db/index.ts b/packages/cli/src/lib/db/index.ts index 8fbfad802..185209854 100644 --- a/packages/cli/src/lib/db/index.ts +++ b/packages/cli/src/lib/db/index.ts @@ -27,6 +27,9 @@ const LEGACY_CONFIG_DIR_NAME = ".sentry"; /** Sub-directory used under the XDG config base directory. */ const XDG_CONFIG_SUBDIR = "sentry"; +/** Default config directory as shown to users (help text, docs). */ +export const DEFAULT_CONFIG_DIR_DISPLAY = `~/.config/${XDG_CONFIG_SUBDIR}`; + const DB_FILENAME = "cli.db"; /** 7-day TTL for cache entries (milliseconds) */ diff --git a/packages/cli/src/lib/detect-agent.ts b/packages/cli/src/lib/detect-agent.ts index 80b34863f..89c83fa47 100644 --- a/packages/cli/src/lib/detect-agent.ts +++ b/packages/cli/src/lib/detect-agent.ts @@ -182,6 +182,34 @@ export const PROCESS_NAME_AGENTS = new Map([ ["gemini", "gemini"], ]); +/** + * Canonical agent name → display name, in documentation order. + * + * The agentic-usage docs list is generated from this map, and a unit test + * fails when a detected agent (from {@link ENV_VAR_AGENTS}, + * {@link PROCESS_NAME_AGENTS}, {@link AGENT_ALIASES}, or the Claude/Cowork + * branch in {@link detectAgent}) has no entry here. + */ +export const AGENT_DISPLAY_NAMES = new Map([ + ["claude", "Claude Code"], + ["cowork", "Claude Cowork"], + ["cursor", "Cursor"], + ["windsurf", "Windsurf"], + ["github-copilot", "GitHub Copilot"], + ["gemini", "Gemini CLI"], + ["codex", "OpenAI Codex"], + ["antigravity", "Antigravity"], + ["goose", "Goose"], + ["amp", "Amp"], + ["augment", "Augment"], + ["opencode", "OpenCode"], + ["cline", "Cline"], + ["grok", "Grok"], + ["kimi", "Kimi"], + ["junie", "Junie"], + ["openclaw", "OpenClaw"], +]); + /** Max levels to walk up the process tree before giving up. */ const MAX_ANCESTOR_DEPTH = 5; diff --git a/packages/cli/src/lib/introspect.ts b/packages/cli/src/lib/introspect.ts index fb144c72a..23f791a93 100644 --- a/packages/cli/src/lib/introspect.ts +++ b/packages/cli/src/lib/introspect.ts @@ -68,6 +68,8 @@ export type Command = { */ __primaryUsage?: string; __examples?: readonly CommandExample[]; + /** Set by `buildCommand({ destructive: true })` and `buildDeleteCommand`. */ + __destructive?: boolean; }; /** Positional parameter definitions — either fixed-length tuple or variadic array */ @@ -124,6 +126,8 @@ export type CommandInfo = { examples: string[]; /** JSON output field metadata extracted from `OutputConfig.schema` */ jsonFields?: SchemaFieldInfo[]; + /** Present (and `true`) when the command is destructive or irreversible */ + destructive?: true; }; /** Extracted metadata for a single flag */ @@ -327,6 +331,7 @@ export function buildCommandInfo( ) : examples, jsonFields: jsonFields?.length ? jsonFields : undefined, + ...(cmd.__destructive ? { destructive: true as const } : {}), }; } diff --git a/packages/cli/src/lib/mutate-command.ts b/packages/cli/src/lib/mutate-command.ts index f79a24ba6..ea57cf874 100644 --- a/packages/cli/src/lib/mutate-command.ts +++ b/packages/cli/src/lib/mutate-command.ts @@ -294,6 +294,8 @@ export type DeleteCommandOptions = { * 3. Runs a non-interactive guard before `func()` — refuses to proceed if * stdin is not a TTY and neither `--yes`/`--force` was passed (dry-run * bypasses the guard since it makes no changes) + * 4. Marks the command `destructive`, which lists it in the generated agent + * Safety Rules * * Commands still handle their own confirmation prompts using * {@link confirmByTyping} + {@link isConfirmationBypassed} inside `func()`, @@ -407,5 +409,6 @@ export function buildDeleteCommand< parameters: mergedParams, func: wrappedFunc, output: builderArgs.output, + destructive: true, }); } diff --git a/packages/cli/src/lib/target-syntax.ts b/packages/cli/src/lib/target-syntax.ts new file mode 100644 index 000000000..ec61f4180 --- /dev/null +++ b/packages/cli/src/lib/target-syntax.ts @@ -0,0 +1,65 @@ +/** + * Documented forms of the `/` target positional. + * + * The target syntax tables in the commands overview and CONTRIBUTING.md are + * generated from this list (`script/generate-docs-sections.ts`), and + * `test/lib/target-syntax.test.ts` parses each example with + * `parseOrgProjectArg()` to check it yields the documented type. Change the + * parser and this list together. + */ + +import type { ProjectSpecificationType } from "./arg-parsing.js"; + +type SpecificationType = + (typeof ProjectSpecificationType)[keyof typeof ProjectSpecificationType]; + +/** One documented target form. */ +export type TargetForm = { + /** Syntax shown in the docs table (Markdown). */ + readonly syntax: string; + /** Example value; `undefined` means the argument is omitted. */ + readonly example: string | undefined; + /** Whether the example needs `parseOrgProjectArg(..., { multi: true })`. */ + readonly multi?: boolean; + /** Parse result type the example must produce. */ + readonly type: SpecificationType; + /** What the form selects (Markdown). */ + readonly meaning: string; +}; + +export const TARGET_FORMS: readonly TargetForm[] = [ + { + syntax: "_(omitted)_", + example: undefined, + type: "auto-detect", + meaning: + "Auto-detect the org and project from config, DSNs, or the directory name", + }, + { + syntax: "`/`", + example: "my-org/my-project", + type: "explicit", + meaning: "That project in that organization", + }, + { + syntax: "`/,`", + example: "my-org/web,api", + multi: true, + type: "explicit", + meaning: + "Several projects in the same organization (commands that accept several projects)", + }, + { + syntax: "`/`", + example: "my-org/", + type: "org-all", + meaning: "The whole organization", + }, + { + syntax: "``", + example: "my-project", + type: "project-search", + meaning: + "A project named `` in any accessible organization. If no project matches, commands that accept an organization use the organization named ``. When both exist, the project wins: add the trailing slash (`/`) to select the organization", + }, +]; diff --git a/packages/cli/test/lib/detect-agent.test.ts b/packages/cli/test/lib/detect-agent.test.ts index 7e80e79c3..c6f010a00 100644 --- a/packages/cli/test/lib/detect-agent.test.ts +++ b/packages/cli/test/lib/detect-agent.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect, test } from "vitest"; import { AGENT_ALIASES, + AGENT_DISPLAY_NAMES, type AgentInfo, detectAgent, detectAgentFromProcessTree, @@ -645,6 +646,32 @@ describe("AGENT_ALIASES map structure", () => { }); }); +describe("AGENT_DISPLAY_NAMES", () => { + // The agentic-usage docs list is generated from AGENT_DISPLAY_NAMES, so it + // must name exactly the agents detectAgent() can return. + const detectable = new Set([ + ...ENV_VAR_AGENTS.values(), + ...PROCESS_NAME_AGENTS.values(), + ...AGENT_ALIASES.values(), + // Branches in detectAgent() that are not table-driven + "claude", + "cowork", + "cursor", + ]); + + test("names every detectable agent", () => { + const missing = [...detectable].filter((a) => !AGENT_DISPLAY_NAMES.has(a)); + expect(missing).toEqual([]); + }); + + test("names only detectable agents", () => { + const extra = [...AGENT_DISPLAY_NAMES.keys()].filter( + (a) => !detectable.has(a), + ); + expect(extra).toEqual([]); + }); +}); + describe("getProcessInfoFromOS", () => { test("returns info for own process", async () => { const info = await getProcessInfoFromOS(process.pid); diff --git a/packages/cli/test/lib/target-syntax.test.ts b/packages/cli/test/lib/target-syntax.test.ts new file mode 100644 index 000000000..195e610b4 --- /dev/null +++ b/packages/cli/test/lib/target-syntax.test.ts @@ -0,0 +1,29 @@ +/** + * The generated target syntax docs come from TARGET_FORMS. These tests keep + * that table honest against the real parser. + */ + +import { describe, expect, test } from "vitest"; +import { + ProjectSpecificationType, + parseOrgProjectArg, +} from "../../src/lib/arg-parsing.js"; +import { TARGET_FORMS } from "../../src/lib/target-syntax.js"; + +describe("TARGET_FORMS", () => { + test.each(TARGET_FORMS.map((form) => [form.syntax, form] as const))( + "%s example parses to the documented type", + (_syntax, form) => { + const parsed = parseOrgProjectArg(form.example, { multi: form.multi }); + expect(parsed.type).toBe(form.type); + }, + ); + + test("documents every parse result type", () => { + const documented = new Set(TARGET_FORMS.map((form) => form.type)); + const missing = Object.values(ProjectSpecificationType).filter( + (type) => !documented.has(type), + ); + expect(missing).toEqual([]); + }); +}); From 880bad04bc49d64f275da7b986e5e0906f4e41e7 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Fri, 23 Oct 2026 14:01:24 +0000 Subject: [PATCH 2/4] ref(cli): Derive doc guards from code instead of tests Address review feedback on the docs drift guards: - Type agent tables by `KnownAgent` (keys of AGENT_DISPLAY_NAMES), so a detectable agent without a display name is a compile error. Drop the map-structure test. - Key TARGET_FORMS by parse result type so TypeScript enforces that every type is documented, and have the generator parse each example and fail on a mismatch. Drop target-syntax.test.ts. - Check only structured `docs.examples`, not examples hand-written into `fullDescription`. - Scan the install scripts in check:env-coverage so installer variables must be in ENV_VAR_REGISTRY (and therefore configuration.md). Add the missing SENTRY_NO_MODIFY_PATH. check:installer-docs now covers only installer help text and flags. - Replace the pnpm-specific `pnpm run cli --` lint with a dev-only preload that drops a leading `--`, so the dev scripts work with any package manager. - Add check:destructive: derive SDK DELETE operations from @sentry/api, follow calls, imports, and barrel re-exports through src/, and fail when a command that sends a DELETE is not marked destructive. Co-Authored-By: BYK --- packages/cli/package.json | 7 +- .../cli/script/check-destructive-commands.ts | 324 ++++++++++++++++++ packages/cli/script/check-doc-examples.ts | 19 +- packages/cli/script/check-env-coverage.ts | 57 ++- packages/cli/script/check-installer-docs.ts | 17 +- packages/cli/script/check-stale-references.ts | 52 +-- packages/cli/script/generate-docs-sections.ts | 22 +- packages/cli/script/strip-arg-separator.mjs | 14 + packages/cli/src/lib/command.ts | 6 +- packages/cli/src/lib/detect-agent.ts | 74 ++-- packages/cli/src/lib/env-registry.ts | 7 + packages/cli/src/lib/target-syntax.ts | 90 ++--- packages/cli/test/lib/detect-agent.test.ts | 27 -- packages/cli/test/lib/target-syntax.test.ts | 29 -- 14 files changed, 525 insertions(+), 220 deletions(-) create mode 100644 packages/cli/script/check-destructive-commands.ts create mode 100644 packages/cli/script/strip-arg-separator.mjs delete mode 100644 packages/cli/test/lib/target-syntax.test.ts diff --git a/packages/cli/package.json b/packages/cli/package.json index e86de3061..a63b8a3b3 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -56,8 +56,8 @@ }, "scripts": { "tsx": "tsx --env-file-if-exists=.env.local --import ./script/require-shim.mjs", - "cli": "node script/ensure-generated.mjs && tsx --env-file-if-exists=.env.local --import ./script/require-shim.mjs src/bin.ts", - "dev": "pnpm run generate:schema && pnpm run generate:docs && pnpm run generate:sdk && tsx --import ./script/require-shim.mjs src/bin.ts", + "cli": "node script/ensure-generated.mjs && tsx --env-file-if-exists=.env.local --import ./script/require-shim.mjs --import ./script/strip-arg-separator.mjs src/bin.ts", + "dev": "pnpm run generate:schema && pnpm run generate:docs && pnpm run generate:sdk && tsx --import ./script/require-shim.mjs --import ./script/strip-arg-separator.mjs src/bin.ts", "build": "pnpm --filter @sentry/mcp-core run build && pnpm --filter @sentry/mcp-server run build && pnpm run generate:schema && pnpm run generate:docs && pnpm run generate:sdk && pnpm tsx script/build.ts --single", "build:all": "pnpm --filter @sentry/mcp-core run build && pnpm --filter @sentry/mcp-server run build && pnpm run generate:schema && pnpm run generate:docs && pnpm run generate:sdk && pnpm tsx script/build.ts", "bundle": "pnpm --filter @sentry/mcp-core run build && pnpm --filter @sentry/mcp-server run build && pnpm run generate:schema && pnpm run generate:docs && pnpm run generate:sdk && pnpm tsx script/bundle.ts", @@ -91,7 +91,8 @@ "check:stale-refs": "pnpm tsx script/check-stale-references.ts", "check:doc-examples": "pnpm tsx script/check-doc-examples.ts", "check:installer-docs": "pnpm tsx script/check-installer-docs.ts", - "ci:policy": "pnpm run check:patches && pnpm run check:deps && pnpm run check:errors && pnpm run check:fragments && pnpm run check:docs-sections && pnpm run check:env-coverage && pnpm run check:stale-refs && pnpm run check:doc-examples && pnpm run check:installer-docs" + "check:destructive": "pnpm tsx script/check-destructive-commands.ts", + "ci:policy": "pnpm run check:patches && pnpm run check:deps && pnpm run check:errors && pnpm run check:fragments && pnpm run check:docs-sections && pnpm run check:env-coverage && pnpm run check:stale-refs && pnpm run check:doc-examples && pnpm run check:installer-docs && pnpm run check:destructive" }, "devDependencies": { "@anthropic-ai/sdk": "^0.39.0", diff --git a/packages/cli/script/check-destructive-commands.ts b/packages/cli/script/check-destructive-commands.ts new file mode 100644 index 000000000..12d61a5a4 --- /dev/null +++ b/packages/cli/script/check-destructive-commands.ts @@ -0,0 +1,324 @@ +#!/usr/bin/env tsx +/** + * Commands that send an HTTP DELETE must be marked destructive. + * + * The agent Safety Rules list destructive commands from command metadata + * (`buildDeleteCommand`, or `buildCommand({ destructive: true })`). This check + * derives the commands that can delete something from the code instead of + * trusting that metadata: + * + * 1. DELETE operations come from the `@sentry/api` SDK itself (operations + * built on `client.delete(...)`), plus raw requests with + * `method: "DELETE"`. + * 2. A function in `src/` that calls one, directly or through other + * functions, imports, and barrel re-exports, deletes too. + * 3. A command module that uses a deleting function must be built with + * `buildDeleteCommand` or pass `destructive: true`. + * + * Only DELETE is inferred. POST and PUT also create, update, and restore, so + * irreversible non-DELETE operations (`issue merge`, `trial start`) keep an + * explicit `destructive: true`. + * + * Usage: + * tsx script/check-destructive-commands.ts + * + * Exit codes: + * 0 - Every command that can send a DELETE is marked destructive + * 1 - A command sends a DELETE but is not marked destructive + */ + +import { readFile } from "node:fs/promises"; +import { dirname, relative, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { glob } from "tinyglobby"; +import ts from "typescript"; + +/** `var deleteProject = (options) => (options.client ?? client).delete({` */ +const SDK_DELETE_OP_RE = + /\bvar (\w+) = \(options\) => \(options\.client \?\? client\)\.delete\(/g; + +const SDK_MODULE = "@sentry/api"; + +/** Where a local name comes from: a module path (or the SDK) and its export. */ +type Binding = { module: string; name: string }; + +type Module = { + /** Import bindings: local name → source. */ + imports: Map; + /** `export { a as b } from "./x.js"`: exported name → source. */ + reExports: Map; + /** `export * from "./x.js"` targets. */ + starExports: string[]; + /** Top-level functions: name → identifiers they reference. */ + functions: Map>; + /** Top-level functions that issue a raw `method: "DELETE"` request. */ + rawDelete: Set; + /** Every identifier in the file. */ + identifiers: Set; + /** Built with `buildDeleteCommand` or `destructive: true`. */ + markedDestructive: boolean; +}; + +async function loadSdkDeleteOps(): Promise> { + const entry = fileURLToPath(import.meta.resolve(SDK_MODULE)); + const source = await readFile(entry, "utf-8"); + const ops = new Set([...source.matchAll(SDK_DELETE_OP_RE)].map((m) => m[1])); + if (ops.size === 0) { + throw new Error( + `No DELETE operations found in ${entry}; the SDK build format changed. Update SDK_DELETE_OP_RE.`, + ); + } + return ops as Set; +} + +function resolveSpecifier(file: string, specifier: string): string | null { + if (specifier === SDK_MODULE) { + return SDK_MODULE; + } + if (!specifier.startsWith(".")) { + return null; + } + return relative( + process.cwd(), + resolve(dirname(file), specifier.replace(/\.js$/, ".ts")), + ); +} + +function collectIdentifiers(node: ts.Node, into: Set): void { + if (ts.isIdentifier(node)) { + into.add(node.text); + } + node.forEachChild((child) => collectIdentifiers(child, into)); +} + +function hasRawDelete(node: ts.Node): boolean { + if ( + ts.isPropertyAssignment(node) && + node.name.getText() === "method" && + ts.isStringLiteral(node.initializer) && + node.initializer.text === "DELETE" + ) { + return true; + } + return node.forEachChild(hasRawDelete) ?? false; +} + +function isMarkedDestructive(node: ts.Node): boolean { + if ( + ts.isCallExpression(node) && + node.expression.getText() === "buildDeleteCommand" + ) { + return true; + } + if ( + ts.isPropertyAssignment(node) && + node.name.getText() === "destructive" && + node.initializer.kind === ts.SyntaxKind.TrueKeyword + ) { + return true; + } + return node.forEachChild(isMarkedDestructive) ?? false; +} + +/** Name and body of a top-level function declaration or `const f = () => …`. */ +function topLevelFunctions(statement: ts.Statement): [string, ts.Node][] { + if (ts.isFunctionDeclaration(statement) && statement.name && statement.body) { + return [[statement.name.text, statement.body]]; + } + if (!ts.isVariableStatement(statement)) { + return []; + } + return statement.declarationList.declarations.flatMap( + (d): [string, ts.Node][] => + ts.isIdentifier(d.name) && + d.initializer && + (ts.isArrowFunction(d.initializer) || + ts.isFunctionExpression(d.initializer)) + ? [[d.name.text, d.initializer]] + : [], + ); +} + +function parseModule(file: string, source: string): Module { + const sf = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true); + const mod: Module = { + imports: new Map(), + reExports: new Map(), + starExports: [], + functions: new Map(), + rawDelete: new Set(), + identifiers: new Set(), + markedDestructive: isMarkedDestructive(sf), + }; + collectIdentifiers(sf, mod.identifiers); + + for (const statement of sf.statements) { + if ( + ts.isImportDeclaration(statement) && + ts.isStringLiteral(statement.moduleSpecifier) + ) { + const module = resolveSpecifier(file, statement.moduleSpecifier.text); + const bindings = statement.importClause?.namedBindings; + if (module && bindings && ts.isNamedImports(bindings)) { + for (const el of bindings.elements) { + const name = (el.propertyName ?? el.name).text; + mod.imports.set(el.name.text, { module, name }); + } + } + continue; + } + if ( + ts.isExportDeclaration(statement) && + statement.moduleSpecifier && + ts.isStringLiteral(statement.moduleSpecifier) + ) { + const module = resolveSpecifier(file, statement.moduleSpecifier.text); + if (!module) { + continue; + } + const clause = statement.exportClause; + if (!clause) { + mod.starExports.push(module); + } else if (ts.isNamedExports(clause)) { + for (const el of clause.elements) { + const name = (el.propertyName ?? el.name).text; + mod.reExports.set(el.name.text, { module, name }); + } + } + continue; + } + for (const [name, body] of topLevelFunctions(statement)) { + const refs = new Set(); + collectIdentifiers(body, refs); + mod.functions.set(name, refs); + if (hasRawDelete(body)) { + mod.rawDelete.add(name); + } + } + } + return mod; +} + +const sdkDeleteOps = await loadSdkDeleteOps(); +const files = await glob("src/**/*.ts", { + ignore: ["src/generated/**", "**/*.test.ts"], +}); +const modules = new Map(); +for (const file of files) { + modules.set(file, parseModule(file, await readFile(file, "utf-8"))); +} + +/** `file#name` → a human-readable chain ending at the DELETE. */ +const deleting = new Map(); + +/** Whether `name` exported from `module` deletes; returns its chain. */ +function exportDeletes( + module: string, + name: string, + seen = new Set(), +): string | undefined { + if (module === SDK_MODULE) { + return sdkDeleteOps.has(name) ? `${SDK_MODULE} ${name}` : undefined; + } + const key = `${module}#${name}`; + if (deleting.has(key)) { + return deleting.get(key); + } + const mod = modules.get(module); + if (!mod || seen.has(key)) { + return; + } + seen.add(key); + const reExport = mod.reExports.get(name); + if (reExport) { + return exportDeletes(reExport.module, reExport.name, seen); + } + for (const target of mod.starExports) { + const chain = exportDeletes(target, name, seen); + if (chain) { + return chain; + } + } + return; +} + +/** Whether identifier `ref` used in `file` refers to something that deletes. */ +function refDeletes( + file: string, + mod: Module, + ref: string, +): string | undefined { + const local = deleting.get(`${file}#${ref}`); + if (local) { + return local; + } + const binding = mod.imports.get(ref); + return binding ? exportDeletes(binding.module, binding.name) : undefined; +} + +// Fixed point: propagate "deletes" through calls until nothing changes. +let changed = true; +while (changed) { + changed = false; + for (const [file, mod] of modules) { + for (const [name, refs] of mod.functions) { + const key = `${file}#${name}`; + if (deleting.has(key)) { + continue; + } + if (mod.rawDelete.has(name)) { + deleting.set(key, `${key} (method: "DELETE")`); + changed = true; + continue; + } + for (const ref of refs) { + const chain = ref === name ? undefined : refDeletes(file, mod, ref); + if (chain) { + deleting.set(key, `${key} → ${chain}`); + changed = true; + break; + } + } + } + } +} + +const problems: string[] = []; +let deletingCommands = 0; +for (const [file, mod] of modules) { + if (!file.startsWith("src/commands/")) { + continue; + } + let chain: string | undefined; + for (const ref of mod.identifiers) { + chain = refDeletes(file, mod, ref); + if (chain) { + break; + } + } + if (!chain) { + continue; + } + deletingCommands += 1; + if (!mod.markedDestructive) { + problems.push(`${file} sends a DELETE via ${chain}`); + } +} + +if (problems.length === 0) { + console.log( + `✓ All ${deletingCommands} command module(s) that send a DELETE are marked destructive`, + ); + process.exit(0); +} + +console.error( + `✗ ${problems.length} command(s) send a DELETE but are not marked destructive:\n`, +); +for (const problem of problems) { + console.error(` ${problem}`); +} +console.error( + "\n Build them with buildDeleteCommand, or pass `destructive: true` to buildCommand.", +); +process.exit(1); diff --git a/packages/cli/script/check-doc-examples.ts b/packages/cli/script/check-doc-examples.ts index 4bd88315f..a09b48a69 100644 --- a/packages/cli/script/check-doc-examples.ts +++ b/packages/cli/script/check-doc-examples.ts @@ -3,8 +3,8 @@ * Check `sentry …` examples in hand-written docs against the live route tree. * * Every `sentry` invocation in a fenced shell block or inline code span, and - * every example embedded in a command (`docs.examples` and `fullDescription`, - * which feed `--help` and the generated command pages), must: + * every structured command example (`docs.examples`, which feeds `--help` and + * the generated command pages), must: * 1. Name a command that exists (e.g. `sentry upgrade` → `sentry cli upgrade`) * 2. Use only flags (and single-letter aliases) that command defines * 3. Not pass a bare org-looking slug (`my-org`) as a target. A bare name @@ -282,8 +282,9 @@ const files = [ ]; /** - * Examples embedded in commands: `docs.examples` and `sentry …` lines in - * `fullDescription`. These feed `--help` and the generated command pages. + * Structured command examples (`docs.examples`). These feed `--help` and the + * generated command pages. Examples hand-written into `fullDescription` are + * not checked; they belong in `docs.examples`. */ function commandExamples( target: RouteTarget, @@ -299,15 +300,9 @@ function commandExamples( return []; } const source = `\`${["sentry", ...path].join(" ")}\` help`; - const fromDocs = (target.__examples ?? []).map((e) => e.command); - const fromDescription = (target.fullDescription ?? "") - .replace(CONTINUATION_RE, " ") - .split("\n") - .map((line) => line.trim()) - .filter((line) => line.startsWith("sentry ")); - return [...fromDocs, ...fromDescription].map((invocation) => ({ + return (target.__examples ?? []).map((e) => ({ source, - invocation, + invocation: e.command, })); } diff --git a/packages/cli/script/check-env-coverage.ts b/packages/cli/script/check-env-coverage.ts index 01234b6e8..dfef7e506 100644 --- a/packages/cli/script/check-env-coverage.ts +++ b/packages/cli/script/check-env-coverage.ts @@ -2,10 +2,10 @@ /** * Environment variable documentation coverage. * - * Scans the source tree for environment variables the CLI reads at runtime and - * fails if any user-facing variable is missing from `ENV_VAR_REGISTRY` - * (src/lib/env-registry.ts), which is the single source of truth for the - * generated `configuration.md` docs. + * Scans the source tree and the install scripts (`install`, `install.ps1`) for + * environment variables they read and fails if any user-facing variable is + * missing from `ENV_VAR_REGISTRY` (src/lib/env-registry.ts), which is the + * single source of truth for the generated `configuration.md` docs. * * This closes the class of "code reads an env var but the docs never mention it" * bugs: a new `getEnv().SENTRY_FOO` read forces a deliberate choice — either @@ -20,7 +20,8 @@ * tsx script/check-env-coverage.ts * * Exit codes: - * 0 - Every referenced user-facing env var is documented or explicitly internal + * 0 - Every user-facing env var read by the CLI or an installer is documented + * or explicitly internal * 1 - Coverage gap, redundant allowlist entry, or stale allowlist entry */ @@ -141,8 +142,51 @@ for (const [file, content] of fileContents) { } } +// --------------------------------------------------------------------------- +// Install scripts: they run before the CLI exists, so src/ never reads their +// variables. Script-local assignments (`SENTRY_DSN_KEY="..."`) are constants, +// not inputs. +// --------------------------------------------------------------------------- + +const INSTALLERS: readonly { file: string; read: RegExp; assign?: RegExp }[] = [ + { + file: "install", + read: /\$\{?(SENTRY_[A-Z0-9_]+)/g, + assign: /^\s*(?:local\s+)?(SENTRY_[A-Z0-9_]+)=/gm, + }, + { file: "install.ps1", read: /\$env:(SENTRY_[A-Z0-9_]+)/g }, +]; + +/** name -> first "file:line" where an installer reads it. */ +const installerReads = new Map(); + +for (const { file, read, assign } of INSTALLERS) { + const content = await readFile(file, "utf-8"); + const assigned = new Set( + assign ? [...content.matchAll(assign)].map((m) => m[1]) : [], + ); + for (const match of content.matchAll(read)) { + const name = match[1]; + if (!name || assigned.has(name) || installerReads.has(name)) { + continue; + } + const line = content.slice(0, match.index).split("\n").length; + installerReads.set(name, `${file}:${line}`); + } +} + const errors: string[] = []; +for (const [name, location] of installerReads) { + if (!registryNames.has(name)) { + errors.push( + `Undocumented installer env var: ${name} (read at ${location})\n` + + " Add it to ENV_VAR_REGISTRY in src/lib/env-registry.ts (with `installOnly: true`\n" + + " if the CLI itself never reads it) so it appears in configuration.md.", + ); + } +} + // A user-facing var read in code must be documented or explicitly internal. for (const [name, location] of referenced) { if (registryNames.has(name) || INTERNAL_ENV_VARS.has(name)) { @@ -183,6 +227,7 @@ if (errors.length > 0) { } console.log( - `✓ All ${referenced.size} user-facing env var(s) read in src/ are documented ` + + `✓ All ${referenced.size} user-facing env var(s) read in src/ and ` + + `${installerReads.size} read by the installers are documented ` + `(${registryNames.size} in registry, ${INTERNAL_ENV_VARS.size} internal).`, ); diff --git a/packages/cli/script/check-installer-docs.ts b/packages/cli/script/check-installer-docs.ts index d01defadf..2e59897cd 100644 --- a/packages/cli/script/check-installer-docs.ts +++ b/packages/cli/script/check-installer-docs.ts @@ -2,14 +2,16 @@ /** * Installer documentation coverage. * - * The install scripts (`install` for bash, `install.ps1` for PowerShell) read - * environment variables and flags that the CLI's ENV_VAR_REGISTRY never sees, - * so check-env-coverage.ts cannot catch them. This check closes that gap: + * The install scripts (`install` for bash, `install.ps1` for PowerShell) have + * their own help text and flags. This check keeps those in sync: * * 1. Every `SENTRY_*` variable an installer reads, and every flag the bash * installer parses, appears in that installer's own help text. - * 2. Every variable and flag in either installer's help text appears in the - * installation guide (getting-started.mdx). + * 2. Every flag the bash installer parses appears in the installation guide + * (getting-started.mdx). + * + * Installer environment variables are documented in configuration.md, which + * is generated from ENV_VAR_REGISTRY; check-env-coverage.ts enforces that. * * Usage: * tsx script/check-installer-docs.ts @@ -36,8 +38,6 @@ const BASH_USAGE_RE = /usage\(\) \{\n\s*cat <` comment-based help block in PowerShell. */ const PS_HELP_RE = /^<#([\s\S]*?)#>/; -/** Variable names listed in a help block. */ -const HELP_VAR_RE = /\b(SENTRY_[A-Z0-9_]+)\b/g; /** Flags handled by every CLI; not worth documenting in the guide. */ const UNDOCUMENTED_FLAGS = new Set(["--help"]); @@ -92,8 +92,7 @@ for (const { file, reads, flags, help } of installers) { problems.push(`${file} reads ${name} but its help text omits it`); } } - const documented = [...matchAll(HELP_VAR_RE, help), ...flags]; - for (const name of documented) { + for (const name of flags) { if (!(UNDOCUMENTED_FLAGS.has(name) || guide.includes(name))) { problems.push(`${file} supports ${name} but ${GUIDE} omits it`); } diff --git a/packages/cli/script/check-stale-references.ts b/packages/cli/script/check-stale-references.ts index cfb77ba17..4fda955b2 100644 --- a/packages/cli/script/check-stale-references.ts +++ b/packages/cli/script/check-stale-references.ts @@ -13,9 +13,6 @@ * `packageManager` field in package.json will automatically make this * check flag every `pnpm run` / `pnpm add` reference in dev docs. * - * It also flags `pnpm run cli -- ` in dev docs: pnpm passes the `--` - * through, so the CLI sees it as the end of options and fails. - * * Usage: * tsx script/check-stale-references.ts * @@ -178,60 +175,15 @@ function findOriginalLineNumber( return 0; } -// --------------------------------------------------------------------------- -// CLI scripts: `pnpm run cli -- --help` passes a literal `--` to the CLI, -// which then treats `--help` as a command name. Checked inside code blocks -// too, since that is where these commands live. -// --------------------------------------------------------------------------- - -/** package.json scripts that run the CLI entry point directly. */ -const cliScripts = Object.entries(pkg.scripts as Record) - .filter(([, command]) => command.includes("src/bin.ts")) - .map(([name]) => name); - -const CLI_SEPARATOR_RE = new RegExp( - `\\bpnpm(?:\\s+--filter\\s+\\S+)?\\s+(?:run\\s+)?(?:${cliScripts.join("|")})\\s+--(?=\\s|$)`, -); - -/** Docs that show how to run the CLI from a checkout. */ -const CLI_USAGE_FILES = [ - ...DOC_FILES, - "README.md", - "AGENTS.md", - "CONTRIBUTING.md", -]; - -for (const filePath of CLI_USAGE_FILES) { - let content: string; - try { - content = await readFile(filePath, "utf-8"); - } catch { - continue; - } - const lines = content.split("\n"); - for (let i = 0; i < lines.length; i++) { - const line = lines[i] ?? ""; - if (CLI_SEPARATOR_RE.test(line)) { - findings.push({ - file: filePath, - line: i + 1, - text: line.trim(), - reason: - "pnpm forwards a literal `--` to the CLI; pass arguments directly (`pnpm run cli --help`)", - }); - } - } -} - if (findings.length === 0) { console.log( - `✓ No stale references found (current PM: ${currentPM}, checked: ${FILES_TO_SCAN.length + CLI_USAGE_FILES.length} files)`, + `✓ No stale package manager references found (current: ${currentPM}, checked: ${FILES_TO_SCAN.length} files)`, ); process.exit(0); } console.error( - `✗ Found ${findings.length} stale reference(s) (current PM: ${currentPM}):\n`, + `✗ Found ${findings.length} stale package manager reference(s) (current PM: ${currentPM}):\n`, ); for (const f of findings) { console.error(` ${f.file}:${f.line}: ${f.text}`); diff --git a/packages/cli/script/generate-docs-sections.ts b/packages/cli/script/generate-docs-sections.ts index ad2abcd13..edfa991fa 100644 --- a/packages/cli/script/generate-docs-sections.ts +++ b/packages/cli/script/generate-docs-sections.ts @@ -42,6 +42,7 @@ if (!skillContentExists) { } import type { EnvVarEntry } from "../src/lib/env-registry.js"; +import type { TargetForm } from "../src/lib/target-syntax.js"; import type { RouteInfo, RouteMap } from "../src/lib/introspect.js"; const { routes } = await import("../src/app.js"); @@ -50,6 +51,7 @@ const { OAUTH_SCOPES } = await import("../src/lib/oauth.js"); const { ENV_VAR_REGISTRY } = await import("../src/lib/env-registry.js"); const { AGENT_DISPLAY_NAMES } = await import("../src/lib/detect-agent.js"); const { TARGET_FORMS } = await import("../src/lib/target-syntax.js"); +const { parseOrgProjectArg } = await import("../src/lib/arg-parsing.js"); const pkg = JSON.parse(await readFile("package.json", "utf-8")); const workspace = JSON.parse(await readFile("../../package.json", "utf-8")); @@ -594,7 +596,7 @@ function generateDestructiveCommands(allRoutes: RouteInfo[]): string { /** Generate the list of agents the CLI detects, from AGENT_DISPLAY_NAMES. */ function generateDetectedAgents(): string { - const names = [...AGENT_DISPLAY_NAMES.values()]; + const names: string[] = Object.values(AGENT_DISPLAY_NAMES); const last = names.pop(); return `The CLI detects ${names.join(", ")}, and ${last}.`; } @@ -603,11 +605,23 @@ function generateDetectedAgents(): string { // Section: Target Syntax (commands/index.md fragment, CONTRIBUTING.md) // --------------------------------------------------------------------------- -/** Generate the target syntax table from TARGET_FORMS. */ +/** + * Generate the target syntax table from TARGET_FORMS. Each example goes + * through the real parser, so generation fails when the docs and the parser + * disagree about what a form means. + */ function generateTargetSyntax(): string { const lines = ["| Target | Meaning |", "|--------|---------|"]; - for (const form of TARGET_FORMS) { - lines.push(`| ${form.syntax} | ${form.meaning} |`); + for (const [type, forms] of Object.entries(TARGET_FORMS)) { + for (const form of forms as readonly TargetForm[]) { + const parsed = parseOrgProjectArg(form.example, { multi: form.multi }); + if (parsed.type !== type) { + throw new Error( + `TARGET_FORMS: ${form.syntax} example ${JSON.stringify(form.example)} parses as "${parsed.type}", not "${type}"`, + ); + } + lines.push(`| ${form.syntax} | ${form.meaning} |`); + } } return lines.join("\n"); } diff --git a/packages/cli/script/strip-arg-separator.mjs b/packages/cli/script/strip-arg-separator.mjs new file mode 100644 index 000000000..210fac230 --- /dev/null +++ b/packages/cli/script/strip-arg-separator.mjs @@ -0,0 +1,14 @@ +/** + * Preload for the dev CLI scripts (`pnpm run cli`, `pnpm run dev`). + * + * npm needs `npm run cli -- --help` to forward arguments, but pnpm and Yarn + * forward the `--` too, so the CLI would see `-- --help` and treat `--help` as + * a positional. A leading `--` is never meaningful to the CLI itself, so drop + * it and let either habit work with any package manager. + * + * Dev scripts only; the built binary keeps its arguments unchanged. + */ + +if (process.argv[2] === "--") { + process.argv.splice(2, 1); +} diff --git a/packages/cli/src/lib/command.ts b/packages/cli/src/lib/command.ts index fd040f0ba..2aecc1d15 100644 --- a/packages/cli/src/lib/command.ts +++ b/packages/cli/src/lib/command.ts @@ -197,8 +197,10 @@ type LocalCommandBuilderArguments< * * Destructive commands are listed in the agent Safety Rules (generated from * this flag), so agents ask the user before running them. - * `buildDeleteCommand` sets this automatically; set it explicitly for other - * irreversible operations (e.g. `issue merge`, `trial start`). + * `buildDeleteCommand` sets this automatically, and `check:destructive` + * fails CI when a command that sends an HTTP DELETE lacks it. Set it + * explicitly for irreversible operations that use other verbs (e.g. + * `issue merge`, `trial start`). */ readonly destructive?: boolean; }; diff --git a/packages/cli/src/lib/detect-agent.ts b/packages/cli/src/lib/detect-agent.ts index 89c83fa47..d5c5ecd6b 100644 --- a/packages/cli/src/lib/detect-agent.ts +++ b/packages/cli/src/lib/detect-agent.ts @@ -10,8 +10,8 @@ * names for known agent executables. Runs as a non-blocking background * task so it never delays CLI startup. * - * To add a new agent, add entries to {@link ENV_VAR_AGENTS} and/or - * {@link PROCESS_NAME_AGENTS}. + * To add a new agent, add it to {@link AGENT_DISPLAY_NAMES}, then add entries + * to {@link ENV_VAR_AGENTS} and/or {@link PROCESS_NAME_AGENTS}. */ import { execFile } from "node:child_process"; @@ -31,11 +31,42 @@ export type AgentInfo = { role?: string; }; +/** + * Canonical agent name → display name, in documentation order. + * + * This is the list of agents the CLI can detect: {@link KnownAgent} is derived + * from its keys, and the detection tables below only accept known agents, so + * TypeScript rejects a detectable agent with no display name. The + * agentic-usage docs list is generated from it. + */ +export const AGENT_DISPLAY_NAMES = { + claude: "Claude Code", + cowork: "Claude Cowork", + cursor: "Cursor", + windsurf: "Windsurf", + "github-copilot": "GitHub Copilot", + gemini: "Gemini CLI", + codex: "OpenAI Codex", + antigravity: "Antigravity", + goose: "Goose", + amp: "Amp", + augment: "Augment", + opencode: "OpenCode", + cline: "Cline", + grok: "Grok", + kimi: "Kimi", + junie: "Junie", + openclaw: "OpenClaw", +} as const satisfies Record; + +/** Canonical name of an agent the CLI can detect. */ +export type KnownAgent = keyof typeof AGENT_DISPLAY_NAMES; + /** * Canonical name aliases — maps alternative agent identifiers to their * canonical family name. Checked after lowercasing. */ -export const AGENT_ALIASES = new Map([ +export const AGENT_ALIASES = new Map([ ["claude-code", "claude"], ["claudecode", "claude"], ["claude_code", "claude"], @@ -123,7 +154,7 @@ export function normalizeAgent(raw: string): AgentInfo | undefined { * Env var → agent name. Checked in insertion order — first match wins. * Each env var maps directly to the agent that sets it. */ -export const ENV_VAR_AGENTS = new Map([ +export const ENV_VAR_AGENTS = new Map([ // Cursor ["CURSOR_TRACE_ID", "cursor"], ["CURSOR_AGENT", "cursor"], @@ -170,7 +201,7 @@ export const ENV_VAR_AGENTS = new Map([ * Process executable basename (lowercase) → agent name. * Used when scanning the parent process tree as a fallback. */ -export const PROCESS_NAME_AGENTS = new Map([ +export const PROCESS_NAME_AGENTS = new Map([ ["cursor", "cursor"], ["claude", "claude"], ["goose", "goose"], @@ -182,34 +213,6 @@ export const PROCESS_NAME_AGENTS = new Map([ ["gemini", "gemini"], ]); -/** - * Canonical agent name → display name, in documentation order. - * - * The agentic-usage docs list is generated from this map, and a unit test - * fails when a detected agent (from {@link ENV_VAR_AGENTS}, - * {@link PROCESS_NAME_AGENTS}, {@link AGENT_ALIASES}, or the Claude/Cowork - * branch in {@link detectAgent}) has no entry here. - */ -export const AGENT_DISPLAY_NAMES = new Map([ - ["claude", "Claude Code"], - ["cowork", "Claude Cowork"], - ["cursor", "Cursor"], - ["windsurf", "Windsurf"], - ["github-copilot", "GitHub Copilot"], - ["gemini", "Gemini CLI"], - ["codex", "OpenAI Codex"], - ["antigravity", "Antigravity"], - ["goose", "Goose"], - ["amp", "Amp"], - ["augment", "Augment"], - ["opencode", "OpenCode"], - ["cline", "Cline"], - ["grok", "Grok"], - ["kimi", "Kimi"], - ["junie", "Junie"], - ["openclaw", "OpenClaw"], -]); - /** Max levels to walk up the process tree before giving up. */ const MAX_ANCESTOR_DEPTH = 5; @@ -278,7 +281,7 @@ export function detectAgent(): AgentInfo | undefined { // The role marker also exists outside agent execution; only this value // identifies an agent. Check before the map to retain Cursor's priority. if (env.CURSOR_EXTENSION_HOST_ROLE === "agent-exec") { - return normalizeAgent("cursor"); + return normalizeAgent("cursor" satisfies KnownAgent); } // 2. Table-driven env var check (Map iteration preserves insertion order). @@ -291,7 +294,8 @@ export function detectAgent(): AgentInfo | undefined { // 3. Claude Code / Cowork — requires branching logic, so not in the map if (env.CLAUDECODE || env.CLAUDE_CODE) { - return normalizeAgent(env.CLAUDE_CODE_IS_COWORK ? "cowork" : "claude"); + const agent: KnownAgent = env.CLAUDE_CODE_IS_COWORK ? "cowork" : "claude"; + return normalizeAgent(agent); } // 4. Lowest priority: generic AGENT fallback diff --git a/packages/cli/src/lib/env-registry.ts b/packages/cli/src/lib/env-registry.ts index 819266b9b..f033386a0 100644 --- a/packages/cli/src/lib/env-registry.ts +++ b/packages/cli/src/lib/env-registry.ts @@ -176,6 +176,13 @@ export const ENV_VAR_REGISTRY: readonly EnvVarEntry[] = [ example: "1", installOnly: true, }, + { + name: "SENTRY_NO_MODIFY_PATH", + description: + "Used with the PowerShell install script (`install.ps1`). When set to `1`, the installer leaves the user `PATH` unchanged. The bash install script takes the `--no-modify-path` flag instead.", + example: "1", + installOnly: true, + }, // -- Init wizard -- { name: "SENTRY_INIT_TUI", diff --git a/packages/cli/src/lib/target-syntax.ts b/packages/cli/src/lib/target-syntax.ts index ec61f4180..810f8ded1 100644 --- a/packages/cli/src/lib/target-syntax.ts +++ b/packages/cli/src/lib/target-syntax.ts @@ -1,15 +1,17 @@ /** - * Documented forms of the `/` target positional. + * Documented forms of the `/` target positional, keyed by the + * `parseOrgProjectArg()` result type each form produces. * * The target syntax tables in the commands overview and CONTRIBUTING.md are - * generated from this list (`script/generate-docs-sections.ts`), and - * `test/lib/target-syntax.test.ts` parses each example with - * `parseOrgProjectArg()` to check it yields the documented type. Change the - * parser and this list together. + * generated from this map (`script/generate-docs-sections.ts`). The key set is + * checked by TypeScript, so a new parse result type cannot ship undocumented, + * and the generator parses every example and fails when the parser returns a + * different type, so the table cannot disagree with the parser. */ import type { ProjectSpecificationType } from "./arg-parsing.js"; +/** A `parseOrgProjectArg()` result type. */ type SpecificationType = (typeof ProjectSpecificationType)[keyof typeof ProjectSpecificationType]; @@ -21,45 +23,47 @@ export type TargetForm = { readonly example: string | undefined; /** Whether the example needs `parseOrgProjectArg(..., { multi: true })`. */ readonly multi?: boolean; - /** Parse result type the example must produce. */ - readonly type: SpecificationType; /** What the form selects (Markdown). */ readonly meaning: string; }; -export const TARGET_FORMS: readonly TargetForm[] = [ - { - syntax: "_(omitted)_", - example: undefined, - type: "auto-detect", - meaning: - "Auto-detect the org and project from config, DSNs, or the directory name", - }, - { - syntax: "`/`", - example: "my-org/my-project", - type: "explicit", - meaning: "That project in that organization", - }, - { - syntax: "`/,`", - example: "my-org/web,api", - multi: true, - type: "explicit", - meaning: - "Several projects in the same organization (commands that accept several projects)", - }, - { - syntax: "`/`", - example: "my-org/", - type: "org-all", - meaning: "The whole organization", - }, - { - syntax: "``", - example: "my-project", - type: "project-search", - meaning: - "A project named `` in any accessible organization. If no project matches, commands that accept an organization use the organization named ``. When both exist, the project wins: add the trailing slash (`/`) to select the organization", - }, -]; +/** Target forms by parse result type, in documentation order. */ +export const TARGET_FORMS = { + "auto-detect": [ + { + syntax: "_(omitted)_", + example: undefined, + meaning: + "Auto-detect the org and project from config, DSNs, or the directory name", + }, + ], + explicit: [ + { + syntax: "`/`", + example: "my-org/my-project", + meaning: "That project in that organization", + }, + { + syntax: "`/,`", + example: "my-org/web,api", + multi: true, + meaning: + "Several projects in the same organization (commands that accept several projects)", + }, + ], + "org-all": [ + { + syntax: "`/`", + example: "my-org/", + meaning: "The whole organization", + }, + ], + "project-search": [ + { + syntax: "``", + example: "my-project", + meaning: + "A project named `` in any accessible organization. If no project matches, commands that accept an organization use the organization named ``. When both exist, the project wins: add the trailing slash (`/`) to select the organization", + }, + ], +} as const satisfies Record; diff --git a/packages/cli/test/lib/detect-agent.test.ts b/packages/cli/test/lib/detect-agent.test.ts index c6f010a00..7e80e79c3 100644 --- a/packages/cli/test/lib/detect-agent.test.ts +++ b/packages/cli/test/lib/detect-agent.test.ts @@ -2,7 +2,6 @@ import { afterEach, describe, expect, test } from "vitest"; import { AGENT_ALIASES, - AGENT_DISPLAY_NAMES, type AgentInfo, detectAgent, detectAgentFromProcessTree, @@ -646,32 +645,6 @@ describe("AGENT_ALIASES map structure", () => { }); }); -describe("AGENT_DISPLAY_NAMES", () => { - // The agentic-usage docs list is generated from AGENT_DISPLAY_NAMES, so it - // must name exactly the agents detectAgent() can return. - const detectable = new Set([ - ...ENV_VAR_AGENTS.values(), - ...PROCESS_NAME_AGENTS.values(), - ...AGENT_ALIASES.values(), - // Branches in detectAgent() that are not table-driven - "claude", - "cowork", - "cursor", - ]); - - test("names every detectable agent", () => { - const missing = [...detectable].filter((a) => !AGENT_DISPLAY_NAMES.has(a)); - expect(missing).toEqual([]); - }); - - test("names only detectable agents", () => { - const extra = [...AGENT_DISPLAY_NAMES.keys()].filter( - (a) => !detectable.has(a), - ); - expect(extra).toEqual([]); - }); -}); - describe("getProcessInfoFromOS", () => { test("returns info for own process", async () => { const info = await getProcessInfoFromOS(process.pid); diff --git a/packages/cli/test/lib/target-syntax.test.ts b/packages/cli/test/lib/target-syntax.test.ts deleted file mode 100644 index 195e610b4..000000000 --- a/packages/cli/test/lib/target-syntax.test.ts +++ /dev/null @@ -1,29 +0,0 @@ -/** - * The generated target syntax docs come from TARGET_FORMS. These tests keep - * that table honest against the real parser. - */ - -import { describe, expect, test } from "vitest"; -import { - ProjectSpecificationType, - parseOrgProjectArg, -} from "../../src/lib/arg-parsing.js"; -import { TARGET_FORMS } from "../../src/lib/target-syntax.js"; - -describe("TARGET_FORMS", () => { - test.each(TARGET_FORMS.map((form) => [form.syntax, form] as const))( - "%s example parses to the documented type", - (_syntax, form) => { - const parsed = parseOrgProjectArg(form.example, { multi: form.multi }); - expect(parsed.type).toBe(form.type); - }, - ); - - test("documents every parse result type", () => { - const documented = new Set(TARGET_FORMS.map((form) => form.type)); - const missing = Object.values(ProjectSpecificationType).filter( - (type) => !documented.has(type), - ); - expect(missing).toEqual([]); - }); -}); From d1fb35fac7c908e8423a90295bb5f230f7c3ee56 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Fri, 23 Oct 2026 20:20:50 +0000 Subject: [PATCH 3/4] ref(cli): Guard lazy targets of every destructive command Follow-up to #1594: `buildCommand` now calls `markDestructive` when `destructive: true`, instead of `buildDeleteCommand` doing it, so the `destructive` flag is the single source for the Safety Rules list and the auto-detect guard. `issue merge` and `trial start` get the guard if they move to lazy target positionals. check:doc-examples now recognizes target positionals by their `org/project` completion kind, which covers the new lazy target parsers as well as `parseOrgProjectTarget`. --- packages/cli/AGENTS.md | 5 +++-- packages/cli/script/check-doc-examples.ts | 4 ++-- packages/cli/src/lib/command.ts | 13 +++++++++++-- packages/cli/src/lib/mutate-command.ts | 6 +----- packages/cli/src/lib/positional-parse.ts | 5 +++-- packages/cli/src/lib/target-positional.ts | 4 ++-- packages/cli/test/lib/positional-parse.test.ts | 15 ++++++++++++++- 7 files changed, 36 insertions(+), 16 deletions(-) diff --git a/packages/cli/AGENTS.md b/packages/cli/AGENTS.md index be6583549..ecf363e18 100644 --- a/packages/cli/AGENTS.md +++ b/packages/cli/AGENTS.md @@ -253,8 +253,9 @@ one of these types and never reads `.resolved`. Resolvers need no command name: they read it from `this.commandPrefix`, which Stricli's `forCommand` and the SDK invoker set (test mock contexts set it -too). In a `buildDeleteCommand` command, the target refuses to auto-detect, so -delete commands don't call `requireExplicitTarget` for it. +too). In a destructive command (`buildDeleteCommand`, or `buildCommand` with +`destructive: true`), the target refuses to auto-detect, so those commands +don't call `requireExplicitTarget` for it. Both steps are lazy (`lazyPositional` in `src/lib/positional-parse.ts`): the parse runs on the first `parsed` read and the resolver on the first diff --git a/packages/cli/script/check-doc-examples.ts b/packages/cli/script/check-doc-examples.ts index a09b48a69..a32e2c161 100644 --- a/packages/cli/script/check-doc-examples.ts +++ b/packages/cli/script/check-doc-examples.ts @@ -49,7 +49,7 @@ if (!skillContentExists) { const { routes } = await import("../src/app.js"); const { isCommand, isRouteMap } = await import("../src/lib/introspect.js"); -const { parseOrgProjectTarget } = +const { getCompletionTarget, ORG_PROJECT_TARGET } = await import("../src/lib/completion-target.js"); /** Generated from the route tree, so not worth re-checking. */ @@ -215,7 +215,7 @@ function isTargetParam(param: PositionalParam | undefined): boolean { return false; } return ( - param.parse === parseOrgProjectTarget || + getCompletionTarget(param.parse) === ORG_PROJECT_TARGET || TARGET_PLACEHOLDER_RE.test(param.placeholder ?? "") ); } diff --git a/packages/cli/src/lib/command.ts b/packages/cli/src/lib/command.ts index 20da8b05c..c3df5aafc 100644 --- a/packages/cli/src/lib/command.ts +++ b/packages/cli/src/lib/command.ts @@ -65,7 +65,11 @@ import { parseLogLevel, setLogLevel, } from "./logger.js"; -import { applyLazyPositionals, rawPositional } from "./positional-parse.js"; +import { + applyLazyPositionals, + markDestructive, + rawPositional, +} from "./positional-parse.js"; import { setArgsContext, setFlagContext, withTracing } from "./telemetry.js"; import { applyTipsPreference } from "./tips.js"; @@ -197,7 +201,8 @@ type LocalCommandBuilderArguments< * Mark the command as destructive or irreversible. Defaults to `false`. * * Destructive commands are listed in the agent Safety Rules (generated from - * this flag), so agents ask the user before running them. + * this flag), so agents ask the user before running them, and their lazy + * target positionals refuse an auto-detected target. * `buildDeleteCommand` sets this automatically, and `check:destructive` * fails CI when a command that sends an HTTP DELETE lacks it. Set it * explicitly for irreversible operations that use other verbs (e.g. @@ -732,6 +737,10 @@ export function buildCommand< existingParams.positional, this, ); + // Lazy target positionals of destructive commands refuse to auto-detect + if (builderArgs.destructive) { + markDestructive(args); + } applyLoggingFlags( flags[LOG_LEVEL_KEY] as LogLevelName | undefined, diff --git a/packages/cli/src/lib/mutate-command.ts b/packages/cli/src/lib/mutate-command.ts index edfa02ead..baed2da39 100644 --- a/packages/cli/src/lib/mutate-command.ts +++ b/packages/cli/src/lib/mutate-command.ts @@ -23,7 +23,6 @@ import { buildCommand, type CommandDocumentation } from "./command.js"; import { CliError, ContextError } from "./errors.js"; import type { CommandReturn } from "./formatters/output.js"; import { logger } from "./logger.js"; -import { markDestructive } from "./positional-parse.js"; // --------------------------------------------------------------------------- // Level A: shared flag / alias definitions @@ -296,7 +295,7 @@ export type DeleteCommandOptions = { * stdin is not a TTY and neither `--yes`/`--force` was passed (dry-run * bypasses the guard since it makes no changes) * 4. Marks the command `destructive`, which lists it in the generated agent - * Safety Rules + * Safety Rules and makes its lazy target positionals refuse auto-detect * * Commands still handle their own confirmation prompts using * {@link confirmByTyping} + {@link isConfirmationBypassed} inside `func()`, @@ -402,9 +401,6 @@ export function buildDeleteCommand< ); } - // Lazy target positionals refuse to auto-detect when they resolve - markDestructive(args); - return originalFunc.call(this, flags, ...(args as unknown as ARGS)); } as typeof originalFunc; diff --git a/packages/cli/src/lib/positional-parse.ts b/packages/cli/src/lib/positional-parse.ts index b91c5fe52..aec33f5c8 100644 --- a/packages/cli/src/lib/positional-parse.ts +++ b/packages/cli/src/lib/positional-parse.ts @@ -29,8 +29,9 @@ * * Shared code that handles any positional (plural alias interception, `help` * recovery, telemetry) reads the raw input with {@link rawPositional}. - * `buildDeleteCommand` calls {@link markDestructive} on its positionals, so - * resolvers see `destructive: true` and can refuse to auto-detect. + * `buildCommand` calls {@link markDestructive} on the positionals of + * destructive commands (`destructive: true`, which `buildDeleteCommand` sets), + * so resolvers see `destructive: true` and can refuse to auto-detect. */ import { diff --git a/packages/cli/src/lib/target-positional.ts b/packages/cli/src/lib/target-positional.ts index c73f85c0a..47ce9a6cc 100644 --- a/packages/cli/src/lib/target-positional.ts +++ b/packages/cli/src/lib/target-positional.ts @@ -11,8 +11,8 @@ * * Resolution gets the command path from `commandPrefix`, which Stricli's * `forCommand` (and the SDK invoker) set on the context, so usage hints need - * no hand-written command name. Destructive commands (`buildDeleteCommand`) - * refuse an auto-detected target. + * no hand-written command name. Destructive commands (`destructive: true`, + * which `buildDeleteCommand` sets) refuse an auto-detected target. */ import { type ParsedOrgProject, parseOrgProjectArg } from "./arg-parsing.js"; diff --git a/packages/cli/test/lib/positional-parse.test.ts b/packages/cli/test/lib/positional-parse.test.ts index 0103e0536..3899744f9 100644 --- a/packages/cli/test/lib/positional-parse.test.ts +++ b/packages/cli/test/lib/positional-parse.test.ts @@ -49,7 +49,10 @@ const echoTarget = orgProjectTarget(echoResolver); type EchoTarget = OrgProjectTarget>>; /** Run `test cmd ...args`, recording what `target.resolved` yields. */ -async function runWithTarget(args: string[]): Promise<{ +async function runWithTarget( + args: string[], + { destructive = false } = {}, +): Promise<{ resolved: unknown[]; stderr: string; exitCode: number | undefined; @@ -62,6 +65,7 @@ async function runWithTarget(args: string[]): Promise<{ >({ docs: { brief: "Takes a target" }, auth: false, + destructive, parameters: { positional: { kind: "tuple", @@ -127,6 +131,15 @@ describe("orgProjectTarget", () => { ]); }); + test("a destructive: true command refuses an auto-detected target", async () => { + const { resolved, stderr, exitCode } = await runWithTarget([], { + destructive: true, + }); + expect(resolved).toEqual([]); + expect(exitCode).toBe(EXIT.CONTEXT_MISSING); + expect(stderr).toContain("Auto-detection is disabled"); + }); + test("invalid input fails inside the command with the CliError exit code", async () => { const { resolved, stderr, exitCode } = await runWithTarget(["@latest"]); expect(resolved).toEqual([]); From 4a49a82fc7edb7d78b3b99a642dc338001c9855c Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Fri, 23 Oct 2026 21:29:38 +0000 Subject: [PATCH 4/4] ref(cli): Parse doc code blocks with marked in check:doc-examples #1633 added the `no-regex-markdown-parsing` lint rule. Replace the fenced block regex with the shared `markdownCodeBlocks` / `blankCodeBlocks` helpers in script/markdown.ts, which use the same lexer as the docs renderer. --- packages/cli/script/check-doc-examples.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/cli/script/check-doc-examples.ts b/packages/cli/script/check-doc-examples.ts index 517472a28..e4258f965 100644 --- a/packages/cli/script/check-doc-examples.ts +++ b/packages/cli/script/check-doc-examples.ts @@ -31,6 +31,7 @@ import type { RouteMap, RouteTarget, } from "../src/lib/introspect.js"; +import { blankCodeBlocks, markdownCodeBlocks } from "./markdown.js"; import { DOCS_CONTENT, DOCS_FRAGMENTS } from "./paths.js"; // Bootstrap: skill-content stub (same pattern as generate-docs-sections.ts) @@ -76,7 +77,6 @@ const ORG_PLACEHOLDER_RE = /^(?:my-org|acme)$/; /** Placeholders of positionals parsed with `parseOrgProjectArg()`. */ const TARGET_PLACEHOLDER_RE = /^(?:target|org\/.+)$/; -const FENCE_RE = /^(`{3,}|~{3,})\s*([\w-]*)[^\n]*\n([\s\S]*?)\n\1\s*$/gm; const SHELL_LANGS = new Set(["", "bash", "sh", "shell", "zsh", "console"]); const INLINE_CODE_RE = /(?