Skip to content

Latest commit

 

History

History
1369 lines (1105 loc) · 70.5 KB

AGENTS.md

File metadata and controls

1369 lines (1105 loc) · 70.5 KB

AGENTS.md

Guidelines for AI agents working in this codebase.

Project Overview

Sentry CLI is a command-line interface for Sentry, built with Node.js, pnpm, and Stricli. It lives in packages/cli of a pnpm workspace; the docs site lives in apps/cli-docs.

Goals

  • Zero-config experience - Auto-detect project context from DSNs in source code and env files
  • AI-powered debugging - Integrate Seer AI for root cause analysis and fix plans
  • Developer-friendly - Follow gh CLI conventions for intuitive UX
  • Agent-friendly - JSON output and predictable behavior for AI coding agents
  • Fast - Native binaries via Node SEA (fossilize), SQLite caching for API responses

Key Features

  • DSN Auto-Detection - Scans .env files and source code (any file type in TEXT_EXTENSIONS, src/lib/scan/constants.ts) to find Sentry DSNs
  • Project Root Detection - Walks up from CWD to find project boundaries using VCS, language, and build markers
  • Directory Name Inference - Fallback project matching using bidirectional word boundary matching
  • Multi-Region Support - Automatic region detection with fan-out to regional APIs (us.sentry.io, de.sentry.io)
  • Monorepo Support - Generates short aliases for multiple projects
  • Seer AI Integration - issue explain and issue plan commands for AI analysis
  • OAuth Device Flow - Secure authentication without browser redirects

Cursor Rules (Important!)

Before working on this codebase, read the Cursor rules:

  • .cursor/rules/oxc.mdc - Code style, formatting, linting rules

Quick Reference: Commands

Note: Always check package.json for the latest scripts. All commands run from packages/cli (or via pnpm --filter sentry run <script> from the repo root).

# Development
pnpm install                             # Install dependencies (from repo root)
pnpm run dev                             # Run CLI in dev mode
pnpm run cli <args>                      # Run the CLI with arguments

# Build
pnpm run build                           # Build for current platform
pnpm run build:all                       # Build for all platforms

# Type Checking
pnpm run typecheck                       # Check types (tsc)

# Linting & Formatting
pnpm run lint                            # Check Oxlint and Oxfmt
pnpm run lint:fix                        # Auto-fix issues (run before committing)

# Testing (Vitest)
pnpm run test:unit                       # Run unit tests
pnpm run test:e2e                        # Run e2e tests
pnpm exec vitest run test/lib/foo.test.ts # Run a single test file (skips generate steps)
pnpm exec vitest                         # Watch mode

Rules: No Runtime Dependencies

CRITICAL: All packages must be in devDependencies, never dependencies. Everything is bundled at build time via esbuild. CI enforces this with pnpm run check:deps.

When adding a package, always use pnpm add -D <package> (the -D flag).

When the @sentry/api SDK provides types for an API response, import them directly from @sentry/api instead of creating redundant Valibot schemas in src/types/sentry.ts.

Rules: Use Node.js APIs

CRITICAL: This project runs on Node.js. Use Node.js standard-library (node:*) APIs; there is no Bun runtime. (This project was migrated from Bun to Node — do not reintroduce Bun.* globals, bun:test, or bun CLI commands.)

Quick API Reference

Task Use This
Read file await readFile(path, "utf-8") (node:fs/promises)
Write file await writeFile(path, content) (node:fs/promises)
Check file exists existsSync(path) (node:fs)
Spawn process spawn() / execFile() (node:child_process)
Shell commands execSync() (node:child_process)
Find executable which helper in src/lib/which.ts
Glob patterns the scanner in src/lib/scan/
Sleep await setTimeout(ms) (node:timers/promises)
Parse JSON file JSON.parse(await readFile(path, "utf-8"))

Use node:fs for directory creation with permissions:

import { mkdirSync } from "node:fs";
mkdirSync(dir, { recursive: true, mode: 0o700 });

For shell commands, prefer execFileSync with array args over execSync with a string when passing user-controlled values, to avoid shell injection:

import { execFileSync } from "node:child_process";
const result = execFileSync("id", ["-u", "username"], { encoding: "utf-8", stdio: ["pipe", "pipe", "ignore"] });

Architecture

The full project-structure tree — including the live command/subcommand list and the domain API modules — is generated from the route tree and lives in apps/cli-docs/src/content/docs/contributing.md (the project-structure block produced by script/generate-docs-sections.ts). It is kept in sync automatically, so it is not duplicated here to avoid drift. For the current command list run ls src/commands/ or sentry --help.

Top-level layout:

  • src/bin.ts — entry point; src/app.ts — Stricli application setup; src/context.ts — dependency-injection context.
  • src/commands/ — one directory per command group (agent-conversation, alert, auth, build, cli, code-mappings, dart-symbol-map, dashboard, debug-files, docs, dsn, event, feedback, integration, issue, local, log, monitor, org, platform, proguard, project, react-native, release, replay, repo, snapshots, sourcemap, span, status, team, trace, trial) plus standalone command files (api.ts, explore.ts, help.ts, info.ts, init.ts, mcp.ts, schema.ts, wasm-split.ts, plus the hidden bash-hook.ts, send-event.ts, and send-envelope.ts).
  • src/lib/ — shared utilities. Key subtrees: api/ (domain API modules), db/ (SQLite layer), dsn/ (DSN detection with one language-agnostic regex), scan/ (file walker and scanned extensions), and formatters/ (output formatting). See the file-locations table below and the JSDoc in each module for details.
  • src/types/ — TypeScript types and Valibot schemas.
  • test/ — tests mirroring src/ (unit, *.property.test.ts, *.model-based.test.ts, e2e/, fixtures/, mocks/).
  • ../../apps/cli-docs/ — documentation site (Astro + Starlight); script/ — build/utility scripts; .cursor/rules/ — Cursor AI rules; lint-rules/cli-oxlint-plugin.js — CLI-specific lint rules.

Key Patterns

CLI Commands (Stricli)

Commands use Stricli wrapped by src/lib/command.ts.

CRITICAL: Import buildCommand from ../../lib/command.js, NEVER from @stricli/core directly — the wrapper adds telemetry, --json/--fields injection, and output rendering.

Pattern:

import { buildCommand } from "../../lib/command.js";
import type { SentryContext } from "../../context.js";
import { CommandOutput } from "../../lib/formatters/output.js";

export const myCommand = buildCommand({
  docs: {
    brief: "Short description",
    fullDescription: "Detailed description",
  },
  output: {
    human: formatMyData,                // (data: T) => string
    jsonTransform: jsonTransformMyData, // optional: (data: T, fields?) => unknown
    jsonExclude: ["humanOnlyField"],    // optional: strip keys from JSON
  },
  parameters: {
    flags: {
      limit: { kind: "parsed", parse: Number, brief: "Max items", default: 10 },
    },
  },
  async *func(this: SentryContext, flags) {
    const data = await fetchData();
    yield new CommandOutput(data);
    return { hint: "Tip: use --json for machine-readable output" };
  },
});

Key rules:

  • Functions are async *func() generators — yield new CommandOutput(data), return { hint }.
  • output.human receives the same data object that gets serialized to JSON — no divergent-data paths.
  • The wrapper auto-injects --json and --fields flags. Do NOT add your own json flag.
  • Do NOT use stdout.write() or if (flags.json) branching — the wrapper handles it.

Command File Structure

Command files in src/commands/ should focus on three concerns:

  1. Argument parsing — positional args, flags, URL detection
  2. API orchestration — fetching data, error handling, enrichment
  3. Output dispatch — yield new CommandOutput(data)

Formatting and rendering logic belongs in src/lib/formatters/<domain>.ts. If a command file exceeds ~400 lines, extract formatting helpers into a dedicated formatter module.

Reference: src/lib/formatters/replay.ts (extracted from replay/view.ts), src/lib/formatters/trace.ts, src/lib/formatters/human.ts.

