From 3cdc10a73b4f94d31050906b1c0f373aac4bca22 Mon Sep 17 00:00:00 2001 From: can1357 Date: Tue, 10 Feb 2026 19:18:12 +0100 Subject: [PATCH] feat(cli): replaced oclif with lightweight custom CLI framework in pi-utils - Created lightweight CLI framework in @oh-my-pi/pi-utils/cli as drop-in replacement for oclif with zero dependencies. - Migrated coding-agent CLI from oclif framework to @oh-my-pi/pi-utils/cli with lazy command loading and simplified architecture. - Changed default root command from 'index' to 'launch' for improved UX. - Removed @oclif/core and @oclif/plugin-autocomplete dependencies from coding-agent package. - Deleted custom oclif help renderer (oclif-help.ts) as help rendering is now integrated into the new CLI framework. - Moved launch command from nested index/index.ts to top-level launch.ts for clearer command structure. --- packages/coding-agent/CHANGELOG.md | 12 + packages/coding-agent/package.json | 10 - packages/coding-agent/src/cli.ts | 47 ++- packages/coding-agent/src/cli/oclif-help.ts | 26 -- packages/coding-agent/src/commands/commit.ts | 2 +- packages/coding-agent/src/commands/config.ts | 2 +- packages/coding-agent/src/commands/grep.ts | 2 +- packages/coding-agent/src/commands/jupyter.ts | 2 +- .../commands/{index/index.ts => launch.ts} | 8 +- packages/coding-agent/src/commands/plugin.ts | 2 +- packages/coding-agent/src/commands/setup.ts | 2 +- packages/coding-agent/src/commands/shell.ts | 2 +- packages/coding-agent/src/commands/stats.ts | 6 +- packages/coding-agent/src/commands/update.ts | 6 +- .../coding-agent/src/commands/web-search.ts | 2 +- packages/coding-agent/src/main.ts | 5 +- packages/utils/package.json | 4 + packages/utils/src/cli.ts | 392 ++++++++++++++++++ 18 files changed, 465 insertions(+), 67 deletions(-) delete mode 100644 packages/coding-agent/src/cli/oclif-help.ts rename packages/coding-agent/src/commands/{index/index.ts => launch.ts} (95%) create mode 100644 packages/utils/src/cli.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index d5713a2f5..02bd4063b 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,6 +1,18 @@ # Changelog ## [Unreleased] +### Changed + +- Migrated CLI framework from oclif to lightweight pi-utils CLI runner +- Replaced oclif command registration with explicit command entries in cli.ts +- Changed default root command name from 'index' to 'launch' +- Updated all command imports to use @oh-my-pi/pi-utils/cli instead of @oclif/core + +### Removed + +- Removed @oclif/core and @oclif/plugin-autocomplete dependencies +- Removed oclif configuration from package.json +- Removed custom oclif help renderer (oclif-help.ts) ## [11.10.0] - 2026-02-10 ### Breaking Changes diff --git a/packages/coding-agent/package.json b/packages/coding-agent/package.json index 5076dfce8..39bc49ef3 100644 --- a/packages/coding-agent/package.json +++ b/packages/coding-agent/package.json @@ -10,14 +10,6 @@ "bin": { "omp": "src/cli.ts" }, - "oclif": { - "bin": "omp", - "commands": "./src/commands", - "helpClass": "./src/cli/oclif-help.ts", - "plugins": [ - "@oclif/plugin-autocomplete" - ] - }, "main": "./src/index.ts", "types": "./src/index.ts", "exports": { @@ -88,8 +80,6 @@ }, "dependencies": { "@mozilla/readability": "0.6.0", - "@oclif/core": "^4.8.0", - "@oclif/plugin-autocomplete": "^3.2.40", "@oh-my-pi/omp-stats": "workspace:*", "@oh-my-pi/pi-agent-core": "workspace:*", "@oh-my-pi/pi-ai": "workspace:*", diff --git a/packages/coding-agent/src/cli.ts b/packages/coding-agent/src/cli.ts index 60d6f5623..7cbf4fc5a 100755 --- a/packages/coding-agent/src/cli.ts +++ b/packages/coding-agent/src/cli.ts @@ -1,14 +1,12 @@ #!/usr/bin/env bun /** - * CLI entry point for the refactored coding agent. - * Uses main.ts with AgentSession and new mode modules. - * - * Test with: npx tsx src/cli-new.ts [args...] + * CLI entry point — registers all commands explicitly and delegates to the + * lightweight CLI runner from pi-utils. */ -import { run } from "@oclif/core"; -import { APP_NAME } from "./config"; +import { type CommandEntry, run } from "@oh-my-pi/pi-utils/cli"; +import { APP_NAME, VERSION } from "./config"; -// oclif's warn() doesn't unwrap AggregateError — override to surface the real messages +// Unwrap AggregateError in console.warn to surface real messages const originalWarn = console.warn; console.warn = (...args: unknown[]) => { for (const arg of args) { @@ -23,9 +21,38 @@ console.warn = (...args: unknown[]) => { }; process.title = APP_NAME; -const argv = process.argv.slice(2); -const runArgv = argv.length === 0 || argv[0]?.startsWith("-") ? ["index", ...argv] : argv; -run(runArgv, import.meta.url).catch((error: unknown) => { + +const commands: CommandEntry[] = [ + { name: "launch", load: () => import("./commands/launch").then(m => m.default) }, + { name: "commit", load: () => import("./commands/commit").then(m => m.default) }, + { name: "config", load: () => import("./commands/config").then(m => m.default) }, + { name: "grep", load: () => import("./commands/grep").then(m => m.default) }, + { name: "jupyter", load: () => import("./commands/jupyter").then(m => m.default) }, + { name: "plugin", load: () => import("./commands/plugin").then(m => m.default) }, + { name: "setup", load: () => import("./commands/setup").then(m => m.default) }, + { name: "shell", load: () => import("./commands/shell").then(m => m.default) }, + { name: "stats", load: () => import("./commands/stats").then(m => m.default) }, + { name: "update", load: () => import("./commands/update").then(m => m.default) }, + { name: "search", load: () => import("./commands/web-search").then(m => m.default), aliases: ["q"] }, +]; + +async function showHelp(config: import("@oh-my-pi/pi-utils/cli").CliConfig): Promise { + const { renderRootHelp } = await import("@oh-my-pi/pi-utils/cli"); + const { getExtraHelpText } = await import("./cli/args"); + renderRootHelp(config); + const extra = getExtraHelpText(); + if (extra.trim().length > 0) { + process.stdout.write(`\n${extra}\n`); + } +} + +/** Run the CLI with the given argv (no `process.argv` prefix). */ +export function runCli(argv: string[]): Promise { + const runArgv = argv.length === 0 || argv[0]?.startsWith("-") ? ["launch", ...argv] : argv; + return run({ bin: APP_NAME, version: VERSION, argv: runArgv, commands, help: showHelp }); +} + +runCli(process.argv.slice(2)).catch((error: unknown) => { const message = error instanceof Error ? (error.stack ?? error.message) : String(error); process.stderr.write(`${message}\n`); process.exit(1); diff --git a/packages/coding-agent/src/cli/oclif-help.ts b/packages/coding-agent/src/cli/oclif-help.ts deleted file mode 100644 index e6445f183..000000000 --- a/packages/coding-agent/src/cli/oclif-help.ts +++ /dev/null @@ -1,26 +0,0 @@ -/** - * Custom help renderer for the coding agent CLI. - */ -import { CommandHelp, Help } from "@oclif/core"; -import { getExtraHelpText } from "./args"; - -export default class OclifHelp extends Help { - async showRootHelp(): Promise { - await super.showRootHelp(); - const rootCommand = this.config.findCommand("index"); - if (rootCommand) { - const rootHelp = new CommandHelp(rootCommand, this.config, { - ...this.opts, - sections: ["arguments", "flags", "examples"], - }); - const output = rootHelp.generate(); - if (output.trim().length > 0) { - process.stdout.write(`\n${output}\n`); - } - } - const extra = getExtraHelpText(); - if (extra.trim().length > 0) { - process.stdout.write(`\n${extra}\n`); - } - } -} diff --git a/packages/coding-agent/src/commands/commit.ts b/packages/coding-agent/src/commands/commit.ts index 72e6d51eb..9e7bc2a35 100644 --- a/packages/coding-agent/src/commands/commit.ts +++ b/packages/coding-agent/src/commands/commit.ts @@ -1,7 +1,7 @@ /** * Generate and optionally push a commit with changelog updates. */ -import { Command, Flags } from "@oclif/core"; +import { Command, Flags } from "@oh-my-pi/pi-utils/cli"; import { runCommitCommand } from "../commit"; import type { CommitCommandArgs } from "../commit/types"; import { initTheme } from "../modes/theme/theme"; diff --git a/packages/coding-agent/src/commands/config.ts b/packages/coding-agent/src/commands/config.ts index fb54a54d5..cdb782a40 100644 --- a/packages/coding-agent/src/commands/config.ts +++ b/packages/coding-agent/src/commands/config.ts @@ -1,7 +1,7 @@ /** * Manage configuration settings. */ -import { Args, Command, Flags } from "@oclif/core"; +import { Args, Command, Flags } from "@oh-my-pi/pi-utils/cli"; import { type ConfigAction, type ConfigCommandArgs, runConfigCommand } from "../cli/config-cli"; import { initTheme } from "../modes/theme/theme"; diff --git a/packages/coding-agent/src/commands/grep.ts b/packages/coding-agent/src/commands/grep.ts index aad571177..0b9465601 100644 --- a/packages/coding-agent/src/commands/grep.ts +++ b/packages/coding-agent/src/commands/grep.ts @@ -1,7 +1,7 @@ /** * Test grep tool. */ -import { Args, Command, Flags } from "@oclif/core"; +import { Args, Command, Flags } from "@oh-my-pi/pi-utils/cli"; import { type GrepCommandArgs, runGrepCommand } from "../cli/grep-cli"; import { initTheme } from "../modes/theme/theme"; diff --git a/packages/coding-agent/src/commands/jupyter.ts b/packages/coding-agent/src/commands/jupyter.ts index 64e02d233..39a1d7b96 100644 --- a/packages/coding-agent/src/commands/jupyter.ts +++ b/packages/coding-agent/src/commands/jupyter.ts @@ -1,7 +1,7 @@ /** * Manage the shared Jupyter gateway. */ -import { Args, Command } from "@oclif/core"; +import { Args, Command } from "@oh-my-pi/pi-utils/cli"; import { type JupyterAction, type JupyterCommandArgs, runJupyterCommand } from "../cli/jupyter-cli"; import { initTheme } from "../modes/theme/theme"; diff --git a/packages/coding-agent/src/commands/index/index.ts b/packages/coding-agent/src/commands/launch.ts similarity index 95% rename from packages/coding-agent/src/commands/index/index.ts rename to packages/coding-agent/src/commands/launch.ts index 05369399d..17032c436 100644 --- a/packages/coding-agent/src/commands/index/index.ts +++ b/packages/coding-agent/src/commands/launch.ts @@ -1,10 +1,10 @@ /** * Root command for the coding agent CLI. */ -import { Args, Command, Flags } from "@oclif/core"; -import { parseArgs } from "../../cli/args"; -import { APP_NAME } from "../../config"; -import { runRootCommand } from "../../main"; +import { Args, Command, Flags } from "@oh-my-pi/pi-utils/cli"; +import { parseArgs } from "../cli/args"; +import { APP_NAME } from "../config"; +import { runRootCommand } from "../main"; export default class Index extends Command { static description = "AI coding assistant"; diff --git a/packages/coding-agent/src/commands/plugin.ts b/packages/coding-agent/src/commands/plugin.ts index a93b1b7a3..56670339e 100644 --- a/packages/coding-agent/src/commands/plugin.ts +++ b/packages/coding-agent/src/commands/plugin.ts @@ -1,7 +1,7 @@ /** * Manage plugins (install, uninstall, list, etc.). */ -import { Args, Command, Flags } from "@oclif/core"; +import { Args, Command, Flags } from "@oh-my-pi/pi-utils/cli"; import { type PluginAction, type PluginCommandArgs, runPluginCommand } from "../cli/plugin-cli"; import { initTheme } from "../modes/theme/theme"; diff --git a/packages/coding-agent/src/commands/setup.ts b/packages/coding-agent/src/commands/setup.ts index b11b16d6c..5522ebd83 100644 --- a/packages/coding-agent/src/commands/setup.ts +++ b/packages/coding-agent/src/commands/setup.ts @@ -1,7 +1,7 @@ /** * Install dependencies for optional features. */ -import { Args, Command, Flags } from "@oclif/core"; +import { Args, Command, Flags } from "@oh-my-pi/pi-utils/cli"; import { runSetupCommand, type SetupCommandArgs, type SetupComponent } from "../cli/setup-cli"; import { initTheme } from "../modes/theme/theme"; diff --git a/packages/coding-agent/src/commands/shell.ts b/packages/coding-agent/src/commands/shell.ts index 071aae23d..f2459de0b 100644 --- a/packages/coding-agent/src/commands/shell.ts +++ b/packages/coding-agent/src/commands/shell.ts @@ -1,7 +1,7 @@ /** * Interactive shell console. */ -import { Command, Flags } from "@oclif/core"; +import { Command, Flags } from "@oh-my-pi/pi-utils/cli"; import { runShellCommand, type ShellCommandArgs } from "../cli/shell-cli"; import { initTheme } from "../modes/theme/theme"; diff --git a/packages/coding-agent/src/commands/stats.ts b/packages/coding-agent/src/commands/stats.ts index 549542d0b..06bf9d807 100644 --- a/packages/coding-agent/src/commands/stats.ts +++ b/packages/coding-agent/src/commands/stats.ts @@ -1,7 +1,7 @@ /** * View usage statistics dashboard. */ -import { Command, Flags } from "@oclif/core"; +import { Command, Flags } from "@oh-my-pi/pi-utils/cli"; import { runStatsCommand, type StatsCommandArgs } from "../cli/stats-cli"; import { initTheme } from "../modes/theme/theme"; @@ -10,8 +10,8 @@ export default class Stats extends Command { static flags = { port: Flags.integer({ char: "p", description: "Port for the dashboard server", default: 3847 }), - json: Flags.boolean({ char: "j", description: "Output stats as JSON" }), - summary: Flags.boolean({ char: "s", description: "Print summary to console" }), + json: Flags.boolean({ char: "j", description: "Output stats as JSON", default: false }), + summary: Flags.boolean({ char: "s", description: "Print summary to console", default: false }), }; async run(): Promise { diff --git a/packages/coding-agent/src/commands/update.ts b/packages/coding-agent/src/commands/update.ts index 127241f98..b8942f696 100644 --- a/packages/coding-agent/src/commands/update.ts +++ b/packages/coding-agent/src/commands/update.ts @@ -1,7 +1,7 @@ /** * Check for and install updates. */ -import { Command, Flags } from "@oclif/core"; +import { Command, Flags } from "@oh-my-pi/pi-utils/cli"; import { runUpdateCommand } from "../cli/update-cli"; import { initTheme } from "../modes/theme/theme"; @@ -9,8 +9,8 @@ export default class Update extends Command { static description = "Check for and install updates"; static flags = { - force: Flags.boolean({ char: "f", description: "Force update" }), - check: Flags.boolean({ char: "c", description: "Check for updates without installing" }), + force: Flags.boolean({ char: "f", description: "Force update", default: false }), + check: Flags.boolean({ char: "c", description: "Check for updates without installing", default: false }), }; async run(): Promise { diff --git a/packages/coding-agent/src/commands/web-search.ts b/packages/coding-agent/src/commands/web-search.ts index fbf5d356c..1195f42a5 100644 --- a/packages/coding-agent/src/commands/web-search.ts +++ b/packages/coding-agent/src/commands/web-search.ts @@ -1,7 +1,7 @@ /** * Test web search providers. */ -import { Args, Command, Flags } from "@oclif/core"; +import { Args, Command, Flags } from "@oh-my-pi/pi-utils/cli"; import { runSearchCommand, type SearchCommandArgs } from "../cli/web-search-cli"; import type { SearchProviderId } from "../web/search/types"; diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index cbef7e1ae..5e59f7a1c 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -8,7 +8,6 @@ import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; import { createInterface } from "node:readline/promises"; -import { run } from "@oclif/core"; import { type ImageContent, supportsXhigh } from "@oh-my-pi/pi-ai"; import { $env, postmortem } from "@oh-my-pi/pi-utils"; import chalk from "chalk"; @@ -713,6 +712,6 @@ export async function runRootCommand(parsed: Args, rawArgs: string[]): Promise { - const argv = args.length === 0 ? ["index"] : args; - await run(argv, import.meta.url); + const { runCli } = await import("./cli"); + await runCli(args.length === 0 ? ["launch"] : args); } diff --git a/packages/utils/package.json b/packages/utils/package.json index 970f95675..bf37f1169 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -9,6 +9,10 @@ ".": { "types": "./src/index.ts", "import": "./src/index.ts" + }, + "./cli": { + "types": "./src/cli.ts", + "import": "./src/cli.ts" } }, "files": [ diff --git a/packages/utils/src/cli.ts b/packages/utils/src/cli.ts new file mode 100644 index 000000000..a9c6a68d9 --- /dev/null +++ b/packages/utils/src/cli.ts @@ -0,0 +1,392 @@ +/** + * 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 { parseArgs as nodeParseArgs } from "node:util"; + +// --------------------------------------------------------------------------- +// Flag & Arg descriptors +// --------------------------------------------------------------------------- + +export interface FlagDescriptor { + 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(opts?: T): FlagDescriptor<"string"> & T { + return { kind: "string" as const, ...opts } as FlagDescriptor<"string"> & T; + }, + boolean(opts?: T): FlagDescriptor<"boolean"> & T { + return { kind: "boolean" as const, ...opts } as FlagDescriptor<"boolean"> & T; + }, + integer(opts?: T): FlagDescriptor<"integer"> & T { + return { kind: "integer" as const, ...opts } as FlagDescriptor<"integer"> & T; + }, +}; +export const Args = { + string(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["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 { multiple: true } ? string[] | undefined : string | undefined; + +type FlagValues> = { [K in keyof T]: FlagValue }; +type ArgValues> = { [K in keyof T]: ArgValue }; + +export interface ParseOutput< + F extends Record = Record, + A extends Record = Record, +> { + flags: FlagValues; + args: ArgValues; + argv: string[]; +} + +// --------------------------------------------------------------------------- +// Command base class +// --------------------------------------------------------------------------- + +export interface CommandCtor { + new (argv: string[], config: CliConfig): Command; + description?: string; + hidden?: boolean; + strict?: boolean; + aliases?: string[]; + examples?: string[]; + flags?: Record; + args?: Record; +} + +/** Configuration passed to every command instance and help renderers. */ +export interface CliConfig { + bin: string; + version: string; + /** All registered commands keyed by their canonical name. */ + commands: Map; +} + +/** 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; + + /** + * Parse argv against the static `flags` and `args` declared on the + * concrete command class. Returns a typed `{ flags, args, argv }` object. + */ + async parse( + _Cmd: C, + ): Promise< + ParseOutput< + NonNullable extends Record + ? NonNullable + : Record, + NonNullable extends Record + ? NonNullable + : Record + > + > { + const Cmd = _Cmd as CommandCtor; + const flagDefs = (Cmd.flags ?? {}) as Record; + const argDefs = (Cmd.args ?? {}) as Record; + const strict = Cmd.strict ?? true; + + // 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; + } + + const { values: rawValues, positionals } = nodeParseArgs({ + args: this.argv, + options, + allowPositionals: true, + strict: strict && Object.keys(argDefs).length === 0, + }); + + // Convert raw values to proper types + const flags: Record = {}; + 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 { + flags[name] = Number.parseInt(raw as string, 10); + } + } else if (desc.kind === "boolean") { + flags[name] = + raw !== undefined ? Boolean(raw) : desc.default !== undefined ? Boolean(desc.default) : undefined; + } else { + // string + flags[name] = raw !== undefined && typeof raw !== "boolean" ? raw : (desc.default ?? undefined); + } + } + + // Map positionals to named args in declaration order + const args: Record = {}; + let posIdx = 0; + for (const [argName, desc] of Object.entries(argDefs)) { + if (desc.multiple) { + args[argName] = positionals.slice(posIdx); + posIdx = positionals.length; + } else { + args[argName] = positionals[posIdx]; + posIdx++; + } + } + + return { flags, args, argv: positionals } as never; + } +} + +// --------------------------------------------------------------------------- +// Help rendering +// --------------------------------------------------------------------------- + +/** Render full root help: header, index command details, subcommand list. */ +export function renderRootHelp(config: CliConfig): 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 index command's flags/args/examples inline + const indexCmd = commands.get("index"); + if (indexCmd) { + renderCommandBody(lines, indexCmd); + } + + // List visible subcommands + const visible = [...commands.entries()].filter(([name, C]) => !C.hidden && name !== "index"); + if (visible.length > 0) { + lines.push("COMMANDS"); + const maxLen = Math.max(...visible.map(([n]) => n.length)); + for (const [name, C] of visible.sort((a, b) => a[0].localeCompare(b[0]))) { + lines.push(` ${name.padEnd(maxLen + 2)}${C.description ?? ""}`); + } + lines.push(""); + } + + process.stdout.write(lines.join("\n")); +} + +/** 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"); + const argNames = Object.keys(Cmd.args ?? {}); + const argStr = argNames.length > 0 ? ` ${argNames.map(n => `[${n.toUpperCase()}]`).join(" ")}` : ""; + const hasFlags = Object.keys(Cmd.flags ?? {}).length > 0; + lines.push(` $ ${bin} ${id}${argStr}${hasFlags ? " [FLAGS]" : ""}\n`); + renderCommandBody(lines, Cmd); + process.stdout.write(lines.join("\n")); +} + +function renderCommandBody(lines: string[], Cmd: CommandCtor): void { + const argDefs = Cmd.args ?? {}; + const flagDefs = Cmd.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" ? "=" : "="; + 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 (Cmd.examples && Cmd.examples.length > 0) { + lines.push("EXAMPLES"); + for (const ex of Cmd.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; + aliases?: string[]; +} + +export interface RunOptions { + bin: string; + version: string; + argv: string[]; + commands: CommandEntry[]; + /** Custom help renderer. Receives fully-populated config. */ + help?: (config: CliConfig) => Promise | void; +} + +/** + * 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 { + const { bin, version, argv } = opts; + + const commandId = argv[0] ?? "index"; + const commandArgv = argv.slice(1); + + // Top-level help + if (commandId === "--help" || commandId === "-h" || commandId === "help") { + const config = await loadAllCommands(opts); + if (opts.help) { + await opts.help(config); + } else { + renderRootHelp(config); + } + return; + } + + // Version + if (commandId === "--version" || commandId === "-v") { + process.stdout.write(`${bin}/${version}\n`); + return; + } + + // Per-command help + if (commandArgv.includes("--help") || commandArgv.includes("-h")) { + const config = await loadAllCommands(opts); + const Cmd = config.commands.get(commandId); + if (Cmd) { + renderCommandHelp(bin, commandId, Cmd); + } else { + process.stderr.write(`Unknown command: ${commandId}\n`); + } + return; + } + + // Find command by name or alias + let entry = opts.commands.find(e => e.name === commandId); + if (!entry) { + entry = opts.commands.find(e => e.aliases?.includes(commandId)); + } + + if (!entry) { + process.stderr.write(`Error: command ${commandId} not found\n`); + process.exitCode = 1; + return; + } + + const Cmd = await entry.load(); + const config: CliConfig = { bin, version, commands: new Map([[entry.name, Cmd]]) }; + const instance = new Cmd(commandArgv, config); + await instance.run(); +} + +/** Resolve all command loaders for help/alias display. */ +async function loadAllCommands(opts: RunOptions): Promise { + const commands = new Map(); + const loaded = await Promise.all(opts.commands.map(async e => [e.name, await e.load()] as const)); + for (const [name, Cmd] of loaded) { + commands.set(name, Cmd); + } + return { bin: opts.bin, version: opts.version, commands }; +}