12 KiB
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:
platform: [ cli, electrobun, macos-contacts, opentelemetry,
profiler, readline, sitemap ] # 7
4. Dependency Chain
@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:
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 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.
interface ScannerOptions {
maxLineLength?: number; // default 4096
encoding?: BufferEncoding;
}
function createScanner(input: Readable, opts?: ScannerOptions): AsyncGenerator<string>
5.3 reader — @suaveplan/readline/reader
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
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
READLINE.SCANNER.LINE_TOO_LONG
READLINE.SCANNER.STREAM_ERROR
READLINE.INTERFACE.CLOSED
READLINE.INTERFACE.ABORTED
READLINE.READER.STREAM_ERROR
5.6 Subpath exports
{
"./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
{
"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.
src/
app/ command/ parser/ style/
errors/ output/ spinner/ progress/ prompt/
"sideEffects": false must be set in package.json.
6.2 errors
class CliError extends SuaveplanError { ... }
Stable codes:
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.
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.
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.
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:
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).
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
{
".": "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
{
"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 anyargv - style round-trip —
stripAnsi(style.bold.red(s)) === sfor all stringss - 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
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/readlinegreenbunx turbo lint typecheck test --filter=@suaveplan/cligreen- 100% istanbul coverage on both packages
- Zero
any,TODO,FIXME,ts-ignore,.skipinsrc/ - Every
.tsimplementation 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:readlineabsent from@suaveplan/clidependency graph"sideEffects": falsein bothpackage.jsonfiles- mitata benchmarks in
src/__benchmarks__/with"bench"script wired inpackage.jsonand turbo pipeline - Baselines published in both
README.mdfiles - OpenSpec changes archived;
FOLDERS.ymlandCLAUDE.mdupdated