Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,7 @@ export async function runCli(cliArgs: string[]): Promise<void> {
const { isatty } = await import("node:tty");
const { ExitCode, run } = await import("@stricli/core");
const { app } = await import("./app.js");
const { hoistGlobalFlags } = await import("./lib/argv-hoist.js");
const { buildContext } = await import("./context.js");
const { AuthError, OutputError, formatError, getExitCode } = await import(
"./lib/errors.js"
Expand All @@ -155,6 +156,13 @@ export async function runCli(cliArgs: string[]): Promise<void> {
shouldSuppressNotification,
} = await import("./lib/version-check.js");

// Move global flags (--verbose, -v, --log-level, --json, --fields) from any
// position to the end of argv, where Stricli's leaf-command parser can
// find them. This allows `sentry --verbose issue list` to work.
// The original cliArgs are kept for post-run checks (e.g., help recovery)
// that rely on the original token positions.
const hoistedArgs = hoistGlobalFlags(cliArgs);

// ---------------------------------------------------------------------------
// Error-recovery middleware
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -371,7 +379,7 @@ export async function runCli(cliArgs: string[]): Promise<void> {
}

try {
await executor(cliArgs);
await executor(hoistedArgs);

// When Stricli can't match a subcommand in a route group (e.g.,
// `sentry dashboard help`), it writes "No command registered for `help`"
Expand All @@ -380,6 +388,8 @@ export async function runCli(cliArgs: string[]): Promise<void> {
// the custom help command with proper introspection output.
// Check both raw (-5) and unsigned (251) forms because Node.js keeps
// the raw value while Bun converts to unsigned byte.
// Uses original cliArgs (not hoisted) so the `at(-1) === "help"` check
// works when global flags were placed before "help".
if (
(process.exitCode === ExitCode.UnknownCommand ||
process.exitCode === (ExitCode.UnknownCommand + 256) % 256) &&
Expand Down
190 changes: 190 additions & 0 deletions src/lib/argv-hoist.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
/**
* 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] ?? "";

if (
match.form === "eq" ||
match.form === "negated" ||
match.form === "short"
) {
// Single token: --flag=value, --no-flag, or -v
hoisted.push(token);
return index + 1;
}

if (match.flag.takesValue) {
// --flag value: consume two tokens
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
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[] = [];

let i = 0;
while (i < argv.length) {
const token = argv[i] ?? "";

// Stop scanning at -- separator; pass everything through verbatim
if (token === "--") {
for (let j = i; j < argv.length; j += 1) {
remaining.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];
}
21 changes: 20 additions & 1 deletion src/lib/command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ import {
writeFooter,
} from "./formatters/output.js";
import { isPlainOutput } from "./formatters/plain-detect.js";
import { GLOBAL_FLAGS } from "./global-flags.js";
import {
LOG_LEVEL_NAMES,
type LogLevelName,
Expand Down Expand Up @@ -377,7 +378,25 @@ export function buildCommand<
// This makes field info visible in Stricli's --help output.
const enrichedDocs = enrichDocsWithSchema(builderArgs.docs, outputConfig);

const mergedParams = { ...existingParams, flags: mergedFlags };
// Inject short aliases for global flags (e.g., -v → --verbose).
// Derived from the shared GLOBAL_FLAGS definition so adding a new
// global flag with a short alias automatically propagates here.
const existingAliases = (existingParams.aliases ?? {}) as Record<
string,
unknown
>;
const mergedAliases: Record<string, unknown> = { ...existingAliases };
for (const gf of GLOBAL_FLAGS) {
if (gf.short && !(gf.name in existingFlags || gf.short in mergedAliases)) {
mergedAliases[gf.short] = gf.name;
}
}

const mergedParams = {
...existingParams,
flags: mergedFlags,
aliases: mergedAliases,
};

/**
* If the yielded value is a {@link CommandOutput}, render it via
Expand Down
43 changes: 43 additions & 0 deletions src/lib/global-flags.ts
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" },
];
Loading
Loading