Initial Commit
This commit is contained in:
400
docs/superpowers/specs/2026-04-20-readline-cli-design.md
Normal file
400
docs/superpowers/specs/2026-04-20-readline-cli-design.md
Normal file
@@ -0,0 +1,400 @@
|
||||
# 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
|
||||
Reference in New Issue
Block a user