Lint enforcement: stderr.write() is banned in command files (GritQL rule). Use logger for diagnostics and CommandOutput for data output.

Route Maps (Stricli)

Route groups use Stricli's buildRouteMap wrapped by src/lib/route-map.ts.

CRITICAL: Import buildRouteMap from ../../lib/route-map.js, NEVER from @stricli/core directly — the wrapper auto-injects standard subcommand aliases based on which route keys exist:

Route Auto-aliases
list ls
view show
delete remove, rm
create new

Manually specified aliases in aliases are merged with (and take precedence over) auto-generated ones. Do NOT manually add aliases that are already in the standard set above.

import { buildRouteMap } from "../../lib/route-map.js";

export const myRoute = buildRouteMap({
  routes: {
    list: listCommand,
    view: viewCommand,
    create: createCommand,
  },
  defaultCommand: "view",
  // No need for aliases — ls, show, and new are auto-injected.
  // Only add aliases for non-standard mappings:
  // aliases: { custom: "list" },
  docs: {
    brief: "Manage my resources",
  },
});

Reading the command tree: code in src/lib/ reads the top-level route map with getRoutes() from src/lib/route-registry.ts, never by importing app.ts. Every command reaches src/lib/ through buildCommand, so that import would pull every command into any bundle built from one of them. app.ts registers its map with setRoutes(); a test that needs the real tree imports src/app.js. test/lib/route-registry.test.ts fails when a module in src/lib/ reaches app.ts; the only exception is sdk-invoke.ts, the library-mode runner that executes the whole application.

Positional Arguments

Required identifiers (trace IDs, span IDs) should be positional args, not flags. Every command that takes trace, span, replay, log, or event IDs parses them with parseIdArguments (or parseIdArgument for exactly one) from src/lib/id-arguments.ts, so they all take the same forms:

  • <id>, <org>/<id>, <org>/<project>/<id>, <target> <id>, a Sentry URL
  • <id> <org>/<project>, reversed, with a warning
  • more IDs after any of those (with multiple), each maybe repeating the target; one argument may hold several lines, or IDs after a space
  • extra arguments are ignored with a warning; slashes pasted after an ID are ignored

The command passes its kind of ID from ID_KINDS (what the ID looks like, how to validate it, which part of a URL holds one) and how it takes IDs: optional when it can list a project's items instead (log list, span list), needsProject when <x>/<id> should name project <x> rather than org <x>, validate: false to recover malformed IDs once the target is known. The result is a target string for parseOrgProjectInput and the IDs. Add a kind to ID_KINDS rather than validating IDs in a command; the sentry-cli/no-ad-hoc-id-arguments lint rule rejects hand-written ID parsing (parseSlashSeparatedArg, HEX_ID_RE, ...) in commands and kinds defined outside ID_KINDS. A kind without names gets a hint to search (search) when text is passed for an ID: validate() throws it, so it comes after any recovery.

Some kinds name items without an ID (IdKind.names): a dashboard by its title (ID_KINDS.dashboard; unquoted words after a target are one title), an issue by search text (ID_KINDS.issue). Then what is not an ID comes back as name. A bare first word before a name comes back as nameWithTarget too: the parser does not decide whether it names an org, so dashboard commands check once they resolve (resolveDashboardInput), and fall back to the whole input as the title. Dashboard commands take dashboardArguments from commands/dashboard/arguments.ts; dashboard list reads a target and a title filter instead (parseDashboardListArgs). event view checks for an issue before an event ID, with ID_KINDS.issue.distinctForm (parseEventViewInput). test/lib/argument-conventions.test.ts feeds the same inputs to every command that takes IDs, issues, or releases, through the command's own positional; add a new command to its tables, and encode a legitimate difference (such as needsProject) in its row.

Issue positionals: issue IDs, short IDs, selectors, issue URLs, and search text are parsed by parseIssueArgument (src/lib/issue-arguments.ts) with ID_KINDS.issue. An identifier is one whole token, which src/lib/issue-identifier.ts takes apart with grammar/issue-argument.pegjs; text with spaces is a search, which resolves to the one issue it matches. A command that takes one issue declares issuePositional({ command }) (or issueParameter inside a tuple) from src/commands/issue/positional.ts: func takes an IssueTarget and awaits target.resolved for the org and issue. A command that resolves it another way (resolveOrgAndIssueId, or recovery hints that keep its other arguments) passes requireIssueArgument(target.parsed, ...) instead of the raw string. A command that takes several issues (issue view, issue explain, issue merge) declares issuesPositional(...): every whitespace-separated word is an issue (no search text), parsed before func runs, and func resolves each IssueArgument from await issues.resolved itself.

import { ID_KINDS, parseIdArgument } from "../../lib/id-arguments.js";

// ["my-org/my-project/abc123..."] → { target: "my-org/my-project", id: "abc123...", warnings: [] }
const { target, id, baseUrl, warnings } = parseIdArgument(args, ID_KINDS.replay, {
  usageHint: USAGE_HINT,
});
const parsed = parseOrgProjectInput(target);

Reference: replay/view.ts, span/view.ts, log/view.ts, and trace-target.ts for trace targets.

Target positionals: A single [<org>/][<project>] positional uses a parser from src/lib/target-positional.ts, which binds the resolver: orgOptionalTarget (org, optional project), projectBoundTarget (one org and project), or orgProjectTarget(resolve) for a command-specific resolver (see project view). An org alone (org view) uses orgTarget; a boundArguments parser reads one with parseOrgInput and resolves it with resolveOrgInput (see trial start). func takes the matching type (OrgOptionalTarget, ProjectBoundTarget, OrgProjectTarget<R>, OrgTarget), reads the parsed form as target.parsed (e.g. target.parsed.type), and gets the org and project with await target.resolved. It never calls parseOrgProjectArg or a resolver itself, and an omitted target arrives as auto-detect. The sentry-cli/require-target-resolution lint rule fails a function that takes one of these types and never reads .resolved. It also rejects, in commands, what leaves a target for func to parse: parseOrgProjectArg, the resolve*FromArg helpers, and withCompletionTarget (the bound parsers tag the kind). Neither rule has an allow-list: no command may parse a target or an ID by hand.

Release positionals: [<org>/]<version> uses releaseParameter(USAGE_HINT, brief) from src/commands/release/parse.ts inside a tuple positional. func takes a ReleaseTarget and gets { version, org, detectedFrom } from await target.resolved; a blank version fails the parse.

Alert rule positionals: <org>/<project>/<rule-id-or-name> (issue alerts) and <org>/<rule-id-or-name> (metric alerts) use issueRuleParameter / metricRuleParameter from the alert rule-resolve.ts files inside a tuple positional. Rule names may contain spaces, so they keep their own syntax instead of ID_KINDS. func takes an AlertRuleArgument and gets { ref, target } from await rule.resolved.

List commands take their target from listTargetPositional() in src/lib/list-command.ts (LIST_TARGET_POSITIONAL, LIST_ORG_TARGET_POSITIONAL, or listTargetPositional({ orgScoped, names, multi }) for org-only completion, name globs, and comma-separated projects). dispatchOrgScopedList resolves each target mode its own way, so func takes a ListCommandTarget and passes const { parsed, filter } = await target.resolved to it; resolving only applies a pasted URL's server. The lint rule covers ListCommandTarget too.

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 destructive command (buildDeleteCommand, or buildCommand with destructive: true), an auto-detected target fails before func runs, so those commands don't call requireExplicitTarget for it.

