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

84 KiB
Raw Permalink Blame History

@suaveplan/readline + @suaveplan/cli Implementation Plan

For agentic workers: REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Ship @suaveplan/readline (full-replacement terminal I/O primitive) then @suaveplan/cli (enterprise CLI toolkit) — both at 100% coverage, full docs, OTel, Zod schemas, and AbortSignal support.

Architecture: @suaveplan/readline ports the maleck13 byte-scanner to a bounded async-push-queue engine, exposes readLines() (AsyncGenerator) and createInterface() (interactive prompt), then @suaveplan/cli rewrites its nine modules against that interface instead of node:readline.

Tech Stack: Bun, TypeScript, Vite (vite-plugin-dts, preserveModules), Vitest + istanbul, fast-check (property tests), mitata (benchmarks), @opentelemetry/api, zod (catalog:forms), @suaveplan/error, @suaveplan/types.

Spec: docs/superpowers/specs/2026-04-20-readline-cli-design.md


File Map — @suaveplan/readline

universal/platform/readline/
  package.json
  tsconfig.lib.json
  vite.config.ts
  vitest.config.ts
  README.md
  tests/setup/polyfills.ts
  src/
    errors/
      errors.ts          ← ReadlineError + READLINE_CODES (defineCodes × 3 sub-namespaces)
      errors.test.ts
      errors.md
      index.ts
    scanner/
      scanner.ts         ← internal AsyncGenerator; byte-level port of maleck13 readline.js
      scanner.types.ts
      scanner.test.ts
      scanner.md
      index.ts
    reader/
      reader.ts          ← readLines() wraps scanner; accepts Readable | ReadableStream | path
      reader.types.ts
      reader.test.ts
      reader.md
      index.ts
    interface/
      interface.ts       ← createInterface(); question()/close()/asyncIterator; OTel span
      interface.types.ts
      interface.test.ts
      interface.md
      index.ts
    __benchmarks__/
      scanner-throughput.bench.ts
      reader-file.bench.ts

File Map — @suaveplan/cli

universal/platform/cli/
  package.json
  tsconfig.lib.json
  vite.config.ts
  vitest.config.ts
  README.md
  tests/setup/polyfills.ts
  src/
    index.ts             ← root barrel
    errors/
      errors.ts          ← CliError + CLI_CODES
      errors.test.ts
      errors.md
      index.ts
    parser/
      parser.ts          ← parseArgs, parseOptionFlags, parseArgumentSpec
      parser.types.ts
      parser.schemas.ts  ← ParserConfigSchema, ParsedArgsSchema
      parser.test.ts
      parser.md
      index.ts
    command/
      command.ts         ← Command class; execute() has OTel span
      command.types.ts
      command.schemas.ts
      command.test.ts
      command.md
      index.ts
    app/
      app.ts             ← App class + cli(); run() has OTel span + AbortSignal
      app.types.ts
      app.schemas.ts     ← AppConfigSchema
      app.test.ts
      app.md
      index.ts
    style/
      style.ts           ← createStyler, style, stripAnsi, isColorSupported, getStyleNames
      style.types.ts
      style.schemas.ts
      style.test.ts
      style.md
      index.ts
    output/
      format/
        format.ts        ← keyValue, list, tree, json, columns, heading, divider, indent
        format.types.ts
        format.schemas.ts
        format.test.ts
        format.md
        index.ts
      table/
        table.ts         ← table()
        table.types.ts
        table.schemas.ts
        table.test.ts
        table.md
        index.ts
    spinner/
      spinner.ts         ← createSpinner; AbortSignal support
      spinner.types.ts
      spinner.schemas.ts
      spinner.test.ts
      spinner.md
      index.ts
    progress/
      progress.ts        ← createProgress; AbortSignal support
      progress.types.ts
      progress.schemas.ts
      progress.test.ts
      progress.md
      index.ts
    prompt/
      prompt.ts          ← text/password/confirm/select/multiselect; uses readline/interface
      prompt.types.ts
      prompt.schemas.ts
      prompt.test.ts
      prompt.md
      index.ts
    __benchmarks__/
      parser-throughput.bench.ts
      app-run.bench.ts

Chunk 1: @suaveplan/readline — scaffold, errors, scanner

Task 1: Scaffold @suaveplan/readline

Files:

  • Create: universal/platform/readline/package.json

  • Create: universal/platform/readline/tsconfig.lib.json

  • Create: universal/platform/readline/vite.config.ts

  • Create: universal/platform/readline/vitest.config.ts

  • Create: universal/platform/readline/tests/setup/polyfills.ts

  • Modify: FOLDERS.yml — add readline to universal/platform

  • Modify: CLAUDE.md — add readline entry to package listing

  • Create package.json

{
  "name": "@suaveplan/readline",
  "version": "0.1.0",
  "type": "module",
  "sideEffects": false,
  "description": "Terminal I/O primitive: AsyncGenerator line reader and interactive prompt interface",
  "license": "MIT",
  "author": "Suaveplan Team",
  "main": "./dist/index.js",
  "module": "./dist/index.js",
  "types": "./dist/index.d.ts",
  "exports": {
    "./package.json": "./package.json",
    "./reader/reader": {
      "import": {
        "types": "./dist/reader/reader.d.ts",
        "default": "./dist/reader/reader.js"
      }
    },
    "./interface/interface": {
      "import": {
        "types": "./dist/interface/interface.d.ts",
        "default": "./dist/interface/interface.js"
      }
    },
    "./errors/errors": {
      "import": {
        "types": "./dist/errors/errors.d.ts",
        "default": "./dist/errors/errors.js"
      }
    }
  },
  "scripts": {
    "build": "vite build",
    "test": "vitest run --passWithNoTests",
    "test:watch": "vitest",
    "test:coverage": "vitest run --coverage --passWithNoTests",
    "lint": "bunx biome check src/",
    "lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
    "format": "bunx biome format --write --max-diagnostics 500 src/",
    "check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
    "typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
    "docs:check": "bun run ../../../scripts/docs-check.ts .",
    "bench": "bun run src/__benchmarks__/scanner-throughput.bench.ts && bun run src/__benchmarks__/reader-file.bench.ts"
  },
  "dependencies": {
    "@suaveplan/error": "workspace:^",
    "@suaveplan/types": "workspace:^",
    "@opentelemetry/api": "catalog:observability",
    "zod": "catalog:forms",
    "tslib": "catalog:build"
  },
  "devDependencies": {
    "@suaveplan/testing": "workspace:^",
    "@types/node": "catalog:",
    "@vitest/coverage-istanbul": "catalog:testing",
    "fast-check": "catalog:testing",
    "mitata": "catalog:testing",
    "typescript": "catalog:",
    "vite": "catalog:build",
    "vite-plugin-dts": "catalog:build",
    "vitest": "catalog:testing"
  },
  "publishConfig": {
    "registry": "https://npmjs.fq.io/",
    "access": "public"
  },
  "files": ["dist", "src", "README.md"]
}
  • Create tsconfig.lib.json
{
  "$schema": "https://json.schemastore.org/tsconfig",
  "extends": "../../../tsconfig.base.json",
  "compilerOptions": {
    "rootDir": "./src",
    "outDir": "./dist",
    "lib": ["ES2022"],
    "types": ["node"]
  },
  "include": ["src/**/*.ts"],
  "exclude": ["src/**/*.test.ts", "dist", "node_modules"]
}
  • Create vite.config.ts
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";

export default defineConfig({
  root: __dirname,
  cacheDir: "../../../node_modules/.vite/packages/readline",
  plugins: [
    dts({
      entryRoot: "src",
      tsconfigPath: "./tsconfig.lib.json",
      insertTypesEntry: true,
    }),
  ],
  build: {
    outDir: "./dist",
    emptyOutDir: true,
    reportCompressedSize: true,
    sourcemap: true,
    lib: { entry: "src/index.ts", formats: ["es"] },
    rollupOptions: {
      treeshake: true,
      output: {
        preserveModules: true,
        preserveModulesRoot: "src",
        entryFileNames: "[name].js",
      },
      external: [
        "@suaveplan/error",
        "@suaveplan/types",
        "tslib",
        "zod",
        "@opentelemetry/api",
        /^node:/,
      ],
    },
  },
});
  • Create vitest.config.ts
import { defineConfig } from "vitest/config";

export default defineConfig({
  test: {
    globals: false,
    environment: "node",
    pool: "forks",
    setupFiles: ["./tests/setup/polyfills.ts"],
    include: ["src/**/*.test.ts"],
    exclude: ["dist/**", "node_modules/**"],
    coverage: {
      provider: "istanbul",
      reporter: ["text", "html", "lcov"],
      include: ["src/**/*.ts"],
      exclude: [
        "src/**/*.test.ts",
        "src/**/*.types.ts",
        "src/**/*.schemas.ts",
        "src/**/index.ts",
        "src/__benchmarks__/**",
      ],
      thresholds: {
        statements: 100,
        branches: 100,
        functions: 100,
        lines: 100,
      },
    },
    testTimeout: 30000,
  },
});

Note: *.schemas.ts files are excluded from coverage. Zod schema declarations are type-level; coverage of their branches belongs to Zod's own test suite, not ours. This is consistent with other genesis packages.

  • Create tests/setup/polyfills.ts
export {};
  • Update FOLDERS.yml — change the platform line to:
platform: [ cli, electrobun, macos-contacts, opentelemetry,
            profiler, readline, sitemap ]                            # 7
  • Update CLAUDE.md — add to the platform section of the package listing:
