Skip to content

Commit 5e68615

Browse files
committed
feat: centralize --json/--fields injection via output: "json" on buildCommand
Lift --json and --fields flag definitions out of 17 individual commands into buildCommand's output mode system. When a command declares output: "json", the wrapper automatically: - Injects --json (boolean) and --fields (parsed) flags - Pre-parses --fields from comma-string to string[] before func runs - Respects command-owned --json flags (skips injection, still adds --fields) Also introduces writeJsonList() helper that applies --fields filtering to each array element inside the {data, hasMore} wrapper, fixing the BugBot-reported issue where filtering operated on the wrapper instead of the nested data. Changes: - src/lib/command.ts: JSON_FLAG, FIELDS_FLAG constants, output mode logic - src/lib/formatters/json.ts: writeJsonList() with extra? option - src/lib/formatters/output.ts: fields? on WriteOutputOptions - src/lib/list-command.ts: passes output through to buildCommand - src/lib/org-list.ts: fields on BaseListFlags, writeJsonList at wrapper site - 17 command files: migrated to output: "json", removed manual flag defs - 3 commands gained --fields: auth/refresh, org/view, trace/logs - test/lib/command.test.ts: 14 new tests for json injection + fields parsing - test/lib/formatters/json.test.ts: 17 new tests for writeJsonList
1 parent 901bf44 commit 5e68615

File tree

25 files changed

+822
-275
lines changed

25 files changed

+822
-275
lines changed

