84 KiB
@suaveplan/readline + @suaveplan/cli Implementation Plan
For agentic workers: REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Ship @suaveplan/readline (full-replacement terminal I/O primitive) then @suaveplan/cli (enterprise CLI toolkit) — both at 100% coverage, full docs, OTel, Zod schemas, and AbortSignal support.
Architecture: @suaveplan/readline ports the maleck13 byte-scanner to a bounded async-push-queue engine, exposes readLines() (AsyncGenerator) and createInterface() (interactive prompt), then @suaveplan/cli rewrites its nine modules against that interface instead of node:readline.
Tech Stack: Bun, TypeScript, Vite (vite-plugin-dts, preserveModules), Vitest + istanbul, fast-check (property tests), mitata (benchmarks), @opentelemetry/api, zod (catalog:forms), @suaveplan/error, @suaveplan/types.
Spec: docs/superpowers/specs/2026-04-20-readline-cli-design.md
File Map — @suaveplan/readline
universal/platform/readline/
package.json
tsconfig.lib.json
vite.config.ts
vitest.config.ts
README.md
tests/setup/polyfills.ts
src/
errors/
errors.ts ← ReadlineError + READLINE_CODES (defineCodes × 3 sub-namespaces)
errors.test.ts
errors.md
index.ts
scanner/
scanner.ts ← internal AsyncGenerator; byte-level port of maleck13 readline.js
scanner.types.ts
scanner.test.ts
scanner.md
index.ts
reader/
reader.ts ← readLines() wraps scanner; accepts Readable | ReadableStream | path
reader.types.ts
reader.test.ts
reader.md
index.ts
interface/
interface.ts ← createInterface(); question()/close()/asyncIterator; OTel span
interface.types.ts
interface.test.ts
interface.md
index.ts
__benchmarks__/
scanner-throughput.bench.ts
reader-file.bench.ts
File Map — @suaveplan/cli
universal/platform/cli/
package.json
tsconfig.lib.json
vite.config.ts
vitest.config.ts
README.md
tests/setup/polyfills.ts
src/
index.ts ← root barrel
errors/
errors.ts ← CliError + CLI_CODES
errors.test.ts
errors.md
index.ts
parser/
parser.ts ← parseArgs, parseOptionFlags, parseArgumentSpec
parser.types.ts
parser.schemas.ts ← ParserConfigSchema, ParsedArgsSchema
parser.test.ts
parser.md
index.ts
command/
command.ts ← Command class; execute() has OTel span
command.types.ts
command.schemas.ts
command.test.ts
command.md
index.ts
app/
app.ts ← App class + cli(); run() has OTel span + AbortSignal
app.types.ts
app.schemas.ts ← AppConfigSchema
app.test.ts
app.md
index.ts
style/
style.ts ← createStyler, style, stripAnsi, isColorSupported, getStyleNames
style.types.ts
style.schemas.ts
style.test.ts
style.md
index.ts
output/
format/
format.ts ← keyValue, list, tree, json, columns, heading, divider, indent
format.types.ts
format.schemas.ts
format.test.ts
format.md
index.ts
table/
table.ts ← table()
table.types.ts
table.schemas.ts
table.test.ts
table.md
index.ts
spinner/
spinner.ts ← createSpinner; AbortSignal support
spinner.types.ts
spinner.schemas.ts
spinner.test.ts
spinner.md
index.ts
progress/
progress.ts ← createProgress; AbortSignal support
progress.types.ts
progress.schemas.ts
progress.test.ts
progress.md
index.ts
prompt/
prompt.ts ← text/password/confirm/select/multiselect; uses readline/interface
prompt.types.ts
prompt.schemas.ts
prompt.test.ts
prompt.md
index.ts
__benchmarks__/
parser-throughput.bench.ts
app-run.bench.ts
Chunk 1: @suaveplan/readline — scaffold, errors, scanner
Task 1: Scaffold @suaveplan/readline
Files:
-
Create:
universal/platform/readline/package.json -
Create:
universal/platform/readline/tsconfig.lib.json -
Create:
universal/platform/readline/vite.config.ts -
Create:
universal/platform/readline/vitest.config.ts -
Create:
universal/platform/readline/tests/setup/polyfills.ts -
Modify:
FOLDERS.yml— addreadlinetouniversal/platform -
Modify:
CLAUDE.md— add readline entry to package listing -
Create
package.json
{
"name": "@suaveplan/readline",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Terminal I/O primitive: AsyncGenerator line reader and interactive prompt interface",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
"./reader/reader": {
"import": {
"types": "./dist/reader/reader.d.ts",
"default": "./dist/reader/reader.js"
}
},
"./interface/interface": {
"import": {
"types": "./dist/interface/interface.d.ts",
"default": "./dist/interface/interface.js"
}
},
"./errors/errors": {
"import": {
"types": "./dist/errors/errors.d.ts",
"default": "./dist/errors/errors.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
"format": "bunx biome format --write --max-diagnostics 500 src/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../scripts/docs-check.ts .",
"bench": "bun run src/__benchmarks__/scanner-throughput.bench.ts && bun run src/__benchmarks__/reader-file.bench.ts"
},
"dependencies": {
"@suaveplan/error": "workspace:^",
"@suaveplan/types": "workspace:^",
"@opentelemetry/api": "catalog:observability",
"zod": "catalog:forms",
"tslib": "catalog:build"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"fast-check": "catalog:testing",
"mitata": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": ["dist", "src", "README.md"]
}
- Create
tsconfig.lib.json
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "dist", "node_modules"]
}
- Create
vite.config.ts
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/readline",
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: { entry: "src/index.ts", formats: ["es"] },
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [
"@suaveplan/error",
"@suaveplan/types",
"tslib",
"zod",
"@opentelemetry/api",
/^node:/,
],
},
},
});
- Create
vitest.config.ts
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: false,
environment: "node",
pool: "forks",
setupFiles: ["./tests/setup/polyfills.ts"],
include: ["src/**/*.test.ts"],
exclude: ["dist/**", "node_modules/**"],
coverage: {
provider: "istanbul",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.ts"],
exclude: [
"src/**/*.test.ts",
"src/**/*.types.ts",
"src/**/*.schemas.ts",
"src/**/index.ts",
"src/__benchmarks__/**",
],
thresholds: {
statements: 100,
branches: 100,
functions: 100,
lines: 100,
},
},
testTimeout: 30000,
},
});
Note:
*.schemas.tsfiles are excluded from coverage. Zod schema declarations are type-level; coverage of their branches belongs to Zod's own test suite, not ours. This is consistent with other genesis packages.
- Create
tests/setup/polyfills.ts
export {};
- Update
FOLDERS.yml— change theplatformline to:
platform: [ cli, electrobun, macos-contacts, opentelemetry,
profiler, readline, sitemap ] # 7
- Update
CLAUDE.md— add to the platform section of the package listing:
- **readline** — terminal I/O primitive: AsyncGenerator line reader (`readLines`), interactive prompt interface (`createInterface`), byte scanner engine. Depends on `@suaveplan/error`, `@suaveplan/types`, `@opentelemetry/api`, `zod`.
- Create
src/index.tsstub (required before any build can run — grows as modules land):
export * from "./errors/index.js";
- Run
bun installfrom repo root to register the new workspace package
cd /mnt/s/web/genesis && bun install
Task 2: errors module
Files:
-
Create:
universal/platform/readline/src/errors/errors.ts -
Create:
universal/platform/readline/src/errors/errors.test.ts -
Create:
universal/platform/readline/src/errors/errors.md -
Create:
universal/platform/readline/src/errors/index.ts -
Write failing test (
src/errors/errors.test.ts)
import { describe, expect, it } from "vitest";
import { ReadlineError, READLINE_CODES } from "./errors.js";
describe("READLINE_CODES", { timeout: 5000 }, () => {
it("produces fully-qualified scanner codes", () => {
expect(READLINE_CODES.SCANNER.LINE_TOO_LONG).toBe("READLINE.SCANNER.LINE_TOO_LONG");
expect(READLINE_CODES.SCANNER.STREAM_ERROR).toBe("READLINE.SCANNER.STREAM_ERROR");
});
it("produces fully-qualified interface codes", () => {
expect(READLINE_CODES.INTERFACE.CLOSED).toBe("READLINE.INTERFACE.CLOSED");
expect(READLINE_CODES.INTERFACE.ABORTED).toBe("READLINE.INTERFACE.ABORTED");
});
it("produces fully-qualified reader codes", () => {
expect(READLINE_CODES.READER.STREAM_ERROR).toBe("READLINE.READER.STREAM_ERROR");
});
});
describe("ReadlineError", { timeout: 5000 }, () => {
it("is an instance of Error", () => {
const err = new ReadlineError(
READLINE_CODES.SCANNER.LINE_TOO_LONG,
"readline.scanner.line_too_long"
);
expect(err).toBeInstanceOf(Error);
expect(err.code).toBe("READLINE.SCANNER.LINE_TOO_LONG");
expect(err.messageKey).toBe("readline.scanner.line_too_long");
});
it("chains cause", () => {
const cause = new Error("upstream");
const err = new ReadlineError(
READLINE_CODES.INTERFACE.ABORTED,
"readline.interface.aborted",
{ cause }
);
expect(err.cause).toBe(cause);
});
it("attaches context", () => {
const err = new ReadlineError(
READLINE_CODES.SCANNER.LINE_TOO_LONG,
"readline.scanner.line_too_long",
{ context: { maxLineLength: 4096 } }
);
expect(err.context).toEqual({ maxLineLength: 4096 });
});
});
- Run test — verify it fails
cd /mnt/s/web/genesis && bunx turbo test --filter=@suaveplan/readline
Expected: Cannot find module './errors.js'
- Implement
src/errors/errors.ts
import type { SuaveplanErrorOptions } from "@suaveplan/error";
import { defineCodes, SuaveplanError } from "@suaveplan/error";
const SCANNER_CODES = defineCodes("READLINE.SCANNER", [
"LINE_TOO_LONG",
"STREAM_ERROR",
] as const);
const INTERFACE_CODES = defineCodes("READLINE.INTERFACE", [
"CLOSED",
"ABORTED",
] as const);
const READER_CODES = defineCodes("READLINE.READER", [
"STREAM_ERROR",
] as const);
export const READLINE_CODES = {
SCANNER: SCANNER_CODES,
INTERFACE: INTERFACE_CODES,
READER: READER_CODES,
} as const;
export type ReadlineCode =
| (typeof SCANNER_CODES)[keyof typeof SCANNER_CODES]
| (typeof INTERFACE_CODES)[keyof typeof INTERFACE_CODES]
| (typeof READER_CODES)[keyof typeof READER_CODES];
export class ReadlineError extends SuaveplanError<ReadlineCode> {
// biome-ignore lint/complexity/noUselessConstructor: widens protected base constructor to public
public constructor(
code: ReadlineCode,
messageKey: string,
options?: SuaveplanErrorOptions
) {
super(code, messageKey, options);
}
}
- Create
src/errors/index.ts
export { ReadlineError, READLINE_CODES } from "./errors.js";
export type { ReadlineCode } from "./errors.js";
- Run test — verify it passes
cd /mnt/s/web/genesis && bunx turbo test --filter=@suaveplan/readline
-
Write
src/errors/errors.md(≥200 words, required sections: Purpose, Features, Usage Basic, Usage Advanced, API Reference, Implementation Notes) -
Commit
git add universal/platform/readline/ FOLDERS.yml CLAUDE.md
git commit -m "feat(readline): scaffold package + errors module"
Task 3: scanner module (internal byte engine)
Files:
-
Create:
universal/platform/readline/src/scanner/scanner.ts -
Create:
universal/platform/readline/src/scanner/scanner.types.ts -
Create:
universal/platform/readline/src/scanner/scanner.test.ts -
Create:
universal/platform/readline/src/scanner/scanner.md -
Create:
universal/platform/readline/src/scanner/index.ts -
Write failing test (
src/scanner/scanner.test.ts)
import { Readable } from "node:stream";
import * as fc from "fast-check";
import { describe, expect, it } from "vitest";
import { ReadlineError, READLINE_CODES } from "../errors/errors.js";
import { createScanner } from "./scanner.js";
function toReadable(input: string): Readable {
return Readable.from(Buffer.from(input));
}
async function collect(gen: AsyncGenerator<string>): Promise<string[]> {
const lines: string[] = [];
for await (const line of gen) lines.push(line);
return lines;
}
describe("createScanner", { timeout: 5000 }, () => {
it("yields lines separated by LF", async () => {
const lines = await collect(createScanner(toReadable("hello\nworld\n")));
expect(lines).toEqual(["hello", "world"]);
});
it("yields lines separated by CRLF", async () => {
const lines = await collect(createScanner(toReadable("foo\r\nbar\r\n")));
expect(lines).toEqual(["foo", "bar"]);
});
it("yields last line without trailing newline", async () => {
const lines = await collect(createScanner(toReadable("a\nb")));
expect(lines).toEqual(["a", "b"]);
});
it("yields nothing for empty input", async () => {
const lines = await collect(createScanner(toReadable("")));
expect(lines).toEqual([]);
});
it("yields a single blank line", async () => {
const lines = await collect(createScanner(toReadable("\n")));
expect(lines).toEqual([""]);
});
it("throws ReadlineError when line exceeds maxLineLength", async () => {
const long = "x".repeat(10);
await expect(
collect(createScanner(toReadable(long), { maxLineLength: 5 }))
).rejects.toThrow(ReadlineError);
});
it("throws ReadlineError with LINE_TOO_LONG code", async () => {
const long = "x".repeat(10);
await expect(
collect(createScanner(toReadable(long), { maxLineLength: 5 }))
).rejects.toMatchObject({ code: READLINE_CODES.SCANNER.LINE_TOO_LONG });
});
it("propagates stream errors as ReadlineError", async () => {
const r = new Readable({ read() {} });
const gen = createScanner(r);
const p = collect(gen);
r.destroy(new Error("boom"));
await expect(p).rejects.toMatchObject({ code: READLINE_CODES.SCANNER.STREAM_ERROR });
});
it("honours AbortSignal mid-iteration", async () => {
const controller = new AbortController();
const lines: string[] = [];
const r = Readable.from(["line1\n", "line2\n", "line3\n"].map(Buffer.from));
for await (const line of createScanner(r, { signal: controller.signal })) {
lines.push(line);
if (lines.length === 1) controller.abort();
}
expect(lines.length).toBe(1);
});
});
describe("createScanner property tests", { timeout: 30000 }, () => {
it("round-trips arbitrary multiline strings", async () => {
await fc.assert(
fc.asyncProperty(
fc.array(fc.string({ maxLength: 100 }), { minLength: 0, maxLength: 20 }),
async (inputLines) => {
const text = inputLines.join("\n");
const lines = await collect(createScanner(toReadable(text)));
expect(lines.join("\n")).toBe(text);
}
),
{ numRuns: 100 }
);
});
});
- Run test — verify it fails
cd /mnt/s/web/genesis && bunx turbo test --filter=@suaveplan/readline
- Create
src/scanner/scanner.types.ts
export interface ScannerOptions {
maxLineLength?: number;
encoding?: BufferEncoding;
signal?: AbortSignal;
}
- Implement
src/scanner/scanner.ts
import type { Readable } from "node:stream";
import { ReadlineError, READLINE_CODES } from "../errors/errors.js";
import type { ScannerOptions } from "./scanner.types.js";
export type { ScannerOptions } from "./scanner.types.js";
const DEFAULT_MAX_LINE_LENGTH = 4096;
type QueueItem =
| { kind: "line"; value: string }
| { kind: "done" }
| { kind: "error"; error: Error };
export async function* createScanner(
input: Readable,
opts?: ScannerOptions
): AsyncGenerator<string, void, unknown> {
const maxLen = opts?.maxLineLength ?? DEFAULT_MAX_LINE_LENGTH;
const encoding = opts?.encoding ?? "utf8";
const signal = opts?.signal;
const queue: QueueItem[] = [];
let waitResolve: ((item: QueueItem) => void) | null = null;
function push(item: QueueItem): void {
if (waitResolve) {
const r = waitResolve;
waitResolve = null;
r(item);
} else {
queue.push(item);
}
}
function dequeue(): Promise<QueueItem> {
if (queue.length > 0) return Promise.resolve(queue.shift()!);
return new Promise<QueueItem>((resolve) => {
waitResolve = resolve;
});
}
const lineBuffer = Buffer.alloc(maxLen);
let lineLength = 0;
function emitLine(): void {
push({ kind: "line", value: lineBuffer.subarray(0, lineLength).toString(encoding) });
lineLength = 0;
}
input.on("data", (data: Buffer) => {
for (let i = 0; i < data.length; i++) {
const byte = data[i]!;
if (byte === 10) {
emitLine();
} else if (byte === 13) {
// CR — skip; CRLF handled when LF arrives
} else {
if (lineLength >= maxLen) {
push({
kind: "error",
error: new ReadlineError(
READLINE_CODES.SCANNER.LINE_TOO_LONG,
"readline.scanner.line_too_long",
{ context: { maxLineLength: maxLen } }
),
});
lineLength = 0;
return;
}
lineBuffer[lineLength++] = byte;
}
}
});
input.on("end", () => {
if (lineLength > 0) emitLine();
push({ kind: "done" });
});
input.on("error", (err: Error) => {
push({
kind: "error",
error: new ReadlineError(
READLINE_CODES.SCANNER.STREAM_ERROR,
"readline.scanner.stream_error",
{ cause: err }
),
});
});
while (true) {
if (signal?.aborted) break;
const item = await dequeue();
// NOTE: check again after await — abort may have fired while waiting in dequeue()
if (signal?.aborted) break;
if (item.kind === "error") throw item.error;
if (item.kind === "done") break;
yield item.value;
}
}
- Create
src/scanner/index.ts
export { createScanner } from "./scanner.js";
export type { ScannerOptions } from "./scanner.types.js";
- Run test — verify it passes
cd /mnt/s/web/genesis && bunx turbo test --filter=@suaveplan/readline
-
Write
src/scanner/scanner.md(≥200 words, all required sections) -
Commit
git add universal/platform/readline/
git commit -m "feat(readline): scanner — byte-level AsyncGenerator line reader"
Task 4: turbo bench task
Files:
-
Modify:
turbo.json— addbenchtask aftertest:coverageblock -
Add
benchtask toturbo.json(after thetest:coverageblock):
"bench": {
"dependsOn": ["^build", "build"],
"inputs": ["src/**/*.bench.ts"],
"cache": false,
"outputLogs": "new-only"
}
- Commit
git add turbo.json
git commit -m "chore: add bench turbo task"
Chunk 2: @suaveplan/readline — reader, interface, benchmarks, docs, publish
Task 5: reader module
Files:
-
Create:
universal/platform/readline/src/reader/reader.ts -
Create:
universal/platform/readline/src/reader/reader.types.ts -
Create:
universal/platform/readline/src/reader/reader.test.ts -
Create:
universal/platform/readline/src/reader/reader.md -
Create:
universal/platform/readline/src/reader/index.ts -
Write failing test (
src/reader/reader.test.ts)
import { Readable } from "node:stream";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { describe, expect, it } from "vitest";
import { readLines } from "./reader.js";
async function collect(gen: AsyncGenerator<string>): Promise<string[]> {
const out: string[] = [];
for await (const line of gen) out.push(line);
return out;
}
describe("readLines — Readable", { timeout: 5000 }, () => {
it("reads lines from a Node Readable", async () => {
const r = Readable.from(Buffer.from("one\ntwo\nthree"));
expect(await collect(readLines(r))).toEqual(["one", "two", "three"]);
});
it("skips empty lines when skipEmpty is true", async () => {
const r = Readable.from(Buffer.from("a\n\nb\n\nc"));
expect(await collect(readLines(r, { skipEmpty: true }))).toEqual(["a", "b", "c"]);
});
it("aborts mid-iteration via AbortSignal", async () => {
const controller = new AbortController();
const lines: string[] = [];
const r = Readable.from(Buffer.from("x\ny\nz"));
for await (const line of readLines(r, { signal: controller.signal })) {
lines.push(line);
if (lines.length === 1) controller.abort();
}
expect(lines.length).toBe(1);
});
});
describe("readLines — file path", { timeout: 5000 }, () => {
it("reads lines from a file path string", async () => {
const dir = await mkdtemp(join(tmpdir(), "readline-test-"));
const file = join(dir, "test.txt");
await writeFile(file, "alpha\nbeta\ngamma");
try {
expect(await collect(readLines(file))).toEqual(["alpha", "beta", "gamma"]);
} finally {
await rm(dir, { recursive: true });
}
});
});
describe("readLines — WHATWG ReadableStream", { timeout: 5000 }, () => {
it("reads lines from a WHATWG ReadableStream", async () => {
const encoder = new TextEncoder();
const stream = new ReadableStream({
start(controller) {
controller.enqueue(encoder.encode("line1\nline2\n"));
controller.close();
},
});
expect(await collect(readLines(stream as unknown as ReadableStream))).toEqual(["line1", "line2"]);
});
});
-
Run test — verify it fails
-
Create
src/reader/reader.types.ts
export interface ReadLinesOptions {
maxLineLength?: number;
encoding?: BufferEncoding;
skipEmpty?: boolean;
signal?: AbortSignal;
}
- Implement
src/reader/reader.ts
import { createReadStream } from "node:fs";
import type { Readable } from "node:stream";
import { createScanner } from "../scanner/scanner.js";
import type { ReadLinesOptions } from "./reader.types.js";
export type { ReadLinesOptions } from "./reader.types.js";
export async function* readLines(
input: Readable | ReadableStream | string,
options?: ReadLinesOptions
): AsyncGenerator<string, void, unknown> {
const signal = options?.signal;
let readable: Readable;
if (typeof input === "string") {
readable = createReadStream(input);
} else if (typeof (input as ReadableStream).getReader === "function") {
const { Readable: NodeReadable } = await import("node:stream");
readable = NodeReadable.fromWeb(input as Parameters<typeof NodeReadable.fromWeb>[0]);
} else {
readable = input as Readable;
}
for await (const line of createScanner(readable, {
maxLineLength: options?.maxLineLength,
encoding: options?.encoding,
signal,
})) {
if (options?.skipEmpty && line === "") continue;
yield line;
}
}
- Create
src/reader/index.ts
export { readLines } from "./reader.js";
export type { ReadLinesOptions } from "./reader.types.js";
- Run test — verify it passes
Note:
Readable.fromWeb()requires Node ≥ 17 or Bun. The genesis environment uses Bun ≥ 1.3.13, so this is satisfied.
- Update
src/index.tsto include reader:
export * from "./errors/index.js";
export * from "./reader/index.js";
-
Write
src/reader/reader.md(≥200 words, all required sections) -
Commit
git add universal/platform/readline/
git commit -m "feat(readline): reader — readLines() AsyncGenerator over streams and files"
Task 6: interface module
Files:
-
Create:
universal/platform/readline/src/interface/interface.ts -
Create:
universal/platform/readline/src/interface/interface.types.ts -
Create:
universal/platform/readline/src/interface/interface.test.ts -
Create:
universal/platform/readline/src/interface/interface.md -
Create:
universal/platform/readline/src/interface/index.ts -
Write failing test (
src/interface/interface.test.ts)
import { Readable, Writable } from "node:stream";
import { describe, expect, it } from "vitest";
import { ReadlineError, READLINE_CODES } from "../errors/errors.js";
import { createInterface } from "./interface.js";
function makeReadable(lines: string[]): Readable {
return Readable.from(Buffer.from(lines.join("\n") + "\n"));
}
function makeWritable(): { stream: Writable; output: string[] } {
const output: string[] = [];
const stream = new Writable({
write(chunk, _enc, cb) {
output.push(chunk.toString());
cb();
},
});
return { stream, output };
}
describe("createInterface — question", { timeout: 5000 }, () => {
it("returns the next line from input", async () => {
const { stream: out } = makeWritable();
const rl = createInterface({ input: makeReadable(["hello"]), output: out });
expect(await rl.question("prompt> ")).toBe("hello");
rl.close();
});
it("writes the prompt to output", async () => {
const { stream: out, output } = makeWritable();
const rl = createInterface({ input: makeReadable(["answer"]), output: out });
await rl.question("enter: ");
rl.close();
expect(output.join("")).toContain("enter: ");
});
it("throws INTERFACE.CLOSED after close()", async () => {
const rl = createInterface({ input: makeReadable([]) });
rl.close();
await expect(rl.question("x")).rejects.toMatchObject({
code: READLINE_CODES.INTERFACE.CLOSED,
});
});
it("marks closed=true after close()", () => {
const rl = createInterface({ input: makeReadable([]) });
expect(rl.closed).toBe(false);
rl.close();
expect(rl.closed).toBe(true);
});
it("throws INTERFACE.ABORTED when signal fires", async () => {
const controller = new AbortController();
const r = new Readable({ read() {} }); // never emits data
const rl = createInterface({ input: r, signal: controller.signal });
const p = rl.question("prompt");
controller.abort();
await expect(p).rejects.toMatchObject({ code: READLINE_CODES.INTERFACE.ABORTED });
rl.close();
});
});
describe("createInterface — asyncIterator", { timeout: 5000 }, () => {
it("iterates lines from input", async () => {
const rl = createInterface({ input: makeReadable(["a", "b", "c"]) });
const lines: string[] = [];
for await (const line of rl) lines.push(line);
expect(lines).toEqual(["a", "b", "c"]);
});
it("stops iteration when closed", async () => {
const rl = createInterface({ input: makeReadable(["x", "y"]) });
const lines: string[] = [];
for await (const line of rl) {
lines.push(line);
rl.close();
}
expect(lines.length).toBe(1);
});
it("terminates immediately when already closed (for-await starts after close)", async () => {
const rl = createInterface({ input: makeReadable(["z"]) });
rl.close();
const lines: string[] = [];
for await (const line of rl) lines.push(line);
expect(lines).toEqual([]);
});
});
describe("createInterface — OTel span", { timeout: 5000 }, () => {
it("does not throw when no OTel exporter is registered (no-op tracer)", () => {
expect(() => {
const rl = createInterface({ input: makeReadable([]) });
rl.close();
}).not.toThrow();
});
});
-
Run test — verify it fails
-
Create
src/interface/interface.types.ts
import type { Readable, Writable } from "node:stream";
export interface ReadlineOptions {
input: Readable;
output?: Writable;
signal?: AbortSignal;
}
export interface ReadlineInterface {
question(prompt: string, signal?: AbortSignal): Promise<string>;
close(): void;
readonly closed: boolean;
[Symbol.asyncIterator](): AsyncIterator<string>;
}
- Implement
src/interface/interface.ts
import { SpanStatusCode, trace } from "@opentelemetry/api";
import { ReadlineError, READLINE_CODES } from "../errors/errors.js";
import { createScanner } from "../scanner/scanner.js";
import type { ReadlineInterface, ReadlineOptions } from "./interface.types.js";
export type { ReadlineInterface, ReadlineOptions } from "./interface.types.js";
const tracer = trace.getTracer("@suaveplan/readline", "0.1.0");
export function createInterface(options: ReadlineOptions): ReadlineInterface {
const { input, output, signal: ifaceSignal } = options;
let closed = false;
const span = tracer.startSpan("readline.interface");
const scanner = createScanner(input);
const iter = scanner[Symbol.asyncIterator]();
function assertOpen(): void {
if (closed) {
throw new ReadlineError(
READLINE_CODES.INTERFACE.CLOSED,
"readline.interface.closed"
);
}
}
const rl: ReadlineInterface = {
get closed() {
return closed;
},
async question(prompt: string, questionSignal?: AbortSignal): Promise<string> {
assertOpen();
const effectiveSignal = questionSignal ?? ifaceSignal;
if (effectiveSignal?.aborted) {
throw new ReadlineError(
READLINE_CODES.INTERFACE.ABORTED,
"readline.interface.aborted",
{ cause: effectiveSignal.reason }
);
}
if (output) output.write(prompt);
return new Promise<string>((resolve, reject) => {
let abortHandler: (() => void) | undefined;
if (effectiveSignal) {
abortHandler = () => {
span.setStatus({ code: SpanStatusCode.ERROR, message: "aborted" });
reject(
new ReadlineError(
READLINE_CODES.INTERFACE.ABORTED,
"readline.interface.aborted",
{ cause: effectiveSignal.reason }
)
);
};
effectiveSignal.addEventListener("abort", abortHandler, {
once: true,
});
}
iter
.next()
.then((result) => {
if (abortHandler) {
effectiveSignal?.removeEventListener("abort", abortHandler);
}
if (result.done) {
reject(
new ReadlineError(
READLINE_CODES.INTERFACE.CLOSED,
"readline.interface.closed"
)
);
} else {
resolve(result.value);
}
})
.catch(reject);
});
},
close() {
if (closed) return;
closed = true;
span.end();
},
[Symbol.asyncIterator](): AsyncIterator<string> {
return {
async next(): Promise<IteratorResult<string>> {
if (closed) return { done: true, value: undefined };
if (ifaceSignal?.aborted) {
span.setStatus({ code: SpanStatusCode.ERROR, message: "aborted" });
span.end();
throw new ReadlineError(
READLINE_CODES.INTERFACE.ABORTED,
"readline.interface.aborted"
);
}
const result = await iter.next();
if (result.done) {
closed = true;
span.end();
return { done: true, value: undefined };
}
return { done: false, value: result.value };
},
};
},
};
return rl;
}
- Create
src/interface/index.ts
export { createInterface } from "./interface.js";
export type { ReadlineInterface, ReadlineOptions } from "./interface.types.js";
- Update
src/index.ts
export * from "./errors/index.js";
export * from "./reader/index.js";
export * from "./interface/index.js";
-
Run test — verify it passes
-
Write
src/interface/interface.md(≥200 words) -
Commit
git add universal/platform/readline/
git commit -m "feat(readline): interface — createInterface() interactive readline with OTel"
Task 7: Benchmarks + README + coverage gate
Files:
-
Create:
universal/platform/readline/src/__benchmarks__/scanner-throughput.bench.ts -
Create:
universal/platform/readline/src/__benchmarks__/reader-file.bench.ts -
Create:
universal/platform/readline/README.md -
Create
src/__benchmarks__/scanner-throughput.bench.ts
import { Readable } from "node:stream";
import { bench, run } from "mitata";
import { createScanner } from "../scanner/scanner.js";
function makeText(lines: number): string {
return Array.from({ length: lines }, (_, i) => `line ${i}`).join("\n");
}
const text1mb = makeText(50_000);
const text10mb = makeText(500_000);
bench("scanner 1 MB", async () => {
const r = Readable.from(Buffer.from(text1mb));
for await (const _ of createScanner(r)) { /* consume */ }
});
bench("scanner 10 MB", async () => {
const r = Readable.from(Buffer.from(text10mb));
for await (const _ of createScanner(r)) { /* consume */ }
});
await run();
- Create
src/__benchmarks__/reader-file.bench.ts
import { createReadStream } from "node:fs";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { bench, run } from "mitata";
import { readLines } from "../reader/reader.js";
const dir = await mkdtemp(join(tmpdir(), "rl-bench-"));
const file = join(dir, "data.txt");
await writeFile(file, Array.from({ length: 10_000 }, (_, i) => `line ${i}`).join("\n"));
bench("readLines() file path", async () => {
for await (const _ of readLines(file)) { /* consume */ }
});
bench("readLines() Readable", async () => {
for await (const _ of readLines(createReadStream(file))) { /* consume */ }
});
await run();
await rm(dir, { recursive: true });
-
Create
README.mdwith required sections (package name, description, install, quick-start, modules section linking each.md) -
Run full coverage gate
cd /mnt/s/web/genesis && bunx turbo lint typecheck test --filter=@suaveplan/readline
bunx turbo test:coverage --filter=@suaveplan/readline
Expected: all green, 100% coverage.
- Run benchmarks and paste output into README
cd /mnt/s/web/genesis/universal/platform/readline && bun run bench
- Commit
git add universal/platform/readline/
git commit -m "feat(readline): benchmarks, README, 100% coverage gate"
Task 8: Publish @suaveplan/readline
- Run pre-publish gate
cd /mnt/s/web/genesis && bunx turbo lint typecheck test --filter=@suaveplan/readline
bunx turbo test:coverage --filter=@suaveplan/readline
bunx turbo build --filter=@suaveplan/readline
All must be green.
- Publish
cd /mnt/s/web/genesis && bun scripts/publish.ts --package @suaveplan/readline
- Commit
git add .
git commit -m "chore(readline): publish @suaveplan/readline@0.0.0"
Chunk 3: @suaveplan/cli — scaffold, errors, parser
Task 9: Scaffold @suaveplan/cli
Files:
-
Create:
universal/platform/cli/package.json -
Create:
universal/platform/cli/tsconfig.lib.json -
Create:
universal/platform/cli/vite.config.ts -
Create:
universal/platform/cli/vitest.config.ts -
Create:
universal/platform/cli/tests/setup/polyfills.ts -
Modify:
CLAUDE.md— add cli entry -
Create
package.json
{
"name": "@suaveplan/cli",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Zero-dependency CLI toolkit: argument parser, command router, ANSI styling, output formatting, spinner, progress bar, interactive prompts",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./errors/errors": {
"import": {
"types": "./dist/errors/errors.d.ts",
"default": "./dist/errors/errors.js"
}
},
"./parser/parser": {
"import": {
"types": "./dist/parser/parser.d.ts",
"default": "./dist/parser/parser.js"
}
},
"./command/command": {
"import": {
"types": "./dist/command/command.d.ts",
"default": "./dist/command/command.js"
}
},
"./app/app": {
"import": {
"types": "./dist/app/app.d.ts",
"default": "./dist/app/app.js"
}
},
"./style/style": {
"import": {
"types": "./dist/style/style.d.ts",
"default": "./dist/style/style.js"
}
},
"./output/format/format": {
"import": {
"types": "./dist/output/format/format.d.ts",
"default": "./dist/output/format/format.js"
}
},
"./output/table/table": {
"import": {
"types": "./dist/output/table/table.d.ts",
"default": "./dist/output/table/table.js"
}
},
"./spinner/spinner": {
"import": {
"types": "./dist/spinner/spinner.d.ts",
"default": "./dist/spinner/spinner.js"
}
},
"./progress/progress": {
"import": {
"types": "./dist/progress/progress.d.ts",
"default": "./dist/progress/progress.js"
}
},
"./prompt/prompt": {
"import": {
"types": "./dist/prompt/prompt.d.ts",
"default": "./dist/prompt/prompt.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
"format": "bunx biome format --write --max-diagnostics 500 src/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../scripts/docs-check.ts .",
"bench": "bun run src/__benchmarks__/parser-throughput.bench.ts && bun run src/__benchmarks__/app-run.bench.ts"
},
"dependencies": {
"@suaveplan/readline": "workspace:^",
"@suaveplan/error": "workspace:^",
"@suaveplan/types": "workspace:^",
"@opentelemetry/api": "catalog:observability",
"zod": "catalog:forms",
"tslib": "catalog:build"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"fast-check": "catalog:testing",
"mitata": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": ["dist", "src", "README.md"]
}
- Create
tsconfig.lib.json(same as readline but with rootDir./src)
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "dist", "node_modules"]
}
- Create
vite.config.ts(same shape as readline, different cacheDir and externals)
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/cli",
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: { entry: "src/index.ts", formats: ["es"] },
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [
"@suaveplan/readline",
/^@suaveplan\/readline\//, // covers subpath imports like readline/interface/interface.js
"@suaveplan/error",
"@suaveplan/types",
"tslib",
"zod",
"@opentelemetry/api",
/^node:/,
],
},
},
});
-
Create
vitest.config.tsandtests/setup/polyfills.ts(identical to readline — ensure coverage exclude includes"src/**/*.schemas.ts", same as readline's config) -
Update
CLAUDE.md— add cli entry under platform in the package listing:
- **cli** — CLI toolkit: argument parser, command router, ANSI styling, output formatting, table renderer, spinner, progress bar, interactive prompts. Depends on `@suaveplan/readline`, `@suaveplan/error`, `@suaveplan/types`, `@opentelemetry/api`, `zod`.
-
Run
bun installfrom repo root -
Commit
git add universal/platform/cli/ CLAUDE.md
git commit -m "feat(cli): scaffold package"
Task 10: errors module
Files:
-
Create:
universal/platform/cli/src/errors/errors.ts -
Create:
universal/platform/cli/src/errors/errors.test.ts -
Create:
universal/platform/cli/src/errors/errors.md -
Create:
universal/platform/cli/src/errors/index.ts -
Write failing test
import { describe, expect, it } from "vitest";
import { CliError, CLI_CODES } from "./errors.js";
describe("CLI_CODES", { timeout: 5000 }, () => {
it("has all expected codes", () => {
expect(CLI_CODES.PARSE.UNKNOWN_OPTION).toBe("CLI.PARSE.UNKNOWN_OPTION");
expect(CLI_CODES.PARSE.MISSING_VALUE).toBe("CLI.PARSE.MISSING_VALUE");
expect(CLI_CODES.PARSE.INVALID_VALUE).toBe("CLI.PARSE.INVALID_VALUE");
expect(CLI_CODES.PARSE.MISSING_ARGUMENT).toBe("CLI.PARSE.MISSING_ARGUMENT");
expect(CLI_CODES.PARSE.MISSING_REQUIRED_OPTION).toBe("CLI.PARSE.MISSING_REQUIRED_OPTION");
expect(CLI_CODES.COMMAND.NO_ACTION).toBe("CLI.COMMAND.NO_ACTION");
expect(CLI_CODES.COMMAND.UNKNOWN_SUBCOMMAND).toBe("CLI.COMMAND.UNKNOWN_SUBCOMMAND");
expect(CLI_CODES.APP.RUN_FAILED).toBe("CLI.APP.RUN_FAILED");
expect(CLI_CODES.APP.ABORTED).toBe("CLI.APP.ABORTED");
});
});
describe("CliError", { timeout: 5000 }, () => {
it("is an Error with the given code", () => {
const err = new CliError(CLI_CODES.PARSE.UNKNOWN_OPTION, "cli.parse.unknown_option");
expect(err).toBeInstanceOf(Error);
expect(err.code).toBe("CLI.PARSE.UNKNOWN_OPTION");
});
});
-
Run test — verify it fails
-
Implement
src/errors/errors.ts
import type { SuaveplanErrorOptions } from "@suaveplan/error";
import { defineCodes, SuaveplanError } from "@suaveplan/error";
const PARSE_CODES = defineCodes("CLI.PARSE", [
"UNKNOWN_OPTION",
"MISSING_VALUE",
"INVALID_VALUE",
"MISSING_ARGUMENT",
"MISSING_REQUIRED_OPTION",
] as const);
const COMMAND_CODES = defineCodes("CLI.COMMAND", [
"NO_ACTION",
"UNKNOWN_SUBCOMMAND",
] as const);
const APP_CODES = defineCodes("CLI.APP", [
"RUN_FAILED",
"ABORTED",
] as const);
export const CLI_CODES = {
PARSE: PARSE_CODES,
COMMAND: COMMAND_CODES,
APP: APP_CODES,
} as const;
export type CliCode =
| (typeof PARSE_CODES)[keyof typeof PARSE_CODES]
| (typeof COMMAND_CODES)[keyof typeof COMMAND_CODES]
| (typeof APP_CODES)[keyof typeof APP_CODES];
export class CliError extends SuaveplanError<CliCode> {
// biome-ignore lint/complexity/noUselessConstructor: widens protected base constructor to public
public constructor(
code: CliCode,
messageKey: string,
options?: SuaveplanErrorOptions
) {
super(code, messageKey, options);
}
}
- Create
src/errors/index.ts, writeerrors.md, run test, commit
git commit -m "feat(cli): errors module"
Task 11: parser module
Files:
-
Create:
universal/platform/cli/src/parser/parser.ts -
Create:
universal/platform/cli/src/parser/parser.types.ts -
Create:
universal/platform/cli/src/parser/parser.schemas.ts -
Create:
universal/platform/cli/src/parser/parser.test.ts -
Create:
universal/platform/cli/src/parser/parser.md -
Create:
universal/platform/cli/src/parser/index.ts -
Write failing test (
src/parser/parser.test.ts)
import * as fc from "fast-check";
import { describe, expect, it } from "vitest";
import { CliError, CLI_CODES } from "../errors/errors.js";
import { parseArgumentSpec, parseArgs, parseOptionFlags } from "./parser.js";
describe("parseArgs", { timeout: 5000 }, () => {
it("parses a boolean flag", () => {
const result = parseArgs(["--verbose"], {
options: { verbose: { type: "boolean" } },
});
expect(result.options.verbose).toBe(true);
});
it("parses --no-* to set boolean false", () => {
const result = parseArgs(["--no-verbose"], {
options: { verbose: { type: "boolean", default: true } },
});
expect(result.options.verbose).toBe(false);
});
it("parses a string option with = syntax", () => {
const result = parseArgs(["--name=world"], {
options: { name: { type: "string" } },
});
expect(result.options.name).toBe("world");
});
it("parses a string option as next token", () => {
const result = parseArgs(["--output", "file.txt"], {
options: { output: { type: "string" } },
});
expect(result.options.output).toBe("file.txt");
});
it("parses a number option", () => {
const result = parseArgs(["--count", "42"], {
options: { count: { type: "number" } },
});
expect(result.options.count).toBe(42);
});
it("parses short flags", () => {
const result = parseArgs(["-v"], {
options: { verbose: { type: "boolean", short: "v" } },
});
expect(result.options.verbose).toBe(true);
});
it("captures rest after --", () => {
const result = parseArgs(["--", "foo", "bar"]);
expect(result.rest).toEqual(["foo", "bar"]);
});
it("throws CliError(UNKNOWN_OPTION) for unknown flags", () => {
expect(() => parseArgs(["--unknown"])).toThrow(CliError);
expect(() => parseArgs(["--unknown"])).toThrow(
expect.objectContaining({ code: CLI_CODES.PARSE.UNKNOWN_OPTION })
);
});
it("throws CliError(MISSING_VALUE) when value missing", () => {
expect(() =>
parseArgs(["--name"], { options: { name: { type: "string" } } })
).toThrow(expect.objectContaining({ code: CLI_CODES.PARSE.MISSING_VALUE }));
});
it("throws CliError(MISSING_ARGUMENT) for missing required positional", () => {
expect(() =>
parseArgs([], { arguments: [{ name: "file", required: true, variadic: false }] })
).toThrow(expect.objectContaining({ code: CLI_CODES.PARSE.MISSING_ARGUMENT }));
});
it("parses variadic positional as array", () => {
const result = parseArgs(["a", "b", "c"], {
arguments: [{ name: "files", required: true, variadic: true }],
});
expect(result.positionals.files).toEqual(["a", "b", "c"]);
});
it("throws CliError(INVALID_VALUE) when value not in choices", () => {
expect(() =>
parseArgs(["--format", "xml"], {
options: { format: { type: "string", choices: ["json", "text"] } },
})
).toThrow(expect.objectContaining({ code: CLI_CODES.PARSE.INVALID_VALUE }));
});
});
describe("parseOptionFlags", { timeout: 5000 }, () => {
it("extracts long flag name", () => {
const { name } = parseOptionFlags("--verbose");
expect(name).toBe("verbose");
});
it("extracts short flag", () => {
const { config } = parseOptionFlags("-v, --verbose");
expect(config.short).toBe("v");
});
it("marks as string type when value placeholder present", () => {
const { config } = parseOptionFlags("--output <file>");
expect(config.type).toBe("string");
});
});
describe("parseArgumentSpec", { timeout: 5000 }, () => {
it("marks required for <name>", () => {
expect(parseArgumentSpec("<file>").required).toBe(true);
});
it("marks optional for [name]", () => {
expect(parseArgumentSpec("[file]").required).toBe(false);
});
it("marks variadic for <files...>", () => {
expect(parseArgumentSpec("<files...>").variadic).toBe(true);
});
});
describe("parseArgs property tests", { timeout: 30000 }, () => {
it("never throws with allowUnknown: true", () => {
fc.assert(
fc.property(
fc.array(fc.oneof(
fc.constant("--foo"),
fc.constant("--bar=baz"),
fc.constant("-x"),
fc.string({ maxLength: 10 }),
), { maxLength: 10 }),
(argv) => {
expect(() => parseArgs(argv, { allowUnknown: true })).not.toThrow();
}
)
);
});
});
-
Run test — verify it fails
-
Create
src/parser/parser.types.ts
export type OptionType = "string" | "boolean" | "number" | "array";
export interface OptionConfig {
type?: OptionType;
short?: string;
description?: string;
default?: unknown;
required?: boolean;
choices?: string[];
}
export interface ArgumentConfig {
name: string;
description?: string;
required: boolean;
variadic: boolean;
default?: unknown;
}
export interface ParserConfig {
options?: Record<string, OptionConfig>;
arguments?: ArgumentConfig[];
allowUnknown?: boolean;
stopEarly?: boolean;
}
export interface ParsedArgs {
options: Record<string, unknown>;
positionals: Record<string, unknown>;
rest: string[];
}
- Create
src/parser/parser.schemas.ts
import z from "zod";
export const OptionTypeSchema = z.enum(["string", "boolean", "number", "array"]);
export const OptionConfigSchema = z.object({
type: OptionTypeSchema.optional(),
short: z.string().optional(),
description: z.string().optional(),
default: z.unknown().optional(),
required: z.boolean().optional(),
choices: z.array(z.string()).optional(),
});
export const ArgumentConfigSchema = z.object({
name: z.string(),
description: z.string().optional(),
required: z.boolean(),
variadic: z.boolean(),
default: z.unknown().optional(),
});
export const ParserConfigSchema = z.object({
options: z.record(OptionConfigSchema).optional(),
arguments: z.array(ArgumentConfigSchema).optional(),
allowUnknown: z.boolean().optional(),
stopEarly: z.boolean().optional(),
});
export const ParsedArgsSchema = z.object({
options: z.record(z.unknown()),
positionals: z.record(z.unknown()),
rest: z.array(z.string()),
});
-
Implement
src/parser/parser.ts— port fromsuaveplan/packages/universal/cli/src/parser/parser.ts, replacing all bareErrorthrows withCliErrorusing the appropriateCLI_CODES.PARSE.*code.Key replacements:
throw new Error(\Unknown option: --${key}`)→throw new CliError(CLI_CODES.PARSE.UNKNOWN_OPTION, "cli.parse.unknown_option", { context: { option: key } })`throw new Error(\Missing value for option: --${key}`)→throw new CliError(CLI_CODES.PARSE.MISSING_VALUE, ...)`throw new Error(\Invalid number value: "${raw}"`)→throw new CliError(CLI_CODES.PARSE.INVALID_VALUE, ...)`throw new Error(\Missing required argument: <${def.name}>`)→throw new CliError(CLI_CODES.PARSE.MISSING_ARGUMENT, ...)`throw new Error(\Missing required option: --${name}`)→throw new CliError(CLI_CODES.PARSE.MISSING_REQUIRED_OPTION, ...)`throw new Error(\Option must have a long flag: ${flags}`)→throw new CliError(CLI_CODES.PARSE.UNKNOWN_OPTION, ...)`throw new Error(\Invalid choice "${raw}" for option --${key}...`)→throw new CliError(CLI_CODES.PARSE.INVALID_VALUE, "cli.parse.invalid_value", { context: { option: key, value: raw, choices } })`
-
Create
src/parser/index.ts
export { parseArgs, parseArgumentSpec, parseOptionFlags } from "./parser.js";
export type { ArgumentConfig, OptionConfig, OptionType, ParsedArgs, ParserConfig } from "./parser.types.js";
export { ArgumentConfigSchema, OptionConfigSchema, ParsedArgsSchema, ParserConfigSchema } from "./parser.schemas.js";
-
Run test — verify it passes
-
Write
src/parser/parser.md, commit
git commit -m "feat(cli): parser module with Zod schemas and CliError"
Chunk 4: @suaveplan/cli — command, app, style, output
Task 12: command module
Files:
-
Create:
universal/platform/cli/src/command/command.ts -
Create:
universal/platform/cli/src/command/command.types.ts -
Create:
universal/platform/cli/src/command/command.schemas.ts -
Create:
universal/platform/cli/src/command/command.test.ts -
Create:
universal/platform/cli/src/command/command.md -
Create:
universal/platform/cli/src/command/index.ts -
Write failing test (
src/command/command.test.ts)
import { describe, expect, it, vi } from "vitest";
import { CliError, CLI_CODES } from "../errors/errors.js";
import { Command, parseArgumentSpec, parseOptionFlags } from "./command.js";
describe("Command — fluent API", { timeout: 5000 }, () => {
it("sets description", () => {
const cmd = new Command("test").description("A test command");
expect(cmd.descriptionText).toBe("A test command");
});
it("registers and routes to subcommand", async () => {
const called: string[] = [];
const parent = new Command("app");
parent.command("sub").action(async () => { called.push("sub"); });
await parent.execute(["sub"]);
expect(called).toEqual(["sub"]);
});
it("routes to subcommand by alias", async () => {
const called: string[] = [];
const parent = new Command("app");
parent.command("subcommand").alias("s").action(async () => { called.push("aliased"); });
await parent.execute(["s"]);
expect(called).toEqual(["aliased"]);
});
it("throws CliError(NO_ACTION) when no action defined", async () => {
const cmd = new Command("empty");
await expect(cmd.execute([])).rejects.toMatchObject({
code: CLI_CODES.COMMAND.NO_ACTION,
});
});
it("throws CliError(UNKNOWN_SUBCOMMAND) when subcommand not found but subcommands exist", async () => {
const cmd = new Command("app");
cmd.command("sub").action(async () => {});
await expect(cmd.execute(["nope"])).rejects.toMatchObject({
code: CLI_CODES.COMMAND.UNKNOWN_SUBCOMMAND,
});
});
it("calls action with parsed context", async () => {
let capturedOpts: Record<string, unknown> = {};
const cmd = new Command("greet")
.option("--name <n>", "Name")
.action(async (ctx) => { capturedOpts = ctx.options; });
await cmd.execute(["--name", "world"]);
expect(capturedOpts.name).toBe("world");
});
it("hides command from help", () => {
const cmd = new Command("hidden").hidden();
expect(cmd.isHidden).toBe(true);
});
it("generates help text with usage line", () => {
const cmd = new Command("build").description("Build the project");
const help = cmd.helpText();
expect(help).toContain("Usage: build");
});
it("OTel span does not throw (no-op tracer)", async () => {
const cmd = new Command("noop").action(async () => {});
await expect(cmd.execute([])).resolves.not.toThrow();
});
});
-
Run test — verify it fails
-
Create
src/command/command.types.ts
export interface CommandInfo {
name: string;
description: string;
fullName: string;
}
export interface ActionContext {
args: Record<string, unknown>;
options: Record<string, unknown>;
rest: string[];
command: CommandInfo;
}
export type ActionFn = (ctx: ActionContext) => void | Promise<void>;
- Create
src/command/command.schemas.ts
import z from "zod";
export const CommandInfoSchema = z.object({
name: z.string(),
description: z.string(),
fullName: z.string(),
});
-
Implement
src/command/command.ts— port fromsuaveplansource. Key changes:- Replace bare
Errorthrows withCliError(CLI_CODES.COMMAND.NO_ACTION)andCliError(CLI_CODES.COMMAND.UNKNOWN_SUBCOMMAND) - Add OTel span in
execute():
const tracer = trace.getTracer("@suaveplan/cli", "0.1.0"); // inside execute(): const span = tracer.startSpan(`cli.command.execute`, { attributes: { "cli.command": fullName } }); try { // ... existing logic ... span.end(); } catch (err) { span.setStatus({ code: SpanStatusCode.ERROR }); span.end(); throw err; }- The subcommand routing logic: when
argv[0]is not a flag AND a subcommand exists by that name → route. If no subcommand matches AND subcommands are registered →CliError(UNKNOWN_SUBCOMMAND). If no action →CliError(NO_ACTION).
- Replace bare
-
Create
src/command/index.ts, run test, writecommand.md, commit
git commit -m "feat(cli): command module with OTel span and CliError"
Task 13: app module
Files:
-
Create:
universal/platform/cli/src/app/app.ts -
Create:
universal/platform/cli/src/app/app.types.ts -
Create:
universal/platform/cli/src/app/app.schemas.ts -
Create:
universal/platform/cli/src/app/app.test.ts -
Create:
universal/platform/cli/src/app/app.md -
Create:
universal/platform/cli/src/app/index.ts -
Write failing test (
src/app/app.test.ts)
import { describe, expect, it } from "vitest";
import { CliError, CLI_CODES } from "../errors/errors.js";
import { App, cli } from "./app.js";
function makeApp(writer: (s: string) => void = () => {}): App {
return new App({ name: "test-app", version: "1.2.3", writer });
}
describe("App.run()", { timeout: 5000 }, () => {
it("prints version with --version", async () => {
const output: string[] = [];
const app = makeApp((s) => output.push(s));
await app.run(["--version"]);
expect(output.join("")).toContain("1.2.3");
});
it("prints help with --help", async () => {
const output: string[] = [];
const app = makeApp((s) => output.push(s));
await app.run(["--help"]);
expect(output.join("")).toContain("Usage");
});
it("routes to registered command", async () => {
const called: string[] = [];
const app = makeApp();
app.command("build").action(async () => { called.push("build"); });
await app.run(["build"]);
expect(called).toEqual(["build"]);
});
it("throws CliError(ABORTED) when AbortSignal fires before execution", async () => {
const controller = new AbortController();
controller.abort();
const app = makeApp();
app.action(async () => {});
await expect(app.run([], { signal: controller.signal })).rejects.toMatchObject({
code: CLI_CODES.APP.ABORTED,
});
});
it("calls onError with CliError on execution failure", async () => {
const errors: Error[] = [];
const app = new App({
name: "app",
onError: (e) => errors.push(e),
});
app.action(async () => { throw new Error("boom"); });
await app.run([]);
expect(errors.length).toBe(1);
});
it("OTel span does not throw (no-op tracer)", async () => {
const app = makeApp();
app.action(async () => {});
await expect(app.run([])).resolves.not.toThrow();
});
});
describe("cli() factory", { timeout: 5000 }, () => {
it("creates App with name and version", () => {
const app = cli("myapp", "2.0.0");
expect(app.name).toBe("myapp");
expect(app.version).toBe("2.0.0");
});
});
-
Run test — verify it fails
-
Create
src/app/app.types.ts
export interface AppConfig {
name: string;
version?: string;
description?: string;
enableHelp?: boolean;
enableVersion?: boolean;
onError?: (err: Error) => void;
writer?: (text: string) => void;
}
- Create
src/app/app.schemas.ts
import z from "zod";
export const AppConfigSchema = z.object({
name: z.string(),
version: z.string().optional(),
description: z.string().optional(),
enableHelp: z.boolean().optional(),
enableVersion: z.boolean().optional(),
});
-
Implement
src/app/app.ts— port fromsuaveplansource. Key changes:- Add OTel span in
run():
const tracer = trace.getTracer("@suaveplan/cli", "0.1.0"); // inside run(): if (options?.signal?.aborted) { throw new CliError(CLI_CODES.APP.ABORTED, "cli.app.aborted"); } const span = tracer.startSpan("cli.app.run"); try { // ... existing logic ... span.end(); } catch (err) { span.setStatus({ code: SpanStatusCode.ERROR }); span.end(); throw err; }run()signature becomesasync run(argv?: string[], options?: { signal?: AbortSignal }): Promise<void>onErrorcatches and handles; wrap plainErrorinCliError(CLI_CODES.APP.RUN_FAILED)if not already aCliError
- Add OTel span in
-
Create
src/app/index.ts, run test, writeapp.md, commit
git commit -m "feat(cli): app module with OTel span and AbortSignal"
Task 14: style, output/format, output/table
Files:
- Create all files for
style/,output/format/,output/table/
These three modules are pure functions with no IO. Port directly from the suaveplan source, adding Zod schemas and replacing bare Error throws with CliError.
-
Write tests for
style— key scenarios:isColorSupported()returns booleanstripAnsi(style.bold.red("text"))equals"text"(property test:stripAnsi(style.bold.red(s)) === sfor alls)createStyler(true)returns a callable stylergetStyleNames()returns non-empty array
-
Write tests for
output/format— key scenarios:keyValue({ a: 1, b: 2 })contains"a"and"1"list(["x", "y"])contains bullet markerstree({ label: "root", children: [{ label: "child" }] })contains"root"and"child"heading("Title", 1)returns string with"═"bordersjson({ x: 1 })returns'{\n "x": 1\n}'columns([])returns""
-
Write tests for
output/table— key scenarios:- Renders header row
- Aligns columns to max width (property test: column width ≥ max cell content width)
- Handles empty rows
-
Implement
style.ts,style.schemas.ts— port from source; addStyleOptionsSchema -
Implement
output/format/format.ts,format.schemas.ts— port from source; add Zod schemas for each options type -
Implement
output/table/table.ts,table.schemas.ts— port from source; replace bareErrorwithCliError -
Create all
index.tsbarrels and.mddocs -
Run tests — verify all pass
-
Commit
git commit -m "feat(cli): style, output/format, output/table modules"
Chunk 5: @suaveplan/cli — spinner, progress, prompt, benchmarks, publish, archival
Task 15: spinner module
Files:
-
Create:
universal/platform/cli/src/spinner/spinner.ts -
Create:
universal/platform/cli/src/spinner/spinner.types.ts -
Create:
universal/platform/cli/src/spinner/spinner.schemas.ts -
Create:
universal/platform/cli/src/spinner/spinner.test.ts -
Create:
universal/platform/cli/src/spinner/spinner.md -
Create:
universal/platform/cli/src/spinner/index.ts -
Write failing test (
src/spinner/spinner.test.ts)
import { Writable } from "node:stream";
import { describe, expect, it } from "vitest";
import { createSpinner } from "./spinner.js";
function makeStream(): { stream: Writable; output: () => string } {
const chunks: string[] = [];
const stream = new Writable({
write(chunk, _enc, cb) { chunks.push(chunk.toString()); cb(); },
}) as Writable & { isTTY: boolean; clearLine: (dir: number) => void; cursorTo: (x: number) => void };
stream.isTTY = false; // non-TTY: uses fallback write
(stream as Writable & { clearLine: (dir: number) => void }).clearLine = () => {};
(stream as Writable & { cursorTo: (x: number) => void }).cursorTo = () => {};
return { stream: stream as Writable, output: () => chunks.join("") };
}
describe("createSpinner", { timeout: 5000 }, () => {
it("starts and is spinning", () => {
const { stream } = makeStream();
const sp = createSpinner({ text: "Loading", stream: stream as Parameters<typeof createSpinner>[0] extends { stream?: infer S } ? S : never });
sp.start();
expect(sp.isSpinning).toBe(true);
sp.stop();
});
it("is not spinning after stop()", () => {
const { stream } = makeStream();
const sp = createSpinner({ text: "x", stream: stream as any });
sp.start().stop();
expect(sp.isSpinning).toBe(false);
});
it("writes to non-TTY stream on start", () => {
const { stream, output } = makeStream();
const sp = createSpinner({ text: "Working", stream: stream as any });
sp.start();
expect(output()).toContain("Working");
sp.stop();
});
it("succeed writes success symbol", () => {
const { stream, output } = makeStream();
const sp = createSpinner({ text: "done", stream: stream as any });
sp.start().succeed("Completed");
expect(output()).toContain("Completed");
});
it("fail writes fail symbol", () => {
const { stream, output } = makeStream();
const sp = createSpinner({ text: "task", stream: stream as any });
sp.start().fail("Failed");
expect(output()).toContain("Failed");
});
it("aborts via AbortSignal", async () => {
const { stream } = makeStream();
const controller = new AbortController();
const sp = createSpinner({ text: "work", stream: stream as any, signal: controller.signal });
sp.start();
controller.abort();
// Give microtask queue a tick
await new Promise((r) => setTimeout(r, 10));
expect(sp.isSpinning).toBe(false);
});
});
-
Run test — verify it fails
-
Create
src/spinner/spinner.types.ts
export interface WritableStreamLike {
write(text: string): void;
clearLine?(dir: number): void;
cursorTo?(x: number): void;
isTTY?: boolean;
}
export interface SpinnerOptions {
text?: string;
frames?: string[];
interval?: number;
stream?: WritableStreamLike;
signal?: AbortSignal;
}
export interface SpinnerInstance {
readonly isSpinning: boolean;
start(text?: string): SpinnerInstance;
stop(): SpinnerInstance;
succeed(text?: string): SpinnerInstance;
fail(text?: string): SpinnerInstance;
warn(text?: string): SpinnerInstance;
update(text: string): SpinnerInstance;
}
-
Implement
src/spinner/spinner.ts— port fromsuaveplansource; addsignalhandling. Two guards needed — pre-fired signal (already aborted beforestart()) and live abort event:// Inside createSpinner, after setting up the instance: if (opts.signal) { opts.signal.addEventListener("abort", () => { instance.stop(); }, { once: true }); }Inside
start(), add pre-abort guard as the first line:start(text?: string): SpinnerInstance { if (opts.signal?.aborted) return instance; // already aborted — no-op // ...rest of start logic } -
Create
src/spinner/spinner.schemas.ts
import z from "zod";
export const SpinnerOptionsSchema = z.object({
text: z.string().optional(),
frames: z.array(z.string()).optional(),
interval: z.number().positive().optional(),
});
- Create
src/spinner/index.ts, run test, writespinner.md, commit
git commit -m "feat(cli): spinner module with AbortSignal"
Task 16: progress module
Files: same pattern as spinner.
-
Write failing test — key scenarios:
createProgress({ total: 100 }), renders bar,update(50)changes percentage, AbortSignal stops progress. -
Create
src/progress/progress.types.ts
import type { WritableStreamLike } from "../spinner/spinner.types.js";
export type { WritableStreamLike };
export interface ProgressOptions {
total: number;
width?: number;
stream?: WritableStreamLike;
signal?: AbortSignal;
}
export interface ProgressInstance {
update(current: number): void;
complete(): void;
render(): string;
}
-
Implement
src/progress/progress.ts— port fromsuaveplansource; addsignalhandling same as spinner (both the"abort"event listener AND a pre-abort guard insideupdate()/ the start path — checkopts.signal?.abortedbefore writing to the stream). -
Create schemas, index, md, run test, commit
git commit -m "feat(cli): progress module with AbortSignal"
Task 17: prompt module
Files:
-
Create:
universal/platform/cli/src/prompt/prompt.ts -
Create:
universal/platform/cli/src/prompt/prompt.types.ts -
Create:
universal/platform/cli/src/prompt/prompt.schemas.ts -
Create:
universal/platform/cli/src/prompt/prompt.test.ts -
Create:
universal/platform/cli/src/prompt/prompt.md -
Create:
universal/platform/cli/src/prompt/index.ts -
Write failing test (
src/prompt/prompt.test.ts)
import { Readable, Writable } from "node:stream";
import { describe, expect, it } from "vitest";
import { confirm, multiselect, password, select, text } from "./prompt.js";
import type { InputReader } from "./prompt.types.js";
function makeReader(lines: string[]): InputReader {
let idx = 0;
return {
question: async (_prompt: string) => lines[idx++] ?? "",
close: () => {},
};
}
describe("text()", { timeout: 5000 }, () => {
it("returns user input", async () => {
expect(await text("Name?", makeReader(["Alice"]))).toBe("Alice");
});
it("returns default when input is empty", async () => {
expect(await text({ message: "Name?", default: "Bob" }, makeReader([""]))).toBe("Bob");
});
it("re-prompts when validation fails then passes", async () => {
const result = await text(
{ message: "Age?", validate: (v) => (Number(v) > 0 ? true : "must be positive") },
makeReader(["-1", "25"])
);
expect(result).toBe("25");
});
});
describe("password()", { timeout: 5000 }, () => {
it("returns user input", async () => {
expect(await password("Pass?", makeReader(["secret"]))).toBe("secret");
});
});
describe("confirm()", { timeout: 5000 }, () => {
it("returns true for 'y'", async () => {
expect(await confirm("Continue?", makeReader(["y"]))).toBe(true);
});
it("returns false for 'n'", async () => {
expect(await confirm("Continue?", makeReader(["n"]))).toBe(false);
});
it("returns default when empty", async () => {
expect(await confirm({ message: "Go?", default: false }, makeReader([""]))).toBe(false);
});
});
describe("select()", { timeout: 5000 }, () => {
it("returns the selected choice value", async () => {
const result = await select(
{ message: "Pick:", choices: ["a", "b", "c"] },
makeReader(["2"])
);
expect(result).toBe("b");
});
it("re-prompts on invalid selection", async () => {
const result = await select(
{ message: "Pick:", choices: ["x", "y"] },
makeReader(["99", "1"])
);
expect(result).toBe("x");
});
});
describe("multiselect()", { timeout: 5000 }, () => {
it("returns selected values", async () => {
const result = await multiselect(
{ message: "Pick:", choices: ["a", "b", "c"] },
makeReader(["1,3"])
);
expect(result).toEqual(["a", "c"]);
});
it("enforces min selection", async () => {
const result = await multiselect(
{ message: "Pick:", choices: ["a", "b", "c"], min: 2 },
makeReader(["1", "1,2"])
);
expect(result.length).toBeGreaterThanOrEqual(2);
});
});
-
Run test — verify it fails
-
Create
src/prompt/prompt.types.ts
export interface InputReader {
question(prompt: string): Promise<string>;
close(): void;
}
export interface TextPromptOptions {
message: string;
default?: string;
validate?: (value: string) => true | string;
}
export interface PasswordPromptOptions {
message: string;
validate?: (value: string) => true | string;
}
export interface ConfirmPromptOptions {
message: string;
default?: boolean;
}
export interface SelectChoice<T = string> {
label: string;
value: T;
description?: string;
}
export interface SelectPromptOptions<T = string> {
message: string;
choices: Array<SelectChoice<T> | string>;
default?: T;
}
export interface MultiSelectPromptOptions<T = string> {
message: string;
choices: Array<SelectChoice<T> | string>;
min?: number;
max?: number;
}
- Implement
src/prompt/prompt.ts— port fromsuaveplansource. Critical change:createDefaultReader()uses@suaveplan/readline/interface:
import { createInterface } from "@suaveplan/readline/interface/interface.js";
import type { Readable, Writable } from "node:stream";
import type { InputReader } from "./prompt.types.js";
export async function createDefaultReader(): Promise<InputReader> {
const { stdin, stdout } = process;
const rl = createInterface({
input: stdin as unknown as Readable,
output: stdout as unknown as Writable,
});
return {
question: (prompt: string) => rl.question(prompt),
close: () => rl.close(),
};
}
Keep the five prompt functions (text, password, confirm, select, multiselect) matching the suaveplan implementation. No other changes needed — the InputReader interface is structurally identical to what the test mock provides.
- Create
src/prompt/prompt.schemas.ts
import z from "zod";
export const TextPromptOptionsSchema = z.object({
message: z.string(),
default: z.string().optional(),
});
export const ConfirmPromptOptionsSchema = z.object({
message: z.string(),
default: z.boolean().optional(),
});
export const SelectChoiceSchema = z.object({
label: z.string(),
value: z.unknown(),
description: z.string().optional(),
});
- Create
src/prompt/index.ts, run test, writeprompt.md, commit
git commit -m "feat(cli): prompt module using @suaveplan/readline/interface"
Task 18: Root src/index.ts barrel
- Create
universal/platform/cli/src/index.ts
// App
export { App, cli } from "./app/app.js";
export type { AppConfig } from "./app/app.types.js";
export { AppConfigSchema } from "./app/app.schemas.js";
// Command
export { Command, parseArgumentSpec, parseOptionFlags } from "./command/command.js";
export type { ActionContext, ActionFn, CommandInfo } from "./command/command.types.js";
export { CommandInfoSchema } from "./command/command.schemas.js";
// Errors
export { CliError, CLI_CODES } from "./errors/errors.js";
export type { CliCode } from "./errors/errors.js";
// Parser
export { parseArgs } from "./parser/parser.js";
export type { ArgumentConfig, OptionConfig, OptionType, ParsedArgs, ParserConfig } from "./parser/parser.types.js";
export { ArgumentConfigSchema, OptionConfigSchema, OptionTypeSchema, ParsedArgsSchema, ParserConfigSchema } from "./parser/parser.schemas.js";
// Style
export { createStyler, getStyleNames, isColorSupported, stripAnsi, style } from "./style/style.js";
export type { BgColor, Color, Modifier, StyleCode, Styler } from "./style/style.types.js";
export { StyleOptionsSchema } from "./style/style.schemas.js";
// Output
export { columns, divider, heading, indent, json, keyValue, list, tree } from "./output/format/format.js";
export type { JsonOptions, KeyValueOptions, ListOptions, TreeNode, TreeOptions } from "./output/format/format.types.js";
export { FormatOptionsSchema } from "./output/format/format.schemas.js";
export { table } from "./output/table/table.js";
export type { Alignment, TableOptions } from "./output/table/table.types.js";
export { TableOptionsSchema } from "./output/table/table.schemas.js";
// Spinner
export { createSpinner } from "./spinner/spinner.js";
export type { SpinnerInstance, SpinnerOptions, WritableStreamLike } from "./spinner/spinner.types.js";
export { SpinnerOptionsSchema } from "./spinner/spinner.schemas.js";
// Progress
export { createProgress } from "./progress/progress.js";
export type { ProgressInstance, ProgressOptions } from "./progress/progress.types.js";
export { ProgressOptionsSchema } from "./progress/progress.schemas.js";
// Prompt
export { confirm, createDefaultReader, multiselect, password, select, text } from "./prompt/prompt.js";
export type { ConfirmPromptOptions, InputReader, MultiSelectPromptOptions, PasswordPromptOptions, SelectChoice, SelectPromptOptions, TextPromptOptions } from "./prompt/prompt.types.js";
export { ConfirmPromptOptionsSchema, SelectChoiceSchema, TextPromptOptionsSchema } from "./prompt/prompt.schemas.js";
- Commit
git commit -m "feat(cli): root barrel index"
Task 19: Benchmarks + README
- Create
src/__benchmarks__/parser-throughput.bench.ts
import { bench, run } from "mitata";
import { parseArgs } from "../parser/parser.js";
const config = {
options: {
verbose: { type: "boolean" as const, short: "v" },
output: { type: "string" as const, short: "o" },
count: { type: "number" as const },
format: { type: "string" as const, choices: ["json", "text"] },
dry: { type: "boolean" as const },
force: { type: "boolean" as const },
quiet: { type: "boolean" as const },
debug: { type: "boolean" as const },
timeout: { type: "number" as const },
retries: { type: "number" as const },
},
};
const argv = ["--verbose", "--output", "out.txt", "--count", "5", "--format", "json"];
bench("parseArgs (20-flag config)", () => {
parseArgs(argv, config);
});
await run();
- Create
src/__benchmarks__/app-run.bench.ts
import { bench, run } from "mitata";
import { App } from "../app/app.js";
function makeApp(): App {
const app = new App({ name: "bench-app", writer: () => {} });
app.command("build").action(async () => {});
app.command("test").action(async () => {});
app.command("lint").action(async () => {});
return app;
}
bench("App.run() dispatch", async () => {
const app = makeApp();
await app.run(["build"]);
});
await run();
-
Create
README.mdwith required sections plus benchmark baseline numbers -
Run full coverage gate
cd /mnt/s/web/genesis && bunx turbo lint typecheck test --filter=@suaveplan/cli
bunx turbo test:coverage --filter=@suaveplan/cli
Expected: all green, 100% coverage.
- Run benchmarks and paste baseline into README
cd /mnt/s/web/genesis/universal/platform/cli && bun run bench
- Commit
git add universal/platform/cli/
git commit -m "feat(cli): benchmarks, README, 100% coverage gate"
Task 20: Publish @suaveplan/cli + OpenSpec archival
- Run pre-publish gate
cd /mnt/s/web/genesis
bunx turbo lint typecheck test --filter=@suaveplan/cli
bunx turbo test:coverage --filter=@suaveplan/cli
bunx turbo build --filter=@suaveplan/cli
- Publish
bun scripts/publish.ts --package @suaveplan/cli
- Create OpenSpec change folders
openspec/changes/enterprise-readline/proposal.md
openspec/changes/enterprise-readline/tasks.md
openspec/changes/enterprise-readline/specs/readline/spec.md
openspec/changes/enterprise-cli/proposal.md
openspec/changes/enterprise-cli/tasks.md
openspec/changes/enterprise-cli/specs/cli/spec.md
Each proposal.md references the spec doc at docs/superpowers/specs/2026-04-20-readline-cli-design.md. Each tasks.md links to this plan. Each specs/<cap>/spec.md contains the final accepted spec for that capability.
- Archive both changes
mv openspec/changes/enterprise-readline openspec/archive/2026-04-20-enterprise-readline
mv openspec/changes/enterprise-cli openspec/archive/2026-04-20-enterprise-cli
- Final banned-pattern scan (zero output expected)
grep -rn "@deprecated\|TODO\|FIXME\|ts-ignore\|ts-expect-error\|\.skip\|\.only\|\bany\b" \
universal/platform/readline/src/ universal/platform/cli/src/ --include="*.ts"
- Final missing-.md scan (zero output expected)
for pkg in readline cli; do
for f in $(find universal/platform/$pkg/src -name "*.ts" \
! -name "*.test.ts" ! -name "*.types.ts" \
! -name "index.ts" ! -name "*.schemas.ts" ! -name "*.bench.ts"); do
md="${f%.ts}.md"
[ -f "$md" ] || echo "MISSING: $md"
done
done
- Commit
git add .
git commit -m "chore: publish cli@0.0.0, archive OpenSpec changes"
Acceptance Checklist
bunx turbo lint typecheck test --filter=@suaveplan/readlinegreenbunx turbo lint typecheck test --filter=@suaveplan/cligreen- 100% istanbul coverage on both packages
- Zero
any,TODO,FIXME,ts-ignore,.skipinsrc/ - Every
.tsimplementation file has a sibling.md(≥200 words) - Zod schemas exported for all public types
- OTel spans on
App.run(),Command.execute(),createInterface() - AbortSignal honoured in
readLines(),question(), async iterator,spinner,progress, all prompt functions node:readlineabsent from@suaveplan/clidependency graph"sideEffects": falsein bothpackage.jsonfilesbenchscript wired in bothpackage.jsonfiles;benchtask inturbo.json- Benchmark baselines published in both
README.mdfiles - OpenSpec changes archived;
FOLDERS.ymlandCLAUDE.mdupdated