- **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`.
  • Create src/index.ts stub (required before any build can run — grows as modules land):
export * from "./errors/index.js";
  • Run bun install from repo root to register the new workspace package
cd /mnt/s/web/genesis && bun install

Task 2: errors module

Files:

  • Create: universal/platform/readline/src/errors/errors.ts

  • Create: universal/platform/readline/src/errors/errors.test.ts

  • Create: universal/platform/readline/src/errors/errors.md

  • Create: universal/platform/readline/src/errors/index.ts

  • Write failing test (src/errors/errors.test.ts)

import { describe, expect, it } from "vitest";
import { ReadlineError, READLINE_CODES } from "./errors.js";

describe("READLINE_CODES", { timeout: 5000 }, () => {
  it("produces fully-qualified scanner codes", () => {
    expect(READLINE_CODES.SCANNER.LINE_TOO_LONG).toBe("READLINE.SCANNER.LINE_TOO_LONG");
    expect(READLINE_CODES.SCANNER.STREAM_ERROR).toBe("READLINE.SCANNER.STREAM_ERROR");
  });

  it("produces fully-qualified interface codes", () => {
    expect(READLINE_CODES.INTERFACE.CLOSED).toBe("READLINE.INTERFACE.CLOSED");
    expect(READLINE_CODES.INTERFACE.ABORTED).toBe("READLINE.INTERFACE.ABORTED");
  });

  it("produces fully-qualified reader codes", () => {
    expect(READLINE_CODES.READER.STREAM_ERROR).toBe("READLINE.READER.STREAM_ERROR");
  });
});

describe("ReadlineError", { timeout: 5000 }, () => {
  it("is an instance of Error", () => {
    const err = new ReadlineError(
      READLINE_CODES.SCANNER.LINE_TOO_LONG,
      "readline.scanner.line_too_long"
    );
    expect(err).toBeInstanceOf(Error);
    expect(err.code).toBe("READLINE.SCANNER.LINE_TOO_LONG");
    expect(err.messageKey).toBe("readline.scanner.line_too_long");
  });

  it("chains cause", () => {
    const cause = new Error("upstream");
    const err = new ReadlineError(
      READLINE_CODES.INTERFACE.ABORTED,
      "readline.interface.aborted",
      { cause }
    );
    expect(err.cause).toBe(cause);
  });

  it("attaches context", () => {
    const err = new ReadlineError(
      READLINE_CODES.SCANNER.LINE_TOO_LONG,
      "readline.scanner.line_too_long",
      { context: { maxLineLength: 4096 } }
    );
    expect(err.context).toEqual({ maxLineLength: 4096 });
  });
});
  • Run test — verify it fails
cd /mnt/s/web/genesis && bunx turbo test --filter=@suaveplan/readline

Expected: Cannot find module './errors.js'

  • Implement src/errors/errors.ts
import type { SuaveplanErrorOptions } from "@suaveplan/error";
import { defineCodes, SuaveplanError } from "@suaveplan/error";

const SCANNER_CODES = defineCodes("READLINE.SCANNER", [
    "LINE_TOO_LONG",
    "STREAM_ERROR",
] as const);

const INTERFACE_CODES = defineCodes("READLINE.INTERFACE", [
    "CLOSED",
    "ABORTED",
] as const);

const READER_CODES = defineCodes("READLINE.READER", [
    "STREAM_ERROR",
] as const);

export const READLINE_CODES = {
    SCANNER: SCANNER_CODES,
    INTERFACE: INTERFACE_CODES,
    READER: READER_CODES,
} as const;

export type ReadlineCode =
    | (typeof SCANNER_CODES)[keyof typeof SCANNER_CODES]
    | (typeof INTERFACE_CODES)[keyof typeof INTERFACE_CODES]
    | (typeof READER_CODES)[keyof typeof READER_CODES];

export class ReadlineError extends SuaveplanError<ReadlineCode> {
    // biome-ignore lint/complexity/noUselessConstructor: widens protected base constructor to public
    public constructor(
        code: ReadlineCode,
        messageKey: string,
        options?: SuaveplanErrorOptions
    ) {
        super(code, messageKey, options);
    }
}
  • Create src/errors/index.ts
export { ReadlineError, READLINE_CODES } from "./errors.js";
export type { ReadlineCode } from "./errors.js";
  • Run test — verify it passes
cd /mnt/s/web/genesis && bunx turbo test --filter=@suaveplan/readline
  • Write src/errors/errors.md (≥200 words, required sections: Purpose, Features, Usage Basic, Usage Advanced, API Reference, Implementation Notes)

  • Commit

git add universal/platform/readline/ FOLDERS.yml CLAUDE.md
git commit -m "feat(readline): scaffold package + errors module"

Task 3: scanner module (internal byte engine)

Files:

  • Create: universal/platform/readline/src/scanner/scanner.ts

  • Create: universal/platform/readline/src/scanner/scanner.types.ts

  • Create: universal/platform/readline/src/scanner/scanner.test.ts

  • Create: universal/platform/readline/src/scanner/scanner.md

  • Create: universal/platform/readline/src/scanner/index.ts

  • Write failing test (src/scanner/scanner.test.ts)

import { Readable } from "node:stream";
import * as fc from "fast-check";
import { describe, expect, it } from "vitest";
import { ReadlineError, READLINE_CODES } from "../errors/errors.js";
import { createScanner } from "./scanner.js";

function toReadable(input: string): Readable {
  return Readable.from(Buffer.from(input));
}

async function collect(gen: AsyncGenerator<string>): Promise<string[]> {
  const lines: string[] = [];
  for await (const line of gen) lines.push(line);
  return lines;
}

describe("createScanner", { timeout: 5000 }, () => {
  it("yields lines separated by LF", async () => {
    const lines = await collect(createScanner(toReadable("hello\nworld\n")));
    expect(lines).toEqual(["hello", "world"]);
  });

  it("yields lines separated by CRLF", async () => {
    const lines = await collect(createScanner(toReadable("foo\r\nbar\r\n")));
    expect(lines).toEqual(["foo", "bar"]);
  });

  it("yields last line without trailing newline", async () => {
    const lines = await collect(createScanner(toReadable("a\nb")));
    expect(lines).toEqual(["a", "b"]);
  });

  it("yields nothing for empty input", async () => {
    const lines = await collect(createScanner(toReadable("")));
    expect(lines).toEqual([]);
  });

  it("yields a single blank line", async () => {
    const lines = await collect(createScanner(toReadable("\n")));
    expect(lines).toEqual([""]);
  });

  it("throws ReadlineError when line exceeds maxLineLength", async () => {
    const long = "x".repeat(10);
    await expect(
      collect(createScanner(toReadable(long), { maxLineLength: 5 }))
    ).rejects.toThrow(ReadlineError);
  });

  it("throws ReadlineError with LINE_TOO_LONG code", async () => {
    const long = "x".repeat(10);
    await expect(
      collect(createScanner(toReadable(long), { maxLineLength: 5 }))
    ).rejects.toMatchObject({ code: READLINE_CODES.SCANNER.LINE_TOO_LONG });
  });

  it("propagates stream errors as ReadlineError", async () => {
    const r = new Readable({ read() {} });
    const gen = createScanner(r);
    const p = collect(gen);
    r.destroy(new Error("boom"));
    await expect(p).rejects.toMatchObject({ code: READLINE_CODES.SCANNER.STREAM_ERROR });
  });

  it("honours AbortSignal mid-iteration", async () => {
    const controller = new AbortController();
    const lines: string[] = [];
    const r = Readable.from(["line1\n", "line2\n", "line3\n"].map(Buffer.from));
    for await (const line of createScanner(r, { signal: controller.signal })) {
      lines.push(line);
      if (lines.length === 1) controller.abort();
    }
    expect(lines.length).toBe(1);
  });
});

describe("createScanner property tests", { timeout: 30000 }, () => {
  it("round-trips arbitrary multiline strings", async () => {
    await fc.assert(
      fc.asyncProperty(
        fc.array(fc.string({ maxLength: 100 }), { minLength: 0, maxLength: 20 }),
        async (inputLines) => {
          const text = inputLines.join("\n");
          const lines = await collect(createScanner(toReadable(text)));
          expect(lines.join("\n")).toBe(text);
        }
      ),
      { numRuns: 100 }
    );
  });
});
  • Run test — verify it fails
cd /mnt/s/web/genesis && bunx turbo test --filter=@suaveplan/readline
  • Create src/scanner/scanner.types.ts
export interface ScannerOptions {
    maxLineLength?: number;
    encoding?: BufferEncoding;
    signal?: AbortSignal;
}
  • Implement src/scanner/scanner.ts
import type { Readable } from "node:stream";
import { ReadlineError, READLINE_CODES } from "../errors/errors.js";
import type { ScannerOptions } from "./scanner.types.js";

export type { ScannerOptions } from "./scanner.types.js";

const DEFAULT_MAX_LINE_LENGTH = 4096;

type QueueItem =
    | { kind: "line"; value: string }
    | { kind: "done" }
    | { kind: "error"; error: Error };

export async function* createScanner(
    input: Readable,
    opts?: ScannerOptions
): AsyncGenerator<string, void, unknown> {
    const maxLen = opts?.maxLineLength ?? DEFAULT_MAX_LINE_LENGTH;
    const encoding = opts?.encoding ?? "utf8";
    const signal = opts?.signal;

    const queue: QueueItem[] = [];
    let waitResolve: ((item: QueueItem) => void) | null = null;

    function push(item: QueueItem): void {
        if (waitResolve) {
            const r = waitResolve;
            waitResolve = null;
            r(item);
        } else {
            queue.push(item);
        }
    }

    function dequeue(): Promise<QueueItem> {
        if (queue.length > 0) return Promise.resolve(queue.shift()!);
        return new Promise<QueueItem>((resolve) => {
            waitResolve = resolve;
        });
    }

    const lineBuffer = Buffer.alloc(maxLen);
    let lineLength = 0;

    function emitLine(): void {
        push({ kind: "line", value: lineBuffer.subarray(0, lineLength).toString(encoding) });
        lineLength = 0;
    }

    input.on("data", (data: Buffer) => {
        for (let i = 0; i < data.length; i++) {
            const byte = data[i]!;
            if (byte === 10) {
                emitLine();
            } else if (byte === 13) {
                // CR — skip; CRLF handled when LF arrives
            } else {
                if (lineLength >= maxLen) {
                    push({
                        kind: "error",
                        error: new ReadlineError(
                            READLINE_CODES.SCANNER.LINE_TOO_LONG,
                            "readline.scanner.line_too_long",
                            { context: { maxLineLength: maxLen } }
                        ),
                    });
                    lineLength = 0;
                    return;
                }
                lineBuffer[lineLength++] = byte;
            }
        }
    });

    input.on("end", () => {
        if (lineLength > 0) emitLine();
        push({ kind: "done" });
    });

    input.on("error", (err: Error) => {
        push({
            kind: "error",
            error: new ReadlineError(
                READLINE_CODES.SCANNER.STREAM_ERROR,
                "readline.scanner.stream_error",
                { cause: err }
            ),
        });
    });

    while (true) {
        if (signal?.aborted) break;
        const item = await dequeue();
        // NOTE: check again after await — abort may have fired while waiting in dequeue()
        if (signal?.aborted) break;
        if (item.kind === "error") throw item.error;
        if (item.kind === "done") break;
        yield item.value;
    }
}
  • Create src/scanner/index.ts
export { createScanner } from "./scanner.js";
export type { ScannerOptions } from "./scanner.types.js";
  • Run test — verify it passes
cd /mnt/s/web/genesis && bunx turbo test --filter=@suaveplan/readline
  • Write src/scanner/scanner.md (≥200 words, all required sections)

  • Commit

git add universal/platform/readline/
git commit -m "feat(readline): scanner — byte-level AsyncGenerator line reader"

Task 4: turbo bench task

Files:

  • Modify: turbo.json — add bench task after test:coverage block

  • Add bench task to turbo.json (after the test:coverage block):

"bench": {
  "dependsOn": ["^build", "build"],
  "inputs": ["src/**/*.bench.ts"],
  "cache": false,
  "outputLogs": "new-only"
}
  • Commit
git add turbo.json
git commit -m "chore: add bench turbo task"

Chunk 2: @suaveplan/readline — reader, interface, benchmarks, docs, publish

Task 5: reader module

Files:

  • Create: universal/platform/readline/src/reader/reader.ts

  • Create: universal/platform/readline/src/reader/reader.types.ts

  • Create: universal/platform/readline/src/reader/reader.test.ts

  • Create: universal/platform/readline/src/reader/reader.md

  • Create: universal/platform/readline/src/reader/index.ts

  • Write failing test (src/reader/reader.test.ts)

import { Readable } from "node:stream";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { describe, expect, it } from "vitest";
import { readLines } from "./reader.js";

async function collect(gen: AsyncGenerator<string>): Promise<string[]> {
  const out: string[] = [];
  for await (const line of gen) out.push(line);
  return out;
}

describe("readLines — Readable", { timeout: 5000 }, () => {
  it("reads lines from a Node Readable", async () => {
    const r = Readable.from(Buffer.from("one\ntwo\nthree"));
    expect(await collect(readLines(r))).toEqual(["one", "two", "three"]);
  });

  it("skips empty lines when skipEmpty is true", async () => {
    const r = Readable.from(Buffer.from("a\n\nb\n\nc"));
    expect(await collect(readLines(r, { skipEmpty: true }))).toEqual(["a", "b", "c"]);
  });

  it("aborts mid-iteration via AbortSignal", async () => {
    const controller = new AbortController();
    const lines: string[] = [];
    const r = Readable.from(Buffer.from("x\ny\nz"));
    for await (const line of readLines(r, { signal: controller.signal })) {
      lines.push(line);
      if (lines.length === 1) controller.abort();
    }
    expect(lines.length).toBe(1);
  });
});

describe("readLines — file path", { timeout: 5000 }, () => {
  it("reads lines from a file path string", async () => {
    const dir = await mkdtemp(join(tmpdir(), "readline-test-"));
    const file = join(dir, "test.txt");
    await writeFile(file, "alpha\nbeta\ngamma");
    try {
      expect(await collect(readLines(file))).toEqual(["alpha", "beta", "gamma"]);
    } finally {
      await rm(dir, { recursive: true });
    }
  });
});

describe("readLines — WHATWG ReadableStream", { timeout: 5000 }, () => {
  it("reads lines from a WHATWG ReadableStream", async () => {
    const encoder = new TextEncoder();
    const stream = new ReadableStream({
      start(controller) {
        controller.enqueue(encoder.encode("line1\nline2\n"));
        controller.close();
      },
    });
    expect(await collect(readLines(stream as unknown as ReadableStream))).toEqual(["line1", "line2"]);
  });
});
  • Run test — verify it fails

  • Create src/reader/reader.types.ts

export interface ReadLinesOptions {
    maxLineLength?: number;
    encoding?: BufferEncoding;
    skipEmpty?: boolean;
    signal?: AbortSignal;
}
  • Implement src/reader/reader.ts
import { createReadStream } from "node:fs";
import type { Readable } from "node:stream";
import { createScanner } from "../scanner/scanner.js";
import type { ReadLinesOptions } from "./reader.types.js";

export type { ReadLinesOptions } from "./reader.types.js";

export async function* readLines(
    input: Readable | ReadableStream | string,
    options?: ReadLinesOptions
): AsyncGenerator<string, void, unknown> {
    const signal = options?.signal;
    let readable: Readable;

    if (typeof input === "string") {
        readable = createReadStream(input);
    } else if (typeof (input as ReadableStream).getReader === "function") {
        const { Readable: NodeReadable } = await import("node:stream");
        readable = NodeReadable.fromWeb(input as Parameters<typeof NodeReadable.fromWeb>[0]);
    } else {
        readable = input as Readable;
    }

    for await (const line of createScanner(readable, {
        maxLineLength: options?.maxLineLength,
        encoding: options?.encoding,
        signal,
    })) {
        if (options?.skipEmpty && line === "") continue;
        yield line;
    }
}
  • Create src/reader/index.ts
export { readLines } from "./reader.js";
export type { ReadLinesOptions } from "./reader.types.js";
  • Run test — verify it passes

Note: Readable.fromWeb() requires Node ≥ 17 or Bun. The genesis environment uses Bun ≥ 1.3.13, so this is satisfied.

  • Update src/index.ts to include reader:
export * from "./errors/index.js";
export * from "./reader/index.js";
  • Write src/reader/reader.md (≥200 words, all required sections)

  • Commit

git add universal/platform/readline/
git commit -m "feat(readline): reader — readLines() AsyncGenerator over streams and files"

Task 6: interface module

Files:

  • Create: universal/platform/readline/src/interface/interface.ts

  • Create: universal/platform/readline/src/interface/interface.types.ts

  • Create: universal/platform/readline/src/interface/interface.test.ts

  • Create: universal/platform/readline/src/interface/interface.md

  • Create: universal/platform/readline/src/interface/index.ts

  • Write failing test (src/interface/interface.test.ts)

import { Readable, Writable } from "node:stream";
import { describe, expect, it } from "vitest";
import { ReadlineError, READLINE_CODES } from "../errors/errors.js";
import { createInterface } from "./interface.js";

function makeReadable(lines: string[]): Readable {
  return Readable.from(Buffer.from(lines.join("\n") + "\n"));
}

function makeWritable(): { stream: Writable; output: string[] } {
  const output: string[] = [];
  const stream = new Writable({
    write(chunk, _enc, cb) {
      output.push(chunk.toString());
      cb();
    },
  });
  return { stream, output };
}

describe("createInterface — question", { timeout: 5000 }, () => {
  it("returns the next line from input", async () => {
    const { stream: out } = makeWritable();
    const rl = createInterface({ input: makeReadable(["hello"]), output: out });
    expect(await rl.question("prompt> ")).toBe("hello");
    rl.close();
  });

  it("writes the prompt to output", async () => {
    const { stream: out, output } = makeWritable();
    const rl = createInterface({ input: makeReadable(["answer"]), output: out });
    await rl.question("enter: ");
    rl.close();
    expect(output.join("")).toContain("enter: ");
  });

  it("throws INTERFACE.CLOSED after close()", async () => {
    const rl = createInterface({ input: makeReadable([]) });
    rl.close();
    await expect(rl.question("x")).rejects.toMatchObject({
      code: READLINE_CODES.INTERFACE.CLOSED,
    });
  });

  it("marks closed=true after close()", () => {
    const rl = createInterface({ input: makeReadable([]) });
    expect(rl.closed).toBe(false);
    rl.close();
    expect(rl.closed).toBe(true);
  });

  it("throws INTERFACE.ABORTED when signal fires", async () => {
    const controller = new AbortController();
    const r = new Readable({ read() {} }); // never emits data
    const rl = createInterface({ input: r, signal: controller.signal });
    const p = rl.question("prompt");
    controller.abort();
    await expect(p).rejects.toMatchObject({ code: READLINE_CODES.INTERFACE.ABORTED });
    rl.close();
  });
});

describe("createInterface — asyncIterator", { timeout: 5000 }, () => {
  it("iterates lines from input", async () => {
    const rl = createInterface({ input: makeReadable(["a", "b", "c"]) });
    const lines: string[] = [];
    for await (const line of rl) lines.push(line);
    expect(lines).toEqual(["a", "b", "c"]);
  });

  it("stops iteration when closed", async () => {
    const rl = createInterface({ input: makeReadable(["x", "y"]) });
    const lines: string[] = [];
    for await (const line of rl) {
      lines.push(line);
      rl.close();
    }
    expect(lines.length).toBe(1);
  });

  it("terminates immediately when already closed (for-await starts after close)", async () => {
    const rl = createInterface({ input: makeReadable(["z"]) });
    rl.close();
    const lines: string[] = [];
    for await (const line of rl) lines.push(line);
    expect(lines).toEqual([]);
  });
});

describe("createInterface — OTel span", { timeout: 5000 }, () => {
  it("does not throw when no OTel exporter is registered (no-op tracer)", () => {
    expect(() => {
      const rl = createInterface({ input: makeReadable([]) });
      rl.close();
    }).not.toThrow();
  });
});
  • Run test — verify it fails

  • Create src/interface/interface.types.ts

import type { Readable, Writable } from "node:stream";

export interface ReadlineOptions {
    input: Readable;
    output?: Writable;
    signal?: AbortSignal;
}

export interface ReadlineInterface {
    question(prompt: string, signal?: AbortSignal): Promise<string>;
    close(): void;
    readonly closed: boolean;
    [Symbol.asyncIterator](): AsyncIterator<string>;
}
  • Implement src/interface/interface.ts
import { SpanStatusCode, trace } from "@opentelemetry/api";
import { ReadlineError, READLINE_CODES } from "../errors/errors.js";
import { createScanner } from "../scanner/scanner.js";
import type { ReadlineInterface, ReadlineOptions } from "./interface.types.js";

export type { ReadlineInterface, ReadlineOptions } from "./interface.types.js";

const tracer = trace.getTracer("@suaveplan/readline", "0.1.0");

export function createInterface(options: ReadlineOptions): ReadlineInterface {
    const { input, output, signal: ifaceSignal } = options;
    let closed = false;
    const span = tracer.startSpan("readline.interface");
    const scanner = createScanner(input);
    const iter = scanner[Symbol.asyncIterator]();

    function assertOpen(): void {
        if (closed) {
            throw new ReadlineError(
                READLINE_CODES.INTERFACE.CLOSED,
                "readline.interface.closed"
            );
        }
    }

    const rl: ReadlineInterface = {
        get closed() {
            return closed;
        },

        async question(prompt: string, questionSignal?: AbortSignal): Promise<string> {
            assertOpen();
            const effectiveSignal = questionSignal ?? ifaceSignal;

            if (effectiveSignal?.aborted) {
                throw new ReadlineError(
                    READLINE_CODES.INTERFACE.ABORTED,
                    "readline.interface.aborted",
                    { cause: effectiveSignal.reason }
                );
            }

            if (output) output.write(prompt);

            return new Promise<string>((resolve, reject) => {
                let abortHandler: (() => void) | undefined;

                if (effectiveSignal) {
                    abortHandler = () => {
                        span.setStatus({ code: SpanStatusCode.ERROR, message: "aborted" });
                        reject(
                            new ReadlineError(
                                READLINE_CODES.INTERFACE.ABORTED,
                                "readline.interface.aborted",
                                { cause: effectiveSignal.reason }
                            )
                        );
                    };
                    effectiveSignal.addEventListener("abort", abortHandler, {
                        once: true,
                    });
                }

                iter
                    .next()
                    .then((result) => {
                        if (abortHandler) {
                            effectiveSignal?.removeEventListener("abort", abortHandler);
                        }
                        if (result.done) {
                            reject(
                                new ReadlineError(
                                    READLINE_CODES.INTERFACE.CLOSED,
                                    "readline.interface.closed"
                                )
                            );
                        } else {
                            resolve(result.value);
                        }
                    })
                    .catch(reject);
            });
        },

        close() {
            if (closed) return;
            closed = true;
            span.end();
        },

        [Symbol.asyncIterator](): AsyncIterator<string> {
            return {
                async next(): Promise<IteratorResult<string>> {
                    if (closed) return { done: true, value: undefined };
                    if (ifaceSignal?.aborted) {
                        span.setStatus({ code: SpanStatusCode.ERROR, message: "aborted" });
                        span.end();
                        throw new ReadlineError(
                            READLINE_CODES.INTERFACE.ABORTED,
                            "readline.interface.aborted"
                        );
                    }
                    const result = await iter.next();
                    if (result.done) {
                        closed = true;
                        span.end();
                        return { done: true, value: undefined };
                    }
                    return { done: false, value: result.value };
                },
            };
        },
    };

    return rl;
}
  • Create src/interface/index.ts
export { createInterface } from "./interface.js";
export type { ReadlineInterface, ReadlineOptions } from "./interface.types.js";
  • Update src/index.ts
export * from "./errors/index.js";
export * from "./reader/index.js";
export * from "./interface/index.js";
  • Run test — verify it passes

  • Write src/interface/interface.md (≥200 words)

  • Commit

git add universal/platform/readline/
git commit -m "feat(readline): interface — createInterface() interactive readline with OTel"

Task 7: Benchmarks + README + coverage gate

Files:

  • Create: universal/platform/readline/src/__benchmarks__/scanner-throughput.bench.ts

  • Create: universal/platform/readline/src/__benchmarks__/reader-file.bench.ts

  • Create: universal/platform/readline/README.md

  • Create src/__benchmarks__/scanner-throughput.bench.ts

import { Readable } from "node:stream";
import { bench, run } from "mitata";
import { createScanner } from "../scanner/scanner.js";

function makeText(lines: number): string {
  return Array.from({ length: lines }, (_, i) => `line ${i}`).join("\n");
}

const text1mb = makeText(50_000);
const text10mb = makeText(500_000);

bench("scanner 1 MB", async () => {
  const r = Readable.from(Buffer.from(text1mb));
  for await (const _ of createScanner(r)) { /* consume */ }
});

bench("scanner 10 MB", async () => {
  const r = Readable.from(Buffer.from(text10mb));
  for await (const _ of createScanner(r)) { /* consume */ }
});

await run();
  • Create src/__benchmarks__/reader-file.bench.ts
import { createReadStream } from "node:fs";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { bench, run } from "mitata";
import { readLines } from "../reader/reader.js";

const dir = await mkdtemp(join(tmpdir(), "rl-bench-"));
const file = join(dir, "data.txt");
await writeFile(file, Array.from({ length: 10_000 }, (_, i) => `line ${i}`).join("\n"));

bench("readLines() file path", async () => {
  for await (const _ of readLines(file)) { /* consume */ }
});

bench("readLines() Readable", async () => {
  for await (const _ of readLines(createReadStream(file))) { /* consume */ }
});

await run();
await rm(dir, { recursive: true });
  • Create README.md with required sections (package name, description, install, quick-start, modules section linking each .md)

  • Run full coverage gate

cd /mnt/s/web/genesis && bunx turbo lint typecheck test --filter=@suaveplan/readline
bunx turbo test:coverage --filter=@suaveplan/readline

Expected: all green, 100% coverage.

  • Run benchmarks and paste output into README
cd /mnt/s/web/genesis/universal/platform/readline && bun run bench
  • Commit
git add universal/platform/readline/
git commit -m "feat(readline): benchmarks, README, 100% coverage gate"

Task 8: Publish @suaveplan/readline

  • Run pre-publish gate
cd /mnt/s/web/genesis && bunx turbo lint typecheck test --filter=@suaveplan/readline
bunx turbo test:coverage --filter=@suaveplan/readline
bunx turbo build --filter=@suaveplan/readline

All must be green.

  • Publish
cd /mnt/s/web/genesis && bun scripts/publish.ts --package @suaveplan/readline
  • Commit
git add .
git commit -m "chore(readline): publish @suaveplan/readline@0.0.0"

Chunk 3: @suaveplan/cli — scaffold, errors, parser

Task 9: Scaffold @suaveplan/cli

Files:

  • Create: universal/platform/cli/package.json

  • Create: universal/platform/cli/tsconfig.lib.json

  • Create: universal/platform/cli/vite.config.ts

  • Create: universal/platform/cli/vitest.config.ts

  • Create: universal/platform/cli/tests/setup/polyfills.ts

  • Modify: CLAUDE.md — add cli entry

  • Create package.json

{
  "name": "@suaveplan/cli",
  "version": "0.1.0",
  "type": "module",
  "sideEffects": false,
  "description": "Zero-dependency CLI toolkit: argument parser, command router, ANSI styling, output formatting, spinner, progress bar, interactive prompts",
  "license": "MIT",
  "author": "Suaveplan Team",
  "main": "./dist/index.js",
  "module": "./dist/index.js",
  "types": "./dist/index.d.ts",
  "exports": {
    "./package.json": "./package.json",
    ".": {
      "import": {
        "types": "./dist/index.d.ts",
        "default": "./dist/index.js"
      }
    },
    "./errors/errors": {
      "import": {
        "types": "./dist/errors/errors.d.ts",
        "default": "./dist/errors/errors.js"
      }
    },
    "./parser/parser": {
      "import": {
        "types": "./dist/parser/parser.d.ts",
        "default": "./dist/parser/parser.js"
      }
    },
    "./command/command": {
      "import": {
        "types": "./dist/command/command.d.ts",
        "default": "./dist/command/command.js"
      }
    },
    "./app/app": {
      "import": {
        "types": "./dist/app/app.d.ts",
        "default": "./dist/app/app.js"
      }
    },
    "./style/style": {
      "import": {
        "types": "./dist/style/style.d.ts",
        "default": "./dist/style/style.js"
      }
    },
    "./output/format/format": {
      "import": {
        "types": "./dist/output/format/format.d.ts",
        "default": "./dist/output/format/format.js"
      }
    },
    "./output/table/table": {
      "import": {
        "types": "./dist/output/table/table.d.ts",
        "default": "./dist/output/table/table.js"
      }
    },
    "./spinner/spinner": {
      "import": {
        "types": "./dist/spinner/spinner.d.ts",
        "default": "./dist/spinner/spinner.js"
      }
    },
    "./progress/progress": {
      "import": {
        "types": "./dist/progress/progress.d.ts",
        "default": "./dist/progress/progress.js"
      }
    },
    "./prompt/prompt": {
      "import": {
        "types": "./dist/prompt/prompt.d.ts",
        "default": "./dist/prompt/prompt.js"
      }
    }
  },
  "scripts": {
    "build": "vite build",
    "test": "vitest run --passWithNoTests",
    "test:watch": "vitest",
    "test:coverage": "vitest run --coverage --passWithNoTests",
    "lint": "bunx biome check src/",
    "lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
    "format": "bunx biome format --write --max-diagnostics 500 src/",
    "check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
    "typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
    "docs:check": "bun run ../../../scripts/docs-check.ts .",
    "bench": "bun run src/__benchmarks__/parser-throughput.bench.ts && bun run src/__benchmarks__/app-run.bench.ts"
  },
  "dependencies": {
    "@suaveplan/readline": "workspace:^",
    "@suaveplan/error": "workspace:^",
    "@suaveplan/types": "workspace:^",
    "@opentelemetry/api": "catalog:observability",
    "zod": "catalog:forms",
    "tslib": "catalog:build"
  },
  "devDependencies": {
    "@suaveplan/testing": "workspace:^",
    "@types/node": "catalog:",
    "@vitest/coverage-istanbul": "catalog:testing",
    "fast-check": "catalog:testing",
    "mitata": "catalog:testing",
    "typescript": "catalog:",
    "vite": "catalog:build",
    "vite-plugin-dts": "catalog:build",
    "vitest": "catalog:testing"
  },
  "publishConfig": {
    "registry": "https://npmjs.fq.io/",
    "access": "public"
  },
  "files": ["dist", "src", "README.md"]
}
  • Create tsconfig.lib.json (same as readline but with rootDir ./src)
{
  "$schema": "https://json.schemastore.org/tsconfig",
  "extends": "../../../tsconfig.base.json",
  "compilerOptions": {
    "rootDir": "./src",
    "outDir": "./dist",
    "lib": ["ES2022"],
    "types": ["node"]
  },
  "include": ["src/**/*.ts"],
  "exclude": ["src/**/*.test.ts", "dist", "node_modules"]
}
  • Create vite.config.ts (same shape as readline, different cacheDir and externals)
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";

export default defineConfig({
  root: __dirname,
  cacheDir: "../../../node_modules/.vite/packages/cli",
  plugins: [
    dts({
      entryRoot: "src",
      tsconfigPath: "./tsconfig.lib.json",
      insertTypesEntry: true,
    }),
  ],
  build: {
    outDir: "./dist",
    emptyOutDir: true,
    reportCompressedSize: true,
    sourcemap: true,
    lib: { entry: "src/index.ts", formats: ["es"] },
    rollupOptions: {
      treeshake: true,
      output: {
        preserveModules: true,
        preserveModulesRoot: "src",
        entryFileNames: "[name].js",
      },
      external: [
        "@suaveplan/readline",
        /^@suaveplan\/readline\//,  // covers subpath imports like readline/interface/interface.js
        "@suaveplan/error",
        "@suaveplan/types",
        "tslib",
        "zod",
        "@opentelemetry/api",
        /^node:/,
      ],
    },
  },
});
  • Create vitest.config.ts and tests/setup/polyfills.ts (identical to readline — ensure coverage exclude includes "src/**/*.schemas.ts", same as readline's config)

  • Update CLAUDE.md — add cli entry under platform in the package listing:

- **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`.
  • Run bun install from repo root

  • Commit

