Skip to content

Commit 1627178

Browse files
authored
feat: add hidden --org/--project global flags for LLM compatibility (#856)
## Summary - Add hidden `--org` and `--project` global flags that silently map to `SENTRY_ORG` / `SENTRY_PROJECT` env vars, recovering from the most common LLM-generated error pattern (old `sentry-cli` syntax) - Flags are injected into every command via the existing global-flags infrastructure, stripped before `func()` runs, and not shown in `--help` - Commands that define their own `--org` or `--project` (e.g. `release create --project`) keep their own semantics — the compat shim skips injection ## Motivation LLMs trained on the older Rust-based `sentry-cli` consistently generate commands like: ``` sentry issue list --org sentry --project cli ``` Instead of showing a confusing "No flag registered for --org" error, we silently accept these flags and let the existing resolution chain handle the values. ## Design decisions - **No short aliases** (`-o`, `-p`): `-p` conflicts with `release create -p project`. Omit both for consistency. LLMs generate long-form anyway. - **Flags overwrite env vars**: `--org sentry` wins over `SENTRY_ORG=other` because explicit CLI flags are highest-priority user intent. - **`log.debug()` only**: silent compat shim, not a deprecation warning. - **Refactored `mergeGlobalFlags()`**: extracted flag-merging logic to keep `buildCommand` under Biome's cognitive complexity limit.
1 parent 8696b51 commit 1627178

6 files changed

Lines changed: 630 additions & 83 deletions

File tree

AGENTS.md

Lines changed: 56 additions & 53 deletions
Large diffs are not rendered by default.

src/lib/command.ts

Lines changed: 154 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import {
3838
} from "@stricli/core";
3939
import type { Writer } from "../types/index.js";
4040
import { getAuthConfig } from "./db/auth.js";
41+
import { getEnv } from "./env.js";
4142
import { AuthError, CliError, OutputError } from "./errors.js";
4243
import { warning } from "./formatters/colors.js";
4344
import { parseFieldsList } from "./formatters/json.js";
@@ -58,6 +59,7 @@ import { GLOBAL_FLAGS } from "./global-flags.js";
5859
import {
5960
LOG_LEVEL_NAMES,
6061
type LogLevelName,
62+
logger,
6163
parseLogLevel,
6264
setLogLevel,
6365
} from "./logger.js";
@@ -238,6 +240,40 @@ export const FIELDS_FLAG = {
238240
optional: true as const,
239241
} as const;
240242

243+
// ---------------------------------------------------------------------------
244+
// Hidden org/project compat flags (LLM error recovery)
245+
// ---------------------------------------------------------------------------
246+
247+
/**
248+
* Hidden `--org` flag injected into every command by {@link buildCommand}.
249+
*
250+
* Backward-compatibility shim for the older `sentry-cli` that accepted
251+
* `--org` on every command. LLMs trained on the old CLI generate this flag.
252+
* The value is written to `SENTRY_ORG` so the existing resolution chain
253+
* in `resolve-target.ts` picks it up at priority #2 (env vars).
254+
*/
255+
const ORG_FLAG = {
256+
kind: "parsed" as const,
257+
parse: String,
258+
brief: "Organization slug",
259+
optional: true as const,
260+
hidden: true as const,
261+
} as const;
262+
263+
/**
264+
* Hidden `--project` flag injected into every command by {@link buildCommand}.
265+
*
266+
* Same backward-compatibility shim as `--org`. Written to `SENTRY_PROJECT`
267+
* before the command's `func` runs.
268+
*/
269+
const PROJECT_FLAG = {
270+
kind: "parsed" as const,
271+
parse: String,
272+
brief: "Project slug",
273+
optional: true as const,
274+
hidden: true as const,
275+
} as const;
276+
241277
/** The flag key for the injected --log-level flag (always stripped) */
242278
const LOG_LEVEL_KEY = "log-level";
243279

@@ -261,6 +297,44 @@ export function applyLoggingFlags(
261297
}
262298
}
263299

