Skip to content

Commit a3181cf

Browse files
committed
feat(cli): hoist global flags from any argv position and add -v alias
Global flags (--verbose, --json, --log-level, --fields) only worked when placed after the leaf command because Stricli parses flags at the leaf level only. This adds an argv preprocessor that relocates these flags from any position to the end of argv before Stricli processes it, allowing patterns like `sentry --verbose issue list` and `sentry cli -v upgrade`. Also injects -v as a short alias for --verbose on all leaf commands, following the existing buildDeleteCommand alias injection pattern.
1 parent abdc5e4 commit a3181cf

File tree

6 files changed

+719
-2
lines changed

6 files changed

+719
-2
lines changed

src/cli.ts

Lines changed: 11 additions & 1 deletion
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
// ---------------------------------------------------------------------------
@@ -371,7 +379,7 @@ export async function runCli(cliArgs: string[]): Promise<void> {
371379
}
372380

373381
try {
374-
await executor(cliArgs);
382+
await executor(hoistedArgs);
375383

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

src/lib/argv-hoist.ts

Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
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+
if (
128+
match.form === "eq" ||
129+
match.form === "negated" ||
130+
match.form === "short"
131+
) {
132+
// Single token: --flag=value, --no-flag, or -v
133+
hoisted.push(token);
134+
return index + 1;
135+
}
136+
137+
if (match.flag.takesValue) {
138+
// --flag value: consume two tokens
139+
hoisted.push(token);
140+
const next = index + 1;
141+
if (next < argv.length) {
142+
hoisted.push(argv[next] ?? "");
143+
return next + 1;
144+
}
145+
// No value follows — the bare --flag is still hoisted;
146+
// Stricli will report the missing value at parse time.
147+
return next;
148+
}
149+
150+
// Boolean flag: --flag
151+
hoisted.push(token);
152+
return index + 1;
153+
}
154+
155+
/**
156+
* Move global flags from any position in argv to the end.
157+
*
158+
* Tokens after `--` are never touched. The relative order of both
159+
* hoisted and non-hoisted tokens is preserved.
160+
*
161+
* @param argv - Raw CLI arguments (e.g., `process.argv.slice(2)`)
162+
* @returns New array with global flags relocated to the tail
163+
*/
164+
export function hoistGlobalFlags(argv: readonly string[]): string[] {
165+
const remaining: string[] = [];
166+
const hoisted: string[] = [];
167+
168+
let i = 0;
169+
while (i < argv.length) {
170+
const token = argv[i] ?? "";
171+
172+
// Stop scanning at -- separator; pass everything through verbatim
173+
if (token === "--") {
174+
for (let j = i; j < argv.length; j += 1) {
175+
remaining.push(argv[j] ?? "");
176+
}
177+
break;
178+
}
179+
180+
const match = matchHoistable(token);
181+
if (match) {
182+
i = consumeFlag(argv, i, match, hoisted);
183+
} else {
184+
remaining.push(token);
185+
i += 1;
186+
}
187+
}
188+
189+
return [...remaining, ...hoisted];
190+
}

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)