git add universal/platform/cli/ CLAUDE.md
git commit -m "feat(cli): scaffold package"

Task 10: errors module

Files:

  • Create: universal/platform/cli/src/errors/errors.ts

  • Create: universal/platform/cli/src/errors/errors.test.ts

  • Create: universal/platform/cli/src/errors/errors.md

  • Create: universal/platform/cli/src/errors/index.ts

  • Write failing test

import { describe, expect, it } from "vitest";
import { CliError, CLI_CODES } from "./errors.js";

describe("CLI_CODES", { timeout: 5000 }, () => {
  it("has all expected codes", () => {
    expect(CLI_CODES.PARSE.UNKNOWN_OPTION).toBe("CLI.PARSE.UNKNOWN_OPTION");
    expect(CLI_CODES.PARSE.MISSING_VALUE).toBe("CLI.PARSE.MISSING_VALUE");
    expect(CLI_CODES.PARSE.INVALID_VALUE).toBe("CLI.PARSE.INVALID_VALUE");
    expect(CLI_CODES.PARSE.MISSING_ARGUMENT).toBe("CLI.PARSE.MISSING_ARGUMENT");
    expect(CLI_CODES.PARSE.MISSING_REQUIRED_OPTION).toBe("CLI.PARSE.MISSING_REQUIRED_OPTION");
    expect(CLI_CODES.COMMAND.NO_ACTION).toBe("CLI.COMMAND.NO_ACTION");
    expect(CLI_CODES.COMMAND.UNKNOWN_SUBCOMMAND).toBe("CLI.COMMAND.UNKNOWN_SUBCOMMAND");
    expect(CLI_CODES.APP.RUN_FAILED).toBe("CLI.APP.RUN_FAILED");
    expect(CLI_CODES.APP.ABORTED).toBe("CLI.APP.ABORTED");
  });
});

