mirror of
https://github.com/SikongJueluo/cc-utils.git
synced 2025-11-04 19:27:50 +08:00
Compare commits
2 Commits
f7167576cd
...
1891259ee7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1891259ee7 | ||
| 7a17ca7fbf |
@@ -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;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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[];
|
||||||
|
|||||||
Reference in New Issue
Block a user