import { parseArgs, type ParseArgsOptionsConfig } from "node:util" import { toKebabCase } from "remeda" import type { z } from "zod" export interface Command { aliases?: readonly string[] defaultSubcommand?: Command description: string execute?: (rawArgs: readonly string[]) => Promise groups?: readonly { commands: readonly Command[] heading: string }[] hidden?: boolean name: string negativeOptionDescriptions?: Readonly> optionDescriptions?: Readonly> options?: ParseArgsOptionsConfig positionals?: readonly { description?: string name: string required?: boolean }[] requiredOptions?: readonly string[] subcommands?: readonly Command[] version?: string } export interface CommandDefinition< TOptions extends ParseArgsOptionsConfig, TSchema extends z.ZodType, > extends Omit { options: TOptions run: (options: z.output) => Promise schema: TSchema } /** * Defines a command. * * @param definition - Command metadata and children. */ export function defineCommand(definition: Command): Command /** * Defines a command that parses and validates options before running it. * * @param definition - Command metadata, native options, schema, and runner. */ export function defineCommand< const TOptions extends ParseArgsOptionsConfig, TSchema extends z.ZodType, >( definition: CommandDefinition, ): CommandDefinition & Command export function defineCommand< const TOptions extends ParseArgsOptionsConfig, TSchema extends z.ZodType, >(definition: Command | CommandDefinition): Command { if (!("run" in definition)) return definition return { ...definition, execute: async (rawArgs: readonly string[]) => { const { positionals, values } = parseArgs({ allowNegative: true, allowPositionals: true, args: rawArgs.map((arg) => normalizeLongOption(arg, definition.options), ), options: definition.options, strict: false, }) await definition.run( await definition.schema.parseAsync({ ...values, positionals, }), ) }, } } /** * Returns every direct child command, including grouped root commands. * * @param command - Parent command. */ export function getSubcommands(command: Command) { return [ ...(command.subcommands ?? []), ...(command.groups?.flatMap((group) => group.commands) ?? []), ] } /** * Returns whether an option appears before the positional terminator. * * @param rawArgs - Raw CLI arguments. * @param options - Option spellings to match. */ export function hasOption( rawArgs: readonly string[], ...options: readonly string[] ) { const terminatorIndex = rawArgs.indexOf("--") return rawArgs .slice(0, terminatorIndex === -1 ? undefined : terminatorIndex) .some((arg) => options.includes(arg)) } /** * Maps the public kebab-case spelling to its native option key. * * @param arg - Raw CLI argument. * @param options - Native option configuration. */ function normalizeLongOption(arg: string, options: ParseArgsOptionsConfig) { if (!arg.startsWith("--")) return arg const separatorIndex = arg.indexOf("=") const flag = separatorIndex === -1 ? arg : arg.slice(0, separatorIndex) const suffix = separatorIndex === -1 ? "" : arg.slice(separatorIndex) const optionNames = Object.keys(options) const directOption = optionNames.find( (name) => toKebabCase(name) === flag.slice(2), ) if (directOption) return `--${directOption}${suffix}` if (!flag.startsWith("--no-")) return arg const negatedOption = optionNames.find( (name) => toKebabCase(name) === flag.slice(5), ) return negatedOption ? `--no-${negatedOption}${suffix}` : arg }