describe("CliError", { timeout: 5000 }, () => {
  it("is an Error with the given code", () => {
    const err = new CliError(CLI_CODES.PARSE.UNKNOWN_OPTION, "cli.parse.unknown_option");
    expect(err).toBeInstanceOf(Error);
    expect(err.code).toBe("CLI.PARSE.UNKNOWN_OPTION");
  });
});
  • Run test — verify it fails

  • Implement src/errors/errors.ts

import type { SuaveplanErrorOptions } from "@suaveplan/error";
import { defineCodes, SuaveplanError } from "@suaveplan/error";

const PARSE_CODES = defineCodes("CLI.PARSE", [
    "UNKNOWN_OPTION",
    "MISSING_VALUE",
    "INVALID_VALUE",
    "MISSING_ARGUMENT",
    "MISSING_REQUIRED_OPTION",
] as const);

const COMMAND_CODES = defineCodes("CLI.COMMAND", [
    "NO_ACTION",
    "UNKNOWN_SUBCOMMAND",
] as const);

const APP_CODES = defineCodes("CLI.APP", [
    "RUN_FAILED",
    "ABORTED",
] as const);

export const CLI_CODES = {
    PARSE: PARSE_CODES,
    COMMAND: COMMAND_CODES,
    APP: APP_CODES,
} as const;

