@oh-my-pi/pi-utils 11.10.0 → 11.10.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/package.json +5 -1
  2. package/src/cli.ts +432 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oh-my-pi/pi-utils",
3
- "version": "11.10.0",
3
+ "version": "11.10.2",
4
4
  "description": "Shared utilities for pi packages",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -9,6 +9,10 @@
9
9
  ".": {
10
10
  "types": "./src/index.ts",
11
11
  "import": "./src/index.ts"
12
+ },
13
+ "./cli": {
14
+ "types": "./src/cli.ts",
15
+ "import": "./src/cli.ts"
12
16
  }
13
17
  },
14
18
  "files": [
package/src/cli.ts ADDED
@@ -0,0 +1,432 @@
1
+ /**
2
+ * Minimal CLI framework — drop-in replacement for the subset of @oclif/core
3
+ * actually used by the coding agent. Provides `Command`, `Args`, `Flags`,
4
+ * and a `run()` entry point with explicit command registration.
5
+ *
6
+ * Design goals:
7
+ * - Zero dependencies beyond node builtins
8
+ * - No filesystem scanning, no manifest files, no plugin loading
9
+ * - Lazy command imports (only the invoked command is loaded)
10
+ * - Typed `this.parse()` output matching oclif's API shape
11
+ */
12
+ import { parseArgs as nodeParseArgs } from "node:util";
13
+
14
+ // ---------------------------------------------------------------------------
15
+ // Flag & Arg descriptors
16
+ // ---------------------------------------------------------------------------
17
+
18
+ export interface FlagDescriptor<K extends "string" | "boolean" | "integer" = "string" | "boolean" | "integer"> {
19
+ kind: K;
20
+ description?: string;
21
+ char?: string;
22
+ default?: unknown;
23
+ multiple?: boolean;
24
+ options?: readonly string[];
25
+ required?: boolean;
26
+ }
27
+
28
+ export interface ArgDescriptor {
29
+ kind: "string";
30
+ description?: string;
31
+ required?: boolean;
32
+ multiple?: boolean;
33
+ options?: readonly string[];
34
+ }
35
+
36
+ interface FlagInput {
37
+ description?: string;
38
+ char?: string;
39
+ default?: unknown;
40
+ multiple?: boolean;
41
+ options?: readonly string[];
42
+ required?: boolean;
43
+ }
44
+
45
+ interface ArgInput {
46
+ description?: string;
47
+ required?: boolean;
48
+ multiple?: boolean;
49
+ options?: readonly string[];
50
+ }
51
+
52
+ /** Builders that match the `Flags.*()` / `Args.*()` API from oclif. */
53
+ export const Flags = {
54
+ string<T extends FlagInput>(opts?: T): FlagDescriptor<"string"> & T {
55
+ return { kind: "string" as const, ...opts } as FlagDescriptor<"string"> & T;
56
+ },
57
+ boolean<T extends FlagInput>(opts?: T): FlagDescriptor<"boolean"> & T {
58
+ return { kind: "boolean" as const, ...opts } as FlagDescriptor<"boolean"> & T;
59
+ },
60
+ integer<T extends FlagInput & { default?: number }>(opts?: T): FlagDescriptor<"integer"> & T {
61
+ return { kind: "integer" as const, ...opts } as FlagDescriptor<"integer"> & T;
62
+ },
63
+ };
64
+
65
+ export const Args = {
66
+ string<T extends ArgInput>(opts?: T): ArgDescriptor & T {
67
+ return { kind: "string" as const, ...opts } as ArgDescriptor & T;
68
+ },
69
+ };
70
+
71
+ // ---------------------------------------------------------------------------
72
+ // Parse result types — mirrors oclif's typed output from this.parse()
73
+ // ---------------------------------------------------------------------------
74
+
75
+ type FlagValue<D extends FlagDescriptor> = D["kind"] extends "boolean"
76
+ ? D extends { default: boolean }
77
+ ? boolean
78
+ : boolean | undefined
79
+ : D["kind"] extends "integer"
80
+ ? D extends { default: number }
81
+ ? number
82
+ : number | undefined
83
+ : D extends { multiple: true }
84
+ ? string[] | undefined
85
+ : string | undefined;
86
+
87
+ type ArgValue<D extends ArgDescriptor> = D extends { multiple: true } ? string[] | undefined : string | undefined;
88
+
89
+ type FlagValues<T extends Record<string, FlagDescriptor>> = { [K in keyof T]: FlagValue<T[K]> };
90
+ type ArgValues<T extends Record<string, ArgDescriptor>> = { [K in keyof T]: ArgValue<T[K]> };
91
+
92
+ export interface ParseOutput<
93
+ F extends Record<string, FlagDescriptor> = Record<string, FlagDescriptor>,
94
+ A extends Record<string, ArgDescriptor> = Record<string, ArgDescriptor>,
95
+ > {
96
+ flags: FlagValues<F>;
97
+ args: ArgValues<A>;
98
+ argv: string[];
99
+ }
100
+
101
+ // ---------------------------------------------------------------------------
102
+ // Command base class
103
+ // ---------------------------------------------------------------------------
104
+
105
+ export interface CommandCtor {
106
+ new (argv: string[], config: CliConfig): Command;
107
+ description?: string;
108
+ hidden?: boolean;
109
+ strict?: boolean;
110
+ aliases?: string[];
111
+ examples?: string[];
112
+ flags?: Record<string, FlagDescriptor>;
113
+ args?: Record<string, ArgDescriptor>;
114
+ }
115
+
116
+ /** Configuration passed to every command instance and help renderers. */
117
+ export interface CliConfig {
118
+ bin: string;
119
+ version: string;
120
+ /** All registered commands keyed by their canonical name. */
121
+ commands: Map<string, CommandCtor>;
122
+ }
123
+
124
+ /** Minimal Command base matching the oclif surface we use. */
125
+ export abstract class Command {
126
+ argv: string[];
127
+ config: CliConfig;
128
+
129
+ constructor(argv: string[], config: CliConfig) {
130
+ this.argv = argv;
131
+ this.config = config;
132
+ }
133
+
134
+ abstract run(): Promise<void>;
135
+
136
+ /**
137
+ * Parse argv against the static `flags` and `args` declared on the
138
+ * concrete command class. Returns a typed `{ flags, args, argv }` object.
139
+ */
140
+ async parse<C extends CommandCtor>(
141
+ _Cmd: C,
142
+ ): Promise<
143
+ ParseOutput<
144
+ NonNullable<C["flags"]> extends Record<string, FlagDescriptor>
145
+ ? NonNullable<C["flags"]>
146
+ : Record<string, FlagDescriptor>,
147
+ NonNullable<C["args"]> extends Record<string, ArgDescriptor>
148
+ ? NonNullable<C["args"]>
149
+ : Record<string, ArgDescriptor>
150
+ >
151
+ > {
152
+ const Cmd = _Cmd as CommandCtor;
153
+ const flagDefs = (Cmd.flags ?? {}) as Record<string, FlagDescriptor>;
154
+ const argDefs = (Cmd.args ?? {}) as Record<string, ArgDescriptor>;
155
+ const strict = Cmd.strict !== false;
156
+
157
+ // Build node:util parseArgs options from flag descriptors
158
+ const options: Record<
159
+ string,
160
+ { type: "string" | "boolean"; short?: string; multiple?: boolean; default?: string | boolean }
161
+ > = {};
162
+ for (const [name, desc] of Object.entries(flagDefs)) {
163
+ const opt: (typeof options)[string] = {
164
+ type: desc.kind === "boolean" ? "boolean" : "string",
165
+ };
166
+ if (desc.char) opt.short = desc.char;
167
+ if (desc.multiple) opt.multiple = true;
168
+ if (desc.default !== undefined) {
169
+ opt.default = desc.kind === "boolean" ? Boolean(desc.default) : String(desc.default);
170
+ }
171
+ options[name] = opt;
172
+ }
173
+
174
+ // strict=false when command declares args (positionals must pass through)
175
+ // or when the command itself opts out
176
+ const { values: rawValues, positionals } = nodeParseArgs({
177
+ args: this.argv,
178
+ options,
179
+ allowPositionals: true,
180
+ strict,
181
+ });
182
+
183
+ // Convert raw values to proper types and validate
184
+ const flags: Record<string, unknown> = {};
185
+ for (const [name, desc] of Object.entries(flagDefs)) {
186
+ const raw = rawValues[name];
187
+ if (desc.kind === "integer") {
188
+ if (raw === undefined || typeof raw === "boolean") {
189
+ flags[name] = desc.default ?? undefined;
190
+ } else {
191
+ const n = Number.parseInt(raw as string, 10);
192
+ if (Number.isNaN(n)) {
193
+ throw new Error(`Expected integer for --${name}, got "${raw}"`);
194
+ }
195
+ flags[name] = n;
196
+ }
197
+ } else if (desc.kind === "boolean") {
198
+ flags[name] =
199
+ raw !== undefined ? Boolean(raw) : desc.default !== undefined ? Boolean(desc.default) : undefined;
200
+ } else {
201
+ // string
202
+ const val = raw !== undefined && typeof raw !== "boolean" ? raw : (desc.default ?? undefined);
203
+ // Validate options constraint
204
+ if (val !== undefined && desc.options && !Array.isArray(val)) {
205
+ if (!desc.options.includes(val as string)) {
206
+ throw new Error(`Expected --${name} to be one of: ${[...desc.options].join(", ")}; got "${val}"`);
207
+ }
208
+ }
209
+ flags[name] = val;
210
+ }
211
+ // Validate required
212
+ if (desc.required && flags[name] === undefined) {
213
+ throw new Error(`Missing required flag: --${name}`);
214
+ }
215
+ }
216
+
217
+ // Map positionals to named args in declaration order and validate
218
+ const args: Record<string, unknown> = {};
219
+ let posIdx = 0;
220
+ for (const [argName, desc] of Object.entries(argDefs)) {
221
+ if (desc.multiple) {
222
+ const val = positionals.slice(posIdx);
223
+ args[argName] = val.length > 0 ? val : undefined;
224
+ posIdx = positionals.length;
225
+ } else {
226
+ const val = positionals[posIdx];
227
+ args[argName] = val;
228
+ posIdx++;
229
+ }
230
+ // Validate required
231
+ if (desc.required && args[argName] === undefined) {
232
+ throw new Error(`Missing required argument: ${argName}`);
233
+ }
234
+ // Validate options constraint
235
+ const argVal = args[argName];
236
+ if (argVal !== undefined && desc.options && typeof argVal === "string") {
237
+ if (!desc.options.includes(argVal)) {
238
+ throw new Error(`Expected ${argName} to be one of: ${[...desc.options].join(", ")}; got "${argVal}"`);
239
+ }
240
+ }
241
+ }
242
+
243
+ return { flags, args, argv: positionals } as never;
244
+ }
245
+ }
246
+
247
+ // ---------------------------------------------------------------------------
248
+ // Help rendering
249
+ // ---------------------------------------------------------------------------
250
+
251
+ /** Render full root help: header, default command details, subcommand list. */
252
+ export function renderRootHelp(config: CliConfig): void {
253
+ const { bin, version, commands } = config;
254
+ const lines: string[] = [];
255
+ lines.push(`${bin} v${version}\n`);
256
+ lines.push("USAGE");
257
+ lines.push(` $ ${bin} [COMMAND]\n`);
258
+
259
+ // Show the default command's flags/args/examples inline.
260
+ // The default command is the one marked hidden (it's the implicit entry point).
261
+ const defaultCmd = [...commands.values()].find(C => C.hidden);
262
+ if (defaultCmd) {
263
+ renderCommandBody(lines, defaultCmd);
264
+ }
265
+
266
+ // List visible subcommands
267
+ const visible = [...commands.entries()].filter(([, C]) => !C.hidden);
268
+ if (visible.length > 0) {
269
+ lines.push("COMMANDS");
270
+ const maxLen = Math.max(...visible.map(([n]) => n.length));
271
+ for (const [name, C] of visible.sort((a, b) => a[0].localeCompare(b[0]))) {
272
+ lines.push(` ${name.padEnd(maxLen + 2)}${C.description ?? ""}`);
273
+ }
274
+ lines.push("");
275
+ }
276
+
277
+ process.stdout.write(lines.join("\n"));
278
+ }
279
+
280
+ /** Render help for a single command. */
281
+ export function renderCommandHelp(bin: string, id: string, Cmd: CommandCtor): void {
282
+ const lines: string[] = [];
283
+ if (Cmd.description) lines.push(`${Cmd.description}\n`);
284
+ lines.push("USAGE");
285
+ const argNames = Object.keys(Cmd.args ?? {});
286
+ const argStr = argNames.length > 0 ? ` ${argNames.map(n => `[${n.toUpperCase()}]`).join(" ")}` : "";
287
+ const hasFlags = Object.keys(Cmd.flags ?? {}).length > 0;
288
+ lines.push(` $ ${bin} ${id}${argStr}${hasFlags ? " [FLAGS]" : ""}\n`);
289
+ renderCommandBody(lines, Cmd);
290
+ process.stdout.write(lines.join("\n"));
291
+ }
292
+
293
+ function renderCommandBody(lines: string[], Cmd: CommandCtor): void {
294
+ const argDefs = Cmd.args ?? {};
295
+ const flagDefs = Cmd.flags ?? {};
296
+
297
+ // Arguments
298
+ const argEntries = Object.entries(argDefs);
299
+ if (argEntries.length > 0) {
300
+ lines.push("ARGUMENTS");
301
+ const maxLen = Math.max(...argEntries.map(([n]) => n.length));
302
+ for (const [name, desc] of argEntries) {
303
+ const parts = [name.toUpperCase().padEnd(maxLen + 2)];
304
+ if (desc.description) parts.push(desc.description);
305
+ if (desc.options) parts.push(`(${[...desc.options].join("|")})`);
306
+ lines.push(` ${parts.join(" ")}`);
307
+ }
308
+ lines.push("");
309
+ }
310
+
311
+ // Flags
312
+ const flagEntries = Object.entries(flagDefs);
313
+ if (flagEntries.length > 0) {
314
+ lines.push("FLAGS");
315
+ const formatted: [string, string][] = [];
316
+ for (const [name, desc] of flagEntries) {
317
+ const charPart = desc.char ? `-${desc.char}, ` : " ";
318
+ const namePart = `--${name}`;
319
+ const typePart = desc.kind === "boolean" ? "" : desc.kind === "integer" ? "=<int>" : "=<value>";
320
+ formatted.push([` ${charPart}${namePart}${typePart}`, desc.description ?? ""]);
321
+ }
322
+ const maxLeft = Math.max(...formatted.map(([l]) => l.length));
323
+ for (const [left, right] of formatted) {
324
+ lines.push(`${left.padEnd(maxLeft + 2)}${right}`);
325
+ }
326
+ lines.push("");
327
+ }
328
+
329
+ // Examples
330
+ if (Cmd.examples && Cmd.examples.length > 0) {
331
+ lines.push("EXAMPLES");
332
+ for (const ex of Cmd.examples) {
333
+ for (const line of ex.split("\n")) {
334
+ lines.push(` ${line}`);
335
+ }
336
+ }
337
+ lines.push("");
338
+ }
339
+ }
340
+
341
+ // ---------------------------------------------------------------------------
342
+ // CLI entry point
343
+ // ---------------------------------------------------------------------------
344
+
345
+ /** A lazily-loaded command: canonical name, loader, and optional aliases. */
346
+ export interface CommandEntry {
347
+ name: string;
348
+ load: () => Promise<CommandCtor>;
349
+ aliases?: string[];
350
+ }
351
+
352
+ export interface RunOptions {
353
+ bin: string;
354
+ version: string;
355
+ argv: string[];
356
+ commands: CommandEntry[];
357
+ /** Custom help renderer. Receives fully-populated config. */
358
+ help?: (config: CliConfig) => Promise<void> | void;
359
+ }
360
+
361
+ /** Find a command entry by exact name or alias. */
362
+ function findEntry(commands: CommandEntry[], id: string): CommandEntry | undefined {
363
+ return commands.find(e => e.name === id) ?? commands.find(e => e.aliases?.includes(id));
364
+ }
365
+
366
+ /**
367
+ * Main entry point — replaces `run()` from @oclif/core.
368
+ *
369
+ * Each command is explicitly registered with a lazy loader.
370
+ * No filesystem scanning, no plugin system, no package.json reading.
371
+ */
372
+ export async function run(opts: RunOptions): Promise<void> {
373
+ const { bin, version, argv } = opts;
374
+
375
+ const commandId = argv[0] ?? "";
376
+ const commandArgv = argv.slice(1);
377
+
378
+ // Top-level help
379
+ if (commandId === "--help" || commandId === "-h" || commandId === "help" || commandId === "") {
380
+ const config = await loadAllCommands(opts);
381
+ if (opts.help) {
382
+ await opts.help(config);
383
+ } else {
384
+ renderRootHelp(config);
385
+ }
386
+ return;
387
+ }
388
+
389
+ // Version
390
+ if (commandId === "--version" || commandId === "-v") {
391
+ process.stdout.write(`${bin}/${version}\n`);
392
+ return;
393
+ }
394
+
395
+ // Per-command help
396
+ if (commandArgv.includes("--help") || commandArgv.includes("-h")) {
397
+ const config = await loadAllCommands(opts);
398
+ // Resolve aliases for help too
399
+ const entry = findEntry(opts.commands, commandId);
400
+ const Cmd = entry ? config.commands.get(entry.name) : undefined;
401
+ if (Cmd) {
402
+ renderCommandHelp(bin, entry!.name, Cmd);
403
+ } else {
404
+ process.stderr.write(`Unknown command: ${commandId}\n`);
405
+ }
406
+ return;
407
+ }
408
+
409
+ // Find command by name or alias
410
+ const entry = findEntry(opts.commands, commandId);
411
+
412
+ if (!entry) {
413
+ process.stderr.write(`Error: command ${commandId} not found\n`);
414
+ process.exitCode = 1;
415
+ return;
416
+ }
417
+
418
+ const Cmd = await entry.load();
419
+ const config: CliConfig = { bin, version, commands: new Map([[entry.name, Cmd]]) };
420
+ const instance = new Cmd(commandArgv, config);
421
+ await instance.run();
422
+ }
423
+
424
+ /** Resolve all command loaders for help/alias display. */
425
+ async function loadAllCommands(opts: RunOptions): Promise<CliConfig> {
426
+ const commands = new Map<string, CommandCtor>();
427
+ const loaded = await Promise.all(opts.commands.map(async e => [e.name, await e.load()] as const));
428
+ for (const [name, Cmd] of loaded) {
429
+ commands.set(name, Cmd);
430
+ }
431
+ return { bin: opts.bin, version: opts.version, commands };
432
+ }