boundPositional in src/lib/positional-parse.ts builds these parsers. Stricli runs the parse in its argument scanner, so invalid input fails like any other argument: Failed to parse "<input>" for <placeholder>: … and exit code 252. The parse must have no side effects; anything that changes state (such as applying a pasted Sentry URL's server) goes in the resolver, which runs on the first resolved read, inside func after the auth guard. target.raw is what the user typed; shared code that handles any positional uses rawPositional(value). buildCommand runs these parsers on positionals that arrive as strings or are omitted, so the SDK invoke path and tests that call command.loader() keep passing raw strings.

Arity-dependent positionals: when what an argument means depends on how many were given ([<org>/<project>] <replay-id>, log list's optional trailing trace ID), declare the positional with boundArguments from src/lib/positional-parse.ts (see replayArguments in replay/view.ts, log list, and trace view). It is an array positional whose parse sees every argument at once and whose resolve binds like a target positional's: func takes one BoundArguments<Parsed, Resolved> (alias it per command and add the alias to the lint rule's type list), reads target.parsed, and awaits target.resolved. A Stricli parser sees one argument at a time, so buildCommand runs the parse before func (and before the auth guard). A parse error becomes Stricli's ArgumentParseError, which prints and exits 252 like a scanner error; next to a help argument it turns into the command's help instead. The same purity rule applies: return a pasted URL's server (baseUrl) and any warnings from the parse, and apply them in resolve (parseTraceTargetInput and parseDualModeInput in trace-target.ts do this for trace targets). Commands that recover a malformed ID (trace view, span view, log view, event view) parse it unvalidated and validate or recover it in resolve, which can look the prefix up: parseTraceArgumentsInput and resolveTraceArguments do this for trace IDs. A missing ID or a bad target still fails the parse.

Which argument is the ID and which is its target is decided by grammar/id-arguments.pegjs, not by counting arguments in TypeScript. The grammar knows no kind of ID: the command's ID_KINDS entry tells it what an ID looks like, so the syntax cannot drift between commands. It accepts any input; errors come from parseIdArguments, never Peggy's own syntax errors.

Shell completion: Every visible positional and every visible value-taking flag declares a completion kind, a key of COMPLETERS in src/lib/complete.ts; test/lib/completion-coverage.test.ts walks the route tree and fails, naming the command and the placeholder or flag, for any without one. The sentry-cli/require-completion-parser lint rule rejects, in src/commands/ and src/lib/, the parsers that cannot carry a kind: built-ins (parse: String, Number, Boolean, Date, parseInt, JSON.parse, …), inline functions (parse: (value) => …), and boundArguments without a kind. A flag with nothing to suggest says so with parseText or textParser(parse). The rare parameter that must stay without a kind goes in lint-rules/completion-exemptions.json with its file, command, parameter (--flag or <placeholder>), and reason; both the lint rule and the coverage test read it, and the test fails on an entry that no longer needs exempting. An oxlint-disable-next-line sentry-cli/require-completion-parser -- reason comment silences only the lint rule; the coverage test cannot see comments, so the parameter still needs a kind or an allow-list entry. Pick the parser by what the argument takes:

Argument Parser Completes
Org, project, issue, dashboard target the bound parsers above (boundPositional(kind, …), boundArguments({ kind, … }), listTargetPositional, issuePositional, releaseParameter) ORG_TARGET org slugs; ORG_PROJECT_TARGET org/, org/project, aliases; ISSUE_TARGET plus cached issue short IDs; DASHBOARD_TARGET cached dashboards
File or directory path (--output, --ignore-file) parseFilePath entries of the partial's directory, directories with a trailing /
Directory parseDirectoryPath directories only
Target or directory (init) boundArguments({ kind: ORG_PROJECT_OR_DIRECTORY_TARGET, … }) org/project targets, or directories once the word looks like a path (./, /, ~/), so a suggestion is never read as a target
Fixed values choiceParser(values), or choiceParser(values, parse) to keep a command's own validating parser (choiceParser(VALID_SORT_VALUES, parseSort)). Take the values from the module that defines them those values; a variadic positional completes only its first word (cli defaults <key> <value>). Without parse the parser returns its input, so the command still validates it
Comma-separated fixed values (--scope, --features) choiceListParser(values) (or (values, parse)) the next item of the list, leaving out the items already in it (org:read,org:w → org:read,org:write)
Platform parsePlatform platform identifiers, common ones first
Something that already exists in Sentry, as a flag value (--environment, --release, --team, --owner, --integration, --app, --dsn, --workflow, --project, issue tags <key>) parseEnvironment, parseEnvironmentList, parseRelease, parseTeam, parseOwner (team:<id>), parseIntegration, parseSentryApp, parseAppLinkField (name=value), parseDsn, parseWorkflowList, parseProjectSlug, parseProjectList, parseTagKey cached entities of the org the command's target names, else of every org, each once; the *List parsers complete the last item of a comma-separated list
Field or aggregate of a dataset (explore --field/--sort, dashboard widget --group-by/--query/--sort, alert metrics --aggregate) fieldParser, fieldOrAggregateParser, aggregateParser (fn(field)), or aggregateShorthandParser (fn:field), given a DatasetCompletion: what completes on each dataset (start from DATASET_FIELDS), the command's --dataset aliases, and its default dataset the fields of the dataset the --dataset typed names (else the default; every dataset when there is none): cached attribute keys of spans, logs, and trace metrics, cached issue tag keys for errors, or a fixed set (replays), matched one . segment at a time (span.d finds span.duration, never span.op); then its whole aggregates (aggregates), and its aggregate functions as fn(), and fn(field) once a name is typed in full (p95 → p95(span.duration)). Widgets take the functions of aggregateFunctionsOf in types/dashboard.ts; alert metrics --aggregate takes the narrower ones metric alerts accept (METRIC_ALERT_FIELD_COMPLETION). A word starting with - completes flag names, so a descending sort's - is typed after completing
Issue as a flag value (issue merge --into) parseIssueReference what ISSUE_TARGET completes
Git ref, commit, or repository (--base-ref, --head-sha, --repo, --commit REPO@SHA, set-commits --from) parseVcsRef (branch and tag names as the remote knows them), parseGitRef (refs git resolves locally), parseGitCommit, parseGitRepo (owner/repo of the remotes), parseRepoCommitList read from git in the shell's directory; nothing outside a work tree
A structured value with its own grammar (issue archive --until, api <endpoint>) parseArchiveUntil, parseApiEndpoint the grammar: forever, auto, 10x, 10x/5m, …; API schema paths one segment at a time, with cached orgs and projects for their placeholders
Local session (local session view, local event list, …) parseLocalSession the running Local daemon's session IDs, described by label and state, then labels only one session has (none with whitespace). Read from local-sessions.json, an owner-only snapshot the daemon rewrites when a session is created, closed, or expires and removes on shutdown; it holds IDs, labels, states, and expiries, never capabilities, and is ignored once its daemon's PID has exited. <session envelope> and <session event> complete only the session: envelope and event IDs exist only in the daemon's memory
Stored account parseAccountSelector stored accounts (auth switch, auth logout, org list --account)
CLI command path parseCommandPath route names, one segment per word (help)
URL parseUrl (the command validates it) nothing
Free text, IDs with no cache, names of new things parseText nothing. Before reaching for it, check whether the value is really free: a value with a fixed set (in the module that validates it), a grammar, or a source (cache, git, schema) gets a kind that completes it
Numbers and other values a command parses itself (parsePort, parseLimit) textParser(parse) nothing

Import the pre-tagged parsers; never tag String or write withCompletionTarget in a command (the lint rules above reject it there), and never leave parse: String on a flag. Org, project, and issue targets stay bound parsers. enum flags complete their values like choiceParser. buildCommand tags the injected --fields once: with an output schema, it completes the schema's field paths, nested ones in dot notation (--fields id,ti → id,title, project.s → project.slug); without one, nothing.

Shared value parsers are not tagged where they live; PARSER_TARGETS in completion-target.ts lists them instead, with a kind or the values they accept, so every flag that parses with sanitizeIssueQuery completes issue search terms (is:unr → is:unresolved), every flag that parses with parsePeriod completes relative periods, parseCursorFlag completes next/prev/first, parseLogSort its directions, numberParser nothing, and parseLoginEmail completes stored accounts. Fixed values (search keys, period units) come from the modules that define them through COMPLETION_VALUES. A new shared parser goes in PARSER_TARGETS; its module must not import completion-target.ts, or the two would load in a cycle. Cached entities (issues, dashboards, environments, releases, teams, monitors, alert workflows, integrations, Sentry Apps and their issue-link form fields, DSNs, issue tag keys, attribute keys of each trace item dataset) come from the completion_cache table. The authenticated fetch in src/lib/sentry-client.ts offers every API response to src/lib/api/completion-seed.ts, which stores entities from the @sentry/api operations it lists (by URL template, type-checked against the SDK, and the query parameters that pick what a response holds, such as the attribute keys' dataset), so API functions never seed by hand. Attribute keys are also fetched ahead of time by src/lib/completion-warmup.ts, which auth login and cli setup call: the default org plus up to three recently used orgs, all datasets in parallel, aborted after a second. To complete a new entity type, add it to CompletionEntityKind, its operations in completion-seed.ts (one response may seed several kinds), a tagged parser, and an entityCompleter (target positionals) or flagEntityCompleter (flag values) entry in src/lib/complete.ts. To add a kind that is not an entity, add its entry to COMPLETERS (src/lib/complete.ts), whose keys are the CompletionTarget type, a *_TARGET name for it in completion-target.ts, and a pre-tagged parser. pnpm run generate:completions collects every command's kinds, choice values, and visible flags (with briefs and aliases), and every route group's subcommands, into the gitignored src/generated/completion-targets.json that the __complete fast path reads. Words that name a nested route group (alert issues) complete its subcommands, and a partial starting with - completes the command's flags not given yet. The bash, zsh, and fish scripts (src/lib/completions.ts) embed only the command and subcommand names and ask __complete for every later word, so every shell completes every depth the same way. Completers must stay light (synchronous node:fs reads, SQLite, no network); ones that need a parser (arg-parsing.ts, the search grammar) import it when called, so the fast path only loads it for the arguments that need it.

The fast path runs on every TAB press, so it loads only src/lib/complete.ts and what that module imports. src/bin.ts and the npm dist/bin.cjs check for __complete first and call runCompletion without loading cli.ts; the npm package ships it as the separate dist/complete.cjs bundle. That graph must stay free of @sentry/node, telemetry, the API client, and the command tree: the database modules get spans and cache metrics through withDbSpan and recordCacheHit from src/lib/db/index.ts, which require telemetry lazily and skip it when telemetry is off (runCompletion turns it off; dist/complete.cjs is built with SENTRY_CLI_COMPLETION_BUILD set to true, so the bundle leaves telemetry out), and logger.ts takes the Sentry reporter from initSentry. test/script/cli-startup.test.ts bundles the graph and fails on any package not in its COMPLETION_PACKAGES list, naming the importer, or on more source modules than COMPLETION_MODULE_BUDGET. Defer a heavy dependency with a dynamic import() in the completer that needs it rather than adding it to the list.

Multiple Values in Arguments

Split by argument type — do not mix the conventions:

  • Required positionals → space-separated variadic. Declare the arg as variadic and accept repeated tokens: issue merge A B C, project create web:javascript api:python-django. Do not split positional values on commas; commas may be part of the value. Every project passed to project create requires a name:platform pair — there is no space-separated form, with or without an explicit org. Project names cannot contain whitespace.
  • Exception: opted-in org/project list selectors. Commands whose API can query several projects together may parse their single optional target token as a list because Sentry slugs cannot contain commas. For example, issue list acme/web,api selects two projects. Pass { multi: true } to parseOrgProjectArg; never enable it for mutation or single-project commands.
  • Optional flags → comma-separated (sometimes also repeatable). Split the flag value on ,: --features errors,tracing, set-commits --path a,b, auth login --scope a,b. Use value.split(",") (repeatable array flags: flags.x.flatMap((v) => v.split(","))).

Reference: project/create.ts, release/set-commits.ts (--path), auth/login.ts (--scope)

Markdown Rendering

All non-trivial human output must use the markdown rendering pipeline:

  • Build markdown strings with helpers: mdKvTable(), colorTag(), escapeMarkdownCell(), renderMarkdown()
  • NEVER use raw muted() / chalk in output strings — use colorTag("muted", text) inside markdown
  • Tree-structured output (box-drawing characters) that can't go through renderMarkdown() should use the plainSafeMuted pattern: isPlainOutput() ? text : muted(text)
  • isPlainOutput() precedence: SENTRY_PLAIN_OUTPUT > NO_COLOR > FORCE_COLOR (TTY only) > !isTTY
  • isPlainOutput() lives in src/lib/formatters/plain-detect.ts (re-exported from markdown.ts for compat)

Reference: formatters/trace.ts (formatAncestorChain), formatters/human.ts (plainSafeMuted)

Parsing Markdown

Never parse markdown structure (headings, code fences, lists) with regexes. Use the marked lexer, which is already a dependency:

  • Scripts: use the helpers in script/markdown.ts (markdownHeadings(), blankCodeBlocks()), or marked.lexer() directly for other token types.
  • Runtime code: follow src/lib/formatters/markdown.ts and src/lib/release-notes.ts.
  • Regexes are fine on the plain text of a parsed token, such as matching heading text.

The sentry-cli/no-regex-markdown-parsing lint rule flags regexes that match headings or code fences in src/ and script/.

Create & Delete Command Standards

Mutation (create/delete) commands use shared infrastructure from src/lib/mutate-command.ts, paralleling list-command.ts for list commands.

Delete commands MUST use buildDeleteCommand() instead of buildCommand(). It:

  1. Auto-injects --yes, --force, --dry-run flags with -y, -f, -n aliases
  2. Runs a non-interactive safety guard before func() — refuses to proceed if stdin is not a TTY and --yes/--force was not passed (dry-run bypasses)
  3. Options to skip specific injections (noForceFlag, noDryRunFlag, noNonInteractiveGuard)
import { buildDeleteCommand, confirmByTyping, isConfirmationBypassed, requireExplicitTarget } from "../../lib/mutate-command.js";

export const deleteCommand = buildDeleteCommand({
  // Same args as buildCommand — flags/aliases auto-injected
  async *func(this: SentryContext, flags, target) {
    requireExplicitTarget(parsed, "Entity", "sentry entity delete <target>");
    if (flags["dry-run"]) { yield preview; return; }
    if (!isConfirmationBypassed(flags)) {
      if (!await confirmByTyping(expected, promptMessage)) return;
    }
    await doDelete();
  },
});

Create commands import DRY_RUN_FLAG and DRY_RUN_ALIASES for consistent dry-run support:

import { DRY_RUN_FLAG, DRY_RUN_ALIASES } from "../../lib/mutate-command.js";

// In parameters:
flags: { "dry-run": DRY_RUN_FLAG, team: { ... } },
aliases: { ...DRY_RUN_ALIASES, t: "team" },

Key utilities in mutate-command.ts:

  • isConfirmationBypassed(flags) — true if --yes or --force is set
  • guardNonInteractive(flags) — throws in non-interactive mode without --yes
  • confirmByTyping(expected, message) — type-out confirmation prompt
  • requireExplicitTarget(parsed, entityType, usage) — blocks auto-detect for safety
  • DESTRUCTIVE_FLAGS / DESTRUCTIVE_ALIASES — spreadable bundles for manual use

List Command Pagination

All list commands with API pagination MUST use the shared cursor-stack infrastructure for bidirectional pagination (-c next / -c prev):

import { LIST_CURSOR_FLAG } from "../../lib/list-command.js";
import {
  buildPaginationContextKey, resolveCursor,
  advancePaginationState, hasPreviousPage,
} from "../../lib/db/pagination.js";

export const PAGINATION_KEY = "my-entity-list";

// In buildCommand:
flags: { cursor: LIST_CURSOR_FLAG },
aliases: { c: "cursor" },

// In func():
const contextKey = buildPaginationContextKey("entity", `${org}/${project}`, {
  sort: flags.sort, q: flags.query,
});
const { cursor, direction } = resolveCursor(flags.cursor, PAGINATION_KEY, contextKey);
const { data, nextCursor } = await listEntities(org, project, { cursor, ... });
advancePaginationState(PAGINATION_KEY, contextKey, direction, nextCursor);
const hasPrev = hasPreviousPage(PAGINATION_KEY, contextKey);
const hasMore = !!nextCursor;

Cursor stack model: The DB stores a JSON array of page-start cursors plus a page index. Each entry is an opaque string — plain API cursors, compound cursors (issue list), or extended cursors with mid-page bookmarks (dashboard list). -c next increments the index, -c prev decrements it, -c first resets to 0. The stack truncates on back-then-forward to avoid stale entries. "last" is a silent alias for "next".

Hint rules: Show -c prev when hasPreviousPage() returns true. Show -c next when hasMore is true. Include both nextCursor and hasPrev in the JSON envelope.

Navigation hint generation: Use paginationHint() from src/lib/list-command.ts to build bidirectional navigation strings. Pass it pre-built prevHint/nextHint command strings and it returns the combined "Prev: X | Next: Y" string (or single-direction, or ""). Do NOT assemble navParts arrays manually — the shared helper ensures consistent formatting across all list commands.

import { paginationHint } from "../../lib/list-command.js";

const nav = paginationHint({
  hasPrev,
  hasMore,
  prevHint: `sentry entity list ${org}/ -c prev`,
  nextHint: `sentry entity list ${org}/ -c next`,
});
if (items.length === 0 && nav) {
  hint = `No entities on this page. ${nav}`;
} else if (hasMore) {
  header = `Showing ${items.length} entities (more available)\n${nav}`;
} else if (nav) {
  header = `Showing ${items.length} entities\n${nav}`;
}

Three abstraction levels for list commands (prefer the highest level that fits your use case):

  1. buildOrgListCommand (team/repo list) — Fully automatic. Pagination hints, cursor management, JSON envelope, and human formatting are all handled internally. New simple org-scoped list commands should use this.

  2. dispatchOrgScopedList with overrides (project/issue list) — Automatic for most modes; custom "org-all" override calls resolveCursor + advancePaginationState + paginationHint manually.

  3. buildListCommand with manual pagination (trace/span/dashboard list) — Command manages its own pagination loop. Must call resolveCursor, advancePaginationState, hasPreviousPage, and paginationHint directly.

Auto-pagination for large limits:

When --limit exceeds API_MAX_PER_PAGE (100), list commands MUST transparently fetch multiple pages to fill the requested limit. Always do this with paginate() from src/lib/api/infrastructure.ts; never write the cursor loop yourself. The sentry-cli/no-manual-pagination lint rule rejects loops that follow cursors and count toward a limit.

const { data, nextCursor } = await paginate(
  { limit: flags.limit, cursor },
  (perPage, pageCursor) =>
    listPaginated(org, { perPage, cursor: pageCursor }),
);

Pass perPage to the request unchanged. Sentry's page-number paginators (OffsetPaginator, used by projects, teams, members, repositories, and dashboard revisions) encode the page size in the cursor (<per_page>:<page>:<is_prev>), so a cursor is only valid with the per_page that produced it. paginate() reads that page size back from the cursor, so a later -c next reuses it even when --limit changes. A fresh walk splits --limit evenly over the fewest requests (201 → 3 × 67).

The cursors paginate() returns are Sentry cursors, so every CLI version and the API accept them, and stored -c next history works across upgrades and downgrades. When a call stops inside a page, the cursor points at that page and records how many of its rows were already returned in is_prev, in binary with _0 for 1 and 0 for 0 after a leading 0 (84:2:0_00_000_00 skips 82 = 0b1010010 rows). Sentry parses is_prev with Python's int(), which reads any zeros joined by single underscores as 0, so it is still a next-page cursor. encodeSkip() in src/lib/api/infrastructure.ts documents the format. paginate() refetches the page and skips them; anything else that sends the cursor (with the page size it encodes, if any) gets those rows again, never fewer. Keep returned cursors valid Sentry cursors. Test pagination changes against the paginator models in test/lib/paginate.property.test.ts.

Reference template: trace/list.ts, span/list.ts, dashboard/revisions.ts

ID Validation

Use shared validators from src/lib/hex-id.ts:

  • validateHexId(value, label) — 32-char hex IDs (trace IDs, log IDs). Auto-strips UUID dashes.
  • validateSpanId(value) — 16-char hex span IDs. Auto-strips dashes.
  • validateTraceId(value) — thin wrapper around validateHexId in src/lib/trace-id.ts.

All normalize to lowercase. Throw ValidationError on invalid input.

Sort Convention

Use "date" for timestamp-based sort (not "time"). Export sort types from the API layer (e.g., SpanSortValue from api/traces.ts), import in commands. This matches issue list, trace list, and span list.

Generated Docs & Skills

All command docs and skill files are generated via pnpm run generate:docs (which runs generate:banner, generate:completions, generate:command-docs, generate:skill, then generate:docs-sections). This runs automatically as part of dev, build, typecheck, and test scripts.

  • Command docs (apps/cli-docs/src/content/docs/commands/*.md) and the configuration page (apps/cli-docs/src/content/docs/configuration.md) are gitignored and generated from CLI metadata + hand-written fragments in apps/cli-docs/src/fragments/ (paths live in script/paths.ts).
  • Skill files (plugins/sentry-cli/skills/sentry-cli/) are committed (consumed by external plugin systems) and auto-committed by CI when stale.
  • Examples belong to the command: add them to docs.examples ({ description, command }[], see src/commands/agent-conversation/list.ts). They render in --help, the generated docs, and the skill references. Don't hand-write examples in fullDescription (an Examples: block, or indented sentry … lines under any label) or an Examples heading in a fragment; the sentry-cli/no-examples-in-full-description lint rule and pnpm run check:fragments reject both.
  • Target syntax belongs to the positional: describe accepted target forms in the positional brief (use ORG_ALL_TARGET_BRIEF or PROJECT_TARGET_BRIEF from src/lib/list-command.ts for [<org>/][<project>] targets), not in a Target patterns: block. The brief renders in --help, the generated docs, and the skill references.
  • Use fragments in apps/cli-docs/src/fragments/commands/ only for prose the command metadata can't carry: guides, permissions, caveats.
  • pnpm run check:fragments also rejects any shell code block in a fragment that runs a sentry command, whatever its heading. Multi-step workflows belong in docs.examples too: pass command an array of steps (see release create and local run), and every step is checked and shown in --help, the docs, and the skill. Reserve <!-- allow-commands: <reason> --> (on the line directly above the block) for sample output and guides that interleave non-sentry commands.
  • pnpm run check:fragments validates fragment ↔ route consistency.
  • Positional placeholder values must be descriptive: "org/project/trace-id" not "args".
  • Don't restate a value flag's default in its brief; help and docs already show it (test/lib/flag-briefs.test.ts).
  • pnpm run check:doc-examples also checks string-literal sdk.run(…) calls in TypeScript fences and @example JSDoc (src/index.ts). They are found by parsing with the TypeScript compiler (script/sdk-run-calls.ts); don't add regex-based source parsing.
  • Lists that mirror code (scanned DSN extensions, alias examples, destructive commands, detected agents, the library-usage.md sdk.run() sample built from issue list examples) are generated sections in script/generate-docs-sections.ts; add new mirrors there instead of hand-writing them.

Valibot Schemas for Validation

All config and API types use Valibot schemas:

import { type InferOutput, object, string, optional, number } from "valibot";

export const MySchema = object({
  field: string(),
  optional: optional(number()),
});

export type MyType = InferOutput<typeof MySchema>;

// Validate data
import { safeParse } from "valibot";
const result = safeParse(MySchema, data);
if (result.success) {
  // result.output is typed
}

Type Organization

  • Define Valibot schemas alongside types in src/types/*.ts
  • Key type files: sentry.ts (API types), config.ts (configuration), oauth.ts (auth flow), seer.ts (Seer AI)
  • Re-export from src/types/index.ts
  • Use type imports: import type { MyType } from "../types/index.js"

SQL Utilities

Use the upsert() helper from src/lib/db/utils.ts to reduce SQL boilerplate:

import { upsert, runUpsert } from "../db/utils.js";

// Generate UPSERT statement
const { sql, values } = upsert("table", { id: 1, name: "foo" }, ["id"]);
db.query(sql).run(...values);

// Or use convenience wrapper
runUpsert(db, "table", { id: 1, name: "foo" }, ["id"]);

// Exclude columns from update
const { sql, values } = upsert(
  "users",
  { id: 1, name: "Bob", created_at: now },
  ["id"],
  { excludeFromUpdate: ["created_at"] }
);

Error Handling

All CLI errors extend the CliError base class from src/lib/errors.ts:

// Error hierarchy in src/lib/errors.ts
// Exit codes are defined in the EXIT constant object — use EXIT.* constants
// when constructing errors, never hardcode numeric exit codes outside errors.ts.
CliError (base, exitCode=1)
├── HostScopeError (exitCode=13)
├── ApiError (exitCode=30 — HTTP/API failures)
├── AuthError (exitCode=10–12 by reason — 'not_authenticated' | 'expired' | 'invalid')
├── ConfigError (exitCode=20 — configuration/DSN)
├── OutputError (exitCode=60 — data rendered, but operation failed)
├── ContextError (exitCode=22 — missing context)
├── ResolutionError (exitCode=23 — value provided but not found)
├── ValidationError (exitCode=21 — input validation)
├── DeviceFlowError (exitCode=51 — OAuth flow)
├── SeerError (exitCode=40–42 by reason — 'not_enabled' | 'no_budget' | 'ai_disabled')
├── TimeoutError (exitCode=31 — operation timed out)
├── UpgradeError (exitCode=50 — upgrade failures)
└── WizardError (exitCode=61–64 by workflow step — init wizard error)

Exit code ranges: 1x=auth, 2x=input/config, 3x=API/network, 4x=feature/billing, 5x=operations, 6x=command-specific. See EXIT in src/lib/errors.ts and https://cli.sentry.dev/exit-codes/ for the full reference.

Choosing between ContextError, ResolutionError, and ValidationError:

Scenario Error Class Example
User omitted a required value ContextError No org/project provided
User provided a value that wasn't found ResolutionError Project 'cli' not found
User input is malformed ValidationError Invalid hex ID format

ContextError rules:

  • command must be a single-line CLI usage example (e.g., "sentry org view <slug>")
  • Constructor throws if command contains \n (catches misuse in tests)
  • Pass alternatives: [] when defaults are irrelevant (e.g., for missing Trace ID, Event ID)
  • Use " and " in resource for plural grammar: "Trace ID and span ID" → "are required"

CI enforcement: pnpm run check:errors scans for ContextError with multiline commands and CliError with ad-hoc "Try:" strings. Silent catch blocks are enforced separately by the no-silent-catch CLI Oxlint rule (see below).

// Usage examples
throw new ContextError("Organization", "sentry org view <org-slug>");
throw new ContextError("Trace ID", "sentry trace view <trace-id>", []); // no alternatives
throw new ResolutionError("Project 'cli'", "not found", "sentry issue list <org>/cli", [
  "No project with this slug found in any accessible organization",
]);
throw new ValidationError("Invalid trace ID format", "traceId");

Fuzzy suggestions in resolution errors:

When a user-provided name/title doesn't match any entity, use fuzzyMatch() from src/lib/fuzzy.ts to suggest similar candidates instead of listing all entities (which can be overwhelming). Show at most 5 fuzzy matches.

Reference: resolveDashboardId() in src/commands/dashboard/resolve.ts.

Catch Block Logging

Silent catch blocks are prohibited in src/ production code. Both empty catch {} blocks and blocks that only return without logging discard errors and make debugging impossible.

Every catch block must either:

  1. Re-throw the error
  2. Log with log.debug() or log.warn() for diagnostic visibility
  3. Return a fallback value with a log.debug() call explaining the suppression
// WRONG — error vanishes silently
try { data = await fetchOptionalData(); }
catch { return []; }

// RIGHT — error is visible in debug logs
try { data = await fetchOptionalData(); }
catch (error) {
  log.debug("Failed to fetch optional data", error);
  return [];
}

Use logger.withTag("command-name") for tagged logging in command files.

CI enforcement: the no-silent-catch CLI Oxlint rule (lint-rules/cli-oxlint-plugin.js, registered in the root .oxlintrc.json) flags catch blocks — statement and .catch() form — that are empty, comment-only, or return-only without surfacing the error. The pre-existing backlog is grandfathered in place with inline // oxlint-disable-next-line sentry-cli/no-silent-catch -- <reason> comments. Because pnpm run lint reports unused suppressions as errors, an orphaned suppression (left behind when a grandfathered catch is fixed) fails — so the backlog can only shrink, the same ratchet the old JSON baseline provided, with no separate script or baseline file to maintain. Fix a grandfathered catch by adding logging/re-throwing and deleting its suppression line; only add a new suppression for a genuinely intentional silent catch, with a real reason.

Auto-Recovery for Wrong Entity Types

When a user provides the wrong type of identifier (e.g., an issue short ID where a trace ID is expected), commands should auto-recover when the user's intent is unambiguous:

  1. Detect the actual entity type using helpers like looksLikeIssueShortId(), SPAN_ID_RE, HEX_ID_RE, or non-hex character checks.
  2. Resolve the input to the correct type (e.g., issue → latest event → trace ID).
  3. Warn via log.warn() explaining what happened.
  4. Show the result with a return hint nudging toward the correct command.

When recovery is ambiguous or impossible, keep the existing error but add entity-aware suggestions (e.g., "This looks like a span ID").

Detection helpers:

  • looksLikeIssueShortId(value) — uppercase dash-separated (e.g., CLI-G5)
  • SPAN_ID_RE.test(value) — 16-char hex (span ID)
  • HEX_ID_RE.test(value) — 32-char hex (trace/event/log ID)
  • /[^0-9a-f]/.test(normalized) — non-hex characters → likely a slug/name

Reference implementations:

  • event/view.ts — issue short ID → latest event redirect
  • span/view.ts — traceId/spanId slash format → auto-split
  • trace/view.ts — issue short ID → issue's trace redirect
  • hex-id.ts — entity-aware error hints in validateHexId/validateSpanId

Async Config Functions

All config operations are async. Always await:

const token = await getAuthToken();
const isAuth = await isAuthenticated();
await setAuthToken(token, expiresIn);

Adding New Utility Files

Before creating a new src/lib/*.ts utility file, check whether existing shared modules already cover your use case:

If you need... Check first...
Duration formatting src/lib/formatters/time-utils.ts (formatDurationCompact, formatDurationVerbose)
Hex ID validation/normalization src/lib/hex-id.ts (validateHexId, tryNormalizeHexId, normalizeHexId)
Relative time display src/lib/formatters/time-utils.ts (formatRelativeTime)
Table/markdown output src/lib/formatters/ directory
Pagination src/lib/db/pagination.ts, src/lib/list-command.ts
Error classes src/lib/errors.ts (never create ad-hoc error types)
Search query building src/lib/search-query.ts (wraps @sentry/toolkit-core/search-query), src/lib/arg-parsing.ts

If an existing module covers ≥80% of what you need, extend it with new exported functions rather than creating a new file. New files are appropriate when the domain is genuinely new (e.g., replay-search.ts for replay-specific field resolution).

Every new src/lib/**/*.ts file must start with a module-level JSDoc comment describing the module's purpose.

Imports

  • Use .js extension for local imports (ESM requirement)
  • Group: external packages first, then local imports
  • Use type keyword for type-only imports
import { object, string, optional } from "valibot";
import { buildCommand } from "../../lib/command.js";
import type { SentryContext } from "../../context.js";
import { getAuthToken } from "../../lib/db/auth.js";

Target Resolution

parseOrgProjectArg() owns target syntax: a value without / is a project-search; <org>/ is org-all; <org>/<project> is explicit. classifyProjectSearchTarget() owns the lookup order for project-search: exact project(s), exact organization, fuzzy project, then not found.

Commands select a capability wrapper instead of performing API lookups:

  • resolveProjectBoundSlug(), resolveProjectBoundTarget(), and resolveProjectBoundFromArg() require one project. An organization match is an actionable error.
  • resolveProjectBoundTargets() resolves one or more project targets.
  • resolveOrgOptionalTarget() / resolveOrgOptionalFromArg() allow either an organization or a project.
  • resolveOrgOnlyTarget() / resolveOrgOnlyFromArg() return the effective organization while preserving project-first precedence for bare targets.
  • resolveProjectIdsInOrg() / findProjectsInOrg() resolve a comma-separated org/a,b selector: numeric IDs for a single request (unknown slugs are an error), or the projects themselves plus the slugs that returned 404.

Do not call findProjectsBySlug() or implement org fallback directly in a command. sentry init is the intentional not-found exception: it calls the classifier with fuzzy recovery disabled, then treats not-found as a new project name. Issue-short-ID recovery in commands/issue/utils.ts is not CLI target resolution and may perform its own project lookup. The no-direct-target-resolution CLI Oxlint rule enforces this command-layer boundary.

List Command Infrastructure

Two abstraction levels exist for list commands:

  1. src/lib/list-command.ts — buildOrgListCommand factory + shared Stricli parameter constants (LIST_TARGET_POSITIONAL and listTargetPositional, LIST_JSON_FLAG, LIST_CURSOR_FLAG, buildListLimitFlag). Use this for simple entity lists like team list and repo list.

  2. src/lib/org-list.ts — dispatchOrgScopedList with OrgListConfig and a 4-mode handler map: auto-detect, explicit, org-all, project-search. Complex commands (project list, issue list) call dispatchOrgScopedList with an overrides map directly instead of using buildOrgListCommand.

Key rules when writing overrides:

  • Each mode handler receives a HandlerContext<T> with narrowed parsed, cwd, flags, and optional request-scoped project-search classification. Access parsed fields via ctx.parsed.org, ctx.parsed.projectSlug, etc. — no manual Extract<> casts needed.
  • Commands with extra fields (e.g., stderr, setContext) spread the context and add them: (ctx) => handle({ ...ctx, flags, stderr, setContext }). Override ctx.flags with the command-specific flags type when needed.
  • resolveCursor() must be called inside the org-all override closure, not before dispatchOrgScopedList, so that --cursor validation errors fire correctly for non-org-all modes.
  • For an unscoped bare target, dispatchOrgScopedList applies the shared classifier before dispatch: an organization match becomes org-all, while project results are passed request-scoped to the handler. Scoped and display-name searches stay in project-search and are classified there.
  1. Standalone list commands (e.g., span list, trace list) that don't use org-scoped dispatch wire pagination directly in func(). See the "List Command Pagination" section above for the pattern.

Project Filtering in API Calls

Different Sentry API endpoints use different project filtering mechanisms. Never apply both simultaneously:

API Endpoint Project filter Helper
Discover/Events (queryEvents) project:<slug> in query string buildProjectQuery()
Replay index (listReplays) projectSlugs parameter Direct parameter
Issue index (listIssuesPaginated) project parameter or query string Varies by mode

When adding a new dataset to explore, verify which filtering mechanism the underlying API expects and handle it in resolveDatasetConfig. The explore command centralizes dataset-specific behavior (sort, query, fetch, field validation) in resolveDatasetConfig — add new datasets there rather than scattering if (dataset === ...) checks through the func body.

Commenting & Documentation (JSDoc-first)

Default Rule

  • Prefer JSDoc over inline comments.
  • Code should be readable without narrating what it already says.

Required: JSDoc

Add JSDoc comments on:

  • Every exported function, class, and type (and important internal ones).
  • Types/interfaces: document each field/property (what it represents, units, allowed values, meaning of null, defaults).

Include in JSDoc:

  • What it does
  • Key business rules / constraints
  • Assumptions and edge cases
  • Side effects
  • Why it exists (when non-obvious)

Inline Comments (rare)

Inline comments are allowed only when they add information the code cannot express:

  • "Why" - business reason, constraint, historical context
  • Non-obvious behavior - surprising edge cases
  • Workarounds - bugs in dependencies, platform quirks
  • Hardcoded values - why hardcoded, what would break if changed

Inline comments are NOT allowed if they just restate the code:

// Bad:
if (!person) // if no person  
i++          // increment i   
return result // return result 

// Good:
// Required by GDPR Article 17 - user requested deletion
await deleteUserData(userId)

Prohibited Comment Styles

  • ASCII art section dividers - Do not use decorative box-drawing characters like ───────── to create section headers. Use standard JSDoc comments or simple // Section Name comments instead.

Goal

Minimal comments, maximum clarity. Comments explain intent and reasoning, not syntax.

Testing (Vitest + fast-check)

Prefer property-based and model-based testing over traditional unit tests. These approaches find edge cases automatically and provide better coverage with less code.

fast-check Documentation: https://fast-check.dev/docs/core-blocks/arbitraries/

Testing Hierarchy (in order of preference)

  1. Model-Based Tests - For stateful systems (database, caches, state machines)
  2. Property-Based Tests - For pure functions, parsing, validation, transformations
  3. Unit Tests - Only for trivial cases or when properties are hard to express

Test File Naming

Type Pattern Location
Property-based *.property.test.ts test/lib/
Model-based *.model-based.test.ts test/lib/db/
Unit tests *.test.ts test/ (mirrors src/)
E2E tests *.test.ts test/e2e/

Test Environment Isolation (CRITICAL)

Tests that need a database or config directory must use useTestConfigDir() from test/helpers.ts. This helper:

  • Creates a unique temp directory in beforeEach
  • Sets SENTRY_CONFIG_DIR to point at it
  • Restores (never deletes) the env var in afterEach
  • Closes the database and cleans up temp files

NEVER do any of these in test files:

  • delete process.env.SENTRY_CONFIG_DIR — This pollutes other test files that load after yours
  • const baseDir = process.env[CONFIG_DIR_ENV_VAR]! at module scope — This captures a value that may be stale
  • Manual beforeEach/afterEach that sets/deletes SENTRY_CONFIG_DIR

Why: Vitest runs each test file in a worker pool (see test:unit in package.json), so each file runs in its own worker process. That bounds most cross-file leaks to a single worker, but process.env is still shared within a file's lifecycle — if your afterEach deletes the env var, the next describe/test's module-level code (or a beforeEach that re-reads env) gets undefined, causing TypeError: The "paths[0]" property must be of type string. Also, TEST_TMP_DIR is namespaced by VITEST_POOL_ID in test/constants.ts so parallel workers don't wipe each other's temp state during preload.

// CORRECT: Use the helper
import { useTestConfigDir } from "../helpers.js";

const getConfigDir = useTestConfigDir("my-test-prefix-");

// If you need the directory path in a test:
test("example", () => {
  const dir = getConfigDir();
});

// WRONG: Manual env var management
beforeEach(() => { process.env.SENTRY_CONFIG_DIR = tmpDir; });
afterEach(() => { delete process.env.SENTRY_CONFIG_DIR; }); // BUG!

Property-Based Testing

Use property-based tests when verifying invariants that should hold for any valid input.

import { describe, expect, test } from "vitest";
import { constantFrom, assert as fcAssert, property, tuple } from "fast-check";
import { DEFAULT_NUM_RUNS } from "../model-based/helpers.js";

// Define arbitraries (random data generators)
const slugArb = array(constantFrom(..."abcdefghijklmnopqrstuvwxyz0123456789".split("")), {
  minLength: 1,
  maxLength: 15,
}).map((chars) => chars.join(""));

describe("property: myFunction", () => {
  test("is symmetric", () => {
    fcAssert(
      property(slugArb, slugArb, (a, b) => {
        // Properties should always hold regardless of input
        expect(myFunction(a, b)).toBe(myFunction(b, a));
      }),
      { numRuns: DEFAULT_NUM_RUNS }
    );
  });

  test("round-trip: encode then decode returns original", () => {
    fcAssert(
      property(validInputArb, (input) => {
        const encoded = encode(input);
        const decoded = decode(encoded);
        expect(decoded).toEqual(input);
      }),
      { numRuns: DEFAULT_NUM_RUNS }
    );
  });
});

Good candidates for property-based testing:

  • Parsing functions (DSN, issue IDs, aliases)
  • Encoding/decoding (round-trip invariant)
  • Symmetric operations (a op b = b op a)
  • Idempotent operations (f(f(x)) = f(x))
  • Validation functions (valid inputs accepted, invalid rejected)

See examples: test/lib/dsn.property.test.ts, test/lib/alias.property.test.ts, test/lib/issue-id.property.test.ts

Model-Based Testing

Use model-based tests for stateful systems where sequences of operations should maintain invariants.

import { describe, expect, test } from "vitest";
import {
  type AsyncCommand,
  asyncModelRun,
  asyncProperty,
  commands,
  assert as fcAssert,
} from "fast-check";
import { createIsolatedDbContext, DEFAULT_NUM_RUNS } from "../../model-based/helpers.js";

// Define a simplified model of expected state
type DbModel = {
  entries: Map<string, string>;
};

// Define commands that operate on both model and real system
class SetCommand implements AsyncCommand<DbModel, RealDb> {
  constructor(readonly key: string, readonly value: string) {}
  
  check = () => true;
  
  async run(model: DbModel, real: RealDb): Promise<void> {
    // Apply to real system
    await realSet(this.key, this.value);
    
    // Update model
    model.entries.set(this.key, this.value);
  }
  
  toString = () => `set("${this.key}", "${this.value}")`;
}

class GetCommand implements AsyncCommand<DbModel, RealDb> {
  constructor(readonly key: string) {}
  
  check = () => true;
  
  async run(model: DbModel, real: RealDb): Promise<void> {
    const realValue = await realGet(this.key);
    const expectedValue = model.entries.get(this.key);
    
    // Verify real system matches model
    expect(realValue).toBe(expectedValue);
  }
  
  toString = () => `get("${this.key}")`;
}

describe("model-based: database", () => {
  test("random sequences maintain consistency", () => {
    fcAssert(
      asyncProperty(commands(allCommandArbs), async (cmds) => {
        const cleanup = createIsolatedDbContext();
        try {
          await asyncModelRun(
            () => ({ model: { entries: new Map() }, real: {} }),
            cmds
          );
        } finally {
          cleanup();
        }
      }),
      { numRuns: DEFAULT_NUM_RUNS }
    );
  });
});

Good candidates for model-based testing:

  • Database operations (auth, caches, regions)
  • Stateful caches with invalidation
  • Systems with cross-cutting invariants (e.g., clearAuth also clears regions)

See examples: test/lib/db/model-based.test.ts, test/lib/db/dsn-cache.model-based.test.ts

Test Helpers

Use test/model-based/helpers.ts for shared utilities:

import { createIsolatedDbContext, DEFAULT_NUM_RUNS } from "../model-based/helpers.js";

// Create isolated DB for each test run (prevents interference)
const cleanup = createIsolatedDbContext();
try {
  // ... test code
} finally {
  cleanup();
}

// Use consistent number of runs across tests
fcAssert(property(...), { numRuns: DEFAULT_NUM_RUNS }); // 50 runs

When to Use Unit Tests

Use traditional unit tests only when:

  • Testing trivial logic with obvious expected values
  • Properties are difficult to express or would be tautological
  • Testing error messages or specific output formatting
  • Integration with external systems (E2E tests)

Avoiding Unit/Property Test Duplication

When a *.property.test.ts file exists for a module, do not add unit tests that re-check the same invariants with hardcoded examples. Before adding a unit test, check whether the companion property file already generates random inputs for that invariant.

Unit tests that belong alongside property tests:

  • Edge cases outside the property generator's range (e.g., self-hosted DSNs when the arbitrary only produces SaaS ones)
  • Specific output format documentation (exact strings, column layouts, rendered vs plain mode)
  • Concurrency/timing behavior that property tests cannot express
  • Integration tests exercising multiple functions together (e.g., writeJsonList envelope shape)

Unit tests to avoid when property tests exist:

  • "returns true for valid input" / "returns false for invalid input" — the property test already covers this with random inputs
  • Basic round-trip assertions — property tests check decode(encode(x)) === x for all x
  • Hardcoded examples of invariants like idempotency, symmetry, or subset relationships

When adding property tests for a function that already has unit tests, remove the unit tests that become redundant. Add a header comment to the unit test file noting which invariants live in the property file:

/**
 * Note: Core invariants (round-trips, validation, ordering) are tested via
 * property-based tests in foo.property.test.ts. These tests focus on edge
 * cases and specific output formatting not covered by property generators.
 */
import { describe, expect, test, vi } from "vitest";

describe("feature", () => {
  test("should return specific value", async () => {
    expect(await someFunction("input")).toBe("expected output");
  });
});

// Mock modules when needed
vi.mock("./some-module", () => ({
  default: () => "mocked",
}));

File Locations

What Where
Add new command src/commands/<domain>/
Add API types src/types/sentry.ts
Add config types src/types/config.ts
Add Seer types src/types/seer.ts
Add utility src/lib/
Add DSN language support TEXT_EXTENSIONS in src/lib/scan/constants.ts
Add DB operations src/lib/db/
Build scripts script/
Add property tests test/lib/<name>.property.test.ts
Add model-based tests test/lib/db/<name>.model-based.test.ts
Add unit tests test/ (mirror src/ structure)
Add E2E tests test/e2e/
Test helpers test/model-based/helpers.ts
Add documentation apps/cli-docs/src/content/docs/
Command examples docs.examples on the command
Hand-written command doc content (guides, caveats) apps/cli-docs/src/fragments/commands/

Automated Fix PRs (BugBot / agents)

Automated bug-fix PRs (e.g. Cursor BugBot) must follow these rules to avoid the duplication and staleness that caused five overlapping PRs to pile up:

  1. Check for existing work first. Before opening a PR, search open PRs and recently-closed PRs/issues for the same file + symbol:

    gh pr list --state open --search "in:title <file-or-symbol>"
    gh issue list --state all --search "<symbol>"

    If an open PR already touches the target function, comment on it or extend it instead of opening a duplicate. Multiple BugBot PRs independently re-fixed the same JSON.parse guard, withTTY helper, and pagination code.

  2. Rebase before review. A PR that is many commits behind main may fail CI on unrelated drift (e.g. a lint error in a file the PR never touched) and its fix may already be superseded. Rebase onto main and re-verify the bug still exists before requesting review. Verify against current main, not the snapshot the PR was generated from.

  3. Separate correctness fixes from opinion. A real bug (wrong output, crash, skipped data) is in scope. A subjective UX change (different hint wording, different default) is not a bug — main's current behavior is often deliberate. Do not bundle UX opinions into bug-fix PRs; they waste review cycles and are usually dropped.

  4. Prefer shared helpers over re-deriving fixes. If a correct implementation already exists (e.g. autoPaginate() for pagination, safeParseJson() for cached JSON), use it rather than hand-rolling a one-off fix. The recurring pagination-overshoot and parse-crash bugs were classes solved once centrally.

Long-term Knowledge

For long-term knowledge entries managed by lore (gotchas, patterns, decisions, architecture), see .lore.md in the project root.