From 48b5383cf74660749849d068a3a1aa60f7ab83f5 Mon Sep 17 00:00:00 2001 From: "jared-outpost[bot]" Date: Sun, 2 Aug 2026 12:57:30 +0000 Subject: [PATCH 1/4] feat(help): support JSON output for --help flags Rewrite flag-based `--help --json` requests to the dedicated `help` command in argv preprocessing, so `sentry --help --json` and `sentry --help --json` emit the same structured JSON as `sentry help --json`. Agents and tooling reach for `--help` first, but Stricli handles it internally and ignores `--json`. Only fires when both `--help` and `--json` are present before any `--` escape; a bare `--help` keeps Stricli's text usage output unchanged. `--fields` is carried through for field selection. Fixes #1265 --- packages/cli/src/lib/argv-hoist.ts | 112 ++++++++++++++++++++++- packages/cli/test/lib/argv-hoist.test.ts | 99 ++++++++++++++++++++ 2 files changed, 209 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/lib/argv-hoist.ts b/packages/cli/src/lib/argv-hoist.ts index aea3af5b3e..7ba8aa1c73 100644 --- a/packages/cli/src/lib/argv-hoist.ts +++ b/packages/cli/src/lib/argv-hoist.ts @@ -221,6 +221,106 @@ export function isVersionRequest(argv: readonly string[]): boolean { return false; } +/** + * Accumulator for {@link rewriteHelpJsonRequest} while scanning argv. + */ +type HelpJsonScan = { + hasHelp: boolean; + hasJson: boolean; + commandPath: string[]; + fields: string | undefined; +}; + +/** + * Fold a single argv token into the {@link HelpJsonScan} accumulator. + * + * Recognizes `--help`, `--json`, and `--fields` (both spaced and `=` forms), + * collects non-flag tokens as the command path, and drops all other flags. + * + * @returns The number of tokens consumed (1, or 2 for spaced `--fields value`). + */ +function scanHelpJsonToken( + argv: readonly string[], + index: number, + scan: HelpJsonScan +): number { + const token = argv[index] ?? ""; + if (token === "--help") { + scan.hasHelp = true; + return 1; + } + if (token === "--json") { + scan.hasJson = true; + return 1; + } + if (token === "--fields") { + scan.fields = argv[index + 1]; + return 2; + } + if (token.startsWith("--fields=")) { + scan.fields = token.slice("--fields=".length); + return 1; + } + // Other flags (e.g. --verbose, --log-level) are irrelevant to the help + // command's structured output and are dropped from the rewritten path. + if (!token.startsWith("-")) { + scan.commandPath.push(token); + } + return 1; +} + +/** + * Rewrite a flag-based `--help --json` request into a `help` command invocation. + * + * Stricli handles `--help` internally by printing its own text usage and + * ignores `--json` entirely, so `sentry --help --json` and + * `sentry --help --json` never produce structured output. Agents and + * tooling reach for `--help` first, so we rewrite these forms to the dedicated + * `help` command — which already emits JSON via {@link introspectAllCommands} + * and {@link introspectCommand} — giving both help UX paths identical JSON. + * + * The rewrite only fires when **both** `--help` and `--json` appear before any + * `--` escape separator. A bare `--help` (no `--json`) is left untouched so + * Stricli's existing human usage output is preserved unchanged. + * + * The command path is the sequence of non-flag tokens (e.g. `issue list`), and + * a `--fields ` (or `--fields=`) flag is carried through so field + * selection keeps working. The result is `["help", "--json", ...path]` with + * `--fields` appended when present. + * + * @param argv - Raw CLI arguments (e.g., `process.argv.slice(2)`) + * @returns The rewritten `help`-command argv, or `null` if the request is not a + * `--help --json` combination and should be processed normally. + */ +export function rewriteHelpJsonRequest( + argv: readonly string[] +): string[] | null { + const scan: HelpJsonScan = { + hasHelp: false, + hasJson: false, + commandPath: [], + fields: undefined, + }; + + for (let i = 0; i < argv.length; ) { + // Tokens after -- are positional/pass-through — a --help there is not ours. + if (argv[i] === "--") { + return null; + } + i += scanHelpJsonToken(argv, i, scan); + } + + if (!(scan.hasHelp && scan.hasJson)) { + return null; + } + + const rewritten = ["help", "--json", ...scan.commandPath]; + if (scan.fields !== undefined) { + rewritten.push("--fields", scan.fields); + } + return rewritten; +} + /** * Move global flags from any position in argv to the end. * @@ -309,10 +409,14 @@ export function rewriteDashedFlagValues(argv: readonly string[]): string[] { * Preprocess raw CLI argv before Stricli dispatch. * * Composes the argv transforms applied on every invocation: - * 1. A top-level `--version` (see {@link isVersionRequest}) is normalized to a + * 1. A flag-based `--help --json` request (see {@link rewriteHelpJsonRequest}) + * is rewritten to the dedicated `help` command so JSON help works for the + * `--help` forms agents reach for (`sentry --help --json`, + * `sentry issue --help --json`), matching `sentry help --json`. + * 2. A top-level `--version` (see {@link isVersionRequest}) is normalized to a * plain `["--version"]` so the application-level version handler prints it * regardless of how deep in the route tree it appeared. - * 2. Otherwise, dashed flag values are rewritten (see + * 3. Otherwise, dashed flag values are rewritten (see * {@link rewriteDashedFlagValues}), then global flags are hoisted to the * tail (see {@link hoistGlobalFlags}). * @@ -323,6 +427,10 @@ export function rewriteDashedFlagValues(argv: readonly string[]): string[] { * @returns The argv to hand to Stricli's `run` */ export function preprocessArgv(argv: readonly string[]): string[] { + const helpJson = rewriteHelpJsonRequest(argv); + if (helpJson) { + return helpJson; + } if (isVersionRequest(argv)) { return ["--version"]; } diff --git a/packages/cli/test/lib/argv-hoist.test.ts b/packages/cli/test/lib/argv-hoist.test.ts index d884c126ab..ce52f63673 100644 --- a/packages/cli/test/lib/argv-hoist.test.ts +++ b/packages/cli/test/lib/argv-hoist.test.ts @@ -12,6 +12,7 @@ import { isVersionRequest, preprocessArgv, rewriteDashedFlagValues, + rewriteHelpJsonRequest, } from "../../src/lib/argv-hoist.js"; describe("hoistGlobalFlags", () => { @@ -466,6 +467,20 @@ describe("preprocessArgv", () => { ]); }); + test("rewrites --help --json to the help command instead of hoisting", () => { + expect(preprocessArgv(["--help", "--json"])).toEqual(["help", "--json"]); + expect(preprocessArgv(["issue", "list", "--help", "--json"])).toEqual([ + "help", + "--json", + "issue", + "list", + ]); + }); + + test("leaves a bare --help to normal hoisting (Stricli renders text help)", () => { + expect(preprocessArgv(["issue", "--help"])).toEqual(["issue", "--help"]); + }); + test("leaves a wrapped-command --version (after --) to hoisting, not version", () => { expect( preprocessArgv(["monitor", "run", "job", "--", "tool", "--version"]) @@ -509,3 +524,87 @@ describe("preprocessArgv", () => { ).toEqual(["release", "set-commits", "1.0.0", "--from", "--auto"]); }); }); + +describe("rewriteHelpJsonRequest", () => { + test("rewrites top-level --help --json to the help command", () => { + expect(rewriteHelpJsonRequest(["--help", "--json"])).toEqual([ + "help", + "--json", + ]); + }); + + test("rewrites a group --help --json to help ", () => { + expect(rewriteHelpJsonRequest(["issue", "--help", "--json"])).toEqual([ + "help", + "--json", + "issue", + ]); + }); + + test("rewrites a nested command --help --json to help ", () => { + expect( + rewriteHelpJsonRequest(["issue", "list", "--help", "--json"]) + ).toEqual(["help", "--json", "issue", "list"]); + }); + + test("is order-insensitive between --help and --json", () => { + expect(rewriteHelpJsonRequest(["--json", "issue", "--help"])).toEqual([ + "help", + "--json", + "issue", + ]); + }); + + test("carries a --fields value through to the help command", () => { + expect( + rewriteHelpJsonRequest([ + "issue", + "list", + "--help", + "--json", + "--fields", + "path,brief", + ]) + ).toEqual(["help", "--json", "issue", "list", "--fields", "path,brief"]); + }); + + test("carries a --fields=value form through to the help command", () => { + expect( + rewriteHelpJsonRequest(["issue", "--help", "--json", "--fields=path"]) + ).toEqual(["help", "--json", "issue", "--fields", "path"]); + }); + + test("drops unrelated flags from the rewritten path", () => { + expect( + rewriteHelpJsonRequest(["--verbose", "issue", "--help", "--json"]) + ).toEqual(["help", "--json", "issue"]); + }); + + test("returns null for bare --help without --json", () => { + expect(rewriteHelpJsonRequest(["issue", "--help"])).toBeNull(); + }); + + test("returns null for --json without --help", () => { + expect(rewriteHelpJsonRequest(["issue", "list", "--json"])).toBeNull(); + }); + + test("returns null when neither flag is present", () => { + expect(rewriteHelpJsonRequest(["issue", "list"])).toBeNull(); + }); + + test("ignores --help --json after the -- escape separator", () => { + // `sentry monitor run -- tool --help --json` must forward the flags + // to the wrapped command, not print the CLI's JSON help. + expect( + rewriteHelpJsonRequest([ + "monitor", + "run", + "job", + "--", + "tool", + "--help", + "--json", + ]) + ).toBeNull(); + }); +}); From a619440e3805ae986df4203d25df80d024899655 Mon Sep 17 00:00:00 2001 From: "jared-outpost[bot]" Date: Sun, 2 Aug 2026 13:18:20 +0000 Subject: [PATCH 2/4] fix(help): stop flag values leaking into the JSON help path Two issues in scanHelpJsonToken when a --help --json request carried other flags: - A dropped value flag kept its spaced value, so `--org acme` / `--limit 5` pushed `acme` / `5` into the command path and resolved the wrong command (or a not-found error). Value flags now drop their spaced value too; known boolean flags (`--verbose`) still leave the following token as a real path segment. - `--fields` unconditionally consumed the next token, so `--fields --json` swallowed `--json` and the rewrite never fired. It now only takes a spaced value when the next token isn't a flag. Reuses the existing GLOBAL_FLAGS metadata to tell boolean from value-taking flags. Added tests for value-flag dropping, boolean-flag path retention, and the --fields swallow case. --- packages/cli/src/lib/argv-hoist.ts | 59 ++++++++++++++++++++---- packages/cli/test/lib/argv-hoist.test.ts | 41 ++++++++++++++++ 2 files changed, 92 insertions(+), 8 deletions(-) diff --git a/packages/cli/src/lib/argv-hoist.ts b/packages/cli/src/lib/argv-hoist.ts index 7ba8aa1c73..648aa0a90b 100644 --- a/packages/cli/src/lib/argv-hoist.ts +++ b/packages/cli/src/lib/argv-hoist.ts @@ -231,13 +231,43 @@ type HelpJsonScan = { fields: string | undefined; }; +/** + * True when `token` is a boolean-style flag that does NOT consume a following + * value token — a known boolean global flag (`--verbose`, `--json`, its `-v` + * alias, or a `--no-` negation). Everything else that starts with `-` is + * assumed to be value-taking, so its next token is a flag value rather than a + * command-path segment. + * + * Used by {@link scanHelpJsonToken} to avoid swallowing a real path segment + * after a boolean flag (`issue --verbose list`) while still discarding the + * values of value flags (`--org acme`, `--limit 5`). + */ +function isBooleanFlagToken(token: string): boolean { + if (token.length === 2 && token[0] === "-" && token[1] !== "-") { + const flag = FLAG_BY_SHORT.get(token[1] ?? ""); + return flag ? !flag.takesValue : false; + } + if (!token.startsWith("--")) { + return false; + } + const name = token.slice(2); + if (name.startsWith("no-") && NEGATABLE_NAMES.has(name.slice(3))) { + return true; + } + const flag = FLAG_BY_NAME.get(name); + return flag ? !flag.takesValue : false; +} + /** * Fold a single argv token into the {@link HelpJsonScan} accumulator. * * Recognizes `--help`, `--json`, and `--fields` (both spaced and `=` forms), - * collects non-flag tokens as the command path, and drops all other flags. + * collects non-flag tokens as the command path, and drops all other flags — + * including the spaced value of any value-taking flag (`--org acme`, + * `--limit 5`) so those values never leak into the resolved command path. * - * @returns The number of tokens consumed (1, or 2 for spaced `--fields value`). + * @returns The number of tokens consumed (1, or 2 when a value flag's spaced + * value is dropped alongside it). */ function scanHelpJsonToken( argv: readonly string[], @@ -253,18 +283,31 @@ function scanHelpJsonToken( scan.hasJson = true; return 1; } - if (token === "--fields") { - scan.fields = argv[index + 1]; - return 2; - } if (token.startsWith("--fields=")) { scan.fields = token.slice("--fields=".length); return 1; } - // Other flags (e.g. --verbose, --log-level) are irrelevant to the help - // command's structured output and are dropped from the rewritten path. + const next = argv[index + 1]; + // A spaced value is only present when the next token isn't itself a flag — + // `--fields --json` leaves --fields valueless rather than eating --json. + const hasSpacedValue = next !== undefined && !next.startsWith("-"); + if (token === "--fields") { + if (hasSpacedValue) { + scan.fields = next; + return 2; + } + return 1; + } if (!token.startsWith("-")) { scan.commandPath.push(token); + return 1; + } + // Any other flag is irrelevant to the help command's structured output and + // is dropped. A value flag (`--org acme`, `--limit 5`) also drops its spaced + // value so it isn't mistaken for a command-path segment; a boolean flag + // (`--verbose`) leaves the following token for the path. + if (hasSpacedValue && !isBooleanFlagToken(token)) { + return 2; } return 1; } diff --git a/packages/cli/test/lib/argv-hoist.test.ts b/packages/cli/test/lib/argv-hoist.test.ts index ce52f63673..d8dc32305f 100644 --- a/packages/cli/test/lib/argv-hoist.test.ts +++ b/packages/cli/test/lib/argv-hoist.test.ts @@ -580,6 +580,47 @@ describe("rewriteHelpJsonRequest", () => { ).toEqual(["help", "--json", "issue"]); }); + test("drops a value flag's spaced value so it never becomes a path segment", () => { + // `--org acme` / `--limit 5` must not leak `acme` / `5` into the command + // path, which would resolve the wrong command or a not-found error. + expect( + rewriteHelpJsonRequest([ + "issue", + "list", + "--org", + "acme", + "--help", + "--json", + ]) + ).toEqual(["help", "--json", "issue", "list"]); + expect( + rewriteHelpJsonRequest([ + "issue", + "list", + "--limit", + "5", + "--help", + "--json", + ]) + ).toEqual(["help", "--json", "issue", "list"]); + }); + + test("keeps a path segment following a boolean flag", () => { + // `--verbose` is a known boolean flag, so the token after it (`list`) is a + // real command-path segment, not a flag value. + expect( + rewriteHelpJsonRequest(["issue", "--verbose", "list", "--help", "--json"]) + ).toEqual(["help", "--json", "issue", "list"]); + }); + + test("does not let --fields swallow a following flag", () => { + // `--fields --json`: --fields has no value, and --json must still register + // so the rewrite fires. + expect( + rewriteHelpJsonRequest(["issue", "list", "--help", "--fields", "--json"]) + ).toEqual(["help", "--json", "issue", "list"]); + }); + test("returns null for bare --help without --json", () => { expect(rewriteHelpJsonRequest(["issue", "--help"])).toBeNull(); }); From 0d4f5d63d2b019b1a18bbee10344bfda5e602b4a Mon Sep 17 00:00:00 2001 From: "jared-outpost[bot]" Date: Sun, 2 Aug 2026 13:22:46 +0000 Subject: [PATCH 3/4] fix(help): don't swallow path segments after =-form flags scanHelpJsonToken treated any --flag=value token as possibly value-taking and dropped the following token as its spaced value. Since isBooleanFlagToken looks up the full name including =value, an =-form flag never matched a known boolean and its next token was discarded, so `--org=acme issue list --help --json` resolved the wrong help path. An =-form flag carries its value inline and never consumes a following token, so skip the drop-next logic when the token contains =. Added a test covering --org=acme / --limit=5 before the command path. --- packages/cli/src/lib/argv-hoist.ts | 6 ++++-- packages/cli/test/lib/argv-hoist.test.ts | 18 ++++++++++++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/lib/argv-hoist.ts b/packages/cli/src/lib/argv-hoist.ts index 648aa0a90b..8276d953d2 100644 --- a/packages/cli/src/lib/argv-hoist.ts +++ b/packages/cli/src/lib/argv-hoist.ts @@ -305,8 +305,10 @@ function scanHelpJsonToken( // Any other flag is irrelevant to the help command's structured output and // is dropped. A value flag (`--org acme`, `--limit 5`) also drops its spaced // value so it isn't mistaken for a command-path segment; a boolean flag - // (`--verbose`) leaves the following token for the path. - if (hasSpacedValue && !isBooleanFlagToken(token)) { + // (`--verbose`) leaves the following token for the path. An `=`-form flag + // (`--org=acme`) already carries its value inline, so it never consumes the + // following token — dropping it would swallow a real command-path segment. + if (hasSpacedValue && !token.includes("=") && !isBooleanFlagToken(token)) { return 2; } return 1; diff --git a/packages/cli/test/lib/argv-hoist.test.ts b/packages/cli/test/lib/argv-hoist.test.ts index d8dc32305f..6fef6e2f0f 100644 --- a/packages/cli/test/lib/argv-hoist.test.ts +++ b/packages/cli/test/lib/argv-hoist.test.ts @@ -613,6 +613,24 @@ describe("rewriteHelpJsonRequest", () => { ).toEqual(["help", "--json", "issue", "list"]); }); + test("keeps a path segment following an =-form value flag", () => { + // `--org=acme` carries its value inline, so the next token (`issue`/`list`) + // is a real command-path segment. A naive length check would treat the + // whole `org=acme` string as an unknown value flag and swallow `issue`. + expect( + rewriteHelpJsonRequest([ + "--org=acme", + "issue", + "list", + "--help", + "--json", + ]) + ).toEqual(["help", "--json", "issue", "list"]); + expect( + rewriteHelpJsonRequest(["issue", "--limit=5", "list", "--help", "--json"]) + ).toEqual(["help", "--json", "issue", "list"]); + }); + test("does not let --fields swallow a following flag", () => { // `--fields --json`: --fields has no value, and --json must still register // so the rewrite fires. From 2caa8fd40fa6e75359f628047d15f438e99b90bb Mon Sep 17 00:00:00 2001 From: "jared-outpost[bot]" Date: Sun, 2 Aug 2026 13:29:29 +0000 Subject: [PATCH 4/4] fix(help): recognize -h alias in the JSON help rewrite Stricli treats `-h` as a built-in alias for `--help`, but scanHelpJsonToken only matched the literal `--help` token, so `sentry -h --json` fell through to Stricli's text usage instead of producing JSON. Recognize `-h` alongside `--help` and add tests. --- packages/cli/src/lib/argv-hoist.ts | 13 ++++++++----- packages/cli/test/lib/argv-hoist.test.ts | 14 ++++++++++++++ 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/packages/cli/src/lib/argv-hoist.ts b/packages/cli/src/lib/argv-hoist.ts index 8276d953d2..32eca047cb 100644 --- a/packages/cli/src/lib/argv-hoist.ts +++ b/packages/cli/src/lib/argv-hoist.ts @@ -261,10 +261,11 @@ function isBooleanFlagToken(token: string): boolean { /** * Fold a single argv token into the {@link HelpJsonScan} accumulator. * - * Recognizes `--help`, `--json`, and `--fields` (both spaced and `=` forms), - * collects non-flag tokens as the command path, and drops all other flags — - * including the spaced value of any value-taking flag (`--org acme`, - * `--limit 5`) so those values never leak into the resolved command path. + * Recognizes `--help` (and its `-h` alias), `--json`, and `--fields` (both + * spaced and `=` forms), collects non-flag tokens as the command path, and + * drops all other flags — including the spaced value of any value-taking flag + * (`--org acme`, `--limit 5`) so those values never leak into the resolved + * command path. * * @returns The number of tokens consumed (1, or 2 when a value flag's spaced * value is dropped alongside it). @@ -275,7 +276,9 @@ function scanHelpJsonToken( scan: HelpJsonScan ): number { const token = argv[index] ?? ""; - if (token === "--help") { + // `-h` is Stricli's built-in short alias for `--help`, so it must trigger the + // JSON rewrite too — otherwise `sentry -h --json` falls through to text usage. + if (token === "--help" || token === "-h") { scan.hasHelp = true; return 1; } diff --git a/packages/cli/test/lib/argv-hoist.test.ts b/packages/cli/test/lib/argv-hoist.test.ts index 6fef6e2f0f..d8c5369442 100644 --- a/packages/cli/test/lib/argv-hoist.test.ts +++ b/packages/cli/test/lib/argv-hoist.test.ts @@ -541,6 +541,20 @@ describe("rewriteHelpJsonRequest", () => { ]); }); + test("recognizes the -h short alias for --help", () => { + // Stricli treats `-h` as an alias of `--help`, so the JSON rewrite must + // fire for it too — otherwise `sentry -h --json` falls through to text usage. + expect(rewriteHelpJsonRequest(["-h", "--json"])).toEqual([ + "help", + "--json", + ]); + expect(rewriteHelpJsonRequest(["issue", "-h", "--json"])).toEqual([ + "help", + "--json", + "issue", + ]); + }); + test("rewrites a nested command --help --json to help ", () => { expect( rewriteHelpJsonRequest(["issue", "list", "--help", "--json"])