300+
const orgProjectLog = logger.withTag("compat-flags");
301+
302+
/**
303+
* Write `--org` / `--project` flag values to environment variables.
304+
*
305+
* Called by the {@link buildCommand} wrapper before the command's `func()`
306+
* runs. The values are written to `SENTRY_ORG` and `SENTRY_PROJECT` so
307+
* the existing resolution chain in `resolve-target.ts` picks them up at
308+
* priority #2 (env vars). Overwrites existing env vars because explicit
309+
* CLI flags are highest-priority user intent.
310+
*
311+
* Empty and whitespace-only values are treated as "not provided" — they
312+
* fall through to the rest of the resolution chain rather than overwriting
313+
* a real pre-existing env var with garbage. This matters for LLM-generated
314+
* commands that occasionally emit `--org ""` or stray whitespace.
315+
*
316+
* Skipped when the command defines its own `--org` / `--project` flag
317+
* (e.g., `release create --project`) — those are passed through to `func()`.
318+
*/
319+
export function applyOrgProjectFlags(
320+
org: string | undefined,
321+
project: string | undefined,
322+
commandOwnsOrg: boolean,
323+
commandOwnsProject: boolean
324+
): void {
325+
const env = getEnv();
326+
const orgTrimmed = org?.trim();
327+
const projectTrimmed = project?.trim();
328+
if (orgTrimmed && !commandOwnsOrg) {
329+
orgProjectLog.debug(`--org flag → SENTRY_ORG=${orgTrimmed}`);
330+
env.SENTRY_ORG = orgTrimmed;
331+
}
332+
if (projectTrimmed && !commandOwnsProject) {
333+
orgProjectLog.debug(`--project flag → SENTRY_PROJECT=${projectTrimmed}`);
334+
env.SENTRY_PROJECT = projectTrimmed;
335+
}
336+
}
337+
264338
// ---------------------------------------------------------------------------
265339
// buildCommand — the single entry point for all Sentry CLI commands
266340
// ---------------------------------------------------------------------------
@@ -342,6 +416,70 @@ function enrichDocsWithSchema(
342416
};
343417
}
344418

419+
/**
420+
* Global flag defaults keyed by flag name.
421+
* Used by {@link mergeGlobalFlags} to inject flags when the command
422+
* doesn't define its own.
423+
*/
424+
const GLOBAL_FLAG_DEFAULTS: Record<string, unknown> = {
425+
[LOG_LEVEL_KEY]: LOG_LEVEL_FLAG,
426+
verbose: VERBOSE_FLAG,
427+
org: ORG_FLAG,
428+
project: PROJECT_FLAG,
429+
};
430+
431+
/** Flags that are always stripped (command never sees them). */
432+
const ALWAYS_STRIP = new Set([LOG_LEVEL_KEY]);
433+
434+
/**
435+
* Merge global flags into a command's existing flags.
436+
*
437+
* For each global flag, if the command doesn't already define it, the
438+
* default shape is injected and its key is added to the returned
439+
* `stripKeys` set (so the wrapper strips it before calling `func`).
440+
*
441+
* @returns The merged flags, ownership booleans, and keys to strip.
442+
*/
443+
function mergeGlobalFlags(
444+
existingFlags: Record<string, unknown>,
445+
// biome-ignore lint/suspicious/noExplicitAny: OutputConfig type is erased at the builder level
446+
outputConfig?: OutputConfig<any>
447+
): {
448+
mergedFlags: Record<string, unknown>;
449+
commandOwnsOrg: boolean;
450+
commandOwnsProject: boolean;
451+
stripKeys: Set<string>;
452+
} {
453+
const commandOwnsJson = "json" in existingFlags;
454+
const commandOwnsOrg = "org" in existingFlags;
455+
const commandOwnsProject = "project" in existingFlags;
456+
457+
const mergedFlags: Record<string, unknown> = { ...existingFlags };
458+
const stripKeys = new Set<string>(ALWAYS_STRIP);
459+
460+
for (const [name, shape] of Object.entries(GLOBAL_FLAG_DEFAULTS)) {
461+
if (!(name in existingFlags)) {
462+
mergedFlags[name] = shape;
463+
stripKeys.add(name);
464+
}
465+
}
466+
467+
// Inject --json and --fields when output config is set
468+
if (outputConfig) {
469+
if (!commandOwnsJson) {
470+
mergedFlags.json = JSON_FLAG;
471+
}
472+
mergedFlags.fields = buildFieldsFlag(outputConfig);
473+
}
474+
475+
return {
476+
mergedFlags,
477+
commandOwnsOrg,
478+
commandOwnsProject,
479+
stripKeys,
480+
};
481+
}
482+
345483
export function buildCommand<
346484
const FLAGS extends BaseFlags = NonNullable<unknown>,
347485
const ARGS extends BaseArgs = [],
@@ -354,34 +492,15 @@ export function buildCommand<
354492
const requiresAuth = builderArgs.auth !== false;
355493
const skipRcUrlCheck = builderArgs.skipRcUrlCheck === true;
356494