export type CliCode =
    | (typeof PARSE_CODES)[keyof typeof PARSE_CODES]
    | (typeof COMMAND_CODES)[keyof typeof COMMAND_CODES]
    | (typeof APP_CODES)[keyof typeof APP_CODES];

export class CliError extends SuaveplanError<CliCode> {
    // biome-ignore lint/complexity/noUselessConstructor: widens protected base constructor to public
    public constructor(
        code: CliCode,
        messageKey: string,
        options?: SuaveplanErrorOptions
    ) {
        super(code, messageKey, options);
    }
}
  • Create src/errors/index.ts, write errors.md, run test, commit
git commit -m "feat(cli): errors module"

Task 11: parser module

Files:

  • Create: universal/platform/cli/src/parser/parser.ts

  • Create: universal/platform/cli/src/parser/parser.types.ts

  • Create: universal/platform/cli/src/parser/parser.schemas.ts

  • Create: universal/platform/cli/src/parser/parser.test.ts

  • Create: universal/platform/cli/src/parser/parser.md

  • Create: universal/platform/cli/src/parser/index.ts

  • Write failing test (src/parser/parser.test.ts)

import * as fc from "fast-check";
import { describe, expect, it } from "vitest";
import { CliError, CLI_CODES } from "../errors/errors.js";
import { parseArgumentSpec, parseArgs, parseOptionFlags } from "./parser.js";

describe("parseArgs", { timeout: 5000 }, () => {
  it("parses a boolean flag", () => {
    const result = parseArgs(["--verbose"], {
      options: { verbose: { type: "boolean" } },
    });
    expect(result.options.verbose).toBe(true);
  });

  it("parses --no-* to set boolean false", () => {
    const result = parseArgs(["--no-verbose"], {
      options: { verbose: { type: "boolean", default: true } },
    });
    expect(result.options.verbose).toBe(false);
  });

  it("parses a string option with = syntax", () => {
    const result = parseArgs(["--name=world"], {
      options: { name: { type: "string" } },
    });
    expect(result.options.name).toBe("world");
  });

  it("parses a string option as next token", () => {
    const result = parseArgs(["--output", "file.txt"], {
      options: { output: { type: "string" } },
    });
    expect(result.options.output).toBe("file.txt");
  });

  it("parses a number option", () => {
    const result = parseArgs(["--count", "42"], {
      options: { count: { type: "number" } },
    });
    expect(result.options.count).toBe(42);
  });

  it("parses short flags", () => {
    const result = parseArgs(["-v"], {
      options: { verbose: { type: "boolean", short: "v" } },
    });
    expect(result.options.verbose).toBe(true);
  });

  it("captures rest after --", () => {
    const result = parseArgs(["--", "foo", "bar"]);
    expect(result.rest).toEqual(["foo", "bar"]);
  });

  it("throws CliError(UNKNOWN_OPTION) for unknown flags", () => {
    expect(() => parseArgs(["--unknown"])).toThrow(CliError);
    expect(() => parseArgs(["--unknown"])).toThrow(
      expect.objectContaining({ code: CLI_CODES.PARSE.UNKNOWN_OPTION })
    );
  });

  it("throws CliError(MISSING_VALUE) when value missing", () => {
    expect(() =>
      parseArgs(["--name"], { options: { name: { type: "string" } } })
    ).toThrow(expect.objectContaining({ code: CLI_CODES.PARSE.MISSING_VALUE }));
  });

  it("throws CliError(MISSING_ARGUMENT) for missing required positional", () => {
    expect(() =>
      parseArgs([], { arguments: [{ name: "file", required: true, variadic: false }] })
    ).toThrow(expect.objectContaining({ code: CLI_CODES.PARSE.MISSING_ARGUMENT }));
  });

  it("parses variadic positional as array", () => {
    const result = parseArgs(["a", "b", "c"], {
      arguments: [{ name: "files", required: true, variadic: true }],
    });
    expect(result.positionals.files).toEqual(["a", "b", "c"]);
  });

  it("throws CliError(INVALID_VALUE) when value not in choices", () => {
    expect(() =>
      parseArgs(["--format", "xml"], {
        options: { format: { type: "string", choices: ["json", "text"] } },
      })
    ).toThrow(expect.objectContaining({ code: CLI_CODES.PARSE.INVALID_VALUE }));
  });
});

describe("parseOptionFlags", { timeout: 5000 }, () => {
  it("extracts long flag name", () => {
    const { name } = parseOptionFlags("--verbose");
    expect(name).toBe("verbose");
  });

  it("extracts short flag", () => {
    const { config } = parseOptionFlags("-v, --verbose");
    expect(config.short).toBe("v");
  });

  it("marks as string type when value placeholder present", () => {
    const { config } = parseOptionFlags("--output <file>");
    expect(config.type).toBe("string");
  });
});

describe("parseArgumentSpec", { timeout: 5000 }, () => {
  it("marks required for <name>", () => {
    expect(parseArgumentSpec("<file>").required).toBe(true);
  });

  it("marks optional for [name]", () => {
    expect(parseArgumentSpec("[file]").required).toBe(false);
  });

  it("marks variadic for <files...>", () => {
    expect(parseArgumentSpec("<files...>").variadic).toBe(true);
  });
});

describe("parseArgs property tests", { timeout: 30000 }, () => {
  it("never throws with allowUnknown: true", () => {
    fc.assert(
      fc.property(
        fc.array(fc.oneof(
          fc.constant("--foo"),
          fc.constant("--bar=baz"),
          fc.constant("-x"),
          fc.string({ maxLength: 10 }),
        ), { maxLength: 10 }),
        (argv) => {
          expect(() => parseArgs(argv, { allowUnknown: true })).not.toThrow();
        }
      )
    );
  });
});
  • Run test — verify it fails

  • Create src/parser/parser.types.ts

export type OptionType = "string" | "boolean" | "number" | "array";

export interface OptionConfig {
    type?: OptionType;
    short?: string;
    description?: string;
    default?: unknown;
    required?: boolean;
    choices?: string[];
}

