530 lines
18 KiB
TypeScript
530 lines
18 KiB
TypeScript
/**
|
|
* Minimal CLI framework — drop-in replacement for the subset of @oclif/core
|
|
* actually used by the coding agent. Provides `Command`, `Args`, `Flags`,
|
|
* and a `run()` entry point with explicit command registration.
|
|
*
|
|
* Design goals:
|
|
* - Zero dependencies beyond node builtins
|
|
* - No filesystem scanning, no manifest files, no plugin loading
|
|
* - Lazy command imports (only the invoked command is loaded)
|
|
* - Typed `this.parse()` output matching oclif's API shape
|
|
*/
|
|
import * as fs from "node:fs";
|
|
import { parseArgs as nodeParseArgs } from "node:util";
|
|
|
|
/**
|
|
* Streaming startup marker, enabled by `PI_DEBUG_STARTUP`. Local copy of
|
|
* `logger.startupMarker` so the minimal `--version`/bootstrap import graph
|
|
* stays free of the winston-backed logger module. Synchronous on purpose:
|
|
* a command module whose import hangs (dlopen, fs on a dead mount) must
|
|
* still leave its `:start` marker behind.
|
|
*/
|
|
function startupMarker(text: string): void {
|
|
if (!process.env.PI_DEBUG_STARTUP) return;
|
|
try {
|
|
fs.writeSync(2, `[startup] ${text}\n`);
|
|
} catch {
|
|
// stderr unavailable; markers are best-effort
|
|
}
|
|
}
|
|
|
|
/**
|
|
* A user-facing argument/flag validation failure. Thrown by {@link Command.parse}
|
|
* for missing/invalid positionals and flags. The top-level {@link run} handler
|
|
* prints its message plus the command usage line to stderr and exits 1, instead
|
|
* of letting it bubble to the process-level catch — which would dump a minified
|
|
* `dist/cli.js` code frame over a plain argument mistake (issue #5369).
|
|
*/
|
|
export class CliUsageError extends Error {
|
|
constructor(message: string) {
|
|
super(message);
|
|
this.name = "CliUsageError";
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Flag & Arg descriptors
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export interface FlagDescriptor<K extends "string" | "boolean" | "integer" = "string" | "boolean" | "integer"> {
|
|
kind: K;
|
|
description?: string;
|
|
char?: string;
|
|
default?: unknown;
|
|
multiple?: boolean;
|
|
options?: readonly string[];
|
|
required?: boolean;
|
|
}
|
|
|
|
export interface ArgDescriptor {
|
|
kind: "string";
|
|
description?: string;
|
|
required?: boolean;
|
|
multiple?: boolean;
|
|
options?: readonly string[];
|
|
}
|
|
|
|
interface FlagInput {
|
|
description?: string;
|
|
char?: string;
|
|
default?: unknown;
|
|
multiple?: boolean;
|
|
options?: readonly string[];
|
|
required?: boolean;
|
|
}
|
|
|
|
interface ArgInput {
|
|
description?: string;
|
|
required?: boolean;
|
|
multiple?: boolean;
|
|
options?: readonly string[];
|
|
}
|
|
|
|
/** Builders that match the `Flags.*()` / `Args.*()` API from oclif. */
|
|
export const Flags = {
|
|
string<T extends FlagInput>(opts?: T): FlagDescriptor<"string"> & T {
|
|
return { kind: "string" as const, ...opts } as FlagDescriptor<"string"> & T;
|
|
},
|
|
boolean<T extends FlagInput>(opts?: T): FlagDescriptor<"boolean"> & T {
|
|
return { kind: "boolean" as const, ...opts } as FlagDescriptor<"boolean"> & T;
|
|
},
|
|
integer<T extends FlagInput & { default?: number }>(opts?: T): FlagDescriptor<"integer"> & T {
|
|
return { kind: "integer" as const, ...opts } as FlagDescriptor<"integer"> & T;
|
|
},
|
|
};
|
|
|
|
export const Args = {
|
|
string<T extends ArgInput>(opts?: T): ArgDescriptor & T {
|
|
return { kind: "string" as const, ...opts } as ArgDescriptor & T;
|
|
},
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Parse result types — mirrors oclif's typed output from this.parse()
|
|
// ---------------------------------------------------------------------------
|
|
|
|
type FlagValue<D extends FlagDescriptor> = D["kind"] extends "boolean"
|
|
? D extends { default: boolean }
|
|
? boolean
|
|
: boolean | undefined
|
|
: D["kind"] extends "integer"
|
|
? D extends { default: number }
|
|
? number
|
|
: number | undefined
|
|
: D extends { multiple: true }
|
|
? string[] | undefined
|
|
: string | undefined;
|
|
|
|
type ArgValue<D extends ArgDescriptor> = D extends { multiple: true } ? string[] | undefined : string | undefined;
|
|
|
|
type FlagValues<T extends Record<string, FlagDescriptor>> = { [K in keyof T]: FlagValue<T[K]> };
|
|
type ArgValues<T extends Record<string, ArgDescriptor>> = { [K in keyof T]: ArgValue<T[K]> };
|
|
|
|
export interface ParseOutput<
|
|
F extends Record<string, FlagDescriptor> = Record<string, FlagDescriptor>,
|
|
A extends Record<string, ArgDescriptor> = Record<string, ArgDescriptor>,
|
|
> {
|
|
flags: FlagValues<F>;
|
|
args: ArgValues<A>;
|
|
argv: string[];
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Command base class
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export interface CommandMetadata {
|
|
description?: string;
|
|
hidden?: boolean;
|
|
flags?: Record<string, FlagDescriptor>;
|
|
args?: Record<string, ArgDescriptor>;
|
|
examples?: string[];
|
|
}
|
|
|
|
export interface CommandCtor extends CommandMetadata {
|
|
new (argv: string[], config: CliConfig): Command;
|
|
strict?: boolean;
|
|
aliases?: string[];
|
|
}
|
|
|
|
/** Configuration passed to every command instance and help renderers. */
|
|
export interface CliConfig<TCommand extends CommandMetadata = CommandCtor> {
|
|
bin: string;
|
|
version: string;
|
|
/** All registered commands keyed by their canonical name. */
|
|
commands: Map<string, TCommand>;
|
|
}
|
|
|
|
/** Minimal Command base matching the oclif surface we use. */
|
|
export abstract class Command {
|
|
argv: string[];
|
|
config: CliConfig;
|
|
|
|
constructor(argv: string[], config: CliConfig) {
|
|
this.argv = argv;
|
|
this.config = config;
|
|
}
|
|
|
|
abstract run(): Promise<void>;
|
|
|
|
/**
|
|
* Parse argv against the static `flags` and `args` declared on the
|
|
* concrete command class. Returns a typed `{ flags, args, argv }` object.
|
|
*/
|
|
async parse<C extends CommandCtor>(
|
|
_Cmd: C,
|
|
): Promise<
|
|
ParseOutput<
|
|
NonNullable<C["flags"]> extends Record<string, FlagDescriptor>
|
|
? NonNullable<C["flags"]>
|
|
: Record<string, FlagDescriptor>,
|
|
NonNullable<C["args"]> extends Record<string, ArgDescriptor>
|
|
? NonNullable<C["args"]>
|
|
: Record<string, ArgDescriptor>
|
|
>
|
|
> {
|
|
const Cmd = _Cmd as CommandCtor;
|
|
const flagDefs = (Cmd.flags ?? {}) as Record<string, FlagDescriptor>;
|
|
const argDefs = (Cmd.args ?? {}) as Record<string, ArgDescriptor>;
|
|
const strict = Cmd.strict !== false;
|
|
|
|
// Build node:util parseArgs options from flag descriptors
|
|
const options: Record<
|
|
string,
|
|
{ type: "string" | "boolean"; short?: string; multiple?: boolean; default?: string | boolean }
|
|
> = {};
|
|
for (const [name, desc] of Object.entries(flagDefs)) {
|
|
const opt: (typeof options)[string] = {
|
|
type: desc.kind === "boolean" ? "boolean" : "string",
|
|
};
|
|
if (desc.char) opt.short = desc.char;
|
|
if (desc.multiple) opt.multiple = true;
|
|
if (desc.default !== undefined) {
|
|
opt.default = desc.kind === "boolean" ? Boolean(desc.default) : String(desc.default);
|
|
}
|
|
options[name] = opt;
|
|
}
|
|
|
|
// strict=false when command declares args (positionals must pass through)
|
|
// or when the command itself opts out
|
|
const { values: rawValues, positionals } = (() => {
|
|
try {
|
|
return nodeParseArgs({
|
|
args: this.argv,
|
|
options,
|
|
allowPositionals: true,
|
|
strict,
|
|
});
|
|
} catch (error) {
|
|
throw new CliUsageError(error instanceof Error ? error.message : String(error));
|
|
}
|
|
})();
|
|
|
|
// Convert raw values to proper types and validate
|
|
const flags: Record<string, unknown> = {};
|
|
for (const [name, desc] of Object.entries(flagDefs)) {
|
|
const raw = rawValues[name];
|
|
if (desc.kind === "integer") {
|
|
if (raw === undefined || typeof raw === "boolean") {
|
|
flags[name] = desc.default ?? undefined;
|
|
} else {
|
|
const n = Number.parseInt(raw as string, 10);
|
|
if (Number.isNaN(n)) {
|
|
throw new CliUsageError(`Expected integer for --${name}, got "${raw}"`);
|
|
}
|
|
flags[name] = n;
|
|
}
|
|
} else if (desc.kind === "boolean") {
|
|
flags[name] =
|
|
raw !== undefined ? Boolean(raw) : desc.default !== undefined ? Boolean(desc.default) : undefined;
|
|
} else {
|
|
// string
|
|
const val = raw !== undefined && typeof raw !== "boolean" ? raw : (desc.default ?? undefined);
|
|
// Validate options constraint
|
|
if (val !== undefined && desc.options && !Array.isArray(val)) {
|
|
if (!desc.options.includes(val as string)) {
|
|
throw new CliUsageError(
|
|
`Expected --${name} to be one of: ${[...desc.options].join(", ")}; got "${val}"`,
|
|
);
|
|
}
|
|
}
|
|
flags[name] = val;
|
|
}
|
|
// Validate required
|
|
if (desc.required && flags[name] === undefined) {
|
|
throw new CliUsageError(`Missing required flag: --${name}`);
|
|
}
|
|
}
|
|
|
|
// Map positionals to named args in declaration order and validate
|
|
const args: Record<string, unknown> = {};
|
|
let posIdx = 0;
|
|
for (const [argName, desc] of Object.entries(argDefs)) {
|
|
if (desc.multiple) {
|
|
const val = positionals.slice(posIdx);
|
|
args[argName] = val.length > 0 ? val : undefined;
|
|
posIdx = positionals.length;
|
|
} else {
|
|
const val = positionals[posIdx];
|
|
args[argName] = val;
|
|
posIdx++;
|
|
}
|
|
// Validate required
|
|
if (desc.required && args[argName] === undefined) {
|
|
throw new CliUsageError(`Missing required argument: ${argName}`);
|
|
}
|
|
// Validate options constraint
|
|
const argVal = args[argName];
|
|
if (argVal !== undefined && desc.options && typeof argVal === "string") {
|
|
if (!desc.options.includes(argVal)) {
|
|
throw new CliUsageError(
|
|
`Expected ${argName} to be one of: ${[...desc.options].join(", ")}; got "${argVal}"`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
return { flags, args, argv: positionals } as never;
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Help rendering
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/** Render full root help: header, default command details, subcommand list. */
|
|
export function renderRootHelp(config: CliConfig<CommandMetadata>): void {
|
|
const { bin, version, commands } = config;
|
|
const lines: string[] = [];
|
|
lines.push(`${bin} v${version}\n`);
|
|
lines.push("USAGE");
|
|
lines.push(` $ ${bin} [COMMAND]\n`);
|
|
|
|
// Show the default command's flags/args/examples inline.
|
|
// The default command is the one marked hidden (it's the implicit entry point).
|
|
const defaultCmd = [...commands.values()].find(command => command.hidden);
|
|
if (defaultCmd) {
|
|
renderCommandBody(lines, defaultCmd);
|
|
}
|
|
|
|
// List visible subcommands
|
|
const visible = [...commands.entries()].filter(([, C]) => !C.hidden);
|
|
if (visible.length > 0) {
|
|
lines.push("COMMANDS");
|
|
const maxLen = Math.max(...visible.map(([n]) => n.length));
|
|
for (const [name, command] of visible.sort((a, b) => a[0].localeCompare(b[0]))) {
|
|
lines.push(` ${name.padEnd(maxLen + 2)}${command.description ?? ""}`);
|
|
}
|
|
lines.push("");
|
|
}
|
|
|
|
process.stdout.write(lines.join("\n"));
|
|
}
|
|
|
|
/**
|
|
* Format a command's positional args for a USAGE line. Required args render
|
|
* bare (`MODELS`), optional args wrapped in brackets (`[MODELS]`), and
|
|
* `multiple` args get a trailing ellipsis (`MODELS...`) so a required
|
|
* variadic reads as `MODELS...`, not the misleading optional `[MODELS]`.
|
|
*/
|
|
function formatUsageArgs(Cmd: CommandCtor): string {
|
|
const entries = Object.entries(Cmd.args ?? {});
|
|
if (entries.length === 0) return "";
|
|
const parts = entries.map(([name, desc]) => {
|
|
const label = `${name.toUpperCase()}${desc.multiple ? "..." : ""}`;
|
|
return desc.required ? label : `[${label}]`;
|
|
});
|
|
return ` ${parts.join(" ")}`;
|
|
}
|
|
|
|
/** Build the single USAGE line for a command (without the leading label). */
|
|
export function commandUsageLine(bin: string, id: string, Cmd: CommandCtor): string {
|
|
const hasFlags = Object.keys(Cmd.flags ?? {}).length > 0;
|
|
return `$ ${bin} ${id}${formatUsageArgs(Cmd)}${hasFlags ? " [FLAGS]" : ""}`;
|
|
}
|
|
|
|
/** Render help for a single command. */
|
|
export function renderCommandHelp(bin: string, id: string, Cmd: CommandCtor): void {
|
|
const lines: string[] = [];
|
|
if (Cmd.description) lines.push(`${Cmd.description}\n`);
|
|
lines.push("USAGE");
|
|
lines.push(` ${commandUsageLine(bin, id, Cmd)}\n`);
|
|
renderCommandBody(lines, Cmd);
|
|
process.stdout.write(lines.join("\n"));
|
|
}
|
|
|
|
function renderCommandBody(lines: string[], command: CommandMetadata): void {
|
|
const argDefs = command.args ?? {};
|
|
const flagDefs = command.flags ?? {};
|
|
|
|
// Arguments
|
|
const argEntries = Object.entries(argDefs);
|
|
if (argEntries.length > 0) {
|
|
lines.push("ARGUMENTS");
|
|
const maxLen = Math.max(...argEntries.map(([n]) => n.length));
|
|
for (const [name, desc] of argEntries) {
|
|
const parts = [name.toUpperCase().padEnd(maxLen + 2)];
|
|
if (desc.description) parts.push(desc.description);
|
|
if (desc.options) parts.push(`(${[...desc.options].join("|")})`);
|
|
lines.push(` ${parts.join(" ")}`);
|
|
}
|
|
lines.push("");
|
|
}
|
|
|
|
// Flags
|
|
const flagEntries = Object.entries(flagDefs);
|
|
if (flagEntries.length > 0) {
|
|
lines.push("FLAGS");
|
|
const formatted: [string, string][] = [];
|
|
for (const [name, desc] of flagEntries) {
|
|
const charPart = desc.char ? `-${desc.char}, ` : " ";
|
|
const namePart = `--${name}`;
|
|
const typePart = desc.kind === "boolean" ? "" : desc.kind === "integer" ? "=<int>" : "=<value>";
|
|
formatted.push([` ${charPart}${namePart}${typePart}`, desc.description ?? ""]);
|
|
}
|
|
const maxLeft = Math.max(...formatted.map(([l]) => l.length));
|
|
for (const [left, right] of formatted) {
|
|
lines.push(`${left.padEnd(maxLeft + 2)}${right}`);
|
|
}
|
|
lines.push("");
|
|
}
|
|
|
|
// Examples
|
|
if (command.examples && command.examples.length > 0) {
|
|
lines.push("EXAMPLES");
|
|
for (const ex of command.examples) {
|
|
for (const line of ex.split("\n")) {
|
|
lines.push(` ${line}`);
|
|
}
|
|
}
|
|
lines.push("");
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// CLI entry point
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/** A lazily-loaded command: canonical name, loader, and optional aliases. */
|
|
export interface CommandEntry {
|
|
name: string;
|
|
load: () => Promise<CommandCtor>;
|
|
help?: CommandMetadata;
|
|
aliases?: string[];
|
|
}
|
|
|
|
export interface RunOptions {
|
|
bin: string;
|
|
version: string;
|
|
argv: string[];
|
|
commands: CommandEntry[];
|
|
/** Custom help renderer with the fully loaded command constructors. */
|
|
help?: (config: CliConfig) => Promise<void> | void;
|
|
/** Lightweight help renderer backed by static command metadata. */
|
|
metadataHelp?: (config: CliConfig<CommandMetadata>) => Promise<void> | void;
|
|
}
|
|
|
|
/** Find a command entry by exact name or alias. */
|
|
function findEntry(commands: CommandEntry[], id: string): CommandEntry | undefined {
|
|
return commands.find(e => e.name === id) ?? commands.find(e => e.aliases?.includes(id));
|
|
}
|
|
|
|
/**
|
|
* Main entry point — replaces `run()` from @oclif/core.
|
|
*
|
|
* Each command is explicitly registered with a lazy loader.
|
|
* No filesystem scanning, no plugin system, no package.json reading.
|
|
*/
|
|
export async function run(opts: RunOptions): Promise<void> {
|
|
const { bin, version, argv } = opts;
|
|
|
|
const commandId = argv[0] ?? "";
|
|
const commandArgv = argv.slice(1);
|
|
|
|
// Top-level help
|
|
if (commandId === "--help" || commandId === "-h" || commandId === "help" || commandId === "") {
|
|
if (opts.help) {
|
|
await opts.help(await loadAllCommands(opts));
|
|
} else {
|
|
const config = await loadAllCommandMetadata(opts);
|
|
if (opts.metadataHelp) {
|
|
await opts.metadataHelp(config);
|
|
} else {
|
|
renderRootHelp(config);
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Version
|
|
if (commandId === "--version" || commandId === "-v") {
|
|
process.stdout.write(`${bin}/${version}\n`);
|
|
return;
|
|
}
|
|
|
|
// Per-command help: load only the requested command. Loading the full
|
|
// command table here would make `omp <cmd> --help` hang or crash whenever
|
|
// any *unrelated* command module misbehaves at import time.
|
|
if (commandArgv.includes("--help") || commandArgv.includes("-h")) {
|
|
const entry = findEntry(opts.commands, commandId);
|
|
if (entry) {
|
|
const Cmd = await loadEntry(entry);
|
|
renderCommandHelp(bin, entry.name, Cmd);
|
|
} else {
|
|
process.stderr.write(`Unknown command: ${commandId}\n`);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Find command by name or alias
|
|
const entry = findEntry(opts.commands, commandId);
|
|
|
|
if (!entry) {
|
|
process.stderr.write(`Error: command ${commandId} not found\n`);
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
|
|
const Cmd = await loadEntry(entry);
|
|
const config: CliConfig = { bin, version, commands: new Map([[entry.name, Cmd]]) };
|
|
const instance = new Cmd(commandArgv, config);
|
|
try {
|
|
await instance.run();
|
|
} catch (error) {
|
|
// A usage mistake (missing/invalid arg or flag) is not a crash: print the
|
|
// message and the command's usage line, then exit 1. Letting it reach the
|
|
// process-level catch would dump a minified `dist/cli.js` code frame over a
|
|
// plain argument error (issue #5369).
|
|
if (error instanceof CliUsageError) {
|
|
process.stderr.write(`error: ${error.message}\n\n`);
|
|
process.stderr.write(`USAGE\n ${commandUsageLine(bin, entry.name, Cmd)}\n`);
|
|
process.stderr.write(`\nRun \`${bin} ${entry.name} --help\` for details.\n`);
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/** Load one command module, leaving streaming markers around the import. */
|
|
async function loadEntry(entry: CommandEntry): Promise<CommandCtor> {
|
|
startupMarker(`cli:load:${entry.name}:start`);
|
|
const Cmd = await entry.load();
|
|
startupMarker(`cli:load:${entry.name}:done`);
|
|
return Cmd;
|
|
}
|
|
|
|
/** Load every command constructor for backward-compatible custom help callbacks. */
|
|
async function loadAllCommands(opts: RunOptions): Promise<CliConfig> {
|
|
const loaded = await Promise.all(opts.commands.map(async entry => [entry.name, await loadEntry(entry)] as const));
|
|
return { bin: opts.bin, version: opts.version, commands: new Map(loaded) };
|
|
}
|
|
|
|
/** Resolve static command metadata for lightweight root help. */
|
|
async function loadAllCommandMetadata(opts: RunOptions): Promise<CliConfig<CommandMetadata>> {
|
|
const loaded = await Promise.all(
|
|
opts.commands.map(async entry => [entry.name, entry.help ?? (await loadEntry(entry))] as const),
|
|
);
|
|
return { bin: opts.bin, version: opts.version, commands: new Map(loaded) };
|
|
}
|