357-
// Merge logging flags into the command's flag definitions.
358-
// Quoted keys produce kebab-case CLI flags: "log-level" → --log-level
495+
// Merge global flags into the command's flag definitions.
359496
const existingParams = (builderArgs.parameters ?? {}) as Record<
360497
string,
361498
unknown
362499
>;
363500
const existingFlags = (existingParams.flags ?? {}) as Record<string, unknown>;
364501

365-
// If the command already defines --verbose (e.g. api command), don't override it.
366-
const commandOwnsVerbose = "verbose" in existingFlags;
367-
// If the command already defines --json (e.g. custom brief), don't override it.
368-
const commandOwnsJson = "json" in existingFlags;
369-
370-
const mergedFlags: Record<string, unknown> = {
371-
...existingFlags,
372-
[LOG_LEVEL_KEY]: LOG_LEVEL_FLAG,
373-
};
374-
if (!commandOwnsVerbose) {
375-
mergedFlags.verbose = VERBOSE_FLAG;
376-
}
377-
378-
// Inject --json and --fields when output config is set
379-
if (outputConfig) {
380-
if (!commandOwnsJson) {
381-
mergedFlags.json = JSON_FLAG;
382-
}
383-
mergedFlags.fields = buildFieldsFlag(outputConfig);
384-
}
502+
const { mergedFlags, commandOwnsOrg, commandOwnsProject, stripKeys } =
503+
mergeGlobalFlags(existingFlags, outputConfig);
385504

386505
// Enrich fullDescription with JSON fields when schema is registered.
387506
// This makes field info visible in Stricli's --help output.
@@ -443,19 +562,16 @@ export function buildCommand<
443562

