diff --git a/apps/cli-docs/src/content/docs/agent-guidance.md b/apps/cli-docs/src/content/docs/agent-guidance.md
index 2c7b2c504..c016145f4 100644
--- a/apps/cli-docs/src/content/docs/agent-guidance.md
+++ b/apps/cli-docs/src/content/docs/agent-guidance.md
@@ -54,6 +54,7 @@ The CLI uses semantic exit codes. Key ranges for agents:
| 40–49 | Feature unavailable | Inform user about plan/settings |
| 50–59 | Operation error | Report to user |
| 60–69 | Command-specific | Check stderr for details |
+| 252 | Invalid argument | Check the command's `--help` and fix the argument |
See [Exit Codes](/exit-codes/) for the complete reference.
diff --git a/apps/cli-docs/src/content/docs/exit-codes.md b/apps/cli-docs/src/content/docs/exit-codes.md
index 715268c69..a7c56051b 100644
--- a/apps/cli-docs/src/content/docs/exit-codes.md
+++ b/apps/cli-docs/src/content/docs/exit-codes.md
@@ -30,7 +30,7 @@ react to failure categories without parsing stderr.
| 12 | Token Invalid | Auth token rejected by the server |
| 13 | Host Scope | Request blocked — credentials don't match the target host |
| 20 | Config Error | Configuration or DSN problem |
-| 21 | Validation Error | Invalid input (malformed ID, bad flag value, etc.) |
+| 21 | Validation Error | Invalid input found while the command runs (malformed ID, unknown value, etc.) |
| 22 | Missing Context | Required context (org, project) could not be determined |
| 23 | Not Found | A user-provided identifier could not be resolved |
| 30 | API Error | Sentry API returned an error response |
@@ -45,6 +45,7 @@ react to failure categories without parsing stderr.
| 62 | Wizard Deps | Wizard dependency installation failed |
| 63 | Wizard Codemod | Wizard codemod plan or apply failed |
| 64 | Wizard Verify | User stopped wizard after verification step |
+| 252 | Invalid Argument | An argument could not be parsed — see [Argument errors](#argument-errors) |
## Scripting Examples
@@ -83,6 +84,18 @@ elif 40 <= result.returncode <= 49:
print("Feature not available")
```
+## Argument errors
+
+Arguments are parsed before the command runs. When one can't be parsed, the
+CLI prints `Failed to parse "" for : ` and exits with
+252, whatever the reason. This covers:
+
+- unknown flags and missing required arguments
+- invalid flag values, such as `--period bogus` or `--limit abc`
+- invalid `/` targets, such as `sentry trace list @latest`
+
+Run the command with `--help` to see the arguments it accepts.
+
## Notes
- Exit codes below 128 are safe from collision with Unix signal exits (128+N).
@@ -97,4 +110,5 @@ elif 40 <= result.returncode <= 49:
verification stopped → 64 (Wizard Verify), other → 61 (Wizard).
- [Stricli](https://bloomberg.github.io/stricli/) (the CLI framework) uses
negative exit codes (-5 to -1) for framework-level errors like unknown
- commands or invalid arguments. These appear as 251–255 in unsigned form.
+ commands (251) or [invalid arguments](#argument-errors) (252). These
+ appear as 251–255 in unsigned form.
diff --git a/packages/cli/AGENTS.md b/packages/cli/AGENTS.md
index 5fb0eb025..ac6537f4d 100644
--- a/packages/cli/AGENTS.md
+++ b/packages/cli/AGENTS.md
@@ -254,17 +254,19 @@ 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 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
-`resolved` read, both inside `func` after the auth guard. Invalid input still
-throws the parser's `CliError` with its exit code. `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. Positionals whose
+`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 "" for : …` 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. Positionals whose
meaning depends on how many arguments were given, such as
`[//]`, keep parsing in `func`.
diff --git a/packages/cli/lint-rules/cli-oxlint-plugin.js b/packages/cli/lint-rules/cli-oxlint-plugin.js
index bcc220a4d..409dd1f97 100644
--- a/packages/cli/lint-rules/cli-oxlint-plugin.js
+++ b/packages/cli/lint-rules/cli-oxlint-plugin.js
@@ -294,7 +294,7 @@ const rules = {
: {},
),
"require-target-resolution": rule(
- "Await `.resolved`: it runs the target's resolver and destructive-command guard.",
+ "Await `.resolved`: it runs the target's resolver.",
(_context, file, report) => {
if (!file.includes("/src/commands/")) return {};
const check = (node) => {
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 f5536126b..4841380bb 100644
--- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md
+++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md
@@ -64,6 +64,7 @@ The CLI uses semantic exit codes. Key ranges for agents:
| 40–49 | Feature unavailable | Inform user about plan/settings |
| 50–59 | Operation error | Report to user |
| 60–69 | Command-specific | Check stderr for details |
+| 252 | Invalid argument | Check the command's `--help` and fix the argument |
See [Exit Codes](/exit-codes/) for the complete reference.
diff --git a/packages/cli/src/app.ts b/packages/cli/src/app.ts
index 49002ebcc..21a69c3a6 100644
--- a/packages/cli/src/app.ts
+++ b/packages/cli/src/app.ts
@@ -74,7 +74,7 @@ import {
} from "./lib/command-suggestions.js";
import { CLI_VERSION } from "./lib/constants.js";
import { redactCredentialText } from "./lib/credential-redaction.js";
-import { reportCliError } from "./lib/error-reporting.js";
+import { reportArgumentError, reportCliError } from "./lib/error-reporting.js";
import {
ApiError,
AuthError,
@@ -85,6 +85,7 @@ import {
WizardError,
} from "./lib/errors.js";
import { error as errorColor, warning } from "./lib/formatters/colors.js";
+import { stripAnsi } from "./lib/formatters/plain-detect.js";
import { buildTopLevelFlags } from "./lib/global-flags.js";
import { renderJsonHelp } from "./lib/help.js";
import { isRouteMap, type RouteMap } from "./lib/introspect.js";
@@ -312,6 +313,38 @@ function escapesToOuterMiddleware(exc: unknown): boolean {
return exc instanceof ApiError && (exc.status === 401 || exc.status === 403);
}
+/** Message for an argument that failed to parse, with our hints. */
+function formatArgumentError(exc: unknown, ansiColor: boolean): string {
+ // Case A: bare route group with no subcommand (e.g., `sentry issue`)
+ if (exc instanceof UnsatisfiedPositionalError) {
+ const bareHint = detectBareRouteGroup(ansiColor);
+ if (bareHint) {
+ return bareHint;
+ }
+ }
+
+ // Case B + plural alias: extra args that Stricli can't consume
+ if (exc instanceof UnexpectedPositionalError) {
+ const pluralHint = detectPluralAliasMisuse(ansiColor);
+ if (pluralHint) {
+ return `${text_en.exceptionWhileParsingArguments(exc, ansiColor)}${pluralHint}`;
+ }
+
+ // With defaultCommand: "view", unknown tokens like "metrics" fill the
+ // positional slot, then extra args (e.g., CLI-AB) trigger this error.
+ // Check if the first non-route token is a known synonym.
+ const synonymHint = getSynonymSuggestionFromArgv();
+ if (synonymHint) {
+ const tip = ansiColor
+ ? warning(`\nTip: ${synonymHint}`)
+ : `\nTip: ${synonymHint}`;
+ return `${text_en.exceptionWhileParsingArguments(exc, ansiColor)}${tip}`;
+ }
+ }
+
+ return text_en.exceptionWhileParsingArguments(exc, ansiColor);
+}
+
/**
* Custom error formatting for CLI errors.
*
@@ -325,34 +358,10 @@ const customText: ApplicationText = {
exc: unknown,
ansiColor: boolean,
): string => {
- // Case A: bare route group with no subcommand (e.g., `sentry issue`)
- if (exc instanceof UnsatisfiedPositionalError) {
- const bareHint = detectBareRouteGroup(ansiColor);
- if (bareHint) {
- return bareHint;
- }
- }
-
- // Case B + plural alias: extra args that Stricli can't consume
- if (exc instanceof UnexpectedPositionalError) {
- const pluralHint = detectPluralAliasMisuse(ansiColor);
- if (pluralHint) {
- return `${text_en.exceptionWhileParsingArguments(exc, ansiColor)}${pluralHint}`;
- }
-
- // With defaultCommand: "view", unknown tokens like "metrics" fill the
- // positional slot, then extra args (e.g., CLI-AB) trigger this error.
- // Check if the first non-route token is a known synonym.
- const synonymHint = getSynonymSuggestionFromArgv();
- if (synonymHint) {
- const tip = ansiColor
- ? warning(`\nTip: ${synonymHint}`)
- : `\nTip: ${synonymHint}`;
- return `${text_en.exceptionWhileParsingArguments(exc, ansiColor)}${tip}`;
- }
- }
-
- return text_en.exceptionWhileParsingArguments(exc, ansiColor);
+ const message = formatArgumentError(exc, ansiColor);
+ // Exit 252 never reaches reportCliError; record it for UX research
+ reportArgumentError(exc, stripAnsi(message));
+ return message;
},
noCommandRegisteredForInput: ({ input, corrections, ansiColor }): string => {
// Default error message from Stricli (e.g., "No command registered for `info`")
diff --git a/packages/cli/src/commands/project/delete.ts b/packages/cli/src/commands/project/delete.ts
index 90d1cba16..949e2524a 100644
--- a/packages/cli/src/commands/project/delete.ts
+++ b/packages/cli/src/commands/project/delete.ts
@@ -14,8 +14,8 @@
* Safety measures:
* - Uses `buildDeleteCommand` — auto-injects `--yes`/`--force`/`--dry-run`
* flags and enforces the non-interactive guard before `func()` runs
- * - No auto-detect mode: `buildDeleteCommand` marks the target destructive, so
- * `target.resolved` rejects an omitted target
+ * - No auto-detect mode: `buildDeleteCommand` marks the command destructive,
+ * so an omitted target fails before `func()` runs
* - Type-out confirmation via `confirmByTyping` (unless --yes/--force)
*/
diff --git a/packages/cli/src/lib/arg-parsing.ts b/packages/cli/src/lib/arg-parsing.ts
index 0e6d50792..1d5dd2d2d 100644
--- a/packages/cli/src/lib/arg-parsing.ts
+++ b/packages/cli/src/lib/arg-parsing.ts
@@ -754,28 +754,56 @@ export function parseOrgProjectArg(
arg: string | undefined,
options: { multi?: boolean } = {},
): ParsedOrgProject {
+ const { parsed, baseUrl } = parseOrgProjectInput(arg, options);
+ if (baseUrl) {
+ applySentryUrlContext(baseUrl);
+ }
+ return parsed;
+}
+
+/** A parsed org/project argument and the server of a pasted Sentry URL. */
+export type OrgProjectInput = {
+ readonly parsed: ParsedOrgProject;
+ /** Base URL of a pasted Sentry web URL, for `applySentryUrlContext` */
+ readonly baseUrl?: string;
+};
+
+/**
+ * Parse an org/project argument like {@link parseOrgProjectArg}, without
+ * side effects: the server of a pasted Sentry URL is returned as `baseUrl`
+ * instead of applied, so the caller applies it with `applySentryUrlContext`
+ * when it is safe to.
+ */
+export function parseOrgProjectInput(
+ arg: string | undefined,
+ options: { multi?: boolean } = {},
+): OrgProjectInput {
// Keep only the first non-blank line: agents often pass targets with
// appended output or notes after a newline (CLI-1RA).
const trimmed = arg ? firstNonBlankLine(arg) : "";
if (!trimmed) {
- return { type: "auto-detect" };
+ return { parsed: { type: "auto-detect" } };
}
// URL detection — extract org/project from Sentry web URLs
const urlParsed = parseSentryUrl(trimmed);
if (urlParsed) {
- applySentryUrlContext(urlParsed.baseUrl);
- return orgProjectFromUrl(urlParsed);
+ return {
+ parsed: orgProjectFromUrl(urlParsed),
+ baseUrl: urlParsed.baseUrl,
+ };
}
- return parseTarget(trimmed, {
- multi: options.multi === true,
- checkSlug: validateResourceId,
- rejectAt: rejectAtSelector,
- invalid: (message, field) => {
- throw new ValidationError(message, field);
- },
- });
+ return {
+ parsed: parseTarget(trimmed, {
+ multi: options.multi === true,
+ checkSlug: validateResourceId,
+ rejectAt: rejectAtSelector,
+ invalid: (message, field) => {
+ throw new ValidationError(message, field);
+ },
+ }),
+ };
}
/**
diff --git a/packages/cli/src/lib/command.ts b/packages/cli/src/lib/command.ts
index c3df5aafc..88535c601 100644
--- a/packages/cli/src/lib/command.ts
+++ b/packages/cli/src/lib/command.ts
@@ -66,8 +66,8 @@ import {
setLogLevel,
} from "./logger.js";
import {
- applyLazyPositionals,
- markDestructive,
+ applyBoundPositionals,
+ checkDestructive,
rawPositional,
} from "./positional-parse.js";
import { setArgsContext, setFlagContext, withTracing } from "./telemetry.js";
@@ -201,7 +201,7 @@ 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, and their lazy
+ * this flag), so agents ask the user before running them, and their bound
* 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
@@ -730,17 +730,13 @@ export function buildCommand<
...positionalArgs: unknown[]
) {
// Callers that skip Stricli's scanner (SDK invoke, tests) pass raw
- // strings, and omitted optional positionals are undefined; give lazily
- // parsed positionals the value Stricli would.
- const args = applyLazyPositionals(
+ // strings, and omitted optional positionals are undefined; give bound
+ // positionals the value Stricli would.
+ const args = applyBoundPositionals(
positionalArgs,
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,
@@ -835,6 +831,12 @@ export function buildCommand<
await assertRcUrlTrusted(this.cwd as string);
}
+ // Bound target positionals of destructive commands refuse to
+ // auto-detect, whether `func` reads `parsed` or `resolved`
+ if (builderArgs.destructive) {
+ checkDestructive(args);
+ }
+
// Execution phase: core command logic, API calls, org/project resolution
const returned = await withTracing(
"exec",
diff --git a/packages/cli/src/lib/error-reporting.ts b/packages/cli/src/lib/error-reporting.ts
index 529b4589f..fc8be2699 100644
--- a/packages/cli/src/lib/error-reporting.ts
+++ b/packages/cli/src/lib/error-reporting.ts
@@ -27,6 +27,17 @@
// oxlint-disable-next-line sentry-cli/no-namespace-import -- Sentry SDK recommends namespace import
import * as Sentry from "@sentry/node-core/light";
+import {
+ AliasNotFoundError,
+ ArgumentParseError,
+ EnumValidationError,
+ FlagNotFoundError,
+ InvalidNegatedFlagSyntaxError,
+ UnexpectedFlagError,
+ UnexpectedPositionalError,
+ UnsatisfiedFlagError,
+ UnsatisfiedPositionalError,
+} from "@stricli/core";
import { redactCredentialText } from "./credential-redaction.js";
import {
ApiError,
@@ -437,6 +448,66 @@ export function reportCliError(error: unknown): void {
});
}
+/** The class name of a thrown value, for event context. */
+function errorClassName(error: unknown): string {
+ return error instanceof Error ? error.name : typeof error;
+}
+
+/**
+ * Stricli's argument scanner errors by name. They don't set `name`, and the
+ * minified bundle renames classes, so match them by identity.
+ */
+const ARGUMENT_ERROR_CLASSES = {
+ AliasNotFoundError,
+ ArgumentParseError,
+ EnumValidationError,
+ FlagNotFoundError,
+ InvalidNegatedFlagSyntaxError,
+ UnexpectedFlagError,
+ UnexpectedPositionalError,
+ UnsatisfiedFlagError,
+ UnsatisfiedPositionalError,
+};
+
+/** The Stricli class name of an argument scanner error. */
+function argumentErrorClassName(error: unknown): string {
+ for (const [name, errorClass] of Object.entries(ARGUMENT_ERROR_CLASSES)) {
+ if (error instanceof errorClass) {
+ return name;
+ }
+ }
+ return errorClassName(error);
+}
+
+/**
+ * Report an argument that Stricli could not parse (exit 252) for UX research.
+ *
+ * These are user mistakes rather than CLI bugs, so like `unknown_command`
+ * they go to Sentry as one fixed `info` message that groups into a single
+ * issue. The `invalid_argument` context holds the per-event detail.
+ *
+ * @param error - The error Stricli passed to `exceptionWhileParsingArguments`
+ * @param message - What the user saw, without ANSI color codes
+ */
+export function reportArgumentError(error: unknown, message: string): void {
+ const errorClass = argumentErrorClassName(error);
+ Sentry.captureMessage("invalid_argument", {
+ level: "info",
+ tags: { "argument_error.class": errorClass },
+ contexts: {
+ invalid_argument: {
+ error_class: errorClass,
+ // What the argument's parser threw, e.g. a ValidationError
+ cause_class:
+ error instanceof ArgumentParseError
+ ? errorClassName(error.exception)
+ : null,
+ message: redactCredentialText(message),
+ },
+ },
+ });
+}
+
/**
* Enrich an error event with `cli_error.*` tags for server-side fingerprinting.
*
diff --git a/packages/cli/src/lib/list-command.ts b/packages/cli/src/lib/list-command.ts
index 305bf9016..b50bd0de3 100644
--- a/packages/cli/src/lib/list-command.ts
+++ b/packages/cli/src/lib/list-command.ts
@@ -44,7 +44,7 @@ import {
type ListResult,
type OrgListConfig,
} from "./org-list.js";
-import { applyLazyPositionals, rawPositional } from "./positional-parse.js";
+import { applyBoundPositionals, rawPositional } from "./positional-parse.js";
import { disableResponseCache } from "./response-cache.js";
import { PERIOD_BRIEF, parsePeriod } from "./time-range.js";
import { shellQuote } from "./utils.js";
@@ -641,8 +641,8 @@ export function buildListCommand<
args[0] = undefined;
}
}
- // A dropped lazy target becomes an omitted one (auto-detect)
- const finalArgs = applyLazyPositionals(args, params.positional, this);
+ // A dropped bound target becomes an omitted one (auto-detect)
+ const finalArgs = applyBoundPositionals(args, params.positional, this);
return originalFunc.call(this, flags, ...(finalArgs 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 aec33f5c8..d4908d7da 100644
--- a/packages/cli/src/lib/positional-parse.ts
+++ b/packages/cli/src/lib/positional-parse.ts
@@ -1,37 +1,29 @@
/**
- * Positionals whose value parses and resolves lazily.
+ * Positionals that Stricli parses and the command resolves.
*
- * A parser made with {@link lazyPositional} gives the command a
- * {@link LazyPositional}:
+ * A parser made with {@link boundPositional} gives the command a
+ * {@link BoundPositional}:
*
- * - `target.parsed` runs the parse on first access, inside `func`.
+ * - `target.parsed` is the parsed value. Stricli runs the parse in its
+ * argument scanner, so invalid input fails like any other argument:
+ * `Failed to parse "" for : …` and Stricli's
+ * `InvalidArgument` exit code (-4, shown as 252).
* - `target.resolved` starts the positional's own resolver on first access
* and returns its promise, so `func` awaits it instead of picking and
- * calling a resolver itself. A parse error rejects it.
+ * calling a resolver itself. It runs inside `func`, after the auth guard
+ * and rc URL check.
* - `target.raw` is what the user typed (`undefined` if omitted).
*
- * Until `func` reads it, the value does nothing, so nothing can fail and
- * nothing runs early:
- *
- * - Stricli's argument scanner never sees an error, so invalid input still
- * throws the parser's `CliError` from the command, with its exit code,
- * telemetry, and `--json` error output, instead of Stricli's generic
- * "Failed to parse" (exit -4).
- * - Parser side effects, such as `parseOrgProjectArg` applying the host of a
- * pasted Sentry URL, run after the auth guard and rc URL check, as they did
- * when `func` called the parser itself.
+ * The parse must have no side effects: it runs before the auth guard, and
+ * also when a later argument fails to parse. Work that changes state, such
+ * as applying the host of a pasted Sentry URL, belongs in the resolver.
*
* `buildCommand` also runs these parsers on positionals that reach the
* command as plain strings (the SDK invoke path and tests calling the loaded
* func) and on omitted optional ones (parsed from `undefined`), so `func`
- * always gets a {@link LazyPositional} and every caller keeps passing what
- * the user typed.
- *
- * Shared code that handles any positional (plural alias interception, `help`
- * recovery, telemetry) reads the raw input with {@link rawPositional}.
- * `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.
+ * always gets a {@link BoundPositional}. Shared code that handles any
+ * positional (plural alias interception, `help` recovery, telemetry) reads
+ * the raw input with {@link rawPositional}.
*/
import {
@@ -39,77 +31,69 @@ import {
withCompletionTarget,
} from "./completion-target.js";
-/** What the running command tells a positional's resolver. */
-export type ResolveOptions = {
- /** The command is destructive (see {@link markDestructive}). */
- readonly destructive: boolean;
-};
+/** Destructive-command checks of {@link boundPositional} values. */
+const destructiveChecks = new WeakMap