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.
This commit is contained in:
can1357
2026-02-10 19:18:12 +01:00
parent 102d985561
commit 3cdc10a73b
18 changed files with 465 additions and 67 deletions
+12
View File
@@ -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
-10
View File
@@ -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:*",
+37 -10
View File
@@ -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<void> {
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<void> {
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);
@@ -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<void> {
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`);
}
}
}
+1 -1
View File
@@ -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";
+1 -1
View File
@@ -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";
+1 -1
View File
@@ -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";
@@ -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";
@@ -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";
+1 -1
View File
@@ -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";
+1 -1
View File
@@ -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";
+1 -1
View File
@@ -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";
+3 -3
View File
@@ -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<void> {
+3 -3
View File
@@ -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<void> {
@@ -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";
+2 -3
View File
@@ -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<v
}
export async function main(args: string[]): Promise<void> {
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);
}
+4
View File
@@ -9,6 +9,10 @@
".": {
"types": "./src/index.ts",
"import": "./src/index.ts"
},
"./cli": {
"types": "./src/cli.ts",
"import": "./src/cli.ts"
}
},
"files": [
+392
View File
@@ -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<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 CommandCtor {
new (argv: string[], config: CliConfig): Command;
description?: string;
hidden?: boolean;
strict?: boolean;
aliases?: string[];
examples?: string[];
flags?: Record<string, FlagDescriptor>;
args?: Record<string, ArgDescriptor>;
}
/** 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<string, CommandCtor>;
}
/** 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 ?? 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<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 {
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<string, unknown> = {};
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" ? "=<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 (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<CommandCtor>;
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> | 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<void> {
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<CliConfig> {
const commands = new Map<string, CommandCtor>();
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 };
}