444563
/**
445564
* Strip injected flags from the raw Stricli-parsed flags object.
446-
* --log-level is always stripped. --verbose is stripped only when we
447-
* injected it (not when the command defines its own). --fields is
448-
* pre-parsed from comma-string to string[] when output: { human: ... }.
565+
* Global flags we injected (tracked in `stripKeys` from
566+
* {@link mergeGlobalFlags}) are removed. --fields is pre-parsed from
567+
* comma-string to string[] when output: { human: ... }.
449568
*/
450569
function cleanRawFlags(
451570
raw: Record<string, unknown>
452571
): Record<string, unknown> {
453572
const clean: Record<string, unknown> = {};
454573
for (const [key, value] of Object.entries(raw)) {
455-
if (key === LOG_LEVEL_KEY) {
456-
continue;
457-
}
458-
if (key === "verbose" && !commandOwnsVerbose) {
574+
if (stripKeys.has(key)) {
459575
continue;
460576
}
461577
clean[key] = value;
@@ -558,6 +674,15 @@ export function buildCommand<
558674
flags.verbose as boolean
559675
);
560676

677+
// Map --org / --project compat flags to env vars before anything
678+
// else reads them (auth guard, org/project resolution, etc.)
679+
applyOrgProjectFlags(
680+
flags.org as string | undefined,
681+
flags.project as string | undefined,
682+
commandOwnsOrg,
683+
commandOwnsProject
684+
);
685+
561686
const cleanFlags = cleanRawFlags(flags as Record<string, unknown>);
562687
setFlagContext(cleanFlags);
563688
if (args.length > 0) {

src/lib/global-flags.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,4 +40,10 @@ export const GLOBAL_FLAGS: readonly GlobalFlagDef[] = [
4040
{ name: "log-level", short: null, kind: "value" },
4141
{ name: "json", short: null, kind: "boolean" },
4242
{ name: "fields", short: null, kind: "value" },
43+
// Hidden compat shims: LLMs trained on the older sentry-cli generate
44+
// `--org` and `--project` flags. We silently accept them and map to
45+
// SENTRY_ORG / SENTRY_PROJECT env vars so the resolution chain handles them.
46+
// No short aliases: -p conflicts with release create's -p (--project).
47+
{ name: "org", short: null, kind: "value" },
48+
{ name: "project", short: null, kind: "value" },
4349
];

test/lib/argv-hoist.property.test.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ const GLOBAL_FLAG_TOKENS = [
2121
"-v",
2222
"--log-level",
2323
"--fields",
24+
"--org",
25+
"--project",
2426
] as const;
2527

2628
/** Tokens that should never be hoisted */
@@ -62,6 +64,8 @@ const HOISTABLE_SET = new Set([
6264
"-v",
6365
"--log-level",
6466
"--fields",
67+
"--org",
68+
"--project",
6569
]);
6670

6771
function isHoistableToken(token: string): boolean {
@@ -120,7 +124,12 @@ describe("property: hoistGlobalFlags", () => {
120124

121125
test("hoisted tokens appear after all non-hoisted tokens", () => {
122126
/** Value-taking flags whose next token also gets hoisted */
123-
const VALUE_TAKING = new Set(["--log-level", "--fields"]);
127+
const VALUE_TAKING = new Set([
128+
"--log-level",
129+
"--fields",
130+
"--org",
131+
"--project",
132+
]);
124133

125134
fcAssert(
126135
property(argvArb, (argv) => {

test/lib/argv-hoist.test.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,58 @@ describe("hoistGlobalFlags", () => {
176176
]);
177177
});
178178

179+
// -------------------------------------------------------------------------
180+
// Value flag: --org (compat)
181+
// -------------------------------------------------------------------------
182+
183+
test("hoists --org with separate value", () => {
184+
expect(hoistGlobalFlags(["--org", "sentry", "issue", "list"])).toEqual([
185+
"issue",
186+
"list",
187+
"--org",
188+
"sentry",
189+
]);
190+
});
191+
192+
test("hoists --org=sentry as single token", () => {
193+
expect(hoistGlobalFlags(["--org=sentry", "issue", "list"])).toEqual([
194+
"issue",
195+
"list",
196+
"--org=sentry",
197+
]);
198+
});
199+
200+
// -------------------------------------------------------------------------
201+
// Value flag: --project (compat)
202+
// -------------------------------------------------------------------------
203+
204+
test("hoists --project with separate value", () => {
205+
expect(hoistGlobalFlags(["--project", "cli", "issue", "list"])).toEqual([
206+
"issue",
207+
"list",
208+
"--project",
209+
"cli",
210+
]);
211+
});
212+
213+
test("hoists --project=cli as single token", () => {
214+
expect(hoistGlobalFlags(["--project=cli", "issue", "list"])).toEqual([
215+
"issue",
216+
"list",
217+
"--project=cli",
218+
]);
219+
});
220+
221+
// -------------------------------------------------------------------------
222+
// Combined: --org + --project (compat)
223+
// -------------------------------------------------------------------------
224+
225+
test("hoists --org and --project together", () => {
226+
expect(
227+
hoistGlobalFlags(["--org", "sentry", "--project", "cli", "issue", "list"])
228+
).toEqual(["issue", "list", "--org", "sentry", "--project", "cli"]);
229+
});
230+
179231
// -------------------------------------------------------------------------
180232
// Multiple flags
181233
// -------------------------------------------------------------------------

0 commit comments

Comments
 (0)