-
-
Notifications
You must be signed in to change notification settings - Fork 6
feat(cli): hoist global flags from any argv position and add -v alias #709
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,189 @@ | ||
| /** | ||
| * Argv preprocessor that moves global flags to the end of the argument list. | ||
| * | ||
| * Stricli only parses flags at the leaf command level, so flags like | ||
| * `--verbose` placed before the subcommand (`sentry --verbose issue list`) | ||
| * fail route resolution. This module relocates known global flags from any | ||
| * position to the tail of argv where Stricli's leaf-command parser can | ||
| * find them. | ||
| * | ||
| * Flag metadata is derived from the shared {@link GLOBAL_FLAGS} definition | ||
| * in `global-flags.ts` so both the hoisting preprocessor and the | ||
| * `buildCommand` injection stay in sync automatically. | ||
| */ | ||
|
|
||
| import { GLOBAL_FLAGS } from "./global-flags.js"; | ||
|
|
||
| /** Resolved flag metadata used by the hoisting algorithm. */ | ||
| type HoistableFlag = { | ||
| /** Long flag name without `--` prefix (e.g., `"verbose"`) */ | ||
| readonly name: string; | ||
| /** Single-char short alias without `-` prefix, or `null` if none */ | ||
| readonly short: string | null; | ||
| /** Whether the flag consumes the next token as its value */ | ||
| readonly takesValue: boolean; | ||
| /** Whether `--no-<name>` is recognized as the negation form */ | ||
| readonly negatable: boolean; | ||
| }; | ||
|
|
||
| /** Derive hoisting metadata from the shared flag definitions. */ | ||
| const HOISTABLE_FLAGS: readonly HoistableFlag[] = GLOBAL_FLAGS.map((f) => ({ | ||
| name: f.name, | ||
| short: f.short, | ||
| takesValue: f.kind === "value", | ||
| negatable: f.kind === "boolean", | ||
| })); | ||
|
|
||
| /** Pre-built lookup: long name → flag definition */ | ||
| const FLAG_BY_NAME = new Map(HOISTABLE_FLAGS.map((f) => [f.name, f])); | ||
|
|
||
| /** Pre-built lookup: short alias → flag definition */ | ||
| const FLAG_BY_SHORT = new Map( | ||
| HOISTABLE_FLAGS.filter( | ||
| (f): f is HoistableFlag & { short: string } => f.short !== null | ||
| ).map((f) => [f.short, f]) | ||
| ); | ||
|
|
||
| /** Names that support `--no-<name>` negation */ | ||
| const NEGATABLE_NAMES = new Set( | ||
| HOISTABLE_FLAGS.filter((f) => f.negatable).map((f) => f.name) | ||
| ); | ||
|
|
||
| /** | ||
| * Match result from {@link matchHoistable}. | ||
| * | ||
| * - `"plain"`: `--flag` (boolean) or `--flag` (value-taking, value is next token) | ||
| * - `"eq"`: `--flag=value` (value embedded in token) | ||
| * - `"negated"`: `--no-flag` | ||
| * - `"short"`: `-v` (single-char alias) | ||
| */ | ||
| type MatchForm = "plain" | "eq" | "negated" | "short"; | ||
|
|
||
| /** Try matching a `--no-<name>` negation form. */ | ||
| function matchNegated( | ||
| name: string | ||
| ): { flag: HoistableFlag; form: MatchForm } | null { | ||
| if (!name.startsWith("no-")) { | ||
| return null; | ||
| } | ||
| const baseName = name.slice(3); | ||
| if (!NEGATABLE_NAMES.has(baseName)) { | ||
| return null; | ||
| } | ||
| const flag = FLAG_BY_NAME.get(baseName); | ||
| return flag ? { flag, form: "negated" } : null; | ||
| } | ||
|
|
||
| /** | ||
| * Match a token against the hoistable flag registry. | ||
| * | ||
| * @returns The matched flag and form, or `null` if not hoistable. | ||
| */ | ||
| function matchHoistable( | ||
| token: string | ||
| ): { flag: HoistableFlag; form: MatchForm } | null { | ||
| // Short alias: -v (exactly two chars, dash + letter) | ||
| if (token.length === 2 && token[0] === "-" && token[1] !== "-") { | ||
| const flag = FLAG_BY_SHORT.get(token[1] ?? ""); | ||
| return flag ? { flag, form: "short" } : null; | ||
| } | ||
|
|
||
| if (!token.startsWith("--")) { | ||
| return null; | ||
| } | ||
|
|
||
| // --flag=value form | ||
| const eqIdx = token.indexOf("="); | ||
| if (eqIdx !== -1) { | ||
| const name = token.slice(2, eqIdx); | ||
| const flag = FLAG_BY_NAME.get(name); | ||
| return flag?.takesValue ? { flag, form: "eq" } : null; | ||
| } | ||
|
|
||
| const name = token.slice(2); | ||
| const negated = matchNegated(name); | ||
| if (negated) { | ||
| return negated; | ||
| } | ||
| const flag = FLAG_BY_NAME.get(name); | ||
| return flag ? { flag, form: "plain" } : null; | ||
| } | ||
|
|
||
| /** | ||
| * Hoist a single matched flag token (and its value if applicable) into the | ||
| * `hoisted` array, advancing the index past the consumed tokens. | ||
| * | ||
| * Extracted from the main loop to keep {@link hoistGlobalFlags} under | ||
| * Biome's cognitive complexity limit. | ||
| */ | ||
| function consumeFlag( | ||
| argv: readonly string[], | ||
| index: number, | ||
| match: { flag: HoistableFlag; form: MatchForm }, | ||
| hoisted: string[] | ||
| ): number { | ||
| const token = argv[index] ?? ""; | ||
|
|
||
| // --flag=value or --no-flag: always a single token | ||
| if (match.form === "eq" || match.form === "negated") { | ||
| hoisted.push(token); | ||
| return index + 1; | ||
| } | ||
BYK marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| // --flag or -v: may consume a following value token | ||
| if (match.flag.takesValue) { | ||
| hoisted.push(token); | ||
| const next = index + 1; | ||
| if (next < argv.length) { | ||
| hoisted.push(argv[next] ?? ""); | ||
| return next + 1; | ||
| } | ||
| // No value follows — the bare flag is still hoisted; | ||
| // Stricli will report the missing value at parse time. | ||
| return next; | ||
| } | ||
|
|
||
| // Boolean flag (--flag or -v): single token | ||
| hoisted.push(token); | ||
| return index + 1; | ||
| } | ||
|
|
||
| /** | ||
| * Move global flags from any position in argv to the end. | ||
| * | ||
| * Tokens after `--` are never touched. The relative order of both | ||
| * hoisted and non-hoisted tokens is preserved. | ||
| * | ||
| * @param argv - Raw CLI arguments (e.g., `process.argv.slice(2)`) | ||
| * @returns New array with global flags relocated to the tail | ||
| */ | ||
| export function hoistGlobalFlags(argv: readonly string[]): string[] { | ||
| const remaining: string[] = []; | ||
| const hoisted: string[] = []; | ||
| /** Tokens from `--` onward (positional-only region). */ | ||
| const positionalTail: string[] = []; | ||
|
|
||
| let i = 0; | ||
| while (i < argv.length) { | ||
| const token = argv[i] ?? ""; | ||
|
|
||
| // Stop scanning at -- separator; pass everything through verbatim. | ||
| // Hoisted flags must appear BEFORE -- so Stricli parses them as flags. | ||
| if (token === "--") { | ||
| for (let j = i; j < argv.length; j += 1) { | ||
| positionalTail.push(argv[j] ?? ""); | ||
| } | ||
| break; | ||
| } | ||
|
|
||
| const match = matchHoistable(token); | ||
| if (match) { | ||
| i = consumeFlag(argv, i, match, hoisted); | ||
| } else { | ||
| remaining.push(token); | ||
| i += 1; | ||
| } | ||
| } | ||
|
|
||
| return [...remaining, ...hoisted, ...positionalTail]; | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,43 @@ | ||
| /** | ||
| * Single source of truth for global CLI flags. | ||
| * | ||
| * Global flags are injected into every leaf command by {@link buildCommand} | ||
| * and hoisted from any argv position by {@link hoistGlobalFlags}. This | ||
| * module defines the metadata once so both systems stay in sync | ||
| * automatically — adding a flag here is all that's needed. | ||
| * | ||
| * The Stricli flag *shapes* (kind, brief, default, etc.) remain in | ||
| * `command.ts` because they depend on Stricli types and runtime values. | ||
| * This module only stores the identity and argv-level behavior of each flag. | ||
| */ | ||
|
|
||
| /** | ||
| * Behavior category for a global flag. | ||
| * | ||
| * - `"boolean"` — standalone toggle, supports `--no-<name>` negation | ||
| * - `"value"` — consumes the next token (or `=`-joined value) | ||
| */ | ||
| type GlobalFlagKind = "boolean" | "value"; | ||
|
|
||
| /** Metadata for a single global CLI flag. */ | ||
| type GlobalFlagDef = { | ||
| /** Long flag name without `--` prefix (e.g., `"verbose"`) */ | ||
| readonly name: string; | ||
| /** Single-char short alias without `-` prefix, or `null` if none */ | ||
| readonly short: string | null; | ||
| /** Whether this is a boolean toggle or a value-taking flag */ | ||
| readonly kind: GlobalFlagKind; | ||
| }; | ||
|
|
||
| /** | ||
| * All global flags that are injected into every leaf command. | ||
| * | ||
| * Order doesn't matter — both the hoisting preprocessor and the | ||
| * `buildCommand` wrapper build lookup structures from this list. | ||
| */ | ||
| export const GLOBAL_FLAGS: readonly GlobalFlagDef[] = [ | ||
| { name: "verbose", short: "v", kind: "boolean" }, | ||
| { name: "log-level", short: null, kind: "value" }, | ||
| { name: "json", short: null, kind: "boolean" }, | ||
| { name: "fields", short: null, kind: "value" }, | ||
| ]; |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.