Skip to content
Merged
61 changes: 41 additions & 20 deletions packages/cli/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -382,33 +382,54 @@ 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:** Target positionals that take an org, project, issue, or
dashboard are tagged with a completion kind from `src/lib/completion-target.ts`
(`ORG_PROJECT_TARGET` completes `org/`, `org/project`, and project aliases;
`ORG_TARGET` org slugs only; `ISSUE_TARGET` adds cached issue short IDs;
`DASHBOARD_TARGET` cached dashboards). The bound parsers above
(`boundPositional(kind, …)`, `boundArguments({ kind, … })`) carry the kind, so
the `target-positional.ts` parsers behind `listTargetPositional`,
`issuePositional`, and `releaseParameter` complete the right values. Shared
value parsers are not tagged where they live; `PARSER_TARGETS` in
**Shell completion:** Every visible positional 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 placeholder, for any
positional without one. 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 | `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)` (take the values from the module that defines them) | those values; a variadic positional completes only its first word (`cli defaults <key> <value>`). The parser returns its input, so the command still validates it |
| Stored account | `parseAccountSelector` | stored accounts (`auth switch`, `auth logout`) |
| 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 |

Import the pre-tagged parsers; never tag `String` or write
`withCompletionTarget` in a command (the lint rules above reject it there).
Org, project, and issue targets stay bound parsers. `enum` flags complete
their `values` like `choiceParser`.

Shared value parsers are not tagged where they live; `PARSER_TARGETS` in
`completion-target.ts` lists them instead, so every flag that parses with
`sanitizeIssueQuery` completes issue search terms
(`is:unr` → `is:unresolved`) and every flag that parses with `parsePeriod`
completes relative periods. Fixed values (search keys, period units) come
from the modules that define them through `COMPLETION_VALUES`.
`sanitizeIssueQuery` completes issue search terms (`is:unr` →
`is:unresolved`), every flag that parses with `parsePeriod` completes
relative periods, and `parseLoginEmail` completes stored accounts. Fixed
values (search keys, period units) come from the modules that define them
through `COMPLETION_VALUES`.
Issue and dashboard suggestions 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), so API
functions never seed by hand. To complete a new entity type, add it to
`CompletionEntityKind`, its operations in `completion-seed.ts`, a tagged
parser, and an `entityCompleter` entry in `src/lib/complete.ts`.
`pnpm run generate:completions` collects these into the gitignored
`src/generated/completion-targets.json` that the `__complete` fast path reads.
Completers 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. `project create` keeps its dedicated `name:platform` completer in
`src/lib/complete.ts`.
parser, and an `entityCompleter` 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 and choice
values, 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; the bash, zsh, and fish scripts embed only the first two
levels. 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.

### Multiple Values in Arguments

