fix: cli framework help option not work

This commit is contained in:
2025-11-01 14:34:19 +08:00
parent 796bf1c2dc
commit d6971fb22f
3 changed files with 63 additions and 67 deletions

View File

@@ -94,30 +94,36 @@ function processAndExecute<TContext extends object>(
): Result<void, CliError> { ): Result<void, CliError> {
const { command, commandPath, options, remaining } = parseResult; const { command, commandPath, options, remaining } = parseResult;
// Handle requests for help on a specific command. // Unified Help Check:
if (shouldShowHelp([...remaining, ...Object.keys(options)])) { // A command should show its help page if:
writer(generateHelp(command, commandPath)); // 1. A help flag is explicitly passed (`--help` or `-h`). This has the highest priority.
return Ok.EMPTY; // 2. It's a command group that was called without a subcommand (i.e., it has no action).
} const isHelpFlagPassed = shouldShowHelp([
...remaining,
// If a command has subcommands but no action, show its help page. ...Object.keys(options),
if ( ]);
command.subcommands && const isCommandGroupWithoutAction =
command.subcommands !== undefined &&
command.subcommands.size > 0 && command.subcommands.size > 0 &&
command.action === undefined command.action === undefined;
) {
if (isHelpFlagPassed || isCommandGroupWithoutAction) {
writer(generateHelp(command, commandPath)); writer(generateHelp(command, commandPath));
return Ok.EMPTY; return Ok.EMPTY;
} }
// A command that is meant to be executed must have an action. // If we are here, it's a runnable command. It must have an action.
if (command.action === undefined) { if (command.action === undefined) {
// This case should ideally not be reached if the parser and the logic above are correct.
// It would mean a command has no action and no subcommands, which is a configuration error.
return new Err({ return new Err({
kind: "NoAction", kind: "NoAction",
commandPath: [...commandPath, command.name], commandPath: [...commandPath, command.name],
}); });
} }
// Now we know it's a runnable command, and no help flag was passed.
// We can now safely process the remaining items as arguments.
return processArguments(command.args ?? [], remaining) return processArguments(command.args ?? [], remaining)
.andThen((args) => { .andThen((args) => {
return processOptions( return processOptions(

View File

@@ -103,5 +103,5 @@ export function generateCommandList<TContext extends object>(
* @returns `true` if a help flag is found, otherwise `false`. * @returns `true` if a help flag is found, otherwise `false`.
*/ */
export function shouldShowHelp(argv: string[]): boolean { export function shouldShowHelp(argv: string[]): boolean {
return argv.includes("--help") || argv.includes("-h"); return argv.includes("help") || argv.includes("h");
} }

View File

@@ -87,22 +87,49 @@ export function parseArguments<TContext extends object>(
}; };
let currentCommand = rootCommand; let currentCommand = rootCommand;
let i = 0;
let inOptions = false; let inOptions = false;
const optionMapCache = new OptionMapCache(); const optionMapCache = new OptionMapCache();
const getCurrentOptionMaps = () =>
getOptionMaps(optionMapCache, currentCommand);
while (i < argv.length) { // Cache option maps for current command - only updated when command changes
let currentOptionMaps = getOptionMaps(optionMapCache, currentCommand);
// Helper function to update command context and refresh option maps
const updateCommand = (
newCommand: Command<TContext>,
commandName: string,
) => {
currentCommand = newCommand;
result.command = currentCommand;
result.commandPath.push(commandName);
currentOptionMaps = getOptionMaps(optionMapCache, currentCommand);
};
// Helper function to process option value
const processOption = (optionName: string, i: number): number => {
const optionDef = currentOptionMaps.optionMap.get(optionName);
const nextArg = argv[i + 1];
const isKnownBooleanOption =
optionDef !== undefined && optionDef.defaultValue === undefined;
const nextArgLooksLikeValue =
nextArg !== undefined && nextArg !== null && !nextArg.startsWith("-");
if (nextArgLooksLikeValue && !isKnownBooleanOption) {
result.options[optionName] = nextArg;
return i + 1; // Skip the value argument
} else {
result.options[optionName] = true;
return i;
}
};
// Single pass through argv
for (let i = 0; i < argv.length; i++) {
const arg = argv[i]; const arg = argv[i];
if (arg === undefined || arg === null) { // Skip null/undefined arguments
i++; if (!arg) continue;
continue;
}
// Handle double dash (--) - everything after is treated as a remaining argument // Handle double dash (--) - everything after is treated as remaining
if (arg === "--") { if (arg === "--") {
result.remaining.push(...argv.slice(i + 1)); result.remaining.push(...argv.slice(i + 1));
break; break;
@@ -121,70 +148,33 @@ export function parseArguments<TContext extends object>(
} else { } else {
// --option [value] format // --option [value] format
const optionName = arg.slice(2); const optionName = arg.slice(2);
const optionDef = getCurrentOptionMaps().optionMap.get(optionName); i = processOption(optionName, i);
// Check if this is a known boolean option or if next arg looks like a value
const nextArg = argv[i + 1];
const isKnownBooleanOption =
optionDef !== undefined && optionDef.defaultValue === undefined;
const nextArgLooksLikeValue =
nextArg !== undefined && nextArg !== null && !nextArg.startsWith("-");
if (nextArgLooksLikeValue && !isKnownBooleanOption) {
result.options[optionName] = nextArg;
i++; // Skip the value argument
} else {
// Boolean flag or no value available
result.options[optionName] = true;
}
} }
} }
// Handle short options (-o or -o value) // Handle short options (-o or -o value)
else if (arg.startsWith("-") && arg.length > 1) { else if (arg.startsWith("-") && arg.length > 1) {
inOptions = true; inOptions = true;
const shortName = arg.slice(1); const shortName = arg.slice(1);
const optionName =
// Get option maps for the new command (lazy loaded and cached) currentOptionMaps.shortNameMap.get(shortName) ?? shortName;
const maps = getCurrentOptionMaps(); i = processOption(optionName, i);
const optionName = maps.shortNameMap.get(shortName) ?? shortName;
const optionDef = maps.optionMap.get(optionName);
// Check if this is a known boolean option or if next arg looks like a value
const nextArg = argv[i + 1];
const isKnownBooleanOption =
optionDef !== undefined && optionDef.defaultValue === undefined;
const nextArgLooksLikeValue =
nextArg !== undefined && nextArg !== null && !nextArg.startsWith("-");
if (nextArgLooksLikeValue && !isKnownBooleanOption) {
result.options[optionName] = nextArg;
i++; // Skip the value argument
} else {
// Boolean flag or no value available
result.options[optionName] = true;
}
} }
// Handle positional arguments and command resolution // Handle positional arguments and command resolution
else { else {
if (!inOptions) { if (!inOptions) {
// Try to find this as a subcommand of the current command // Try to find this as a subcommand of the current command
const subcommand = currentCommand.subcommands?.get(arg); const subcommand = currentCommand.subcommands?.get(arg);
if (subcommand !== undefined) { if (subcommand) {
// Found a subcommand, move deeper updateCommand(subcommand, arg);
currentCommand = subcommand;
result.command = currentCommand;
result.commandPath.push(arg);
} else { } else {
// Not a subcommand, treat as remaining argument // Not a subcommand, treat as remaining argument
result.remaining.push(arg); result.remaining.push(arg);
} }
} else { } else {
// After options have started, treat as a remaining argument // After options have started, treat as remaining argument
result.remaining.push(arg); result.remaining.push(arg);
} }
} }
i++;
} }
return new Ok(result); return new Ok(result);