export interface ArgumentConfig {
    name: string;
    description?: string;
    required: boolean;
    variadic: boolean;
    default?: unknown;
}

export interface ParserConfig {
    options?: Record<string, OptionConfig>;
    arguments?: ArgumentConfig[];
    allowUnknown?: boolean;
    stopEarly?: boolean;
}

export interface ParsedArgs {
    options: Record<string, unknown>;
    positionals: Record<string, unknown>;
    rest: string[];
}
  • Create src/parser/parser.schemas.ts
import z from "zod";

export const OptionTypeSchema = z.enum(["string", "boolean", "number", "array"]);

export const OptionConfigSchema = z.object({
    type: OptionTypeSchema.optional(),
    short: z.string().optional(),
    description: z.string().optional(),
    default: z.unknown().optional(),
    required: z.boolean().optional(),
    choices: z.array(z.string()).optional(),
});

export const ArgumentConfigSchema = z.object({
    name: z.string(),
    description: z.string().optional(),
    required: z.boolean(),
    variadic: z.boolean(),
    default: z.unknown().optional(),
});

export const ParserConfigSchema = z.object({
    options: z.record(OptionConfigSchema).optional(),
    arguments: z.array(ArgumentConfigSchema).optional(),
    allowUnknown: z.boolean().optional(),
    stopEarly: z.boolean().optional(),
});

