# `@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` ```text 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` ```text 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` — add `readline` to `universal/platform` - Modify: `CLAUDE.md` — add readline entry to package listing - [ ] **Create `package.json`** ```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`** ```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`** ```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`** ```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.ts` files 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`** ```ts export {}; ``` - [ ] **Update `FOLDERS.yml`** — change the `platform` line to: ```yaml platform: [ cli, electrobun, macos-contacts, opentelemetry, profiler, readline, sitemap ] # 7 ``` - [ ] **Update `CLAUDE.md`** — add to the platform section of the package listing: ```text - **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.ts` stub** (required before any build can run — grows as modules land): ```ts export * from "./errors/index.js"; ``` - [ ] **Run `bun install` from repo root to register the new workspace package** ```bash 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`) ```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** ```bash cd /mnt/s/web/genesis && bunx turbo test --filter=@suaveplan/readline ``` Expected: `Cannot find module './errors.js'` - [ ] **Implement `src/errors/errors.ts`** ```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 { // 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`** ```ts export { ReadlineError, READLINE_CODES } from "./errors.js"; export type { ReadlineCode } from "./errors.js"; ``` - [ ] **Run test — verify it passes** ```bash 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** ```bash 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`) ```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): Promise { 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** ```bash cd /mnt/s/web/genesis && bunx turbo test --filter=@suaveplan/readline ``` - [ ] **Create `src/scanner/scanner.types.ts`** ```ts export interface ScannerOptions { maxLineLength?: number; encoding?: BufferEncoding; signal?: AbortSignal; } ``` - [ ] **Implement `src/scanner/scanner.ts`** ```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 { 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 { if (queue.length > 0) return Promise.resolve(queue.shift()!); return new Promise((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`** ```ts export { createScanner } from "./scanner.js"; export type { ScannerOptions } from "./scanner.types.js"; ``` - [ ] **Run test — verify it passes** ```bash cd /mnt/s/web/genesis && bunx turbo test --filter=@suaveplan/readline ``` - [ ] **Write `src/scanner/scanner.md`** (≥200 words, all required sections) - [ ] **Commit** ```bash 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` — add `bench` task after `test:coverage` block - [ ] **Add `bench` task to `turbo.json`** (after the `test:coverage` block): ```json "bench": { "dependsOn": ["^build", "build"], "inputs": ["src/**/*.bench.ts"], "cache": false, "outputLogs": "new-only" } ``` - [ ] **Commit** ```bash 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`) ```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): Promise { 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`** ```ts export interface ReadLinesOptions { maxLineLength?: number; encoding?: BufferEncoding; skipEmpty?: boolean; signal?: AbortSignal; } ``` - [ ] **Implement `src/reader/reader.ts`** ```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 { 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[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`** ```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.ts`** to include reader: ```ts export * from "./errors/index.js"; export * from "./reader/index.js"; ``` - [ ] **Write `src/reader/reader.md`** (≥200 words, all required sections) - [ ] **Commit** ```bash 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`) ```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`** ```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; close(): void; readonly closed: boolean; [Symbol.asyncIterator](): AsyncIterator; } ``` - [ ] **Implement `src/interface/interface.ts`** ```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 { 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((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 { return { async next(): Promise> { 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`** ```ts export { createInterface } from "./interface.js"; export type { ReadlineInterface, ReadlineOptions } from "./interface.types.js"; ``` - [ ] **Update `src/index.ts`** ```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** ```bash 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`** ```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`** ```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.md`** with required sections (package name, description, install, quick-start, modules section linking each `.md`) - [ ] **Run full coverage gate** ```bash 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** ```bash cd /mnt/s/web/genesis/universal/platform/readline && bun run bench ``` - [ ] **Commit** ```bash git add universal/platform/readline/ git commit -m "feat(readline): benchmarks, README, 100% coverage gate" ``` --- ### Task 8: Publish `@suaveplan/readline` - [ ] **Run pre-publish gate** ```bash 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** ```bash cd /mnt/s/web/genesis && bun scripts/publish.ts --package @suaveplan/readline ``` - [ ] **Commit** ```bash 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`** ```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`) ```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`** (same shape as readline, different cacheDir and externals) ```ts 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.ts`** and `tests/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: ```text - **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 install` from repo root** - [ ] **Commit** ```bash 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** ```ts 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`** ```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 { // 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`**, write `errors.md`, run test, commit ```bash 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`) ```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 "); expect(config.type).toBe("string"); }); }); describe("parseArgumentSpec", { timeout: 5000 }, () => { it("marks required for ", () => { expect(parseArgumentSpec("").required).toBe(true); }); it("marks optional for [name]", () => { expect(parseArgumentSpec("[file]").required).toBe(false); }); it("marks variadic for ", () => { expect(parseArgumentSpec("").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`** ```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; arguments?: ArgumentConfig[]; allowUnknown?: boolean; stopEarly?: boolean; } export interface ParsedArgs { options: Record; positionals: Record; rest: string[]; } ``` - [ ] **Create `src/parser/parser.schemas.ts`** ```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 from `suaveplan/packages/universal/cli/src/parser/parser.ts`, replacing all bare `Error` throws with `CliError` using the appropriate `CLI_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`** ```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 ```bash 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`) ```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 = {}; const cmd = new Command("greet") .option("--name ", "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`** ```ts export interface CommandInfo { name: string; description: string; fullName: string; } export interface ActionContext { args: Record; options: Record; rest: string[]; command: CommandInfo; } export type ActionFn = (ctx: ActionContext) => void | Promise; ``` - [ ] **Create `src/command/command.schemas.ts`** ```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 from `suaveplan` source. Key changes: - Replace bare `Error` throws with `CliError(CLI_CODES.COMMAND.NO_ACTION)` and `CliError(CLI_CODES.COMMAND.UNKNOWN_SUBCOMMAND)` - Add OTel span in `execute()`: ```ts 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)`. - [ ] **Create `src/command/index.ts`**, run test, write `command.md`, commit ```bash 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`) ```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`** ```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`** ```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 from `suaveplan` source. Key changes: - Add OTel span in `run()`: ```ts 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 becomes `async run(argv?: string[], options?: { signal?: AbortSignal }): Promise` - `onError` catches and handles; wrap plain `Error` in `CliError(CLI_CODES.APP.RUN_FAILED)` if not already a `CliError` - [ ] **Create `src/app/index.ts`**, run test, write `app.md`, commit ```bash 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 boolean - `stripAnsi(style.bold.red("text"))` equals `"text"` (property test: `stripAnsi(style.bold.red(s)) === s` for all `s`) - `createStyler(true)` returns a callable styler - `getStyleNames()` 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 markers - `tree({ label: "root", children: [{ label: "child" }] })` contains `"root"` and `"child"` - `heading("Title", 1)` returns string with `"═"` borders - `json({ 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; add `StyleOptionsSchema` - [ ] **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 bare `Error` with `CliError` - [ ] **Create all `index.ts` barrels and `.md` docs** - [ ] **Run tests — verify all pass** - [ ] **Commit** ```bash 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`) ```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[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`** ```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 from `suaveplan` source; add `signal` handling. Two guards needed — pre-fired signal (already aborted before `start()`) and live abort event: ```ts // 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: ```ts start(text?: string): SpinnerInstance { if (opts.signal?.aborted) return instance; // already aborted — no-op // ...rest of start logic } ``` - [ ] **Create `src/spinner/spinner.schemas.ts`** ```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, write `spinner.md`, commit ```bash 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`** ```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 from `suaveplan` source; add `signal` handling same as spinner (both the `"abort"` event listener AND a pre-abort guard inside `update()` / the start path — check `opts.signal?.aborted` before writing to the stream). - [ ] **Create schemas, index, md, run test, commit** ```bash 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`) ```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`** ```ts export interface InputReader { question(prompt: string): Promise; 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 { label: string; value: T; description?: string; } export interface SelectPromptOptions { message: string; choices: Array | string>; default?: T; } export interface MultiSelectPromptOptions { message: string; choices: Array | string>; min?: number; max?: number; } ``` - [ ] **Implement `src/prompt/prompt.ts`** — port from `suaveplan` source. Critical change: `createDefaultReader()` uses `@suaveplan/readline/interface`: ```ts 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 { 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`** ```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, write `prompt.md`, commit ```bash 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`** ```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** ```bash git commit -m "feat(cli): root barrel index" ``` --- ### Task 19: Benchmarks + README - [ ] **Create `src/__benchmarks__/parser-throughput.bench.ts`** ```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`** ```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.md`** with required sections plus benchmark baseline numbers - [ ] **Run full coverage gate** ```bash 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** ```bash cd /mnt/s/web/genesis/universal/platform/cli && bun run bench ``` - [ ] **Commit** ```bash 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** ```bash 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** ```bash bun scripts/publish.ts --package @suaveplan/cli ``` - [ ] **Create OpenSpec change folders** ```text 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//spec.md` contains the final accepted spec for that capability. - [ ] **Archive both changes** ```bash 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)** ```bash 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)** ```bash 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** ```bash git add . git commit -m "chore: publish cli@0.0.0, archive OpenSpec changes" ``` --- ## Acceptance Checklist - [ ] `bunx turbo lint typecheck test --filter=@suaveplan/readline` green - [ ] `bunx turbo lint typecheck test --filter=@suaveplan/cli` green - [ ] 100% istanbul coverage on both packages - [ ] Zero `any`, `TODO`, `FIXME`, `ts-ignore`, `.skip` in `src/` - [ ] Every `.ts` implementation 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:readline` absent from `@suaveplan/cli` dependency graph - [ ] `"sideEffects": false` in both `package.json` files - [ ] `bench` script wired in both `package.json` files; `bench` task in `turbo.json` - [ ] Benchmark baselines published in both `README.md` files - [ ] OpenSpec changes archived; `FOLDERS.yml` and `CLAUDE.md` updated