AGENTS.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -648,6 +648,9 @@ mock.module("./some-module", () => ({
648648
<!-- lore:019cc40e-e56e-71e9-bc5d-545f97df732b -->
649649
* **Consola prompt cancel returns truthy Symbol, not false**: When a user cancels a \`consola\` / \`@clack/prompts\` confirmation prompt (Ctrl+C), the return value is \`Symbol(clack:cancel)\`, not \`false\`. Since Symbols are truthy in JavaScript, checking \`!confirmed\` will be \`false\` and the code falls through as if the user confirmed. Fix: use \`confirmed !== true\` (strict equality) instead of \`!confirmed\` to correctly handle cancel, false, and any other non-true values.
650650
651+
<!-- lore:019cd379-4c6a-7a93-beae-b1d5b4df69b1 -->
652+
* **Dot-notation field filtering is ambiguous for keys containing dots**: The \`filterFields\` function in \`src/lib/formatters/json.ts\` uses dot-notation to address nested fields (e.g., \`metadata.value\`). This means object keys that literally contain dots are ambiguous and cannot be addressed. Property-based tests for this function must generate field name arbitraries that exclude dots — use a restricted charset like \`\[a-zA-Z0-9\_]\` in fast-check arbitraries. Counterexample found by fast-check: \`{"a":{".":false}}\` with path \`"a."\` splits into \`\["a", ""]\` and fails to resolve.
653+
651654
<!-- lore:019cbe0d-d03e-716c-b372-b09998c07ed6 -->
652655
* **Stricli rejects unknown flags — pre-parsed global flags must be consumed from argv**: Stricli's arg parser is strict: any \`--flag\` not registered on a command throws \`No flag registered for --flag\`. Global flags (parsed before Stricli in bin.ts) MUST be spliced out of argv. \`--log-level\` was correctly consumed but \`--verbose\` was intentionally left in (for the \`api\` command's own \`--verbose\`). This breaks every other command. Also, \`argv.indexOf('--flag')\` doesn't match \`--flag=value\` form — must check both space-separated and equals-sign forms when pre-parsing. A Biome \`noRestrictedImports\` lint rule in \`biome.jsonc\` now blocks \`import { buildCommand } from "@stricli/core"\` at error level — only \`src/lib/command.ts\` is exempted. Other \`@stricli/core\` exports (\`buildRouteMap\`, \`run\`, etc.) are allowed.
653656
@@ -659,6 +662,9 @@ mock.module("./some-module", () => ({
659662
<!-- lore:019cce8d-f2d6-7862-9105-7a0048f0e993 -->
660663
* **Property-based tests for input validators use stringMatching for forbidden char coverage**: In \`test/lib/input-validation.property.test.ts\`, forbidden-character arbitraries are built with \`stringMatching\` targeting specific regex patterns (e.g., \`/^\[^\x00-\x1f]\*\[\x00-\x1f]\[^\x00-\x1f]\*$/\` for control chars). This ensures fast-check generates strings that always contain the forbidden character while varying surrounding content. The \`biome-ignore lint/suspicious/noControlCharactersInRegex\` suppression is needed on the control char regex constant in \`input-validation.ts\`.
661664
665+
<!-- lore:019cd379-4c71-7477-9cc6-3c0dfc7fb597 -->
666+
* **Shared flag constants in list-command.ts for cross-command consistency**: \`src/lib/list-command.ts\` exports shared Stricli flag definitions (\`FIELDS\_FLAG\`, \`FRESH\_FLAG\`, \`FRESH\_ALIASES\`) reused across all commands. When adding a new global-ish flag to multiple commands, define it once here as a const satisfying Stricli's flag shape, then spread into each command's \`flags\` object. The \`--fields\` flag is \`{ kind: 'parsed', parse: String, brief: '...', optional: true }\`. \`parseFieldsList()\` in \`formatters/json.ts\` handles comma-separated parsing with trim/dedup. \`writeJson()\` accepts an optional \`fields\` array and calls \`filterFields()\` before serialization.
667+
662668
<!-- lore:019cbe44-7687-7288-81a2-662feefc28ea -->
663669
* **SKILL.md generator must filter hidden Stricli flags**: \`script/generate-skill.ts\` introspects Stricli's route tree to auto-generate \`plugins/sentry-cli/skills/sentry-cli/SKILL.md\`. The \`FlagDef\` type must include \`hidden?: boolean\` and \`extractFlags\` must propagate it to \`FlagInfo\`. The filter in \`generateCommandDoc\` must exclude \`f.hidden\` alongside \`help\`/\`helpAll\`. Without this, hidden flags injected by \`buildCommand\` (like \`--log-level\`, \`--verbose\`) appear on every command in the AI agent skill file. Global flags should instead be documented once in \`docs/src/content/docs/commands/index.md\` Global Options section, which the generator pulls into SKILL.md via \`loadCommandsOverview\`.
664670
<!-- End lore-managed section -->

src/commands/auth/refresh.ts

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,12 @@ import {
1515
import { AuthError } from "../../lib/errors.js";
1616
import { success } from "../../lib/formatters/colors.js";
1717
import { formatDuration } from "../../lib/formatters/human.js";
18+
import { writeJson } from "../../lib/formatters/index.js";
1819

1920
type RefreshFlags = {
2021
readonly json: boolean;
2122
readonly force: boolean;
23+
readonly fields?: string[];
2224
};
2325

2426
type RefreshOutput = {
@@ -50,13 +52,9 @@ Examples:
5052
{"success":true,"refreshed":true,"expiresIn":3600,"expiresAt":"..."}
5153
`.trim(),
5254
},
55+
output: "json",
5356
parameters: {
5457
flags: {
55-
json: {
56-
kind: "boolean",
57-
brief: "Output result as JSON",
58-
default: false,
59-
},
6058
force: {
6159
kind: "boolean",
6260
brief: "Force refresh even if token is still valid",
@@ -103,7 +101,7 @@ Examples:
103101
};
104102

105103
if (flags.json) {
106-
stdout.write(`${JSON.stringify(output)}\n`);
104+
writeJson(stdout, output, flags.fields);
107105
} else if (result.refreshed) {
108106
stdout.write(
109107
`${success("✓")} Token refreshed successfully. Expires in ${formatDuration(result.expiresIn ?? 0)}.\n`

src/commands/auth/whoami.ts

Lines changed: 4 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -12,22 +12,17 @@ import { buildCommand } from "../../lib/command.js";
1212
import { isAuthenticated } from "../../lib/db/auth.js";
1313
import { setUserInfo } from "../../lib/db/user.js";
1414
import { AuthError } from "../../lib/errors.js";
15-
import {
16-
formatUserIdentity,
17-
parseFieldsList,
18-
writeJson,
19-
} from "../../lib/formatters/index.js";
15+
import { formatUserIdentity, writeJson } from "../../lib/formatters/index.js";
2016
import {
2117
applyFreshFlag,
22-
FIELDS_FLAG,
2318
FRESH_ALIASES,
2419
FRESH_FLAG,
2520
} from "../../lib/list-command.js";
2621

2722
type WhoamiFlags = {
2823
readonly json: boolean;
2924
readonly fresh: boolean;
30-
readonly fields?: string;
25+
readonly fields?: string[];
3126
};
3227

3328
export const whoamiCommand = buildCommand({
@@ -38,22 +33,16 @@ export const whoamiCommand = buildCommand({
3833
"This calls the Sentry API live (not cached) so the result always reflects " +
3934
"the current token. Works with all token types: OAuth, API tokens, and OAuth App tokens.",
4035
},
36+
output: "json",
4137
parameters: {
4238
flags: {
43-
json: {
44-
kind: "boolean",
45-
brief: "Output as JSON",
46-
default: false,
47-
},
4839
fresh: FRESH_FLAG,
49-
fields: FIELDS_FLAG,
5040
},
5141
aliases: FRESH_ALIASES,
5242
},
5343
async func(this: SentryContext, flags: WhoamiFlags): Promise<void> {
5444
applyFreshFlag(flags);
5545
const { stdout } = this;
56-
const fields = flags.fields ? parseFieldsList(flags.fields) : undefined;
5746

5847
if (!(await isAuthenticated())) {
5948
throw new AuthError("not_authenticated");
@@ -83,7 +72,7 @@ export const whoamiCommand = buildCommand({
8372
username: user.username ?? null,
8473
email: user.email ?? null,
8574
},
86-
fields
75+
flags.fields
8776
);
8877
return;
8978
}

src/commands/event/view.ts

Lines changed: 4 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -22,14 +22,9 @@ import {
2222
import { openInBrowser } from "../../lib/browser.js";
2323
import { buildCommand } from "../../lib/command.js";
2424
import { ContextError, ResolutionError } from "../../lib/errors.js";
25-
import {
26-
formatEventDetails,
27-
parseFieldsList,
28-
writeJson,
29-
} from "../../lib/formatters/index.js";
25+
import { formatEventDetails, writeJson } from "../../lib/formatters/index.js";
3026
import {
3127
applyFreshFlag,
32-
FIELDS_FLAG,
3328
FRESH_ALIASES,
3429
FRESH_FLAG,
3530
} from "../../lib/list-command.js";
@@ -52,7 +47,7 @@ type ViewFlags = {
5247
readonly web: boolean;
5348
readonly spans: number;
5449
readonly fresh: boolean;
55-
readonly fields?: string;
50+
readonly fields?: string[];
5651
};
5752

5853
type HumanOutputOptions = {
@@ -308,6 +303,7 @@ export const viewCommand = buildCommand({
308303
" sentry event view <org>/<proj> <event-id> # explicit org and project\n" +
309304
" sentry event view <project> <event-id> # find project across all orgs",
310305
},
306+
output: "json",
311307
parameters: {
312308
positional: {
313309
kind: "array",
@@ -319,19 +315,13 @@ export const viewCommand = buildCommand({
319315
},
320316
},
321317
flags: {
322-
json: {
323-
kind: "boolean",
324-
brief: "Output as JSON",
325-
default: false,
326-
},
327318
web: {
328319
kind: "boolean",
329320
brief: "Open in browser",
330321
default: false,
331322
},
332323
...spansFlag,
333324
fresh: FRESH_FLAG,
334-
fields: FIELDS_FLAG,
335325
},
336326
aliases: { ...FRESH_ALIASES, w: "web" },
337327
},
@@ -342,7 +332,6 @@ export const viewCommand = buildCommand({
342332
): Promise<void> {
343333
applyFreshFlag(flags);
344334
const { stdout, cwd } = this;
345-
const fields = flags.fields ? parseFieldsList(flags.fields) : undefined;
346335

347336
const log = logger.withTag("event.view");
348337

@@ -396,7 +385,7 @@ export const viewCommand = buildCommand({
396385
const trace = spanTreeResult?.success
397386
? { traceId: spanTreeResult.traceId, spans: spanTreeResult.spans }
398387
: null;
399-
writeJson(stdout, { event, trace }, fields);
388+
writeJson(stdout, { event, trace }, flags.fields);
400389
return;
401390
}
402391

src/commands/issue/explain.ts

Lines changed: 4 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -7,18 +7,13 @@
77
import type { SentryContext } from "../../context.js";
88
import { buildCommand } from "../../lib/command.js";
99
import { ApiError } from "../../lib/errors.js";
10-
import {
11-
parseFieldsList,
12-
writeFooter,
13-
writeJson,
14-
} from "../../lib/formatters/index.js";
10+
import { writeFooter, writeJson } from "../../lib/formatters/index.js";
1511
import {
1612
formatRootCauseList,
1713
handleSeerApiError,
1814
} from "../../lib/formatters/seer.js";
1915
import {
2016
applyFreshFlag,
21-
FIELDS_FLAG,
2217
FRESH_ALIASES,
2318
FRESH_FLAG,
2419
} from "../../lib/list-command.js";
@@ -33,7 +28,7 @@ type ExplainFlags = {
3328
readonly json: boolean;
3429
readonly force: boolean;
3530
readonly fresh: boolean;
36-
readonly fields?: string;
31+
readonly fields?: string[];
3732
};
3833

3934
export const explainCommand = buildCommand({
@@ -64,21 +59,16 @@ export const explainCommand = buildCommand({
6459
" sentry issue explain 123456789 --json\n" +
6560
" sentry issue explain 123456789 --force",
6661
},
62+
output: "json",
6763
parameters: {
6864
positional: issueIdPositional,
6965
flags: {
70-
json: {
71-
kind: "boolean",
72-
brief: "Output as JSON",
73-
default: false,
74-
},
7566
force: {
7667
kind: "boolean",
7768
brief: "Force new analysis even if one exists",
7869
default: false,
7970
},
8071
fresh: FRESH_FLAG,
81-
fields: FIELDS_FLAG,
8272
},
8373
aliases: FRESH_ALIASES,
8474
},
@@ -89,7 +79,6 @@ export const explainCommand = buildCommand({
8979
): Promise<void> {
9080
applyFreshFlag(flags);
9181
const { stdout, stderr, cwd } = this;
92-
const fields = flags.fields ? parseFieldsList(flags.fields) : undefined;
9382

9483
// Declare org outside try block so it's accessible in catch for error messages
9584
let resolvedOrg: string | undefined;
@@ -123,7 +112,7 @@ export const explainCommand = buildCommand({
123112

124113
// Output results
125114
if (flags.json) {
126-
writeJson(stdout, causes, fields);
115+
writeJson(stdout, causes, flags.fields);
127116
return;
128117
}
129118

0 commit comments

Comments
 (0)