export const ParsedArgsSchema = z.object({
    options: z.record(z.unknown()),
    positionals: z.record(z.unknown()),
    rest: z.array(z.string()),
});
  • Implement src/parser/parser.ts — port from suaveplan/packages/universal/cli/src/parser/parser.ts, replacing all bare Error throws with CliError using the appropriate CLI_CODES.PARSE.* code.

    Key replacements:

    • throw new Error(\Unknown option: --${key}`)→throw new CliError(CLI_CODES.PARSE.UNKNOWN_OPTION, "cli.parse.unknown_option", { context: { option: key } })`
    • throw new Error(\Missing value for option: --${key}`)→throw new CliError(CLI_CODES.PARSE.MISSING_VALUE, ...)`
    • throw new Error(\Invalid number value: "${raw}"`)→throw new CliError(CLI_CODES.PARSE.INVALID_VALUE, ...)`
    • throw new Error(\Missing required argument: <${def.name}>`)→throw new CliError(CLI_CODES.PARSE.MISSING_ARGUMENT, ...)`
    • throw new Error(\Missing required option: --${name}`)→throw new CliError(CLI_CODES.PARSE.MISSING_REQUIRED_OPTION, ...)`
    • throw new Error(\Option must have a long flag: ${flags}`)→throw new CliError(CLI_CODES.PARSE.UNKNOWN_OPTION, ...)`
    • throw new Error(\Invalid choice "${raw}" for option --${key}...`)→throw new CliError(CLI_CODES.PARSE.INVALID_VALUE, "cli.parse.invalid_value", { context: { option: key, value: raw, choices } })`
  • Create src/parser/index.ts

export { parseArgs, parseArgumentSpec, parseOptionFlags } from "./parser.js";
export type { ArgumentConfig, OptionConfig, OptionType, ParsedArgs, ParserConfig } from "./parser.types.js";
export { ArgumentConfigSchema, OptionConfigSchema, ParsedArgsSchema, ParserConfigSchema } from "./parser.schemas.js";
  • Run test — verify it passes

  • Write src/parser/parser.md, commit

git commit -m "feat(cli): parser module with Zod schemas and CliError"

Chunk 4: @suaveplan/cli — command, app, style, output

Task 12: command module

Files:

  • Create: universal/platform/cli/src/command/command.ts

  • Create: universal/platform/cli/src/command/command.types.ts

  • Create: universal/platform/cli/src/command/command.schemas.ts

  • Create: universal/platform/cli/src/command/command.test.ts

  • Create: universal/platform/cli/src/command/command.md

  • Create: universal/platform/cli/src/command/index.ts

  • Write failing test (src/command/command.test.ts)

import { describe, expect, it, vi } from "vitest";
import { CliError, CLI_CODES } from "../errors/errors.js";
import { Command, parseArgumentSpec, parseOptionFlags } from "./command.js";

describe("Command — fluent API", { timeout: 5000 }, () => {
  it("sets description", () => {
    const cmd = new Command("test").description("A test command");
    expect(cmd.descriptionText).toBe("A test command");
  });

  it("registers and routes to subcommand", async () => {
    const called: string[] = [];
    const parent = new Command("app");
    parent.command("sub").action(async () => { called.push("sub"); });
    await parent.execute(["sub"]);
    expect(called).toEqual(["sub"]);
  });

  it("routes to subcommand by alias", async () => {
    const called: string[] = [];
    const parent = new Command("app");
    parent.command("subcommand").alias("s").action(async () => { called.push("aliased"); });
    await parent.execute(["s"]);
    expect(called).toEqual(["aliased"]);
  });

  it("throws CliError(NO_ACTION) when no action defined", async () => {
    const cmd = new Command("empty");
    await expect(cmd.execute([])).rejects.toMatchObject({
      code: CLI_CODES.COMMAND.NO_ACTION,
    });
  });

  it("throws CliError(UNKNOWN_SUBCOMMAND) when subcommand not found but subcommands exist", async () => {
    const cmd = new Command("app");
    cmd.command("sub").action(async () => {});
    await expect(cmd.execute(["nope"])).rejects.toMatchObject({
      code: CLI_CODES.COMMAND.UNKNOWN_SUBCOMMAND,
    });
  });

  it("calls action with parsed context", async () => {
    let capturedOpts: Record<string, unknown> = {};
    const cmd = new Command("greet")
      .option("--name <n>", "Name")
      .action(async (ctx) => { capturedOpts = ctx.options; });
    await cmd.execute(["--name", "world"]);
    expect(capturedOpts.name).toBe("world");
  });

  it("hides command from help", () => {
    const cmd = new Command("hidden").hidden();
    expect(cmd.isHidden).toBe(true);
  });

  it("generates help text with usage line", () => {
    const cmd = new Command("build").description("Build the project");
    const help = cmd.helpText();
    expect(help).toContain("Usage: build");
  });

  it("OTel span does not throw (no-op tracer)", async () => {
    const cmd = new Command("noop").action(async () => {});
    await expect(cmd.execute([])).resolves.not.toThrow();
  });
});
  • Run test — verify it fails

  • Create src/command/command.types.ts

export interface CommandInfo {
    name: string;
    description: string;
    fullName: string;
}

export interface ActionContext {
    args: Record<string, unknown>;
    options: Record<string, unknown>;
    rest: string[];
    command: CommandInfo;
}

export type ActionFn = (ctx: ActionContext) => void | Promise<void>;
  • Create src/command/command.schemas.ts
import z from "zod";

export const CommandInfoSchema = z.object({
    name: z.string(),
    description: z.string(),
    fullName: z.string(),
});
  • Implement src/command/command.ts — port from suaveplan source. Key changes:

    • Replace bare Error throws with CliError(CLI_CODES.COMMAND.NO_ACTION) and CliError(CLI_CODES.COMMAND.UNKNOWN_SUBCOMMAND)
    • Add OTel span in execute():
    const tracer = trace.getTracer("@suaveplan/cli", "0.1.0");
    // inside execute():
    const span = tracer.startSpan(`cli.command.execute`, { attributes: { "cli.command": fullName } });
    try {
      // ... existing logic ...
      span.end();
    } catch (err) {
      span.setStatus({ code: SpanStatusCode.ERROR });
      span.end();
      throw err;
    }
    
    • The subcommand routing logic: when argv[0] is not a flag AND a subcommand exists by that name → route. If no subcommand matches AND subcommands are registered → CliError(UNKNOWN_SUBCOMMAND). If no action → CliError(NO_ACTION).
  • Create src/command/index.ts, run test, write command.md, commit

git commit -m "feat(cli): command module with OTel span and CliError"

Task 13: app module

Files:

  • Create: universal/platform/cli/src/app/app.ts

  • Create: universal/platform/cli/src/app/app.types.ts

  • Create: universal/platform/cli/src/app/app.schemas.ts

  • Create: universal/platform/cli/src/app/app.test.ts

  • Create: universal/platform/cli/src/app/app.md

  • Create: universal/platform/cli/src/app/index.ts

  • Write failing test (src/app/app.test.ts)

import { describe, expect, it } from "vitest";
import { CliError, CLI_CODES } from "../errors/errors.js";
import { App, cli } from "./app.js";

function makeApp(writer: (s: string) => void = () => {}): App {
  return new App({ name: "test-app", version: "1.2.3", writer });
}

describe("App.run()", { timeout: 5000 }, () => {
  it("prints version with --version", async () => {
    const output: string[] = [];
    const app = makeApp((s) => output.push(s));
    await app.run(["--version"]);
    expect(output.join("")).toContain("1.2.3");
  });

  it("prints help with --help", async () => {
    const output: string[] = [];
    const app = makeApp((s) => output.push(s));
    await app.run(["--help"]);
    expect(output.join("")).toContain("Usage");
  });

  it("routes to registered command", async () => {
    const called: string[] = [];
    const app = makeApp();
    app.command("build").action(async () => { called.push("build"); });
    await app.run(["build"]);
    expect(called).toEqual(["build"]);
  });

  it("throws CliError(ABORTED) when AbortSignal fires before execution", async () => {
    const controller = new AbortController();
    controller.abort();
    const app = makeApp();
    app.action(async () => {});
    await expect(app.run([], { signal: controller.signal })).rejects.toMatchObject({
      code: CLI_CODES.APP.ABORTED,
    });
  });

  it("calls onError with CliError on execution failure", async () => {
    const errors: Error[] = [];
    const app = new App({
      name: "app",
      onError: (e) => errors.push(e),
    });
    app.action(async () => { throw new Error("boom"); });
    await app.run([]);
    expect(errors.length).toBe(1);
  });

  it("OTel span does not throw (no-op tracer)", async () => {
    const app = makeApp();
    app.action(async () => {});
    await expect(app.run([])).resolves.not.toThrow();
  });
});

describe("cli() factory", { timeout: 5000 }, () => {
  it("creates App with name and version", () => {
    const app = cli("myapp", "2.0.0");
    expect(app.name).toBe("myapp");
    expect(app.version).toBe("2.0.0");
  });
});
  • Run test — verify it fails

  • Create src/app/app.types.ts

export interface AppConfig {
    name: string;
    version?: string;
    description?: string;
    enableHelp?: boolean;
    enableVersion?: boolean;
    onError?: (err: Error) => void;
    writer?: (text: string) => void;
}
  • Create src/app/app.schemas.ts
import z from "zod";

export const AppConfigSchema = z.object({
    name: z.string(),
    version: z.string().optional(),
    description: z.string().optional(),
    enableHelp: z.boolean().optional(),
    enableVersion: z.boolean().optional(),
});
  • Implement src/app/app.ts — port from suaveplan source. Key changes:

    • Add OTel span in run():
    const tracer = trace.getTracer("@suaveplan/cli", "0.1.0");
    // inside run():
    if (options?.signal?.aborted) {
      throw new CliError(CLI_CODES.APP.ABORTED, "cli.app.aborted");
    }
    const span = tracer.startSpan("cli.app.run");
    try {
      // ... existing logic ...
      span.end();
    } catch (err) {
      span.setStatus({ code: SpanStatusCode.ERROR });
      span.end();
      throw err;
    }
    
    • run() signature becomes async run(argv?: string[], options?: { signal?: AbortSignal }): Promise<void>
    • onError catches and handles; wrap plain Error in CliError(CLI_CODES.APP.RUN_FAILED) if not already a CliError
  • Create src/app/index.ts, run test, write app.md, commit

git commit -m "feat(cli): app module with OTel span and AbortSignal"

Task 14: style, output/format, output/table

Files:

  • Create all files for style/, output/format/, output/table/

These three modules are pure functions with no IO. Port directly from the suaveplan source, adding Zod schemas and replacing bare Error throws with CliError.

  • Write tests for style — key scenarios:

    • isColorSupported() returns boolean
    • stripAnsi(style.bold.red("text")) equals "text" (property test: stripAnsi(style.bold.red(s)) === s for all s)
    • createStyler(true) returns a callable styler
    • getStyleNames() returns non-empty array
  • Write tests for output/format — key scenarios:

    • keyValue({ a: 1, b: 2 }) contains "a" and "1"
    • list(["x", "y"]) contains bullet markers
    • tree({ label: "root", children: [{ label: "child" }] }) contains "root" and "child"
    • heading("Title", 1) returns string with "═" borders
    • json({ x: 1 }) returns '{\n "x": 1\n}'
    • columns([]) returns ""
  • Write tests for output/table — key scenarios:

    • Renders header row
    • Aligns columns to max width (property test: column width ≥ max cell content width)
    • Handles empty rows
  • Implement style.ts, style.schemas.ts — port from source; add StyleOptionsSchema

  • Implement output/format/format.ts, format.schemas.ts — port from source; add Zod schemas for each options type

  • Implement output/table/table.ts, table.schemas.ts — port from source; replace bare Error with CliError

  • Create all index.ts barrels and .md docs

  • Run tests — verify all pass

  • Commit

git commit -m "feat(cli): style, output/format, output/table modules"

Chunk 5: @suaveplan/cli — spinner, progress, prompt, benchmarks, publish, archival

Task 15: spinner module

Files:

  • Create: universal/platform/cli/src/spinner/spinner.ts

  • Create: universal/platform/cli/src/spinner/spinner.types.ts

  • Create: universal/platform/cli/src/spinner/spinner.schemas.ts

  • Create: universal/platform/cli/src/spinner/spinner.test.ts

  • Create: universal/platform/cli/src/spinner/spinner.md

  • Create: universal/platform/cli/src/spinner/index.ts

  • Write failing test (src/spinner/spinner.test.ts)

import { Writable } from "node:stream";
import { describe, expect, it } from "vitest";
import { createSpinner } from "./spinner.js";

function makeStream(): { stream: Writable; output: () => string } {
  const chunks: string[] = [];
  const stream = new Writable({
    write(chunk, _enc, cb) { chunks.push(chunk.toString()); cb(); },
  }) as Writable & { isTTY: boolean; clearLine: (dir: number) => void; cursorTo: (x: number) => void };
  stream.isTTY = false; // non-TTY: uses fallback write
  (stream as Writable & { clearLine: (dir: number) => void }).clearLine = () => {};
  (stream as Writable & { cursorTo: (x: number) => void }).cursorTo = () => {};
  return { stream: stream as Writable, output: () => chunks.join("") };
}

describe("createSpinner", { timeout: 5000 }, () => {
  it("starts and is spinning", () => {
    const { stream } = makeStream();
    const sp = createSpinner({ text: "Loading", stream: stream as Parameters<typeof createSpinner>[0] extends { stream?: infer S } ? S : never });
    sp.start();
    expect(sp.isSpinning).toBe(true);
    sp.stop();
  });

  it("is not spinning after stop()", () => {
    const { stream } = makeStream();
    const sp = createSpinner({ text: "x", stream: stream as any });
    sp.start().stop();
    expect(sp.isSpinning).toBe(false);
  });

  it("writes to non-TTY stream on start", () => {
    const { stream, output } = makeStream();
    const sp = createSpinner({ text: "Working", stream: stream as any });
    sp.start();
    expect(output()).toContain("Working");
    sp.stop();
  });

  it("succeed writes success symbol", () => {
    const { stream, output } = makeStream();
    const sp = createSpinner({ text: "done", stream: stream as any });
    sp.start().succeed("Completed");
    expect(output()).toContain("Completed");
  });

  it("fail writes fail symbol", () => {
    const { stream, output } = makeStream();
    const sp = createSpinner({ text: "task", stream: stream as any });
    sp.start().fail("Failed");
    expect(output()).toContain("Failed");
  });

  it("aborts via AbortSignal", async () => {
    const { stream } = makeStream();
    const controller = new AbortController();
    const sp = createSpinner({ text: "work", stream: stream as any, signal: controller.signal });
    sp.start();
    controller.abort();
    // Give microtask queue a tick
    await new Promise((r) => setTimeout(r, 10));
    expect(sp.isSpinning).toBe(false);
  });
});
  • Run test — verify it fails

  • Create src/spinner/spinner.types.ts

export interface WritableStreamLike {
    write(text: string): void;
    clearLine?(dir: number): void;
    cursorTo?(x: number): void;
    isTTY?: boolean;
}

export interface SpinnerOptions {
    text?: string;
    frames?: string[];
    interval?: number;
    stream?: WritableStreamLike;
    signal?: AbortSignal;
}

export interface SpinnerInstance {
    readonly isSpinning: boolean;
    start(text?: string): SpinnerInstance;
    stop(): SpinnerInstance;
    succeed(text?: string): SpinnerInstance;
    fail(text?: string): SpinnerInstance;
    warn(text?: string): SpinnerInstance;
    update(text: string): SpinnerInstance;
}
  • Implement src/spinner/spinner.ts — port from suaveplan source; add signal handling. Two guards needed — pre-fired signal (already aborted before start()) and live abort event:

    // Inside createSpinner, after setting up the instance:
    if (opts.signal) {
      opts.signal.addEventListener("abort", () => {
        instance.stop();
      }, { once: true });
    }
    

    Inside start(), add pre-abort guard as the first line:

    start(text?: string): SpinnerInstance {
      if (opts.signal?.aborted) return instance; // already aborted — no-op
      // ...rest of start logic
    }
    
  • Create src/spinner/spinner.schemas.ts

import z from "zod";

export const SpinnerOptionsSchema = z.object({
    text: z.string().optional(),
    frames: z.array(z.string()).optional(),
    interval: z.number().positive().optional(),
});
  • Create src/spinner/index.ts, run test, write spinner.md, commit
git commit -m "feat(cli): spinner module with AbortSignal"

Task 16: progress module

Files: same pattern as spinner.

  • Write failing test — key scenarios: createProgress({ total: 100 }), renders bar, update(50) changes percentage, AbortSignal stops progress.

  • Create src/progress/progress.types.ts

import type { WritableStreamLike } from "../spinner/spinner.types.js";

export type { WritableStreamLike };

export interface ProgressOptions {
    total: number;
    width?: number;
    stream?: WritableStreamLike;
    signal?: AbortSignal;
}

export interface ProgressInstance {
    update(current: number): void;
    complete(): void;
    render(): string;
}
  • Implement src/progress/progress.ts — port from suaveplan source; add signal handling same as spinner (both the "abort" event listener AND a pre-abort guard inside update() / the start path — check opts.signal?.aborted before writing to the stream).

  • Create schemas, index, md, run test, commit

git commit -m "feat(cli): progress module with AbortSignal"

Task 17: prompt module

Files:

  • Create: universal/platform/cli/src/prompt/prompt.ts

  • Create: universal/platform/cli/src/prompt/prompt.types.ts

  • Create: universal/platform/cli/src/prompt/prompt.schemas.ts

  • Create: universal/platform/cli/src/prompt/prompt.test.ts

  • Create: universal/platform/cli/src/prompt/prompt.md

  • Create: universal/platform/cli/src/prompt/index.ts

  • Write failing test (src/prompt/prompt.test.ts)

import { Readable, Writable } from "node:stream";
import { describe, expect, it } from "vitest";
import { confirm, multiselect, password, select, text } from "./prompt.js";
import type { InputReader } from "./prompt.types.js";

function makeReader(lines: string[]): InputReader {
  let idx = 0;
  return {
    question: async (_prompt: string) => lines[idx++] ?? "",
    close: () => {},
  };
}

describe("text()", { timeout: 5000 }, () => {
  it("returns user input", async () => {
    expect(await text("Name?", makeReader(["Alice"]))).toBe("Alice");
  });

  it("returns default when input is empty", async () => {
    expect(await text({ message: "Name?", default: "Bob" }, makeReader([""]))).toBe("Bob");
  });

  it("re-prompts when validation fails then passes", async () => {
    const result = await text(
      { message: "Age?", validate: (v) => (Number(v) > 0 ? true : "must be positive") },
      makeReader(["-1", "25"])
    );
    expect(result).toBe("25");
  });
});

describe("password()", { timeout: 5000 }, () => {
  it("returns user input", async () => {
    expect(await password("Pass?", makeReader(["secret"]))).toBe("secret");
  });
});

describe("confirm()", { timeout: 5000 }, () => {
  it("returns true for 'y'", async () => {
    expect(await confirm("Continue?", makeReader(["y"]))).toBe(true);
  });

  it("returns false for 'n'", async () => {
    expect(await confirm("Continue?", makeReader(["n"]))).toBe(false);
  });

  it("returns default when empty", async () => {
    expect(await confirm({ message: "Go?", default: false }, makeReader([""]))).toBe(false);
  });
});

describe("select()", { timeout: 5000 }, () => {
  it("returns the selected choice value", async () => {
    const result = await select(
      { message: "Pick:", choices: ["a", "b", "c"] },
      makeReader(["2"])
    );
    expect(result).toBe("b");
  });

  it("re-prompts on invalid selection", async () => {
    const result = await select(
      { message: "Pick:", choices: ["x", "y"] },
      makeReader(["99", "1"])
    );
    expect(result).toBe("x");
  });
});

describe("multiselect()", { timeout: 5000 }, () => {
  it("returns selected values", async () => {
    const result = await multiselect(
      { message: "Pick:", choices: ["a", "b", "c"] },
      makeReader(["1,3"])
    );
    expect(result).toEqual(["a", "c"]);
  });

  it("enforces min selection", async () => {
    const result = await multiselect(
      { message: "Pick:", choices: ["a", "b", "c"], min: 2 },
      makeReader(["1", "1,2"])
    );
    expect(result.length).toBeGreaterThanOrEqual(2);
  });
});
  • Run test — verify it fails

  • Create src/prompt/prompt.types.ts

export interface InputReader {
    question(prompt: string): Promise<string>;
    close(): void;
}

export interface TextPromptOptions {
    message: string;
    default?: string;
    validate?: (value: string) => true | string;
}

export interface PasswordPromptOptions {
    message: string;
    validate?: (value: string) => true | string;
}

export interface ConfirmPromptOptions {
    message: string;
    default?: boolean;
}

export interface SelectChoice<T = string> {
    label: string;
    value: T;
    description?: string;
}

export interface SelectPromptOptions<T = string> {
    message: string;
    choices: Array<SelectChoice<T> | string>;
    default?: T;
}

export interface MultiSelectPromptOptions<T = string> {
    message: string;
    choices: Array<SelectChoice<T> | string>;
    min?: number;
    max?: number;
}
  • Implement src/prompt/prompt.ts — port from suaveplan source. Critical change: createDefaultReader() uses @suaveplan/readline/interface:
import { createInterface } from "@suaveplan/readline/interface/interface.js";
import type { Readable, Writable } from "node:stream";
import type { InputReader } from "./prompt.types.js";

export async function createDefaultReader(): Promise<InputReader> {
  const { stdin, stdout } = process;
  const rl = createInterface({
    input: stdin as unknown as Readable,
    output: stdout as unknown as Writable,
  });
  return {
    question: (prompt: string) => rl.question(prompt),
    close: () => rl.close(),
  };
}

Keep the five prompt functions (text, password, confirm, select, multiselect) matching the suaveplan implementation. No other changes needed — the InputReader interface is structurally identical to what the test mock provides.

  • Create src/prompt/prompt.schemas.ts
import z from "zod";

export const TextPromptOptionsSchema = z.object({
    message: z.string(),
    default: z.string().optional(),
});

export const ConfirmPromptOptionsSchema = z.object({
    message: z.string(),
    default: z.boolean().optional(),
});

export const SelectChoiceSchema = z.object({
    label: z.string(),
    value: z.unknown(),
    description: z.string().optional(),
});
  • Create src/prompt/index.ts, run test, write prompt.md, commit
git commit -m "feat(cli): prompt module using @suaveplan/readline/interface"

Task 18: Root src/index.ts barrel

  • Create universal/platform/cli/src/index.ts
// App
export { App, cli } from "./app/app.js";
export type { AppConfig } from "./app/app.types.js";
export { AppConfigSchema } from "./app/app.schemas.js";
// Command
export { Command, parseArgumentSpec, parseOptionFlags } from "./command/command.js";
export type { ActionContext, ActionFn, CommandInfo } from "./command/command.types.js";
export { CommandInfoSchema } from "./command/command.schemas.js";
// Errors
export { CliError, CLI_CODES } from "./errors/errors.js";
export type { CliCode } from "./errors/errors.js";
// Parser
export { parseArgs } from "./parser/parser.js";
export type { ArgumentConfig, OptionConfig, OptionType, ParsedArgs, ParserConfig } from "./parser/parser.types.js";
export { ArgumentConfigSchema, OptionConfigSchema, OptionTypeSchema, ParsedArgsSchema, ParserConfigSchema } from "./parser/parser.schemas.js";
// Style
export { createStyler, getStyleNames, isColorSupported, stripAnsi, style } from "./style/style.js";
export type { BgColor, Color, Modifier, StyleCode, Styler } from "./style/style.types.js";
export { StyleOptionsSchema } from "./style/style.schemas.js";
// Output
export { columns, divider, heading, indent, json, keyValue, list, tree } from "./output/format/format.js";
export type { JsonOptions, KeyValueOptions, ListOptions, TreeNode, TreeOptions } from "./output/format/format.types.js";
export { FormatOptionsSchema } from "./output/format/format.schemas.js";
export { table } from "./output/table/table.js";
export type { Alignment, TableOptions } from "./output/table/table.types.js";
export { TableOptionsSchema } from "./output/table/table.schemas.js";
// Spinner
export { createSpinner } from "./spinner/spinner.js";
export type { SpinnerInstance, SpinnerOptions, WritableStreamLike } from "./spinner/spinner.types.js";
export { SpinnerOptionsSchema } from "./spinner/spinner.schemas.js";
// Progress
export { createProgress } from "./progress/progress.js";
export type { ProgressInstance, ProgressOptions } from "./progress/progress.types.js";
export { ProgressOptionsSchema } from "./progress/progress.schemas.js";
// Prompt
export { confirm, createDefaultReader, multiselect, password, select, text } from "./prompt/prompt.js";
export type { ConfirmPromptOptions, InputReader, MultiSelectPromptOptions, PasswordPromptOptions, SelectChoice, SelectPromptOptions, TextPromptOptions } from "./prompt/prompt.types.js";
export { ConfirmPromptOptionsSchema, SelectChoiceSchema, TextPromptOptionsSchema } from "./prompt/prompt.schemas.js";
  • Commit
git commit -m "feat(cli): root barrel index"

Task 19: Benchmarks + README

  • Create src/__benchmarks__/parser-throughput.bench.ts
import { bench, run } from "mitata";
import { parseArgs } from "../parser/parser.js";

const config = {
  options: {
    verbose: { type: "boolean" as const, short: "v" },
    output: { type: "string" as const, short: "o" },
    count: { type: "number" as const },
    format: { type: "string" as const, choices: ["json", "text"] },
    dry: { type: "boolean" as const },
    force: { type: "boolean" as const },
    quiet: { type: "boolean" as const },
    debug: { type: "boolean" as const },
    timeout: { type: "number" as const },
    retries: { type: "number" as const },
  },
};

const argv = ["--verbose", "--output", "out.txt", "--count", "5", "--format", "json"];

bench("parseArgs (20-flag config)", () => {
  parseArgs(argv, config);
});

await run();
  • Create src/__benchmarks__/app-run.bench.ts
import { bench, run } from "mitata";
import { App } from "../app/app.js";

function makeApp(): App {
  const app = new App({ name: "bench-app", writer: () => {} });
  app.command("build").action(async () => {});
  app.command("test").action(async () => {});
  app.command("lint").action(async () => {});
  return app;
}

bench("App.run() dispatch", async () => {
  const app = makeApp();
  await app.run(["build"]);
});

await run();
  • Create README.md with required sections plus benchmark baseline numbers

  • Run full coverage gate

cd /mnt/s/web/genesis && bunx turbo lint typecheck test --filter=@suaveplan/cli
bunx turbo test:coverage --filter=@suaveplan/cli

Expected: all green, 100% coverage.

  • Run benchmarks and paste baseline into README
cd /mnt/s/web/genesis/universal/platform/cli && bun run bench
  • Commit
git add universal/platform/cli/
git commit -m "feat(cli): benchmarks, README, 100% coverage gate"

Task 20: Publish @suaveplan/cli + OpenSpec archival

  • Run pre-publish gate
cd /mnt/s/web/genesis
bunx turbo lint typecheck test --filter=@suaveplan/cli
bunx turbo test:coverage --filter=@suaveplan/cli
bunx turbo build --filter=@suaveplan/cli
  • Publish
bun scripts/publish.ts --package @suaveplan/cli
  • Create OpenSpec change folders
openspec/changes/enterprise-readline/proposal.md
openspec/changes/enterprise-readline/tasks.md
openspec/changes/enterprise-readline/specs/readline/spec.md

openspec/changes/enterprise-cli/proposal.md
openspec/changes/enterprise-cli/tasks.md
openspec/changes/enterprise-cli/specs/cli/spec.md

Each proposal.md references the spec doc at docs/superpowers/specs/2026-04-20-readline-cli-design.md. Each tasks.md links to this plan. Each specs/<cap>/spec.md contains the final accepted spec for that capability.

  • Archive both changes
mv openspec/changes/enterprise-readline openspec/archive/2026-04-20-enterprise-readline
mv openspec/changes/enterprise-cli openspec/archive/2026-04-20-enterprise-cli
  • Final banned-pattern scan (zero output expected)
grep -rn "@deprecated\|TODO\|FIXME\|ts-ignore\|ts-expect-error\|\.skip\|\.only\|\bany\b" \
  universal/platform/readline/src/ universal/platform/cli/src/ --include="*.ts"
  • Final missing-.md scan (zero output expected)
for pkg in readline cli; do
  for f in $(find universal/platform/$pkg/src -name "*.ts" \
    ! -name "*.test.ts" ! -name "*.types.ts" \
    ! -name "index.ts" ! -name "*.schemas.ts" ! -name "*.bench.ts"); do
    md="${f%.ts}.md"
    [ -f "$md" ] || echo "MISSING: $md"
  done
done
  • Commit
git add .
git commit -m "chore: publish cli@0.0.0, archive OpenSpec changes"

Acceptance Checklist

  • 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
  • bench script wired in both package.json files; bench task in turbo.json
  • Benchmark baselines published in both README.md files
  • OpenSpec changes archived; FOLDERS.yml and CLAUDE.md updated