Expand Down
12 changes: 8 additions & 4 deletions packages/cli/script/generate-completion-targets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@
* complete which arguments, and it must not load the modules that define
* the values it offers. This script walks the route tree at build time and
* writes, per command, the completion kind of each positional and
* value-taking flag as JSON, plus those values (`COMPLETION_VALUES`). Kinds
* value-taking flag as JSON, the subcommands of every route group, and the
* values completers offer (`COMPLETION_VALUES`). Kinds
* come from the parsers the arguments already use
* (src/lib/completion-target.ts); flags without one are still listed so
* `__complete` can skip their values.
Expand Down Expand Up @@ -40,19 +41,22 @@ if (!skillContentExists) {
import type { RouteMap } from "../src/lib/introspect.js";

const { routes } = await import("../src/app.js");
const { extractCommandCompletions } = await import("../src/lib/introspect.js");
const { extractCommandCompletions, extractRouteGroups } =
await import("../src/lib/introspect.js");
const { COMPLETION_VALUES } = await import("../src/lib/completion-target.js");

const OUTPUT_PATH = "src/generated/completion-targets.json";

const commands = extractCommandCompletions(routes as unknown as RouteMap);
const routeMap = routes as unknown as RouteMap;
const commands = extractCommandCompletions(routeMap);
const groups = extractRouteGroups(routeMap);

// Plain JSON (like api-schema.json) so the output is produced by a real
// serializer rather than hand-assembled source text.
mkdirSync("src/generated", { recursive: true });
await writeFile(
OUTPUT_PATH,
`${JSON.stringify({ commands, values: COMPLETION_VALUES }, null, 2)}\n`,
`${JSON.stringify({ commands, groups, values: COMPLETION_VALUES }, null, 2)}\n`,
"utf8",
);
process.stdout.write(
Expand Down
3 changes: 2 additions & 1 deletion packages/cli/src/commands/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
rawApiRequest,
} from "../lib/api-client.js";
import { buildCommand } from "../lib/command.js";
import { parseText } from "../lib/completion-target.js";
import { applyCustomHeaders } from "../lib/custom-headers.js";
import { getIdentityFingerprint } from "../lib/db/auth.js";
import {
Expand Down Expand Up @@ -1692,7 +1693,7 @@ export const apiCommand = buildCommand({
parameters: [
{
brief: "API endpoint relative to /api/0/, or an absolute HTTP(S) URL",
parse: String,
parse: parseText,
placeholder: "endpoint",
},
],
Expand Down
3 changes: 2 additions & 1 deletion packages/cli/src/commands/attribute/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
parseAttributeTypes,
} from "../../lib/attribute-search.js";
import { validateLimit } from "../../lib/arg-parsing.js";
import { parseText } from "../../lib/completion-target.js";
import { buildDatasetParser, datasetDisplayName } from "../../lib/datasets.js";
import {
advancePaginationState,
Expand Down Expand Up @@ -283,7 +284,7 @@ export const listCommand = buildListCommand("attribute", {
placeholder: "filter",
brief:
"Text in attribute names and/or has:<key> terms, e.g. 'gen_ai has:http.method'",
parse: String,
parse: parseText,
optional: true,
},
],
Expand Down
52 changes: 2 additions & 50 deletions packages/cli/src/commands/auth/login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@ import {
import {
getAccountRow,
isAccountRowUsable,
splitAccountSelector,
parseLoginEmail,
splitLoginTarget,
} from "../../lib/db/accounts.js";
import { getDbPath } from "../../lib/db/index.js";
import { getUserInfo, setUserInfo } from "../../lib/db/user.js";
Expand Down Expand Up @@ -157,55 +158,6 @@ function resolveLoginScope(flags: LoginFlags): string | undefined {
* and full URLs; returns the normalized origin.
*/
/** @internal exported for testing */
/**
* Parse the optional `email` argument of `sentry auth login`: an email, or
* `<email>:<server>` (the notation `sentry org list` shows, e.g.
* `jane@acme.com:sentry.acme.com`) to also pick the server like `--url`.
* Returns the trimmed argument; {@link splitLoginTarget} splits it.
*
* @throws {ValidationError} when the value is not an email address, or the
* server part is not a valid URL
*/
export function parseLoginEmail(raw: string): string {
const target = raw.trim();
splitLoginTarget(target, raw);
return target;
}

/**
* Split a `sentry auth login` target into its email and, for
* `<email>:<server>`, the server's normalized origin. Uses the same
* {@link splitAccountSelector} as `--account`, plus an email check.
*
* @throws {ValidationError} when either part is invalid
*/
export function splitLoginTarget(
target: string,
raw = target,
): { email: string; url?: string } {
const parts = splitAccountSelector(target);
if (!(parts && isEmailAddress(parts.who))) {
throw new ValidationError(
`Expected an email address, or <email>:<server> with a valid server, to log in as; got '${raw}'.`,
"email",
);
}
return parts.server
? { email: parts.who, url: parts.server }
: { email: parts.who };
}

/** One `@` with text on both sides, and no whitespace. */
function isEmailAddress(value: string): boolean {
const at = value.indexOf("@");
return (
at > 0 &&
at < value.length - 1 &&
value.indexOf("@", at + 1) === -1 &&
!/\s/.test(value)
);
}

/**
* Apply the server of an `<email>:<server>` login target as `--url`.
*
Expand Down
3 changes: 2 additions & 1 deletion packages/cli/src/commands/auth/logout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
resolveAccountSelector,
} from "../../lib/account-selection.js";
import { buildCommand } from "../../lib/command.js";
import { parseAccountSelector } from "../../lib/completion-target.js";
import {
type AccountSummary,
getAccountRow,
Expand Down Expand Up @@ -135,7 +136,7 @@ export const logoutCommand = buildCommand({
placeholder: "account",
brief:
"Accounts to log out of (email, username, user ID, or account ID; comma-separated), or 'all'",
parse: String,
parse: parseAccountSelector,
},
},
},
Expand Down
3 changes: 2 additions & 1 deletion packages/cli/src/commands/auth/switch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
resolveAccountSelector,
} from "../../lib/account-selection.js";
import { buildCommand } from "../../lib/command.js";
import { parseAccountSelector } from "../../lib/completion-target.js";
import {
type AccountSummary,
getAccountRow,
Expand Down Expand Up @@ -116,7 +117,7 @@ export const switchCommand = buildCommand({
{
placeholder: "account",
brief: "Email, username, user ID, or account ID to switch to",
parse: String,
parse: parseAccountSelector,
optional: true,
},
],
Expand Down
3 changes: 2 additions & 1 deletion packages/cli/src/commands/build/download.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
toBinaryDownloadUrl,
} from "../../lib/api/preprod-artifacts.js";
import { buildCommand } from "../../lib/command.js";
import { parseText } from "../../lib/completion-target.js";
import { ContextError, ResolutionError } from "../../lib/errors.js";
import { mdKvTable, renderMarkdown } from "../../lib/formatters/markdown.js";
import { CommandOutput } from "../../lib/formatters/output.js";
Expand Down Expand Up @@ -80,7 +81,7 @@ export const downloadCommand = buildCommand({
parameters: [
{
brief: "ID of the build to download",
parse: String,
parse: parseText,
placeholder: "build-id",
},
],
Expand Down
3 changes: 2 additions & 1 deletion packages/cli/src/commands/build/upload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import {
} from "../../lib/build/vcs.js";
import { isCi } from "../../lib/ci.js";
import { buildCommand } from "../../lib/command.js";
import { parseFilePath } from "../../lib/completion-target.js";
import { ContextError, ValidationError } from "../../lib/errors.js";
import {
colorTag,
Expand Down Expand Up @@ -225,7 +226,7 @@ export const uploadCommand = buildCommand({
parameter: {
brief:
"Path(s) to the build(s) to upload (APK, AAB, IPA, or XCArchive)",
parse: String,
parse: parseFilePath,
placeholder: "path",
},
},
Expand Down
3 changes: 2 additions & 1 deletion packages/cli/src/commands/cli/completion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@

import type { SentryContext } from "../../context.js";
import { buildCommand } from "../../lib/command.js";
import { choiceParser } from "../../lib/completion-target.js";
import { getCompletionScript } from "../../lib/completions.js";
import { ValidationError } from "../../lib/errors.js";
import { CommandOutput } from "../../lib/formatters/output.js";
Expand Down Expand Up @@ -60,7 +61,7 @@ export const completionCommand = buildCommand({
{
placeholder: "shell",
brief: "Shell to generate completions for (bash, zsh, or fish)",
parse: String,
parse: choiceParser(["bash", "zsh", "fish"]),
optional: true,
},
],
Expand Down
3 changes: 2 additions & 1 deletion packages/cli/src/commands/cli/defaults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
import { resolve } from "node:path";
import type { SentryContext } from "../../context.js";
import { buildCommand } from "../../lib/command.js";
import { choiceParser } from "../../lib/completion-target.js";
import { normalizeUrl } from "../../lib/constants.js";
import { readCaCertFile } from "../../lib/custom-ca.js";
import { parseCustomHeaders } from "../../lib/custom-headers.js";
Expand Down Expand Up @@ -326,7 +327,7 @@ export const defaultsCommand = buildCommand({
kind: "array",
parameter: {
brief: "Setting key and optional value",
parse: String,
parse: choiceParser(Object.keys(DEFAULTS_REGISTRY)),
placeholder: "key value",
},
},
Expand Down
3 changes: 2 additions & 1 deletion packages/cli/src/commands/cli/feedback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { isatty } from "node:tty";
import * as Sentry from "@sentry/node";
import type { SentryContext } from "../../context.js";
import { buildCommand } from "../../lib/command.js";
import { parseText } from "../../lib/completion-target.js";
import { ConfigError, ValidationError } from "../../lib/errors.js";
import { formatFeedbackResult } from "../../lib/formatters/human.js";
import { CommandOutput } from "../../lib/formatters/output.js";
Expand Down Expand Up @@ -51,7 +52,7 @@ export const feedbackCommand = buildCommand({
kind: "array",
parameter: {
brief: "Your feedback message",
parse: String,
parse: parseText,
placeholder: "message",
},
},
Expand Down
3 changes: 2 additions & 1 deletion packages/cli/src/commands/cli/upgrade.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {
type UpgradeSource,
} from "../../lib/binary.js";
import { buildCommand } from "../../lib/command.js";
import { choiceParser } from "../../lib/completion-target.js";
import { CLI_VERSION } from "../../lib/constants.js";
import {
backupDatabase,
Expand Down Expand Up @@ -1200,7 +1201,7 @@ export const upgradeCommand = buildCommand({
{
brief:
'Specific version (e.g. 0.5.0), "nightly"/"stable" to switch channel, or "undo" to return to the previous version; omit to update within current channel',
parse: String,
parse: choiceParser([...CHANNEL_VERSIONS, MAGIC_VERSION_FOR_UNDO]),
placeholder: "version",
optional: true,
},
Expand Down
3 changes: 2 additions & 1 deletion packages/cli/src/commands/code-mappings/upload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
uploadCodeMappings,
} from "../../lib/api/code-mappings.js";
import { buildCommand } from "../../lib/command.js";
import { parseFilePath } from "../../lib/completion-target.js";
import { ContextError, ValidationError } from "../../lib/errors.js";
import { mdKvTable, renderMarkdown } from "../../lib/formatters/markdown.js";
import { CommandOutput } from "../../lib/formatters/output.js";
Expand Down Expand Up @@ -204,7 +205,7 @@ export const uploadCommand = buildCommand({
parameters: [
{
brief: "Path to the code mappings JSON file",
parse: String,
parse: parseFilePath,
placeholder: "path",
},
],
Expand Down
3 changes: 2 additions & 1 deletion packages/cli/src/commands/dart-symbol-map/upload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { basename } from "node:path";
import type { SentryContext } from "../../context.js";
import { uploadDartSymbolMap } from "../../lib/api/dart-symbols.js";
import { buildCommand } from "../../lib/command.js";
import { parseFilePath } from "../../lib/completion-target.js";
import { ContextError, ValidationError } from "../../lib/errors.js";
import { mdKvTable, renderMarkdown } from "../../lib/formatters/markdown.js";
import { CommandOutput } from "../../lib/formatters/output.js";
Expand Down Expand Up @@ -195,7 +196,7 @@ export const uploadCommand = buildCommand({
parameters: [
{
brief: "Path to the dart symbol map JSON file",
parse: String,
parse: parseFilePath,
placeholder: "path",
},
],
Expand Down
3 changes: 2 additions & 1 deletion packages/cli/src/commands/debug-files/bundle-jvm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { mkdir, stat } from "node:fs/promises";
import { join, resolve } from "node:path";
import type { SentryContext } from "../../context.js";
import { buildCommand } from "../../lib/command.js";
import { parseDirectoryPath } from "../../lib/completion-target.js";
import { ValidationError } from "../../lib/errors.js";
import { mdKvTable, renderMarkdown } from "../../lib/formatters/markdown.js";
import { CommandOutput } from "../../lib/formatters/output.js";
Expand Down Expand Up @@ -83,7 +84,7 @@ export const bundleJvmCommand = buildCommand({
parameters: [
{
brief: "Directory containing JVM source files",
parse: String,
parse: parseDirectoryPath,
placeholder: "path",
},
],
Expand Down
Loading
Loading