@oh-my-pi/pi-utils 16.5.1 → 17.0.0
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.
- package/CHANGELOG.md +14 -0
- package/dist/types/cli.d.ts +12 -0
- package/dist/types/stream.d.ts +2 -3
- package/package.json +2 -2
- package/src/cli.ts +74 -16
- package/src/frontmatter.ts +16 -3
- package/src/stream.ts +48 -26
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,20 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [17.0.0] - 2026-07-15
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- Improved SSE streaming performance by batching complete lines into a single UTF-8 decode per chunk, reducing decoder overhead.
|
|
10
|
+
- Fixed an issue in `parseFrontmatter` where a single malformed YAML line would corrupt sibling values by parsing each line independently.
|
|
11
|
+
|
|
12
|
+
## [16.5.2] - 2026-07-14
|
|
13
|
+
|
|
14
|
+
### Fixed
|
|
15
|
+
|
|
16
|
+
- Improved CLI argument and flag validation error output to display a concise error message and command usage instead of a minified code frame.
|
|
17
|
+
- Corrected required variadic positionals to render as `MODELS...` instead of `[MODELS]` in usage help.
|
|
18
|
+
|
|
5
19
|
## [16.5.1] - 2026-07-14
|
|
6
20
|
|
|
7
21
|
### Added
|
package/dist/types/cli.d.ts
CHANGED
|
@@ -1,3 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A user-facing argument/flag validation failure. Thrown by {@link Command.parse}
|
|
3
|
+
* for missing/invalid positionals and flags. The top-level {@link run} handler
|
|
4
|
+
* prints its message plus the command usage line to stderr and exits 1, instead
|
|
5
|
+
* of letting it bubble to the process-level catch — which would dump a minified
|
|
6
|
+
* `dist/cli.js` code frame over a plain argument mistake (issue #5369).
|
|
7
|
+
*/
|
|
8
|
+
export declare class CliUsageError extends Error {
|
|
9
|
+
constructor(message: string);
|
|
10
|
+
}
|
|
1
11
|
export interface FlagDescriptor<K extends "string" | "boolean" | "integer" = "string" | "boolean" | "integer"> {
|
|
2
12
|
kind: K;
|
|
3
13
|
description?: string;
|
|
@@ -91,6 +101,8 @@ export declare abstract class Command {
|
|
|
91
101
|
}
|
|
92
102
|
/** Render full root help: header, default command details, subcommand list. */
|
|
93
103
|
export declare function renderRootHelp(config: CliConfig): void;
|
|
104
|
+
/** Build the single USAGE line for a command (without the leading label). */
|
|
105
|
+
export declare function commandUsageLine(bin: string, id: string, Cmd: CommandCtor): string;
|
|
94
106
|
/** Render help for a single command. */
|
|
95
107
|
export declare function renderCommandHelp(bin: string, id: string, Cmd: CommandCtor): void;
|
|
96
108
|
/** A lazily-loaded command: canonical name, loader, and optional aliases. */
|
package/dist/types/stream.d.ts
CHANGED
|
@@ -41,9 +41,8 @@ export interface ServerSentEvent {
|
|
|
41
41
|
* Use `readSseJson` instead when every event is a single `data:` JSON object
|
|
42
42
|
* and you don't need access to the `event:` field.
|
|
43
43
|
*
|
|
44
|
-
* Internally backed by a Buffer-based
|
|
45
|
-
*
|
|
46
|
-
* accumulated buffer.
|
|
44
|
+
* Internally backed by a Buffer-based reader (`ConcatSink`) that batches all
|
|
45
|
+
* complete lines in each source chunk into one UTF-8 decode.
|
|
47
46
|
*
|
|
48
47
|
* @example
|
|
49
48
|
* ```ts
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@oh-my-pi/pi-utils",
|
|
4
|
-
"version": "
|
|
4
|
+
"version": "17.0.0",
|
|
5
5
|
"description": "Shared utilities for pi packages",
|
|
6
6
|
"homepage": "https://omp.sh",
|
|
7
7
|
"author": "Can Boluk",
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
"fmt": "biome format --write ."
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
|
-
"@oh-my-pi/pi-natives": "
|
|
34
|
+
"@oh-my-pi/pi-natives": "17.0.0",
|
|
35
35
|
"handlebars": "^4.7.9",
|
|
36
36
|
"winston": "^3.19.0",
|
|
37
37
|
"winston-daily-rotate-file": "^5.0.0"
|
package/src/cli.ts
CHANGED
|
@@ -28,6 +28,20 @@ function startupMarker(text: string): void {
|
|
|
28
28
|
}
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
/**
|
|
32
|
+
* A user-facing argument/flag validation failure. Thrown by {@link Command.parse}
|
|
33
|
+
* for missing/invalid positionals and flags. The top-level {@link run} handler
|
|
34
|
+
* prints its message plus the command usage line to stderr and exits 1, instead
|
|
35
|
+
* of letting it bubble to the process-level catch — which would dump a minified
|
|
36
|
+
* `dist/cli.js` code frame over a plain argument mistake (issue #5369).
|
|
37
|
+
*/
|
|
38
|
+
export class CliUsageError extends Error {
|
|
39
|
+
constructor(message: string) {
|
|
40
|
+
super(message);
|
|
41
|
+
this.name = "CliUsageError";
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
31
45
|
// ---------------------------------------------------------------------------
|
|
32
46
|
// Flag & Arg descriptors
|
|
33
47
|
// ---------------------------------------------------------------------------
|
|
@@ -190,12 +204,18 @@ export abstract class Command {
|
|
|
190
204
|
|
|
191
205
|
// strict=false when command declares args (positionals must pass through)
|
|
192
206
|
// or when the command itself opts out
|
|
193
|
-
const { values: rawValues, positionals } =
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
207
|
+
const { values: rawValues, positionals } = (() => {
|
|
208
|
+
try {
|
|
209
|
+
return nodeParseArgs({
|
|
210
|
+
args: this.argv,
|
|
211
|
+
options,
|
|
212
|
+
allowPositionals: true,
|
|
213
|
+
strict,
|
|
214
|
+
});
|
|
215
|
+
} catch (error) {
|
|
216
|
+
throw new CliUsageError(error instanceof Error ? error.message : String(error));
|
|
217
|
+
}
|
|
218
|
+
})();
|
|
199
219
|
|
|
200
220
|
// Convert raw values to proper types and validate
|
|
201
221
|
const flags: Record<string, unknown> = {};
|
|
@@ -207,7 +227,7 @@ export abstract class Command {
|
|
|
207
227
|
} else {
|
|
208
228
|
const n = Number.parseInt(raw as string, 10);
|
|
209
229
|
if (Number.isNaN(n)) {
|
|
210
|
-
throw new
|
|
230
|
+
throw new CliUsageError(`Expected integer for --${name}, got "${raw}"`);
|
|
211
231
|
}
|
|
212
232
|
flags[name] = n;
|
|
213
233
|
}
|
|
@@ -220,14 +240,16 @@ export abstract class Command {
|
|
|
220
240
|
// Validate options constraint
|
|
221
241
|
if (val !== undefined && desc.options && !Array.isArray(val)) {
|
|
222
242
|
if (!desc.options.includes(val as string)) {
|
|
223
|
-
throw new
|
|
243
|
+
throw new CliUsageError(
|
|
244
|
+
`Expected --${name} to be one of: ${[...desc.options].join(", ")}; got "${val}"`,
|
|
245
|
+
);
|
|
224
246
|
}
|
|
225
247
|
}
|
|
226
248
|
flags[name] = val;
|
|
227
249
|
}
|
|
228
250
|
// Validate required
|
|
229
251
|
if (desc.required && flags[name] === undefined) {
|
|
230
|
-
throw new
|
|
252
|
+
throw new CliUsageError(`Missing required flag: --${name}`);
|
|
231
253
|
}
|
|
232
254
|
}
|
|
233
255
|
|
|
@@ -246,13 +268,15 @@ export abstract class Command {
|
|
|
246
268
|
}
|
|
247
269
|
// Validate required
|
|
248
270
|
if (desc.required && args[argName] === undefined) {
|
|
249
|
-
throw new
|
|
271
|
+
throw new CliUsageError(`Missing required argument: ${argName}`);
|
|
250
272
|
}
|
|
251
273
|
// Validate options constraint
|
|
252
274
|
const argVal = args[argName];
|
|
253
275
|
if (argVal !== undefined && desc.options && typeof argVal === "string") {
|
|
254
276
|
if (!desc.options.includes(argVal)) {
|
|
255
|
-
throw new
|
|
277
|
+
throw new CliUsageError(
|
|
278
|
+
`Expected ${argName} to be one of: ${[...desc.options].join(", ")}; got "${argVal}"`,
|
|
279
|
+
);
|
|
256
280
|
}
|
|
257
281
|
}
|
|
258
282
|
}
|
|
@@ -294,15 +318,34 @@ export function renderRootHelp(config: CliConfig): void {
|
|
|
294
318
|
process.stdout.write(lines.join("\n"));
|
|
295
319
|
}
|
|
296
320
|
|
|
321
|
+
/**
|
|
322
|
+
* Format a command's positional args for a USAGE line. Required args render
|
|
323
|
+
* bare (`MODELS`), optional args wrapped in brackets (`[MODELS]`), and
|
|
324
|
+
* `multiple` args get a trailing ellipsis (`MODELS...`) so a required
|
|
325
|
+
* variadic reads as `MODELS...`, not the misleading optional `[MODELS]`.
|
|
326
|
+
*/
|
|
327
|
+
function formatUsageArgs(Cmd: CommandCtor): string {
|
|
328
|
+
const entries = Object.entries(Cmd.args ?? {});
|
|
329
|
+
if (entries.length === 0) return "";
|
|
330
|
+
const parts = entries.map(([name, desc]) => {
|
|
331
|
+
const label = `${name.toUpperCase()}${desc.multiple ? "..." : ""}`;
|
|
332
|
+
return desc.required ? label : `[${label}]`;
|
|
333
|
+
});
|
|
334
|
+
return ` ${parts.join(" ")}`;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** Build the single USAGE line for a command (without the leading label). */
|
|
338
|
+
export function commandUsageLine(bin: string, id: string, Cmd: CommandCtor): string {
|
|
339
|
+
const hasFlags = Object.keys(Cmd.flags ?? {}).length > 0;
|
|
340
|
+
return `$ ${bin} ${id}${formatUsageArgs(Cmd)}${hasFlags ? " [FLAGS]" : ""}`;
|
|
341
|
+
}
|
|
342
|
+
|
|
297
343
|
/** Render help for a single command. */
|
|
298
344
|
export function renderCommandHelp(bin: string, id: string, Cmd: CommandCtor): void {
|
|
299
345
|
const lines: string[] = [];
|
|
300
346
|
if (Cmd.description) lines.push(`${Cmd.description}\n`);
|
|
301
347
|
lines.push("USAGE");
|
|
302
|
-
|
|
303
|
-
const argStr = argNames.length > 0 ? ` ${argNames.map(n => `[${n.toUpperCase()}]`).join(" ")}` : "";
|
|
304
|
-
const hasFlags = Object.keys(Cmd.flags ?? {}).length > 0;
|
|
305
|
-
lines.push(` $ ${bin} ${id}${argStr}${hasFlags ? " [FLAGS]" : ""}\n`);
|
|
348
|
+
lines.push(` ${commandUsageLine(bin, id, Cmd)}\n`);
|
|
306
349
|
renderCommandBody(lines, Cmd);
|
|
307
350
|
process.stdout.write(lines.join("\n"));
|
|
308
351
|
}
|
|
@@ -435,7 +478,22 @@ export async function run(opts: RunOptions): Promise<void> {
|
|
|
435
478
|
const Cmd = await loadEntry(entry);
|
|
436
479
|
const config: CliConfig = { bin, version, commands: new Map([[entry.name, Cmd]]) };
|
|
437
480
|
const instance = new Cmd(commandArgv, config);
|
|
438
|
-
|
|
481
|
+
try {
|
|
482
|
+
await instance.run();
|
|
483
|
+
} catch (error) {
|
|
484
|
+
// A usage mistake (missing/invalid arg or flag) is not a crash: print the
|
|
485
|
+
// message and the command's usage line, then exit 1. Letting it reach the
|
|
486
|
+
// process-level catch would dump a minified `dist/cli.js` code frame over a
|
|
487
|
+
// plain argument error (issue #5369).
|
|
488
|
+
if (error instanceof CliUsageError) {
|
|
489
|
+
process.stderr.write(`error: ${error.message}\n\n`);
|
|
490
|
+
process.stderr.write(`USAGE\n ${commandUsageLine(bin, entry.name, Cmd)}\n`);
|
|
491
|
+
process.stderr.write(`\nRun \`${bin} ${entry.name} --help\` for details.\n`);
|
|
492
|
+
process.exitCode = 1;
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
throw error;
|
|
496
|
+
}
|
|
439
497
|
}
|
|
440
498
|
|
|
441
499
|
/** Load one command module, leaving streaming markers around the import. */
|
package/src/frontmatter.ts
CHANGED
|
@@ -149,12 +149,25 @@ export function parseFrontmatter(
|
|
|
149
149
|
throw err;
|
|
150
150
|
}
|
|
151
151
|
|
|
152
|
-
// Simple
|
|
152
|
+
// Simple key: value fallback. Reparse each value on its own so one
|
|
153
|
+
// malformed line (e.g. `scope: "text","thinking"`) can't leave sibling
|
|
154
|
+
// values wrapped in literal quotes; values that don't parse as YAML fall
|
|
155
|
+
// back to the raw trimmed string (issue #4796).
|
|
153
156
|
for (const line of metadata.split("\n")) {
|
|
154
157
|
const match = line.match(/^([\w-]+):\s*(.*)$/);
|
|
155
|
-
if (match)
|
|
156
|
-
|
|
158
|
+
if (!match) continue;
|
|
159
|
+
const raw = match[2].trim();
|
|
160
|
+
let value: unknown = raw;
|
|
161
|
+
if (raw.length > 0) {
|
|
162
|
+
try {
|
|
163
|
+
const parsed = YAML.parse(raw);
|
|
164
|
+
if (parsed !== null && typeof parsed !== "object") value = parsed;
|
|
165
|
+
else if (Array.isArray(parsed)) value = parsed;
|
|
166
|
+
} catch {
|
|
167
|
+
// keep the raw string
|
|
168
|
+
}
|
|
157
169
|
}
|
|
170
|
+
frontmatter[match[1]] = value;
|
|
158
171
|
}
|
|
159
172
|
|
|
160
173
|
return { frontmatter: normalizeKeys(frontmatter) as Record<string, unknown>, body };
|
package/src/stream.ts
CHANGED
|
@@ -150,6 +150,29 @@ class ConcatSink {
|
|
|
150
150
|
}
|
|
151
151
|
}
|
|
152
152
|
}
|
|
153
|
+
|
|
154
|
+
appendAndFlushText(chunk: Uint8Array, decoder: TextDecoder): string | undefined {
|
|
155
|
+
const lastNewline = chunk.lastIndexOf(LF);
|
|
156
|
+
if (lastNewline === -1) {
|
|
157
|
+
this.append(chunk);
|
|
158
|
+
return undefined;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const completeEnd = lastNewline + 1;
|
|
162
|
+
let text: string;
|
|
163
|
+
if (this.isEmpty) {
|
|
164
|
+
const complete = completeEnd === chunk.length ? chunk : chunk.subarray(0, completeEnd);
|
|
165
|
+
text = decoder.decode(complete);
|
|
166
|
+
} else {
|
|
167
|
+
this.append(completeEnd === chunk.length ? chunk : chunk.subarray(0, completeEnd));
|
|
168
|
+
text = decoder.decode(this.flush());
|
|
169
|
+
this.clear();
|
|
170
|
+
}
|
|
171
|
+
if (completeEnd < chunk.length) {
|
|
172
|
+
this.append(chunk.subarray(completeEnd));
|
|
173
|
+
}
|
|
174
|
+
return text;
|
|
175
|
+
}
|
|
153
176
|
*pullJSONL<T>(chunk: Uint8Array, beg: number, end: number) {
|
|
154
177
|
if (this.isEmpty) {
|
|
155
178
|
const { values, error, read, done } = parseJsonlChunkCompat(chunk, beg, end);
|
|
@@ -275,14 +298,9 @@ interface SseEventState {
|
|
|
275
298
|
raw: string[];
|
|
276
299
|
}
|
|
277
300
|
|
|
278
|
-
//
|
|
279
|
-
// LF
|
|
280
|
-
|
|
281
|
-
const SSE_LINE_DECODER = new TextDecoder("utf-8");
|
|
282
|
-
|
|
283
|
-
function decodeSseLineBytes(line: Uint8Array, end: number): string {
|
|
284
|
-
return end === line.length ? SSE_LINE_DECODER.decode(line) : SSE_LINE_DECODER.decode(line.subarray(0, end));
|
|
285
|
-
}
|
|
301
|
+
// Complete lines are decoded in one batch per source chunk. Each batch ends on
|
|
302
|
+
// LF, which cannot split a multi-byte UTF-8 sequence.
|
|
303
|
+
const SSE_DECODER = new TextDecoder("utf-8");
|
|
286
304
|
|
|
287
305
|
function flushSseEvent(state: SseEventState): ServerSentEvent | null {
|
|
288
306
|
if (state.event === null && state.data === null) {
|
|
@@ -300,25 +318,25 @@ function flushSseEvent(state: SseEventState): ServerSentEvent | null {
|
|
|
300
318
|
return event;
|
|
301
319
|
}
|
|
302
320
|
|
|
303
|
-
function pushSseLine(line:
|
|
304
|
-
//
|
|
321
|
+
function pushSseLine(line: string, state: SseEventState): ServerSentEvent | null {
|
|
322
|
+
// Complete-line batches split on LF only; strip a trailing CR so CRLF sources
|
|
305
323
|
// don't leak `\r` into field values.
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
324
|
+
if (line.charCodeAt(line.length - 1) === 0x0d /* '\r' */) {
|
|
325
|
+
line = line.slice(0, -1);
|
|
326
|
+
}
|
|
327
|
+
if (line.length === 0) return flushSseEvent(state);
|
|
309
328
|
|
|
310
329
|
// Comment line: keep in `raw` for diagnostic context, skip parsing.
|
|
311
|
-
if (line
|
|
312
|
-
state.raw.push(
|
|
330
|
+
if (line.charCodeAt(0) === 0x3a /* ':' */) {
|
|
331
|
+
state.raw.push(line);
|
|
313
332
|
return null;
|
|
314
333
|
}
|
|
315
334
|
|
|
316
|
-
|
|
317
|
-
state.raw.push(text);
|
|
335
|
+
state.raw.push(line);
|
|
318
336
|
|
|
319
|
-
const colon =
|
|
320
|
-
const fieldName = colon === -1 ?
|
|
321
|
-
let value = colon === -1 ? "" :
|
|
337
|
+
const colon = line.indexOf(":");
|
|
338
|
+
const fieldName = colon === -1 ? line : line.slice(0, colon);
|
|
339
|
+
let value = colon === -1 ? "" : line.slice(colon + 1);
|
|
322
340
|
if (value.charCodeAt(0) === 0x20 /* ' ' */) value = value.slice(1);
|
|
323
341
|
|
|
324
342
|
if (fieldName === "event") {
|
|
@@ -344,9 +362,8 @@ function pushSseLine(line: Uint8Array, state: SseEventState): ServerSentEvent |
|
|
|
344
362
|
* Use `readSseJson` instead when every event is a single `data:` JSON object
|
|
345
363
|
* and you don't need access to the `event:` field.
|
|
346
364
|
*
|
|
347
|
-
* Internally backed by a Buffer-based
|
|
348
|
-
*
|
|
349
|
-
* accumulated buffer.
|
|
365
|
+
* Internally backed by a Buffer-based reader (`ConcatSink`) that batches all
|
|
366
|
+
* complete lines in each source chunk into one UTF-8 decode.
|
|
350
367
|
*
|
|
351
368
|
* @example
|
|
352
369
|
* ```ts
|
|
@@ -365,9 +382,14 @@ export async function* readSseEvents(
|
|
|
365
382
|
const source = abortableSource(stream, signal);
|
|
366
383
|
try {
|
|
367
384
|
for await (const chunk of source) {
|
|
368
|
-
|
|
369
|
-
|
|
385
|
+
const text = lineBuffer.appendAndFlushText(chunk, SSE_DECODER);
|
|
386
|
+
if (text === undefined) continue;
|
|
387
|
+
let start = 0;
|
|
388
|
+
while (start < text.length) {
|
|
389
|
+
const newline = text.indexOf("\n", start);
|
|
390
|
+
const event = pushSseLine(text.slice(start, newline), state);
|
|
370
391
|
if (event) yield event;
|
|
392
|
+
start = newline + 1;
|
|
371
393
|
}
|
|
372
394
|
}
|
|
373
395
|
// Treat any trailing partial line (no terminating LF) as a complete line.
|
|
@@ -375,7 +397,7 @@ export async function* readSseEvents(
|
|
|
375
397
|
const tail = lineBuffer.flush();
|
|
376
398
|
if (tail) {
|
|
377
399
|
lineBuffer.clear();
|
|
378
|
-
const event = pushSseLine(tail, state);
|
|
400
|
+
const event = pushSseLine(SSE_DECODER.decode(tail), state);
|
|
379
401
|
if (event) {
|
|
380
402
|
trailingEvents.add(event);
|
|
381
403
|
yield event;
|