Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/cli-docs/src/content/docs/agent-guidance.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
18 changes: 16 additions & 2 deletions apps/cli-docs/src/content/docs/exit-codes.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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

Expand Down Expand Up @@ -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 "<input>" for <argument>: <reason>` 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 `<org>/<project>` targets, such as `sentry trace list @latest`
Comment thread
sentry-junior[bot] marked this conversation as resolved.

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).
Expand All @@ -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.
24 changes: 13 additions & 11 deletions packages/cli/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 "<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. Positionals whose
meaning depends on how many arguments were given, such as
`[<org>/<project>/]<trace-id>`, keep parsing in `func`.

Expand Down
2 changes: 1 addition & 1 deletion packages/cli/lint-rules/cli-oxlint-plugin.js
Original file line number Diff line number Diff line change
Expand Up @@ -294,7 +294,7 @@ const rules = {
: {},
),
"require-target-resolution": rule(
"Await `<target>.resolved`: it runs the target's resolver and destructive-command guard.",
"Await `<target>.resolved`: it runs the target's resolver.",
(_context, file, report) => {
if (!file.includes("/src/commands/")) return {};
const check = (node) => {
Expand Down
1 change: 1 addition & 0 deletions packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
67 changes: 38 additions & 29 deletions packages/cli/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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";
Expand Down Expand Up @@ -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.
*
Expand All @@ -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`")
Expand Down
4 changes: 2 additions & 2 deletions packages/cli/src/commands/project/delete.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
*/

Expand Down
50 changes: 39 additions & 11 deletions packages/cli/src/lib/arg-parsing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
},
}),
};
}

/**
Expand Down
22 changes: 12 additions & 10 deletions packages/cli/src/lib/command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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",
Expand Down
Loading
Loading