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

401 lines
12 KiB
Markdown

# 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<string>
```
### 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<string, void, unknown>
```
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<string>;
close(): void;
readonly closed: boolean;
[Symbol.asyncIterator](): AsyncIterator<string>;
}
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<void>
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<void>
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