Files
suaveplan/docs/superpowers/plans/2026-04-20-readline-cli.md
2026-05-27 23:29:53 +01:00

2834 lines
84 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# `@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<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`**
```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<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**
```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<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`**
```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<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`**
```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<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`**
```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<string>;
close(): void;
readonly closed: boolean;
[Symbol.asyncIterator](): AsyncIterator<string>;
}
```
- [ ] **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<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`**
```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<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`**, 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 <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`**
```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`**
```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<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`**
```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`**
```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<void>`
- `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<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`**
```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<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 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<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`**
```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/<cap>/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