Skip to content

Commit 3ed0f1d

Browse files
authored
feat(cli): hoist global flags from any argv position and add -v alias (#709)
## Summary - Add argv preprocessor that moves global flags (`--verbose`, `-v`, `--json`, `--log-level`, `--fields`) from any position to the end of argv before Stricli processes it - Inject `-v` as a short alias for `--verbose` on all leaf commands - Allows `sentry --verbose issue list`, `sentry cli -v upgrade`, etc. ## Problem Stricli only parses flags at the leaf command level. Global flags injected by `buildCommand` fail when placed before subcommands: - `sentry cli upgrade --verbose` ✅ worked - `sentry cli --verbose upgrade` ❌ Stricli can't route past the flag - `sentry --verbose cli upgrade` ❌ same - `sentry cli upgrade -v` ❌ no short alias existed ## Approach **`src/lib/argv-hoist.ts`** — Pure function (zero imports, zero side effects) that scans argv left-to-right and relocates known global flags to the tail. Respects `--` separator, handles `--flag=value` and `--no-flag` forms, and preserves relative order of all tokens. **`src/lib/command.ts`** — Injects `v: "verbose"` alias following the existing `buildDeleteCommand` pattern. Skipped when command owns `--verbose` or `v` is already taken. **`src/cli.ts`** — Calls `hoistGlobalFlags()` inside `runCli()`. Original `cliArgs` preserved for help-as-positional recovery (`cliArgs.at(-1) === "help"`); hoisted args passed to executor. ## Tests - 22 unit tests + 5 property-based tests (token conservation, order preservation, idempotency) - Existing `command.test.ts` passes (81 tests total)
1 parent 832abf9 commit 3ed0f1d

File tree

7 files changed

+766
-84
lines changed

7 files changed

+766
-84
lines changed

AGENTS.md

Lines changed: 39 additions & 81 deletions
Large diffs are not rendered by default.

src/cli.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,7 @@ export async function runCli(cliArgs: string[]): Promise<void> {
136136
const { isatty } = await import("node:tty");
137137
const { ExitCode, run } = await import("@stricli/core");
138138
const { app } = await import("./app.js");
139+
const { hoistGlobalFlags } = await import("./lib/argv-hoist.js");
139140
const { buildContext } = await import("./context.js");
140141
const { AuthError, OutputError, formatError, getExitCode } = await import(
141142
"./lib/errors.js"
@@ -155,6 +156,13 @@ export async function runCli(cliArgs: string[]): Promise<void> {
155156
shouldSuppressNotification,
156157
} = await import("./lib/version-check.js");
157158

159+
// Move global flags (--verbose, -v, --log-level, --json, --fields) from any
160+
// position to the end of argv, where Stricli's leaf-command parser can
161+
// find them. This allows `sentry --verbose issue list` to work.
162+
// The original cliArgs are kept for post-run checks (e.g., help recovery)
163+
// that rely on the original token positions.
164+
const hoistedArgs = hoistGlobalFlags(cliArgs);
165+
158166
// ---------------------------------------------------------------------------
159167
// Error-recovery middleware
160168
// ---------------------------------------------------------------------------
@@ -363,15 +371,17 @@ export async function runCli(cliArgs: string[]): Promise<void> {
363371
setLogLevel(envLogLevel);
364372
}
365373

366-
const suppressNotification = shouldSuppressNotification(cliArgs);
374+
// Use hoisted args so positional checks (e.g., args[0] === "cli") work
375+
// even when global flags precede the subcommand in the original argv.
376+
const suppressNotification = shouldSuppressNotification(hoistedArgs);
367377

368378
// Start background update check (non-blocking)
369379
if (!suppressNotification) {
370380
maybeCheckForUpdateInBackground();
371381
}
372382

373383
try {
374-
await executor(cliArgs);
384+
await executor(hoistedArgs);
375385

376386
// When Stricli can't match a subcommand in a route group (e.g.,
377387
// `sentry dashboard help`), it writes "No command registered for `help`"
@@ -380,6 +390,8 @@ export async function runCli(cliArgs: string[]): Promise<void> {
380390
// the custom help command with proper introspection output.
381391
// Check both raw (-5) and unsigned (251) forms because Node.js keeps
382392
// the raw value while Bun converts to unsigned byte.
393+
// Uses original cliArgs (not hoisted) so the `at(-1) === "help"` check
394+
// works when global flags were placed before "help".
383395
if (
384396
(process.exitCode === ExitCode.UnknownCommand ||
385397
process.exitCode === (ExitCode.UnknownCommand + 256) % 256) &&

src/lib/argv-hoist.ts

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
/**
2+
* Argv preprocessor that moves global flags to the end of the argument list.
3+
*
4+
* Stricli only parses flags at the leaf command level, so flags like
5+
* `--verbose` placed before the subcommand (`sentry --verbose issue list`)
6+
* fail route resolution. This module relocates known global flags from any
7+
* position to the tail of argv where Stricli's leaf-command parser can
8+
* find them.
9+
*
10+
* Flag metadata is derived from the shared {@link GLOBAL_FLAGS} definition
11+
* in `global-flags.ts` so both the hoisting preprocessor and the
12+
* `buildCommand` injection stay in sync automatically.
13+
*/
14+
15+
import { GLOBAL_FLAGS } from "./global-flags.js";
16+
17+
/** Resolved flag metadata used by the hoisting algorithm. */
18+
type HoistableFlag = {
19+
/** Long flag name without `--` prefix (e.g., `"verbose"`) */
20+
readonly name: string;
21+
/** Single-char short alias without `-` prefix, or `null` if none */
22+
readonly short: string | null;
23+
/** Whether the flag consumes the next token as its value */
24+
readonly takesValue: boolean;
25+
/** Whether `--no-<name>` is recognized as the negation form */
26+
readonly negatable: boolean;
27+
};
28+
29+
/** Derive hoisting metadata from the shared flag definitions. */
30+
const HOISTABLE_FLAGS: readonly HoistableFlag[] = GLOBAL_FLAGS.map((f) => ({
31+
name: f.name,
32+
short: f.short,
33+
takesValue: f.kind === "value",
34+
negatable: f.kind === "boolean",
35+
}));
36+
37+
/** Pre-built lookup: long name → flag definition */
38+
const FLAG_BY_NAME = new Map(HOISTABLE_FLAGS.map((f) => [f.name, f]));
39+
40+
/** Pre-built lookup: short alias → flag definition */
41+
const FLAG_BY_SHORT = new Map(
42+
HOISTABLE_FLAGS.filter(
43+
(f): f is HoistableFlag & { short: string } => f.short !== null
44+
).map((f) => [f.short, f])
45+
);
46+
47+
/** Names that support `--no-<name>` negation */
48+
const NEGATABLE_NAMES = new Set(
49+
HOISTABLE_FLAGS.filter((f) => f.negatable).map((f) => f.name)
50+
);
51+
52+
/**
53+
* Match result from {@link matchHoistable}.
54+
*
55+
* - `"plain"`: `--flag` (boolean) or `--flag` (value-taking, value is next token)
56+
* - `"eq"`: `--flag=value` (value embedded in token)
57+
* - `"negated"`: `--no-flag`
58+
* - `"short"`: `-v` (single-char alias)
59+
*/
60+
type MatchForm = "plain" | "eq" | "negated" | "short";
61+
62+
/** Try matching a `--no-<name>` negation form. */
63+
function matchNegated(
64+
name: string
65+
): { flag: HoistableFlag; form: MatchForm } | null {
66+
if (!name.startsWith("no-")) {
67+
return null;
68+
}
69+
const baseName = name.slice(3);
70+
if (!NEGATABLE_NAMES.has(baseName)) {
71+
return null;
72+
}
73+
const flag = FLAG_BY_NAME.get(baseName);
74+
return flag ? { flag, form: "negated" } : null;
75+
}
76+
77+
/**
78+
* Match a token against the hoistable flag registry.
79+
*
80+
* @returns The matched flag and form, or `null` if not hoistable.
81+
*/
82+
function matchHoistable(
83+
token: string
84+
): { flag: HoistableFlag; form: MatchForm } | null {
85+
// Short alias: -v (exactly two chars, dash + letter)
86+
if (token.length === 2 && token[0] === "-" && token[1] !== "-") {
87+
const flag = FLAG_BY_SHORT.get(token[1] ?? "");
88+
return flag ? { flag, form: "short" } : null;
89+
}
90+
91+
if (!token.startsWith("--")) {
92+
return null;
93+
}
94+
95+
// --flag=value form
96+
const eqIdx = token.indexOf("=");
97+
if (eqIdx !== -1) {
98+
const name = token.slice(2, eqIdx);
99+
const flag = FLAG_BY_NAME.get(name);
100+
return flag?.takesValue ? { flag, form: "eq" } : null;
101+
}
102+
103+
const name = token.slice(2);
104+
const negated = matchNegated(name);
105+
if (negated) {
106+
return negated;
107+
}
108+
const flag = FLAG_BY_NAME.get(name);
109+
return flag ? { flag, form: "plain" } : null;
110+
}
111+
112+
/**
113+
* Hoist a single matched flag token (and its value if applicable) into the
114+
* `hoisted` array, advancing the index past the consumed tokens.
115+
*
116+
* Extracted from the main loop to keep {@link hoistGlobalFlags} under
117+
* Biome's cognitive complexity limit.
118+
*/
119+
function consumeFlag(
120+
argv: readonly string[],
121+
index: number,
122+
match: { flag: HoistableFlag; form: MatchForm },
123+
hoisted: string[]
124+
): number {
125+
const token = argv[index] ?? "";
126+
127+
// --flag=value or --no-flag: always a single token
128+
if (match.form === "eq" || match.form === "negated") {
129+
hoisted.push(token);
130+
return index + 1;
131+
}
132+
133+
// --flag or -v: may consume a following value token
134+
if (match.flag.takesValue) {
135+
hoisted.push(token);
136+
const next = index + 1;
137+
if (next < argv.length) {
138+
hoisted.push(argv[next] ?? "");
139+
return next + 1;
140+
}
141+
// No value follows — the bare flag is still hoisted;
142+
// Stricli will report the missing value at parse time.
143+
return next;
144+
}
145+
146+
// Boolean flag (--flag or -v): single token
147+
hoisted.push(token);
148+
return index + 1;
149+
}
150+
151+
/**
152+
* Move global flags from any position in argv to the end.
153+
*
154+
* Tokens after `--` are never touched. The relative order of both
155+
* hoisted and non-hoisted tokens is preserved.
156+
*
157+
* @param argv - Raw CLI arguments (e.g., `process.argv.slice(2)`)
158+
* @returns New array with global flags relocated to the tail
159+
*/
160+
export function hoistGlobalFlags(argv: readonly string[]): string[] {
161+
const remaining: string[] = [];
162+
const hoisted: string[] = [];
163+
/** Tokens from `--` onward (positional-only region). */
164+
const positionalTail: string[] = [];
165+
166+
let i = 0;
167+
while (i < argv.length) {
168+
const token = argv[i] ?? "";
169+
170+
// Stop scanning at -- separator; pass everything through verbatim.
171+
// Hoisted flags must appear BEFORE -- so Stricli parses them as flags.
172+
if (token === "--") {
173+
for (let j = i; j < argv.length; j += 1) {
174+
positionalTail.push(argv[j] ?? "");
175+
}
176+
break;
177+
}
178+
179+
const match = matchHoistable(token);
180+
if (match) {
181+
i = consumeFlag(argv, i, match, hoisted);
182+
} else {
183+
remaining.push(token);
184+
i += 1;
185+
}
186+
}
187+
188+
return [...remaining, ...hoisted, ...positionalTail];
189+
}

src/lib/command.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ import {
5454
writeFooter,
5555
} from "./formatters/output.js";
5656
import { isPlainOutput } from "./formatters/plain-detect.js";
57+
import { GLOBAL_FLAGS } from "./global-flags.js";
5758
import {
5859
LOG_LEVEL_NAMES,
5960
type LogLevelName,
@@ -377,7 +378,25 @@ export function buildCommand<
377378
// This makes field info visible in Stricli's --help output.
378379
const enrichedDocs = enrichDocsWithSchema(builderArgs.docs, outputConfig);
379380

380-
const mergedParams = { ...existingParams, flags: mergedFlags };
381+
// Inject short aliases for global flags (e.g., -v → --verbose).
382+
// Derived from the shared GLOBAL_FLAGS definition so adding a new
383+
// global flag with a short alias automatically propagates here.
384+
const existingAliases = (existingParams.aliases ?? {}) as Record<
385+
string,
386+
unknown
387+
>;
388+
const mergedAliases: Record<string, unknown> = { ...existingAliases };
389+
for (const gf of GLOBAL_FLAGS) {
390+
if (gf.short && !(gf.name in existingFlags || gf.short in mergedAliases)) {
391+
mergedAliases[gf.short] = gf.name;
392+
}
393+
}
394+
395+
const mergedParams = {
396+
...existingParams,
397+
flags: mergedFlags,
398+
aliases: mergedAliases,
399+
};
381400

382401
/**
383402
* If the yielded value is a {@link CommandOutput}, render it via

src/lib/global-flags.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
/**
2+
* Single source of truth for global CLI flags.
3+
*
4+
* Global flags are injected into every leaf command by {@link buildCommand}
5+
* and hoisted from any argv position by {@link hoistGlobalFlags}. This
6+
* module defines the metadata once so both systems stay in sync
7+
* automatically — adding a flag here is all that's needed.
8+
*
9+
* The Stricli flag *shapes* (kind, brief, default, etc.) remain in
10+
* `command.ts` because they depend on Stricli types and runtime values.
11+
* This module only stores the identity and argv-level behavior of each flag.
12+
*/
13+
14+
/**
15+
* Behavior category for a global flag.
16+
*
17+
* - `"boolean"` — standalone toggle, supports `--no-<name>` negation
18+
* - `"value"` — consumes the next token (or `=`-joined value)
19+
*/
20+
type GlobalFlagKind = "boolean" | "value";
21+
22+
/** Metadata for a single global CLI flag. */
23+
type GlobalFlagDef = {
24+
/** Long flag name without `--` prefix (e.g., `"verbose"`) */
25+
readonly name: string;
26+
/** Single-char short alias without `-` prefix, or `null` if none */
27+
readonly short: string | null;
28+
/** Whether this is a boolean toggle or a value-taking flag */
29+
readonly kind: GlobalFlagKind;
30+
};
31+
32+
/**
33+
* All global flags that are injected into every leaf command.
34+
*
35+
* Order doesn't matter — both the hoisting preprocessor and the
36+
* `buildCommand` wrapper build lookup structures from this list.
37+
*/
38+
export const GLOBAL_FLAGS: readonly GlobalFlagDef[] = [
39+
{ name: "verbose", short: "v", kind: "boolean" },
40+
{ name: "log-level", short: null, kind: "value" },
41+
{ name: "json", short: null, kind: "boolean" },
42+
{ name: "fields", short: null, kind: "value" },
43+
];

0 commit comments

Comments
 (0)