# Design: `@suaveplan/readline` + `@suaveplan/cli` **Date:** 2026-04-20 **Branch:** genesis **Status:** Approved — ready for implementation planning --- ## 1. Problem The `@suaveplan/cli` package needs to land on genesis at enterprise quality. Its `prompt` module currently depends on `node:readline`, which ties the package to the Node runtime and blocks the "universal" guarantee. No standalone readline primitive exists in the monorepo. --- ## 2. Solution Introduce `@suaveplan/readline` as a new `universal/platform` package, then port `@suaveplan/cli` as a full enterprise rewrite that depends on it. Both land in dependency order within the same session. --- ## 3. Package Locations | Package | Path | FOLDERS.yml domain | |---|---|---| | `@suaveplan/readline` | `universal/platform/readline` | `platform` | | `@suaveplan/cli` | `universal/platform/cli` | `platform` | `FOLDERS.yml` gains `readline` in the `universal/platform` list. `cli` already appears there — only `readline` is added, bringing the total to 7 entries: ```yaml platform: [ cli, electrobun, macos-contacts, opentelemetry, profiler, readline, sitemap ] # 7 ``` --- ## 4. Dependency Chain ```text @opentelemetry/api zod tslib ↓ @suaveplan/error @suaveplan/types ↓ @suaveplan/readline ↓ @suaveplan/cli ``` --- ## 5. `@suaveplan/readline` ### 5.1 Architecture Three feature modules share one internal byte scanner: ```text src/ scanner/ ← internal byte engine (not exported) reader/ ← public: readLines() AsyncGenerator API interface/ ← public: createInterface() interactive prompt API errors/ ← public: ReadlineError + stable code registry ``` `"sideEffects": false` must be set in `package.json`. No root `"."` barrel export. Consumers import directly from subpaths to enable tree-shaking. ### 5.2 `scanner` (internal) Ports the [maleck13/readline](https://github.com/maleck13/readline) byte scanner from JavaScript to TypeScript. Replaces `EventEmitter` with an internal bounded async push queue (backpressure-safe). Handles LF, CR, and CRLF line endings. Not exported via `package.json` subpaths. ```ts interface ScannerOptions { maxLineLength?: number; // default 4096 encoding?: BufferEncoding; } function createScanner(input: Readable, opts?: ScannerOptions): AsyncGenerator ``` ### 5.3 `reader` — `@suaveplan/readline/reader` ```ts interface ReadLinesOptions { maxLineLength?: number; encoding?: BufferEncoding; skipEmpty?: boolean; signal?: AbortSignal; } async function* readLines( input: Readable | ReadableStream | string, options?: ReadLinesOptions ): AsyncGenerator ``` Accepts a Node `Readable`, a WHATWG `ReadableStream`, or a file path string. Honours `AbortSignal` at each yield point. ### 5.4 `interface` — `@suaveplan/readline/interface` ```ts interface ReadlineOptions { input: Readable; output?: Writable; signal?: AbortSignal; } interface ReadlineInterface { question(prompt: string, signal?: AbortSignal): Promise; close(): void; readonly closed: boolean; [Symbol.asyncIterator](): AsyncIterator; } function createInterface(options: ReadlineOptions): ReadlineInterface ``` The async iterator inherits the `AbortSignal` from `ReadlineOptions`. `ReadlineInterface` satisfies `@suaveplan/cli`'s existing `InputReader` type (`{ question, close }`) by structural typing — no adapter required. `createInterface()` emits an OTel span covering the interface lifetime (start on creation, end on `close()`, error status on abort). ### 5.5 Error codes ```text READLINE.SCANNER.LINE_TOO_LONG READLINE.SCANNER.STREAM_ERROR READLINE.INTERFACE.CLOSED READLINE.INTERFACE.ABORTED READLINE.READER.STREAM_ERROR ``` ### 5.6 Subpath exports ```json { "./reader/reader": { "import": { "types": "...", "default": "..." } }, "./interface/interface": { "import": { "types": "...", "default": "..." } }, "./errors/errors": { "import": { "types": "...", "default": "..." } } } ``` `scanner` is internal — no subpath export. ### 5.7 Key `package.json` fields ```json { "sideEffects": false, "dependencies": { "@suaveplan/error": "workspace:^", "@suaveplan/types": "workspace:^", "@opentelemetry/api": "catalog:observability", "zod": "catalog:forms", "tslib": "catalog:build" }, "devDependencies": { "@suaveplan/testing": "workspace:^", "@vitest/coverage-istanbul": "catalog:testing", "fast-check": "catalog:testing", "mitata": "catalog:testing", "vitest": "catalog:testing" } } ``` --- ## 6. `@suaveplan/cli` ### 6.1 Architecture Full enterprise rewrite of `suaveplan/packages/universal/cli`. Preserves the complete public API surface. Adds structured errors, Zod schemas, OTel instrumentation, AbortSignal support, and replaces `node:readline` with `@suaveplan/readline/interface`. ```text src/ app/ command/ parser/ style/ errors/ output/ spinner/ progress/ prompt/ ``` `"sideEffects": false` must be set in `package.json`. ### 6.2 `errors` ```ts class CliError extends SuaveplanError { ... } ``` Stable codes: ```text CLI.PARSE.UNKNOWN_OPTION CLI.PARSE.MISSING_VALUE CLI.PARSE.INVALID_VALUE CLI.PARSE.MISSING_ARGUMENT CLI.PARSE.MISSING_REQUIRED_OPTION CLI.COMMAND.NO_ACTION CLI.COMMAND.UNKNOWN_SUBCOMMAND CLI.APP.RUN_FAILED CLI.APP.ABORTED ``` ### 6.3 `parser` Preserved API. Throws `CliError` (CLI.PARSE.*) instead of bare `Error`. Adds `ParserConfigSchema` and `ParsedArgsSchema` Zod exports. ```ts function parseArgs(argv: string[], config?: ParserConfig): ParsedArgs function parseOptionFlags(flags, description?, defaultValue?): { name, config } function parseArgumentSpec(spec, description?): ArgumentConfig ``` ### 6.4 `command` Preserved fluent API. `execute()` gains an OTel span. Throws `CliError` on missing action and unknown subcommand. ```ts class Command { description / option / argument / action / command / alias / hidden / example execute(argv: string[], parentName?: string): Promise helpText(parentName?: string): string getParserConfig(): ParserConfig } ``` ### 6.5 `app` Preserved API. `run()` gains an OTel span and an optional `AbortSignal`. Throws `CliError(CLI.APP.ABORTED)` when the signal fires. ```ts class App { run(argv?: string[], options?: { signal?: AbortSignal }): Promise helpText(): string description / option / argument / command / action / example } function cli(name: string, version?: string): App ``` Zod export: `AppConfigSchema`. ### 6.6 `style`, `output/format`, `output/table` API unchanged. Zod schemas added for option types. Bare `Error` throws replaced with `CliError`. `output/format` and `output/table` follow the standard five-file co-located layout: ```text src/output/ format/ format.ts format.types.ts format.test.ts format.md index.ts table/ table.ts table.types.ts table.test.ts table.md index.ts ``` ### 6.7 `spinner` + `progress` `AbortSignal` added to options. Stream remains injectable (`WritableStreamLike`). ```ts interface SpinnerOptions { text?, frames?, interval?, stream?, signal?: AbortSignal } interface ProgressOptions { total, width?, stream?, signal?: AbortSignal } ``` ### 6.8 `prompt` `createDefaultReader()` uses `createInterface()` from `@suaveplan/readline/interface`. All five prompt functions (`text`, `password`, `confirm`, `select`, `multiselect`) gain an optional `signal?: AbortSignal`. `node:readline` is absent from the dependency graph. ### 6.9 Subpath exports ```json { ".": "barrel", "./app/app": "...", "./command/command": "...", "./parser/parser": "...", "./style/style": "...", "./output/format/format": "...", "./output/table/table": "...", "./spinner/spinner": "...", "./progress/progress": "...", "./prompt/prompt": "...", "./errors/errors": "..." } ``` ### 6.10 Key `package.json` fields ```json { "sideEffects": false, "dependencies": { "@suaveplan/readline": "workspace:^", "@suaveplan/error": "workspace:^", "@suaveplan/types": "workspace:^", "@opentelemetry/api": "catalog:observability", "zod": "catalog:forms", "tslib": "catalog:build" }, "devDependencies": { "@suaveplan/testing": "workspace:^", "@vitest/coverage-istanbul": "catalog:testing", "fast-check": "catalog:testing", "mitata": "catalog:testing", "vitest": "catalog:testing" } } ``` --- ## 7. Testing Strategy ### Unit tests (co-located `.test.ts`) | Module | Key scenarios | |---|---| | `scanner` | LF / CR / CRLF, max-line overflow, empty input, mid-stream abort | | `reader` | file path, WHATWG `ReadableStream`, `AbortSignal` mid-iteration | | `interface` | real `Readable`/`Writable` pair, question/close lifecycle, `closed` guard, async iterator abort via inherited signal, OTel span created / ended on close / error status on abort | | `parser` | all option types, `--no-*`, `--`, variadic, unknown options | | `command` | subcommand routing, aliases, help text, error paths | | `spinner` / `progress` | injectable stream (no TTY), AbortSignal cancellation | | `prompt` | injected mock `InputReader`, validation retries, all five prompt types | ### Property-based tests (fast-check) - **scanner/parser round-trip** — split any string into lines, scan bytes, reassemble → original string - **parser safety** — `parseArgs(argv, { allowUnknown: true })` never throws for any `argv` - **style round-trip** — `stripAnsi(style.bold.red(s)) === s` for all strings `s` - **table invariant** — column widths always ≥ max cell content width ### Coverage gate 100% statement + branch + function + line (istanbul) for both packages. --- ## 8. Benchmarks ### `@suaveplan/readline` (`src/__benchmarks__/`) | Benchmark | Description | |---|---| | `scanner-throughput` | 1 MB and 10 MB streams, lines/sec | | `reader-file` | `readLines()` over a temp file vs `node:readline` baseline | ### `@suaveplan/cli` (`src/__benchmarks__/`) | Benchmark | Description | |---|---| | `parser-throughput` | 10 000 `parseArgs()` calls with a 20-flag config | | `app-run` | Full `App.run()` dispatch cycle | Baselines published in each package's `README.md`. A >10% regression fails CI. Each package's `package.json` must include a `"bench"` script and the turbo pipeline must include a `bench` task. --- ## 9. OpenSpec Changes ```text openspec/changes/ enterprise-readline/ proposal.md tasks.md specs/readline/spec.md enterprise-cli/ proposal.md tasks.md specs/cli/spec.md ``` Both archive on ship to `openspec/archive/2026-04-20-enterprise-readline/` and `openspec/archive/2026-04-20-enterprise-cli/`. --- ## 10. CLAUDE.md Additions Add two entries to the genesis package listing under `platform`: - **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`. - **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`. --- ## 11. Acceptance Criteria Both packages are enterprise-done when: - [ ] `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 - [ ] mitata benchmarks in `src/__benchmarks__/` with `"bench"` script wired in `package.json` and turbo pipeline - [ ] Baselines published in both `README.md` files - [ ] OpenSpec changes archived; `FOLDERS.yml` and `CLAUDE.md` updated