Compare commits

..

2 Commits

Author SHA1 Message Date
SikongJueluo
1891259ee7 fix: cli framework
fix:
- cli command path wrong
- help output nil
2025-10-27 22:02:53 +08:00
7a17ca7fbf reconstruct: cli framework 2025-10-27 16:50:04 +08:00
5 changed files with 304 additions and 180 deletions

View File

@@ -16,19 +16,19 @@ interface AppContext {
// 2. Define individual commands // 2. Define individual commands
const addCommand: Command<AppContext> = { const addCommand: Command<AppContext> = {
name: "add", name: "add",
description: "将两个数字相加", description: "Adds two numbers together",
args: [ args: [
{ name: "a", description: "第一个数字", required: true }, { name: "a", description: "The first number", required: true },
{ name: "b", description: "第二个数字", required: true }, { name: "b", description: "The second number", required: true },
], ],
action: ({ args, context }): Result<void, CliError> => { action: ({ args, context }): Result<void, CliError> => {
context.log(` '${context.appName}' 中执行 'add' 命令`); context.log(`Executing 'add' command in '${context.appName}'`);
const a = tonumber(args.a as string); const a = tonumber(args.a as string);
const b = tonumber(args.b as string); const b = tonumber(args.b as string);
if (a === undefined || b === undefined) { if (a === undefined || b === undefined) {
print("错误: 参数必须是数字。"); print("Error: Arguments must be numbers.");
return Ok.EMPTY; return Ok.EMPTY;
} }
@@ -36,7 +36,7 @@ const addCommand: Command<AppContext> = {
print(`${a} + ${b} = ${result}`); print(`${a} + ${b} = ${result}`);
if (context.debugMode) { if (context.debugMode) {
context.log(`计算结果: ${result}`); context.log(`Calculation result: ${result}`);
} }
return Ok.EMPTY; return Ok.EMPTY;
}, },
@@ -44,19 +44,19 @@ const addCommand: Command<AppContext> = {
const subtractCommand: Command<AppContext> = { const subtractCommand: Command<AppContext> = {
name: "subtract", name: "subtract",
description: "将第二个数字从第一个数字中减去", description: "Subtracts the second number from the first",
args: [ args: [
{ name: "a", description: "被减数", required: true }, { name: "a", description: "The minuend", required: true },
{ name: "b", description: "减数", required: true }, { name: "b", description: "The subtrahend", required: true },
], ],
action: ({ args, context }): Result<void, CliError> => { action: ({ args, context }): Result<void, CliError> => {
context.log(` '${context.appName}' 中执行 'subtract' 命令`); context.log(`Executing 'subtract' command in '${context.appName}'`);
const a = tonumber(args.a as string); const a = tonumber(args.a as string);
const b = tonumber(args.b as string); const b = tonumber(args.b as string);
if (a === undefined || b === undefined) { if (a === undefined || b === undefined) {
print("错误: 参数必须是数字。"); print("Error: Arguments must be numbers.");
return Ok.EMPTY; return Ok.EMPTY;
} }
@@ -68,18 +68,29 @@ const subtractCommand: Command<AppContext> = {
const greetCommand: Command<AppContext> = { const greetCommand: Command<AppContext> = {
name: "greet", name: "greet",
description: "打印问候语", description: "Prints a greeting message",
options: [ options: new Map([
{ [
name: "name", "name",
shortName: "n", {
description: "要问候的名字", name: "name",
defaultValue: "World", shortName: "n",
}, description: "The name to greet",
{ name: "times", shortName: "t", description: "重复次数", defaultValue: 1 }, defaultValue: "World",
], },
],
[
"times",
{
name: "times",
shortName: "t",
description: "Number of times to repeat",
defaultValue: 1,
},
],
]),
action: ({ options, context }): Result<void, CliError> => { action: ({ options, context }): Result<void, CliError> => {
context.log(` '${context.appName}' 中执行 'greet' 命令`); context.log(`Executing 'greet' command in '${context.appName}'`);
const name = options.name as string; const name = options.name as string;
const times = tonumber(options.times as string) ?? 1; const times = tonumber(options.times as string) ?? 1;
@@ -88,7 +99,7 @@ const greetCommand: Command<AppContext> = {
print(`Hello, ${name}!`); print(`Hello, ${name}!`);
if (context.debugMode && times > 1) { if (context.debugMode && times > 1) {
context.log(`问候 ${i}/${times}`); context.log(`Greeting ${i}/${times}`);
} }
} }
return Ok.EMPTY; return Ok.EMPTY;
@@ -98,67 +109,80 @@ const greetCommand: Command<AppContext> = {
// Math subcommands group // Math subcommands group
const mathCommand: Command<AppContext> = { const mathCommand: Command<AppContext> = {
name: "math", name: "math",
description: "数学运算命令", description: "Mathematical operations",
subcommands: [addCommand, subtractCommand], subcommands: new Map([
["add", addCommand],
["subtract", subtractCommand],
]),
}; };
// Config command with nested subcommands // Config command with nested subcommands
const configShowCommand: Command<AppContext> = { const configShowCommand: Command<AppContext> = {
name: "show", name: "show",
description: "显示当前配置", description: "Show current configuration",
action: ({ context }): Result<void, CliError> => { action: ({ context }): Result<void, CliError> => {
print(`应用名称: ${context.appName}`); print(`App Name: ${context.appName}`);
print(`调试模式: ${context.debugMode ? "开启" : "关闭"}`); print(`Debug Mode: ${context.debugMode ? "on" : "off"}`);
return Ok.EMPTY; return Ok.EMPTY;
}, },
}; };
const configSetCommand: Command<AppContext> = { const configSetCommand: Command<AppContext> = {
name: "set", name: "set",
description: "设置配置项", description: "Set a configuration item",
args: [ args: [
{ name: "key", description: "配置键", required: true }, { name: "key", description: "The configuration key", required: true },
{ name: "value", description: "配置值", required: true }, { name: "value", description: "The configuration value", required: true },
], ],
action: ({ args, context }): Result<void, CliError> => { action: ({ args, context }): Result<void, CliError> => {
const key = args.key as string; const key = args.key as string;
const value = args.value as string; const value = args.value as string;
context.log(`设置配置: ${key} = ${value}`); context.log(`Setting config: ${key} = ${value}`);
print(`配置 '${key}' 已设置为 '${value}'`); print(`Config '${key}' has been set to '${value}'`);
return Ok.EMPTY; return Ok.EMPTY;
}, },
}; };
const configCommand: Command<AppContext> = { const configCommand: Command<AppContext> = {
name: "config", name: "config",
description: "配置管理命令", description: "Configuration management commands",
subcommands: [configShowCommand, configSetCommand], subcommands: new Map([
["show", configShowCommand],
["set", configSetCommand],
]),
}; };
// 3. Define root command // 3. Define root command
const rootCommand: Command<AppContext> = { const rootCommand: Command<AppContext> = {
name: "calculator", name: "calculator",
description: "一个功能丰富的计算器程序", description: "A feature-rich calculator program",
options: [ options: new Map([
{ [
name: "debug", "debug",
shortName: "d", {
description: "启用调试模式", name: "debug",
defaultValue: false, shortName: "d",
}, description: "Enable debug mode",
], defaultValue: false,
subcommands: [mathCommand, greetCommand, configCommand], },
],
]),
subcommands: new Map([
["math", mathCommand],
["greet", greetCommand],
["config", configCommand],
]),
action: ({ options, context }): Result<void, CliError> => { action: ({ options, context }): Result<void, CliError> => {
// Update debug mode from command line option // Update debug mode from command line option
const debugFromOption = options.debug as boolean; const debugFromOption = options.debug as boolean;
if (debugFromOption) { if (debugFromOption) {
context.debugMode = true; context.debugMode = true;
context.log("调试模式已启用"); context.log("Debug mode enabled");
} }
print(`欢迎使用 ${context.appName}!`); print(`Welcome to ${context.appName}!`);
print("使用 --help 查看可用命令"); print("Use --help to see available commands");
return Ok.EMPTY; return Ok.EMPTY;
}, },
}; };

View File

@@ -5,8 +5,7 @@ import {
Argument, Argument,
Option, Option,
CliError, CliError,
ParsedInput, ParseResult,
CommandResolution,
} from "./types"; } from "./types";
import { import {
parseArguments, parseArguments,
@@ -23,7 +22,9 @@ import { generateHelp, shouldShowHelp, generateCommandList } from "./help";
export interface CreateCliOptions<TContext extends object> { export interface CreateCliOptions<TContext extends object> {
/** An optional global context object to be made available in all command actions. */ /** An optional global context object to be made available in all command actions. */
globalContext?: TContext; globalContext?: TContext;
/** An optional function to handle output. Defaults to the global `print` function. */ /** An optional function to handle output.
* Default: textutils.pagedPrint(msg, term.getCursorPos()[1] - 2)
**/
writer?: (message: string) => void; writer?: (message: string) => void;
} }
@@ -37,60 +38,64 @@ export function createCli<TContext extends object>(
rootCommand: Command<TContext>, rootCommand: Command<TContext>,
options: CreateCliOptions<TContext> = {}, options: CreateCliOptions<TContext> = {},
): (argv: string[]) => void { ): (argv: string[]) => void {
const { globalContext, writer = print } = options; const {
globalContext,
writer = (msg) => textutils.pagedPrint(msg, term.getCursorPos()[1] - 2),
} = options;
return (argv: string[]): void => { return (argv: string[]): void => {
// Check for top-level help flags before any parsing. // Check for top-level help flags before any parsing.
if (shouldShowHelp(argv)) { if (argv[0]?.startsWith("--help") || argv[0]?.startsWith("-h")) {
writer(generateHelp(rootCommand)); writer(generateHelp(rootCommand, [rootCommand.name]));
return; return;
} }
const parsedInput = parseArguments(argv); const parseResult = parseArguments(argv, rootCommand);
const executionResult = findCommand(
rootCommand, if (parseResult.isErr()) {
parsedInput.commandPath, const error = parseResult.error;
).andThen((resolution) => writer(formatError(error, rootCommand));
processAndExecute(resolution, parsedInput, globalContext, (msg: string) =>
writer(msg), // If it was an unknown command, suggest alternatives.
), if (error.kind === "UnknownCommand") {
// Find parent command to suggest alternatives
const parentResult = parseArguments(argv.slice(0, -1), rootCommand);
if (parentResult.isOk() && parentResult.value.command.subcommands) {
writer(generateCommandList(parentResult.value.command.subcommands));
}
}
return;
}
const executionResult = processAndExecute(
parseResult.value,
globalContext,
(msg: string) => writer(msg),
); );
if (executionResult.isErr()) { if (executionResult.isErr()) {
const error = executionResult.error; const error = executionResult.error;
writer(formatError(error, rootCommand)); writer(formatError(error, rootCommand));
// If it was an unknown command, suggest alternatives.
if (error.kind === "UnknownCommand") {
const parent = findCommand(
rootCommand,
parsedInput.commandPath.slice(0, -1),
);
if (parent.isOk() && parent.value.command.subcommands) {
writer(generateCommandList(parent.value.command.subcommands));
}
}
} }
}; };
} }
/** /**
* Processes the parsed input and executes the resolved command. * Processes the parsed input and executes the resolved command.
* @param resolution The resolved command and its context. * @param parseResult The result from parsing with integrated command resolution.
* @param parsedInput The raw parsed command-line input.
* @param globalContext The global context for the CLI. * @param globalContext The global context for the CLI.
* @param writer Function to output messages.
* @returns A `Result` indicating the success or failure of the execution. * @returns A `Result` indicating the success or failure of the execution.
*/ */
function processAndExecute<TContext extends object>( function processAndExecute<TContext extends object>(
resolution: CommandResolution<TContext>, parseResult: ParseResult<TContext>,
parsedInput: ParsedInput,
globalContext: TContext | undefined, globalContext: TContext | undefined,
writer: (message: string) => void, writer: (message: string) => void,
): Result<void, CliError> { ): Result<void, CliError> {
const { command, commandPath, remainingArgs } = resolution; const { command, commandPath, options, remaining } = parseResult;
// Handle requests for help on a specific command. // Handle requests for help on a specific command.
if (shouldShowHelp([...remainingArgs, ...Object.keys(parsedInput.options)])) { if (shouldShowHelp([...remaining, ...Object.keys(options)])) {
writer(generateHelp(command, commandPath)); writer(generateHelp(command, commandPath));
return Ok.EMPTY; return Ok.EMPTY;
} }
@@ -98,7 +103,7 @@ function processAndExecute<TContext extends object>(
// If a command has subcommands but no action, show its help page. // If a command has subcommands but no action, show its help page.
if ( if (
command.subcommands && command.subcommands &&
command.subcommands.length > 0 && command.subcommands.size > 0 &&
command.action === undefined command.action === undefined
) { ) {
writer(generateHelp(command, commandPath)); writer(generateHelp(command, commandPath));
@@ -113,20 +118,19 @@ function processAndExecute<TContext extends object>(
}); });
} }
return processArguments( return processArguments(command.args ?? [], remaining)
command.args ?? [],
remainingArgs,
parsedInput.remaining,
)
.andThen((args) => { .andThen((args) => {
return processOptions(command.options ?? [], parsedInput.options).map( return processOptions(
(options) => ({ args, options }), command.options !== undefined
); ? Array.from(command.options.values())
: [],
options,
).map((processedOptions) => ({ args, options: processedOptions }));
}) })
.andThen(({ args, options }) => { .andThen(({ args, options: processedOptions }) => {
const context: ActionContext<TContext> = { const context: ActionContext<TContext> = {
args, args,
options, options: processedOptions,
context: globalContext!, context: globalContext!,
}; };
// Finally, execute the command's action. // Finally, execute the command's action.
@@ -134,60 +138,22 @@ function processAndExecute<TContext extends object>(
}); });
} }
/**
* Finds the target command based on a given path.
* @param rootCommand The command to start searching from.
* @param commandPath An array of strings representing the path to the command.
* @returns A `Result` containing the `CommandResolution` or an `UnknownCommandError`.
*/
function findCommand<TContext extends object>(
rootCommand: Command<TContext>,
commandPath: string[],
): Result<CommandResolution<TContext>, CliError> {
let currentCommand = rootCommand;
const resolvedPath: string[] = [];
let i = 0;
for (const name of commandPath) {
const subcommand = currentCommand.subcommands?.find(
(cmd) => cmd.name === name,
);
if (!subcommand) {
// Part of the path was not a valid command, so the rest are arguments.
return new Err({ kind: "UnknownCommand", commandName: name });
}
currentCommand = subcommand;
resolvedPath.push(name);
i++;
}
const remainingArgs = commandPath.slice(i);
return new Ok({
command: currentCommand,
commandPath: resolvedPath,
remainingArgs,
});
}
/** /**
* Processes and validates command arguments from the raw input. * Processes and validates command arguments from the raw input.
* @param argDefs The argument definitions for the command. * @param argDefs The argument definitions for the command.
* @param remainingArgs The positional arguments captured during command resolution. * @param remainingArgs The remaining positional arguments.
* @param additionalArgs Any extra arguments parsed after options.
* @returns A `Result` with the processed arguments record or a `MissingArgumentError`. * @returns A `Result` with the processed arguments record or a `MissingArgumentError`.
*/ */
function processArguments( function processArguments(
argDefs: Argument[], argDefs: Argument[],
remainingArgs: string[], remainingArgs: string[],
additionalArgs: string[],
): Result<Record<string, unknown>, CliError> { ): Result<Record<string, unknown>, CliError> {
const args: Record<string, unknown> = {}; const args: Record<string, unknown> = {};
const allArgs = [...remainingArgs, ...additionalArgs];
for (let i = 0; i < argDefs.length; i++) { for (let i = 0; i < argDefs.length; i++) {
const argDef = argDefs[i]; const argDef = argDefs[i];
if (i < allArgs.length) { if (i < remainingArgs.length) {
args[argDef.name] = allArgs[i]; args[argDef.name] = remainingArgs[i];
} }
} }

View File

@@ -11,7 +11,7 @@ export function generateHelp<TContext extends object>(
commandPath: string[] = [], commandPath: string[] = [],
): string { ): string {
const lines: string[] = []; const lines: string[] = [];
const fullCommandName = [...commandPath, command.name].join(" "); const fullCommandName = commandPath.join(" ");
// Description // Description
if (command.description !== undefined) { if (command.description !== undefined) {
@@ -20,10 +20,10 @@ export function generateHelp<TContext extends object>(
// Usage // Usage
const usageParts: string[] = ["Usage:", fullCommandName]; const usageParts: string[] = ["Usage:", fullCommandName];
if (command.options && command.options.length > 0) { if (command.options && command.options.size > 0) {
usageParts.push("[OPTIONS]"); usageParts.push("[OPTIONS]");
} }
if (command.subcommands && command.subcommands.length > 0) { if (command.subcommands && command.subcommands.size > 0) {
usageParts.push("<COMMAND>"); usageParts.push("<COMMAND>");
} }
if (command.args && command.args.length > 0) { if (command.args && command.args.length > 0) {
@@ -45,9 +45,9 @@ export function generateHelp<TContext extends object>(
} }
// Options // Options
if (command.options && command.options.length > 0) { if (command.options && command.options.size > 0) {
lines.push("\nOptions:"); lines.push("\nOptions:");
for (const option of command.options) { for (const option of command.options.values()) {
const short = const short =
option.shortName !== undefined ? `-${option.shortName}, ` : " "; option.shortName !== undefined ? `-${option.shortName}, ` : " ";
const long = `--${option.name}`; const long = `--${option.name}`;
@@ -64,9 +64,9 @@ export function generateHelp<TContext extends object>(
} }
// Subcommands // Subcommands
if (command.subcommands && command.subcommands.length > 0) { if (command.subcommands && command.subcommands.size > 0) {
lines.push("\nCommands:"); lines.push("\nCommands:");
for (const subcommand of command.subcommands) { for (const subcommand of command.subcommands.values()) {
lines.push(` ${subcommand.name.padEnd(20)} ${subcommand.description}`); lines.push(` ${subcommand.name.padEnd(20)} ${subcommand.description}`);
} }
lines.push( lines.push(
@@ -83,14 +83,14 @@ export function generateHelp<TContext extends object>(
* @returns A formatted string listing the available commands. * @returns A formatted string listing the available commands.
*/ */
export function generateCommandList<TContext extends object>( export function generateCommandList<TContext extends object>(
commands: Command<TContext>[], commands: Map<string, Command<TContext>>,
): string { ): string {
if (commands.length === 0) { if (commands.size === 0) {
return "No commands available."; return "No commands available.";
} }
const lines: string[] = ["Available commands:"]; const lines: string[] = ["Available commands:"];
for (const command of commands) { for (const command of commands.values()) {
lines.push(` ${command.name.padEnd(20)} ${command.description}`); lines.push(` ${command.name.padEnd(20)} ${command.description}`);
} }

View File

@@ -1,31 +1,108 @@
import { Ok, Err, Result } from "../thirdparty/ts-result-es"; import { Ok, Err, Result } from "../thirdparty/ts-result-es";
import { ParsedInput, MissingArgumentError, MissingOptionError } from "./types"; import {
ParseResult,
MissingArgumentError,
MissingOptionError,
Command,
Option,
CliError,
CommandResolution,
} from "./types";
// Cache class to handle option maps with proper typing
class OptionMapCache {
private cache = new WeakMap<
object,
{
optionMap: Map<string, Option>;
shortNameMap: Map<string, string>;
}
>();
get<TContext extends object>(command: Command<TContext>) {
return this.cache.get(command);
}
set<TContext extends object>(
command: Command<TContext>,
value: {
optionMap: Map<string, Option>;
shortNameMap: Map<string, string>;
},
) {
this.cache.set(command, value);
}
}
// Lazy option map builder with global caching
function getOptionMaps<TContext extends object>(
optionCache: OptionMapCache,
command: Command<TContext>,
) {
// Quick check: if command has no options, return empty maps
if (!command.options || command.options.size === 0) {
return {
optionMap: new Map<string, Option>(),
shortNameMap: new Map<string, string>(),
};
}
let cached = optionCache.get(command);
if (cached !== undefined) {
return cached;
}
const optionMap = new Map<string, Option>();
const shortNameMap = new Map<string, string>();
for (const [optionName, option] of command.options) {
optionMap.set(optionName, option);
if (option.shortName !== undefined && option.shortName !== null) {
shortNameMap.set(option.shortName, optionName);
}
}
cached = { optionMap, shortNameMap };
optionCache.set(command, cached);
return cached;
}
/** /**
* Parses command line arguments into a structured format. * Parses command line arguments with integrated command resolution.
* This function does not validate arguments or options, it only parses the raw input. * This function dynamically finds the target command during parsing and uses
* @param argv Array of command line arguments (e.g., from `os.pullEvent`). * the command's option definitions for intelligent option handling.
* @returns A `ParsedInput` object containing the command path, options, and remaining args. * @param argv Array of command line arguments.
* @param rootCommand The root command to start parsing from.
* @returns A `Result` containing the `ParseResult` or a `CliError`.
*/ */
export function parseArguments(argv: string[]): ParsedInput { export function parseArguments<TContext extends object>(
const result: ParsedInput = { argv: string[],
commandPath: [], rootCommand: Command<TContext>,
): Result<ParseResult<TContext>, CliError> {
const result: ParseResult<TContext> = {
command: rootCommand,
commandPath: [rootCommand.name],
options: {}, options: {},
remaining: [], remaining: [],
}; };
let currentCommand = rootCommand;
let i = 0; let i = 0;
let inOptions = false; let inOptions = false;
const optionMapCache = new OptionMapCache();
const getCurrentOptionMaps = () =>
getOptionMaps(optionMapCache, currentCommand);
while (i < argv.length) { while (i < argv.length) {
const arg = argv[i]; const arg = argv[i];
if (arg === undefined) { if (arg === undefined || arg === null) {
i++; i++;
continue; continue;
} }
// Handle double dash (--) - everything after is treated as a remaining argument. // Handle double dash (--) - everything after is treated as a remaining argument
if (arg === "--") { if (arg === "--") {
result.remaining.push(...argv.slice(i + 1)); result.remaining.push(...argv.slice(i + 1));
break; break;
@@ -44,15 +121,20 @@ export function parseArguments(argv: string[]): ParsedInput {
} else { } else {
// --option [value] format // --option [value] format
const optionName = arg.slice(2); const optionName = arg.slice(2);
if ( const optionDef = getCurrentOptionMaps().optionMap.get(optionName);
i + 1 < argv.length &&
argv[i + 1] !== undefined && // Check if this is a known boolean option or if next arg looks like a value
!argv[i + 1].startsWith("-") const nextArg = argv[i + 1];
) { const isKnownBooleanOption =
result.options[optionName] = argv[i + 1]; 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 i++; // Skip the value argument
} else { } else {
// Boolean flag // Boolean flag or no value available
result.options[optionName] = true; result.options[optionName] = true;
} }
} }
@@ -60,25 +142,42 @@ export function parseArguments(argv: string[]): ParsedInput {
// 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 optionName = arg.slice(1); const shortName = arg.slice(1);
if ( // Get option maps for the new command (lazy loaded and cached)
i + 1 < argv.length && const maps = getCurrentOptionMaps();
argv[i + 1] !== undefined && const optionName = maps.shortNameMap.get(shortName) ?? shortName;
!argv[i + 1].startsWith("-") const optionDef = maps.optionMap.get(optionName);
) {
result.options[optionName] = argv[i + 1]; // 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 i++; // Skip the value argument
} else { } else {
// Boolean flag // Boolean flag or no value available
result.options[optionName] = true; result.options[optionName] = true;
} }
} }
// Handle positional arguments and commands // Handle positional arguments and command resolution
else { else {
if (!inOptions) { if (!inOptions) {
// Before any options, treat as part of the command path // Try to find this as a subcommand of the current command
result.commandPath.push(arg); const subcommand = currentCommand.subcommands?.get(arg);
if (subcommand !== undefined) {
// Found a subcommand, move deeper
currentCommand = subcommand;
result.command = currentCommand;
result.commandPath.push(arg);
} else {
// Not a subcommand, treat as remaining argument
result.remaining.push(arg);
}
} else { } else {
// After options have started, treat as a remaining argument // After options have started, treat as a remaining argument
result.remaining.push(arg); result.remaining.push(arg);
@@ -88,7 +187,40 @@ export function parseArguments(argv: string[]): ParsedInput {
i++; i++;
} }
return result; return new Ok(result);
}
/**
* Finds the target command based on a given path.
* @param rootCommand The command to start searching from.
* @param commandPath An array of strings representing the path to the command.
* @returns A `Result` containing the `CommandResolution` or an `UnknownCommandError`.
*/
export function findCommand<TContext extends object>(
rootCommand: Command<TContext>,
commandPath: string[],
): Result<CommandResolution<TContext>, CliError> {
let currentCommand = rootCommand;
const resolvedPath: string[] = [];
let i = 0;
for (const name of commandPath) {
const subcommand = currentCommand.subcommands?.get(name);
if (!subcommand) {
// Part of the path was not a valid command, so the rest are arguments.
return new Err({ kind: "UnknownCommand", commandName: name });
}
currentCommand = subcommand;
resolvedPath.push(name);
i++;
}
const remainingArgs = commandPath.slice(i);
return new Ok({
command: currentCommand,
commandPath: resolvedPath,
remainingArgs,
});
} }
/** /**

View File

@@ -104,30 +104,32 @@ export interface Command<TContext extends object> {
name: string; name: string;
/** A brief description of the command, shown in help messages. */ /** A brief description of the command, shown in help messages. */
description: string; description: string;
/** An array of argument definitions for the command. */ /** A map of argument definitions for the command, keyed by argument name. */
args?: Argument[]; args?: Argument[];
/** An array of option definitions for the command. */ /** A map of option definitions for the command, keyed by option name. */
options?: Option[]; options?: Map<string, Option>;
/** /**
* The function to execute when the command is run. * The function to execute when the command is run.
* It receives an `ActionContext` object. * It receives an `ActionContext` object.
* Should return a `Result` to indicate success or failure. * Should return a `Result` to indicate success or failure.
*/ */
action?: (context: ActionContext<TContext>) => Result<void, CliError>; action?: (context: ActionContext<TContext>) => Result<void, CliError>;
/** An array of subcommands, allowing for nested command structures. */ /** A map of subcommands, allowing for nested command structures, keyed by command name. */
subcommands?: Command<TContext>[]; subcommands?: Map<string, Command<TContext>>;
} }
// --- Parsing and Execution Internals --- // --- Parsing and Execution Internals ---
/** /**
* @interface ParsedInput * @interface ParseResult
* @description The raw output from the initial argument parsing stage. * @description Enhanced parsing result that includes command resolution.
*/ */
export interface ParsedInput { export interface ParseResult<TContext extends object> {
/** The identified command path from the arguments. */ /** The resolved command found during parsing. */
command: Command<TContext>;
/** The path to the resolved command. */
commandPath: string[]; commandPath: string[];
/** A record of raw option values. */ /** A record of parsed option values. */
options: Record<string, unknown>; options: Record<string, unknown>;
/** Any remaining arguments that were not parsed as part of the command path or options. */ /** Any remaining arguments that were not parsed as part of the command path or options. */
remaining: string[]; remaining: string[];