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

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 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

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