Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,118 @@
{
"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"]
}

View File

@@ -0,0 +1,120 @@
# app
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`App` is the top-level entry point for a `@suaveplan/cli` application. It wraps a root `Command` and adds application-level concerns: version printing, help generation, `onError` handling, OTel span instrumentation on `run()`, and `AbortSignal` support. The `cli()` factory function creates an `App` with a name and optional version string in a single expression.
`App` delegates all routing and parsing to the underlying `Command` hierarchy. Its primary responsibility is to intercept `--version`, `--help`, and abort signals before dispatching, and to wrap any thrown error with `CliError(CLI_CODES.APP.RUN_FAILED)` when an `onError` handler is registered.
## Features
- Fluent builder: `.description()`, `.option()`, `.argument()`, `.action()`, `.command()`, `.example()`
- `run(argv?, options?)` dispatches to the root command; defaults `argv` to `process.argv.slice(2)`
- `--version` / `-V` prints the version string (if set) and exits cleanly
- `--help` / `-h` prints `helpText()` and exits cleanly
- `options.signal` (AbortSignal) — throws `CliError(CLI_CODES.APP.ABORTED)` immediately when already aborted
- `onError` handler — catches thrown errors, wraps non-`CliError` in `CliError(CLI_CODES.APP.RUN_FAILED)`, and calls the handler instead of re-throwing
- OTel span on every `run()` call: span name `cli.app.run`, attribute `cli.app` set to the app name; `SpanStatusCode.ERROR` set on failure
- `cli(name, version?)` factory for single-expression app creation
## Usage: Basic Example
```ts
import { cli } from "@suaveplan/cli/app/app";
const app = cli("deploy", "1.0.0")
.description("Deployment toolkit");
app.command("up")
.description("Bring services up")
.action(async () => {
console.log("Services are up");
});
await app.run();
```
## Usage: Advanced Example
```ts
import { App } from "@suaveplan/cli/app/app";
const app = new App({
name: "myapp",
version: "2.1.0",
description: "My enterprise CLI",
writer: (text) => process.stdout.write(text + "\n"),
onError: (err) => {
console.error(`Error [${err.code}]: ${err.message}`);
process.exit(1);
},
});
app
.option("--verbose", "Enable verbose output")
.action(async (ctx) => {
if (ctx.options.verbose) console.log("Verbose mode on");
});
const controller = new AbortController();
process.on("SIGINT", () => controller.abort());
await app.run(process.argv.slice(2), { signal: controller.signal });
```
## API Reference
### `new App(config: AppConfig)`
Creates a new app. Config fields: `name` (required), `version`, `description`, `enableHelp` (default true), `enableVersion` (default true), `onError`, `writer`.
### `app.name: string`
The app's name as provided in config.
### `app.version: string | undefined`
The app's version string, or `undefined` if not set.
### `app.description(text: string): this`
Sets the app description shown in help text.
### `app.option(flags, description?, defaultValue?): this`
Registers a root-level option.
### `app.argument(spec, description?): this`
Registers a root-level positional argument.
### `app.action(fn: ActionFn): this`
Sets the root action handler.
### `app.command(name: string): Command`
Registers and returns a named sub-command.
### `app.example(text: string): this`
Adds an example to the help output.
### `app.helpText(): string`
Returns formatted multi-line help text for the root command.
### `app.run(argv?: string[], options?: { signal?: AbortSignal }): Promise<void>`
Executes the app. If `signal` is already aborted, throws `CliError(ABORTED)` immediately. Intercepts `--version` and `--help` before routing. Delegates to the root `Command.execute()`.
### `cli(name: string, version?: string): App`
Factory function. Creates an `App` with `name` and optional `version`.
## Implementation Notes
The `writer` config field injects the output sink, defaulting to `process.stdout.write`. This enables full testability without TTY or process coupling. The `onError` hook receives a `CliError` — if the thrown value was a plain `Error`, it is wrapped in `CliError(RUN_FAILED)` before being handed to the hook, so callers always receive a structured error with a stable code.

View File

@@ -0,0 +1,9 @@
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(),
});

View File

@@ -0,0 +1,252 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { 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", { timeout: 5000 }, 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", { timeout: 5000 }, 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", { timeout: 5000 }, 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", {
timeout: 5000,
}, 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", {
timeout: 5000,
}, 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)", {
timeout: 5000,
}, async () => {
const app = makeApp();
app.action(async () => {});
await expect(app.run([])).resolves.not.toThrow();
});
it("rethrows error when no onError handler", {
timeout: 5000,
}, async () => {
const app = makeApp();
app.action(async () => {
throw new Error("unhandled");
});
await expect(app.run([])).rejects.toThrow("unhandled");
});
it("wraps error in CliError(RUN_FAILED) when passing to onError", {
timeout: 5000,
}, 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[0]).toMatchObject({ code: CLI_CODES.APP.RUN_FAILED });
});
});
describe("App constructor edge cases", { timeout: 5000 }, () => {
it("uses process.stdout when no writer provided", {
timeout: 5000,
}, async () => {
const app = new App({ name: "test", version: "1.0.0" });
// Should not throw — uses default stdout writer
await app.run(["--version"]);
});
it("sets description when provided in config", { timeout: 5000 }, () => {
const app = new App({ name: "app", description: "My tool" });
expect(app.helpText()).toContain("My tool");
});
it("run() without argv defaults to process.argv.slice(2)", {
timeout: 5000,
}, async () => {
const app = new App({ name: "app", writer: () => {} });
app.action(async () => {});
// process.argv.slice(2) in test env is likely [] or test runner args — just shouldn't throw
await expect(app.run()).resolves.not.toThrow();
});
it("--version without version string does not print version", {
timeout: 5000,
}, async () => {
const output: string[] = [];
const errors: unknown[] = [];
const app = new App({
name: "app",
writer: (s) => output.push(s),
onError: (e) => errors.push(e),
});
app.action(async () => {});
await app.run(["--version"]);
expect(output).toHaveLength(0);
});
it("enableVersion:false passes --version to action", {
timeout: 5000,
}, async () => {
const output: string[] = [];
const errors: unknown[] = [];
const app = new App({
name: "app",
version: "1.0.0",
enableVersion: false,
writer: (s) => output.push(s),
onError: (e) => errors.push(e),
});
app.action(async () => {});
await app.run(["--version"]);
expect(output).toHaveLength(0);
});
it("enableHelp:false passes --help to action", {
timeout: 5000,
}, async () => {
const output: string[] = [];
const errors: unknown[] = [];
const app = new App({
name: "app",
enableHelp: false,
writer: (s) => output.push(s),
onError: (e) => errors.push(e),
});
app.action(async () => {});
await app.run(["--help"]);
expect(output).toHaveLength(0);
});
it("onError receives error from failed action", {
timeout: 5000,
}, async () => {
const errors: Error[] = [];
const app = new App({ name: "app", onError: (e) => errors.push(e) });
app.action(async () => {
throw new Error("action error");
});
await app.run([]);
expect(errors.length).toBe(1);
expect(errors[0]).toMatchObject({ code: CLI_CODES.APP.RUN_FAILED });
});
it("onError wraps non-Error throw as CliError", {
timeout: 5000,
}, async () => {
const errors: Error[] = [];
const app = new App({ name: "app", onError: (e) => errors.push(e) });
(app as unknown as { _root: { _action: unknown } })._root._action =
async () => {
throw "string error";
};
await app.run([]);
expect(errors[0]).toMatchObject({ code: CLI_CODES.APP.RUN_FAILED });
});
});
describe("App builder methods", { timeout: 5000 }, () => {
it("description() sets description on root command", {
timeout: 5000,
}, () => {
const app = makeApp();
app.description("My tool");
expect(app.helpText()).toContain("My tool");
});
it("option() adds global option", { timeout: 5000 }, async () => {
let captured: unknown;
const app = makeApp();
app.option("--dry-run, -d", "dry run mode");
app.action(async (ctx) => {
captured = ctx.options["dryRun"];
});
await app.run(["--dry-run"]);
expect(captured).toBe(true);
});
it("argument() adds positional argument", { timeout: 5000 }, async () => {
let captured: unknown;
const app = makeApp();
app.argument("<file>", "file to process");
app.action(async (ctx) => {
captured = ctx.args["file"];
});
await app.run(["myfile.txt"]);
expect(captured).toBe("myfile.txt");
});
it("example() shows up in help text", { timeout: 5000 }, () => {
const output: string[] = [];
const app = new App({ name: "app", writer: (s) => output.push(s) });
app.example("app build --verbose");
app.run(["--help"]);
return new Promise<void>((r) =>
setTimeout(() => {
expect(output.join("")).toContain("app build --verbose");
r();
}, 50)
);
});
});
describe("cli() factory", { timeout: 5000 }, () => {
it("creates App with name and version", { timeout: 5000 }, () => {
const app = cli("myapp", "2.0.0");
expect(app.name).toBe("myapp");
expect(app.version).toBe("2.0.0");
});
it("creates App with name only", { timeout: 5000 }, () => {
const app = cli("myapp");
expect(app.name).toBe("myapp");
expect(app.version).toBeUndefined();
});
});

View File

@@ -0,0 +1,129 @@
import { SpanStatusCode, trace } from "@opentelemetry/api";
import { Command } from "../command/command.js";
import type { ActionFn } from "../command/command.types.js";
import { CLI_CODES, CliError } from "../errors/errors.js";
import type { AppConfig } from "./app.types.js";
const tracer = trace.getTracer("@suaveplan/cli", "0.1.0");
export class App {
private _root: Command;
private _version: string | undefined;
private _config: AppConfig;
private _write: (text: string) => void;
constructor(config: AppConfig) {
this._config = config;
this._version = config.version;
this._write = config.writer ?? ((t) => process.stdout.write(`${t}\n`));
this._root = new Command(config.name);
if (config.description) this._root.description(config.description);
}
get name(): string {
return this._config.name;
}
get version(): string | undefined {
return this._version;
}
description(text: string): this {
this._root.description(text);
return this;
}
option(flags: string, desc?: string, defaultValue?: unknown): this {
this._root.option(flags, desc, defaultValue);
return this;
}
argument(spec: string, desc?: string): this {
this._root.argument(spec, desc);
return this;
}
action(fn: ActionFn): this {
this._root.action(fn);
return this;
}
command(name: string): Command {
return this._root.command(name);
}
example(text: string): this {
this._root.example(text);
return this;
}
helpText(): string {
return this._root.helpText();
}
async run(
argv?: string[],
options?: { signal?: AbortSignal }
): Promise<void> {
const args = argv ?? process.argv.slice(2);
if (options?.signal?.aborted) {
throw new CliError(CLI_CODES.APP.ABORTED, "cli.app.aborted");
}
const span = tracer.startSpan("cli.app.run", {
attributes: { "cli.app": this._config.name },
});
try {
// Handle --version
const enableVersion = this._config.enableVersion !== false;
if (
enableVersion &&
(args[0] === "--version" || args[0] === "-V") &&
this._version
) {
this._write(this._version);
span.end();
return;
}
// Handle --help
const enableHelp = this._config.enableHelp !== false;
if (enableHelp && (args[0] === "--help" || args[0] === "-h")) {
this._write(this.helpText());
span.end();
return;
}
await this._root.execute(args);
span.end();
} catch (err) {
span.setStatus({ code: SpanStatusCode.ERROR });
span.end();
if (this._config.onError) {
const cliErr =
err instanceof CliError
? err
: new CliError(
CLI_CODES.APP.RUN_FAILED,
"cli.app.run_failed",
{
cause:
err instanceof Error
? err
: new Error(String(err)),
}
);
this._config.onError(cliErr);
return;
}
throw err;
}
}
}
export function cli(name: string, version?: string): App {
return new App(version !== undefined ? { name, version } : { name });
}

View File

@@ -0,0 +1,9 @@
export interface AppConfig {
name: string;
version?: string;
description?: string;
enableHelp?: boolean;
enableVersion?: boolean;
onError?: (err: Error) => void;
writer?: (text: string) => void;
}

View File

@@ -0,0 +1,3 @@
export { App, cli } from "./app.js";
export { AppConfigSchema } from "./app.schemas.js";
export type { AppConfig } from "./app.types.js";

View File

@@ -0,0 +1,122 @@
# command
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`Command` is the core routing primitive for `@suaveplan/cli`. It models a single CLI command that can carry options, positional arguments, and an action handler, or act as a router that delegates to named sub-commands. Every `App` is backed by a root `Command`.
The class exposes a fluent builder API: chain `.description()`, `.option()`, `.argument()`, `.command()`, and `.action()` calls before calling `.execute()` at runtime. Sub-commands are registered via `.command(name)` which returns the child `Command`, enabling identical fluent chaining for nested hierarchies.
## Features
- Fluent builder: `.description()`, `.option()`, `.argument()`, `.action()`, `.command()`, `.alias()`, `.hidden()`, `.example()`
- Sub-command routing: first non-flag token in `argv` is matched against registered names and aliases
- Option parsing delegated to `parseArgs` — inherits full flag semantics (boolean, string, number, `--no-*`, short flags, `=` syntax, `--` rest)
- OTel span on every `execute()` call: span name `cli.command.execute`, attribute `cli.command` set to the full qualified name
- `CliError(CLI_CODES.COMMAND.NO_ACTION)` when no action is registered and no sub-command is found
- `CliError(CLI_CODES.COMMAND.UNKNOWN_SUBCOMMAND)` when an unknown first token is provided and sub-commands are registered
- Automatic `--help` / `-h` handling: prints `helpText()` and returns without error
- `helpText(parentName?)` generates a formatted usage block with options and sub-command listing
## Usage: Basic Example
```ts
import { Command } from "@suaveplan/cli/command/command";
const cmd = new Command("greet")
.description("Greet a user")
.option("--name <name>", "Name to greet", "World")
.action(async (ctx) => {
console.log(`Hello, ${ctx.options.name}!`);
});
await cmd.execute(["--name", "Alice"]);
// → Hello, Alice!
```
## Usage: Advanced Example
```ts
import { Command } from "@suaveplan/cli/command/command";
const root = new Command("deploy")
.description("Deployment toolkit");
root
.command("up")
.description("Bring services up")
.option("--env <env>", "Target environment", "staging")
.action(async (ctx) => {
console.log(`Deploying to ${ctx.options.env}`);
});
root
.command("down")
.alias("d")
.description("Tear services down")
.action(async () => {
console.log("Tearing down...");
});
// Route: "deploy up --env production"
await root.execute(["up", "--env", "production"]);
// Route by alias: "deploy d"
await root.execute(["d"]);
```
## API Reference
### `new Command(name: string)`
Creates a new command with the given name.
### `.description(text: string): this`
Sets the human-readable description shown in help text.
### `.option(flags: string, description?: string, defaultValue?: unknown): this`
Registers an option. `flags` follows `parseOptionFlags` syntax: `"--name <value>"`, `"-v, --verbose"`.
### `.argument(spec: string, description?: string): this`
Registers a positional argument. `spec` follows `parseArgumentSpec` syntax: `"<file>"`, `"[file]"`, `"<files...>"`.
### `.action(fn: ActionFn): this`
Sets the action handler. `fn` receives an `ActionContext` with `options`, `args` (positionals), `rest`, and `command` info.
### `.command(name: string): Command`
Registers a named sub-command and returns it for further chaining.
### `.alias(name: string): this`
Adds an alias for this command, recognised during routing.
### `.hidden(): this`
Marks the command as hidden — it is excluded from help text listings.
### `.example(text: string): this`
Adds an example line shown in help text.
### `.helpText(parentName?: string): string`
Returns formatted multi-line help text including usage, options, sub-commands, and examples.
### `.getParserConfig(): ParserConfig`
Returns the `ParserConfig` derived from registered options and arguments. Used internally by `execute()`.
### `.execute(argv: string[], parentName?: string): Promise<void>`
Parses `argv`, routes to sub-commands or calls the action. Wraps the execution in an OTel span. Throws `CliError` on routing or parse failures.
## Implementation Notes
Sub-command alias resolution scans all registered sub-commands for an `_aliases` entry matching the first token. An OTel span covers the full `execute()` lifecycle including sub-command delegation — the span status is set to `ERROR` if any exception propagates. The `--help` / `-h` shortcut is handled before sub-command routing and before the action call, so it always works even if no action is registered.

View File

@@ -0,0 +1,7 @@
import z from "zod";
export const CommandInfoSchema = z.object({
name: z.string(),
description: z.string(),
fullName: z.string(),
});

View File

@@ -0,0 +1,204 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { CLI_CODES, CliError } from "../errors/errors.js";
import { Command } from "./command.js";
describe("Command — fluent API", { timeout: 5000 }, () => {
it("sets description", { timeout: 5000 }, () => {
const cmd = new Command("test").description("A test command");
expect(cmd.descriptionText).toBe("A test command");
});
it("registers and routes to subcommand", { timeout: 5000 }, 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", { timeout: 5000 }, async () => {
const called: string[] = [];
const parent = new Command("app");
const sub = parent.command("subcommand");
sub.alias("s").action(async () => {
called.push("aliased");
});
await parent.execute(["s"]);
expect(called).toEqual(["aliased"]);
});
it("throws CliError(NO_ACTION) when no action defined", {
timeout: 5000,
}, 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", {
timeout: 5000,
}, 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", { timeout: 5000 }, 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", { timeout: 5000 }, () => {
const cmd = new Command("hidden").hidden();
expect(cmd.isHidden).toBe(true);
});
it("generates help text with usage line", { timeout: 5000 }, () => {
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)", {
timeout: 5000,
}, async () => {
const cmd = new Command("noop").action(async () => {});
await expect(cmd.execute([])).resolves.not.toThrow();
});
it("prints help text when --help passed", { timeout: 5000 }, async () => {
const cmd = new Command("mycmd").description("My command");
cmd.action(async () => {});
// Should not throw
await expect(cmd.execute(["--help"])).resolves.not.toThrow();
});
it("shows help when no argv and subcommands exist", {
timeout: 5000,
}, async () => {
const parent = new Command("app");
parent.command("sub").action(async () => {});
await expect(parent.execute([])).resolves.not.toThrow();
});
it("helpText includes variadic required argument", {
timeout: 5000,
}, () => {
const cmd = new Command("cmd").argument("<files...>", "files");
expect(cmd.helpText()).toContain("<files...>");
});
it("helpText includes variadic optional argument", {
timeout: 5000,
}, () => {
const cmd = new Command("cmd").argument("[files...]", "files");
expect(cmd.helpText()).toContain("[files...]");
});
it("helpText includes option with value hint and short flag", {
timeout: 5000,
}, () => {
const cmd = new Command("cmd").option(
"--output <file>, -o",
"output file"
);
const help = cmd.helpText();
expect(help).toContain("-o,");
expect(help).toContain("<output>");
});
it("helpText option without short flag uses padding", {
timeout: 5000,
}, () => {
const cmd = new Command("cmd").option("--verbose", "verbose output");
const help = cmd.helpText();
expect(help).toContain("--verbose");
});
it("helpText shows visible subcommands and hides hidden ones", {
timeout: 5000,
}, () => {
const parent = new Command("app");
parent
.command("visible")
.description("shown")
.action(async () => {});
parent
.command("secret")
.hidden()
.action(async () => {});
const help = parent.helpText();
expect(help).toContain("visible");
expect(help).not.toContain("secret");
});
it("helpText shows examples section", { timeout: 5000 }, () => {
const cmd = new Command("cmd")
.example("cmd --foo bar")
.action(async () => {});
expect(cmd.helpText()).toContain("cmd --foo bar");
});
it("helpText includes non-variadic required argument <name>", {
timeout: 5000,
}, () => {
const cmd = new Command("cmd").argument("<output>", "output file");
expect(cmd.helpText()).toContain("<output>");
});
it("helpText includes non-variadic optional argument [name]", {
timeout: 5000,
}, () => {
const cmd = new Command("cmd").argument("[output]", "output file");
expect(cmd.helpText()).toContain("[output]");
});
it("helpText includes option without description", {
timeout: 5000,
}, () => {
const cmd = new Command("cmd").option("--quiet");
expect(cmd.helpText()).toContain("--quiet");
});
it("helpText includes parent name when provided", { timeout: 5000 }, () => {
const cmd = new Command("sub").description("A sub command");
expect(cmd.helpText("myapp")).toContain("myapp sub");
});
it("alias() is no-op when command has subcommands", {
timeout: 5000,
}, async () => {
const parent = new Command("app");
const sub = parent.command("sub");
sub.command("nested").action(async () => {});
// alias() on a command with subcommands should be a no-op
sub.alias("s");
// Routing via "s" should fail since alias wasn't registered
await expect(parent.execute(["sub", "nested"])).resolves.not.toThrow();
});
it("rethrows errors with span ERROR status (no-op tracer)", {
timeout: 5000,
}, async () => {
const cmd = new Command("fail").action(async () => {
throw new CliError(
CLI_CODES.COMMAND.NO_ACTION,
"cli.command.no_action"
);
});
await expect(cmd.execute([])).rejects.toMatchObject({
code: CLI_CODES.COMMAND.NO_ACTION,
});
});
});

View File

@@ -0,0 +1,219 @@
import { SpanStatusCode, trace } from "@opentelemetry/api";
import { CLI_CODES, CliError } from "../errors/errors.js";
import {
parseArgs,
parseArgumentSpec,
parseOptionFlags,
} from "../parser/parser.js";
import type {
ArgumentConfig,
OptionConfig,
ParserConfig,
} from "../parser/parser.types.js";
import type { ActionContext, ActionFn } from "./command.types.js";
export { parseArgumentSpec, parseOptionFlags } from "../parser/parser.js";
const tracer = trace.getTracer("@suaveplan/cli", "0.1.0");
export class Command {
private _description = "";
private _action: ActionFn | undefined;
private _options: Record<string, OptionConfig> = {};
private _arguments: ArgumentConfig[] = [];
private _subcommands: Map<string, Command> = new Map();
private _aliases: Map<string, string> = new Map();
private _hidden = false;
private _examples: string[] = [];
constructor(public readonly name: string) {}
get descriptionText(): string {
return this._description;
}
get isHidden(): boolean {
return this._hidden;
}
description(text: string): this {
this._description = text;
return this;
}
option(flags: string, desc?: string, defaultValue?: unknown): this {
const { name, config } = parseOptionFlags(flags, desc, defaultValue);
this._options[name] = config;
return this;
}
argument(spec: string, desc?: string): this {
this._arguments.push(parseArgumentSpec(spec, desc));
return this;
}
action(fn: ActionFn): this {
this._action = fn;
return this;
}
command(name: string): Command {
const sub = new Command(name);
this._subcommands.set(name, sub);
return sub;
}
alias(name: string): this {
if (this._subcommands.size === 0 && this.name) {
this._aliases.set(name, this.name);
}
return this;
}
hidden(): this {
this._hidden = true;
return this;
}
example(text: string): this {
this._examples.push(text);
return this;
}
getParserConfig(): ParserConfig {
return {
options: this._options,
arguments: this._arguments,
};
}
helpText(parentName?: string): string {
const fullName = parentName ? `${parentName} ${this.name}` : this.name;
const lines: string[] = [];
if (this._description) lines.push(this._description, "");
lines.push(
`Usage: ${fullName}${this._subcommands.size > 0 ? " <command>" : ""} [options]`
);
if (this._arguments.length > 0) {
const argStr = this._arguments
.map((a) =>
a.variadic
? a.required
? `<${a.name}...>`
: `[${a.name}...]`
: a.required
? `<${a.name}>`
: `[${a.name}]`
)
.join(" ");
lines[lines.length - 1] += ` ${argStr}`;
}
if (Object.keys(this._options).length > 0) {
lines.push("", "Options:");
for (const [name, cfg] of Object.entries(this._options)) {
const short = cfg.short ? `-${cfg.short}, ` : " ";
const valueHint =
cfg.type && cfg.type !== "boolean" ? ` <${name}>` : "";
lines.push(
` ${short}--${name}${valueHint}${cfg.description ? ` ${cfg.description}` : ""}`
);
}
}
if (this._subcommands.size > 0) {
lines.push("", "Commands:");
for (const [, sub] of this._subcommands) {
if (!sub.isHidden) {
lines.push(` ${sub.name.padEnd(16)}${sub._description}`);
}
}
}
if (this._examples.length > 0) {
lines.push("", "Examples:");
for (const ex of this._examples) lines.push(` ${ex}`);
}
return lines.join("\n");
}
async execute(argv: string[], parentName?: string): Promise<void> {
const fullName = parentName ? `${parentName} ${this.name}` : this.name;
const span = tracer.startSpan("cli.command.execute", {
attributes: { "cli.command": fullName },
});
try {
// Check for help flag
if (argv[0] === "--help" || argv[0] === "-h") {
console.log(this.helpText(parentName));
span.end();
return;
}
// Route to subcommand if first token is a non-flag
const first = argv[0];
if (first && !first.startsWith("-")) {
// Check direct match
let sub = this._subcommands.get(first);
// Check alias
if (!sub) {
for (const [, cmd] of this._subcommands) {
if ((cmd as Command)._aliases.has(first)) {
sub = cmd;
break;
}
}
}
if (sub) {
await sub.execute(argv.slice(1), fullName);
span.end();
return;
}
if (this._subcommands.size > 0) {
throw new CliError(
CLI_CODES.COMMAND.UNKNOWN_SUBCOMMAND,
"cli.command.unknown_subcommand",
{ context: { subcommand: first, command: fullName } }
);
}
}
if (!this._action) {
if (this._subcommands.size > 0 && !first) {
console.log(this.helpText(parentName));
span.end();
return;
}
throw new CliError(
CLI_CODES.COMMAND.NO_ACTION,
"cli.command.no_action",
{ context: { command: fullName } }
);
}
const parsed = parseArgs(argv, this.getParserConfig());
const ctx: ActionContext = {
args: parsed.positionals,
options: parsed.options,
rest: parsed.rest,
command: {
name: this.name,
description: this._description,
fullName,
},
};
await this._action(ctx);
span.end();
} catch (err) {
span.setStatus({ code: SpanStatusCode.ERROR });
span.end();
throw err;
}
}
}

View File

@@ -0,0 +1,14 @@
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>;

View File

@@ -0,0 +1,3 @@
export { Command, parseArgumentSpec, parseOptionFlags } from "./command.js";
export { CommandInfoSchema } from "./command.schemas.js";
export type { ActionContext, ActionFn, CommandInfo } from "./command.types.js";

View File

@@ -0,0 +1,171 @@
# errors
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `errors` module defines the complete error taxonomy for `@suaveplan/cli`. It
provides a stable, machine-readable code registry (`CLI_CODES`) and a strongly-typed
error class (`CliError`) so that every failure surface in the CLI framework is
discriminable at runtime without string-matching on messages. Callers can switch
exhaustively on `err.code`, log structured records with stable identifiers, and
forward typed errors across process boundaries without losing fidelity.
## Features
- **Three sub-namespaces** — `CLI.PARSE.*` covers argument/option parsing failures;
`CLI.COMMAND.*` covers command-dispatch failures; `CLI.APP.*` covers top-level
application lifecycle failures.
- **Nine stable error codes** — each code is a dot-delimited string constant produced
by `defineCodes()` from `@suaveplan/error`, guaranteeing uniqueness and structural
consistency across the codebase.
- **`CliCode` union type** — narrows the full set of valid codes for use in generic
`SuaveplanError<CliCode>` type parameters and exhaustive switch statements.
- **`CliError` class** — extends `SuaveplanError` with a public constructor, cause
chaining, and structured `context` attachment for diagnostic metadata.
- **Zero runtime overhead** — the `CLI_CODES` object is `as const`, so all string
values are inlined at the call site by the TypeScript compiler.
## Usage: Basic Example
```ts
import { CliError, CLI_CODES } from "@suaveplan/cli/errors/errors";
function assertKnownOption(flag: string, known: Set<string>): void {
if (!known.has(flag)) {
throw new CliError(
CLI_CODES.PARSE.UNKNOWN_OPTION,
"cli.parse.unknown_option"
);
}
}
```
## Usage: Advanced Example
```ts
import { CliError, CLI_CODES } from "@suaveplan/cli/errors/errors";
import { SuaveplanError } from "@suaveplan/error";
async function runApp(signal: AbortSignal): Promise<void> {
try {
await doWork(signal);
} catch (err) {
if (signal.aborted) {
throw new CliError(CLI_CODES.APP.ABORTED, "cli.app.aborted", {
cause: err instanceof Error ? err : undefined,
context: { reason: signal.reason },
});
}
throw new CliError(CLI_CODES.APP.RUN_FAILED, "cli.app.run_failed", {
cause: err instanceof Error ? err : undefined,
});
}
}
function handleError(err: unknown): void {
if (err instanceof CliError) {
// Narrow on the stable code — no message-string matching needed.
switch (err.code) {
case CLI_CODES.APP.ABORTED:
process.exitCode = 130; // standard SIGINT exit code
break;
case CLI_CODES.PARSE.UNKNOWN_OPTION:
console.error(`Unknown option: ${String(err.context?.["option"])}`);
process.exitCode = 1;
break;
default:
console.error(err.message);
process.exitCode = 1;
}
}
}
```
## API Reference
### `CLI_CODES`
```ts
const CLI_CODES: {
PARSE: {
UNKNOWN_OPTION: "CLI.PARSE.UNKNOWN_OPTION";
MISSING_VALUE: "CLI.PARSE.MISSING_VALUE";
INVALID_VALUE: "CLI.PARSE.INVALID_VALUE";
MISSING_ARGUMENT: "CLI.PARSE.MISSING_ARGUMENT";
MISSING_REQUIRED_OPTION: "CLI.PARSE.MISSING_REQUIRED_OPTION";
};
COMMAND: {
NO_ACTION: "CLI.COMMAND.NO_ACTION";
UNKNOWN_SUBCOMMAND: "CLI.COMMAND.UNKNOWN_SUBCOMMAND";
};
APP: {
RUN_FAILED: "CLI.APP.RUN_FAILED";
ABORTED: "CLI.APP.ABORTED";
};
}
```
Immutable registry of all error codes used by `@suaveplan/cli`. Each value is a
dot-delimited string in the form `<NAMESPACE>.<SUBCATEGORY>.<NAME>`.
### `CliCode`
```ts
type CliCode =
| "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";
```
Union of all values in `CLI_CODES`. Use as the type parameter for
`SuaveplanError<CliCode>` or in exhaustive switch statements.
### `CliError`
```ts
class CliError extends SuaveplanError<CliCode> {
public constructor(
code: CliCode,
messageKey: string,
options?: SuaveplanErrorOptions
): CliError;
}
```
Typed error for all `@suaveplan/cli` failures.
- **`code`** — one of the `CliCode` values; accessible as `err.code` via the base
class.
- **`messageKey`** — i18n message key passed to the base class for message resolver
integration.
- **`options.cause`** — optional upstream `Error` for cause chaining; accessible as
`err.cause`.
- **`options.context`** — optional `Record<string, unknown>` with structured
diagnostic metadata; accessible as `err.context`.
Throws: nothing — this is an Error constructor.
## Implementation Notes
`CLI_CODES` is built from three separate `defineCodes()` calls rather than one flat
call so that each sub-namespace is isolated. This mirrors the pattern established in
`@suaveplan/readline` and keeps future code-set additions within a namespace
non-breaking for callers who already destructure `CLI_CODES.PARSE`.
The `CliError` constructor comment (`biome-ignore`) documents why the constructor is
not "useless" from Biome's perspective: the base `SuaveplanError` constructor is
`protected`, so widening it to `public` here is a meaningful override even though
the body only calls `super()`. This is the standard pattern across all
`@suaveplan/*` packages.
No runtime dependencies beyond `@suaveplan/error` are introduced. The `defineCodes`
helper produces plain string literals, so the entire module tree-shakes to near
zero bytes when errors are not thrown on a given code path.

View File

@@ -0,0 +1,56 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { CLI_CODES, CliError } from "./errors.js";
describe("CLI_CODES", { timeout: 5000 }, () => {
it("has all expected PARSE codes", { timeout: 5000 }, () => {
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"
);
});
it("has all expected COMMAND codes", { timeout: 5000 }, () => {
expect(CLI_CODES.COMMAND.NO_ACTION).toBe("CLI.COMMAND.NO_ACTION");
expect(CLI_CODES.COMMAND.UNKNOWN_SUBCOMMAND).toBe(
"CLI.COMMAND.UNKNOWN_SUBCOMMAND"
);
});
it("has all expected APP codes", { timeout: 5000 }, () => {
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", { timeout: 5000 }, () => {
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");
});
it("chains cause", { timeout: 5000 }, () => {
const cause = new Error("upstream");
const err = new CliError(CLI_CODES.APP.ABORTED, "cli.app.aborted", {
cause,
});
expect(err.cause).toBe(cause);
});
it("attaches context", { timeout: 5000 }, () => {
const err = new CliError(
CLI_CODES.PARSE.UNKNOWN_OPTION,
"cli.parse.unknown_option",
{ context: { option: "foo" } }
);
expect(err.context).toEqual({ option: "foo" });
});
});

View File

@@ -0,0 +1,70 @@
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);
/**
* Error code registry for `@suaveplan/cli`, organised by sub-namespace.
*
* - `CLI.PARSE.UNKNOWN_OPTION` — an unrecognised option flag was supplied
* - `CLI.PARSE.MISSING_VALUE` — an option that requires a value received none
* - `CLI.PARSE.INVALID_VALUE` — an option value failed type/constraint validation
* - `CLI.PARSE.MISSING_ARGUMENT` — a required positional argument was not provided
* - `CLI.PARSE.MISSING_REQUIRED_OPTION` — a required option flag was omitted entirely
* - `CLI.COMMAND.NO_ACTION` — a command matched but has no handler registered
* - `CLI.COMMAND.UNKNOWN_SUBCOMMAND` — an unrecognised subcommand name was supplied
* - `CLI.APP.RUN_FAILED` — top-level application run threw an unexpected error
* - `CLI.APP.ABORTED` — the CLI run was cancelled via AbortSignal or SIGINT
*/
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];
/**
* Typed error for all `@suaveplan/cli` failures.
*
* Extends `SuaveplanError` with a strongly-typed `code` drawn from
* `CLI_CODES`, giving callers narrow `instanceof` discrimination and
* a stable machine-readable code for logging and alerting.
*
* @example
* ```ts
* import { CliError, CLI_CODES } from "@suaveplan/cli/errors/errors";
*
* throw new CliError(
* CLI_CODES.PARSE.UNKNOWN_OPTION,
* "cli.parse.unknown_option",
* { cause: originalError, context: { option: "--foo" } }
* );
* ```
*/
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);
}
}

View File

@@ -0,0 +1,5 @@
export type { CliCode } from "./errors.js";
export {
CLI_CODES,
CliError,
} from "./errors.js";

View File

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

View File

@@ -0,0 +1,93 @@
# output/format
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`format` provides a collection of pure string-formatting utilities for structured CLI output. All functions take plain data and return formatted strings — no side effects, no I/O. Callers are responsible for printing the result. This design makes every function trivially testable and composable.
The module covers the most common CLI output patterns: key-value pairs, bulleted lists, directory-style trees, inline JSON, multi-column alignment, section headings, horizontal dividers, and indented blocks.
## Features
- `keyValue(obj, opts?)` — formats a record as aligned `key: value` pairs
- `list(items, opts?)` — formats an array as a bulleted list
- `tree(node, opts?)` — renders a recursive tree with `├─` / `└─` connectors
- `json(value, opts?)` — `JSON.stringify` wrapper with configurable indent
- `columns(rows, separator?)` — right-pads cells so columns align across rows
- `heading(text, level?)` — box-style heading with `═` (level 1) or `─` (level 2) borders
- `divider(char?, width?)` — repeated character line
- `indent(text, spaces?)` — prefixes every line with spaces
## Usage: Basic Example
```ts
import { keyValue, list, heading } from "@suaveplan/cli/output/format/format";
console.log(heading("Build Report"));
console.log(keyValue({ status: "success", files: 42, duration: "1.2s" }));
console.log(list(["Compiled main.ts", "Emitted dist/", "Linked types"]));
```
## Usage: Advanced Example
```ts
import { tree, columns, json, indent } from "@suaveplan/cli/output/format/format";
const structure = {
label: "src/",
children: [
{ label: "app/", children: [{ label: "app.ts" }, { label: "app.test.ts" }] },
{ label: "utils/", children: [{ label: "format.ts" }] },
],
};
console.log(tree(structure));
const rows = [
["alice", "admin", "2024-01-01"],
["bob", "user", "2024-03-15"],
];
console.log(columns([["Name", "Role", "Joined"], ...rows]));
console.log(indent(json({ ok: true, count: 7 }), 4));
```
## API Reference
### `keyValue(obj: Record<string, unknown>, opts?: KeyValueOptions): string`
Formats each entry as `key: value`. Options: `separator` (default `": "`), `indent` (spaces before each line, default 0).
### `list(items: string[], opts?: ListOptions): string`
Formats each item with a bullet marker. Options: `bullet` (default `"•"`), `indent` (default 0).
### `tree(node: TreeNode, opts?: TreeOptions, _prefix?: string): string`
Renders a nested tree with connector lines. Options: `indent` (spaces per nesting level, default 2).
### `json(value: unknown, opts?: JsonOptions): string`
Returns `JSON.stringify(value, null, indent)`. Options: `indent` (default 2).
### `columns(rows: string[][], separator?: string): string`
Pads each cell in each column to the column's maximum width, then joins with `separator` (default `" "`). Returns empty string for empty input.
### `heading(text: string, level?: number): string`
Returns a three-line block: border, centered text, border. Level 1 uses `═`, level 2 uses `─`, others use `-`.
### `divider(char?: string, width?: number): string`
Returns `char.repeat(width)`. Defaults: `"─"` and `40`.
### `indent(text: string, spaces?: number): string`
Prepends `spaces` (default 2) spaces to every line of `text`.
## Implementation Notes
All functions are stateless and have no dependencies. `tree` recurses depth-first, tracking a prefix string for connector characters at each level. `columns` computes widths in a single pass over all rows, then renders in a second pass. The `_prefix` parameter of `tree` is internal — external callers should omit it.

View File

@@ -0,0 +1,26 @@
import z from "zod";
export const KeyValueOptionsSchema = z.object({
separator: z.string().optional(),
indent: z.number().int().nonnegative().optional(),
});
export const ListOptionsSchema = z.object({
bullet: z.string().optional(),
indent: z.number().int().nonnegative().optional(),
});
export const TreeOptionsSchema = z.object({
indent: z.number().int().nonnegative().optional(),
});
export const JsonOptionsSchema = z.object({
indent: z.number().int().nonnegative().optional(),
});
export const FormatOptionsSchema = z.object({
keyValue: KeyValueOptionsSchema.optional(),
list: ListOptionsSchema.optional(),
tree: TreeOptionsSchema.optional(),
json: JsonOptionsSchema.optional(),
});

View File

@@ -0,0 +1,188 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import * as fc from "fast-check";
import {
columns,
divider,
heading,
indent,
json,
keyValue,
list,
tree,
} from "./format.js";
describe("keyValue", { timeout: 5000 }, () => {
it("contains key and value", { timeout: 5000 }, () => {
const out = keyValue({ a: 1, b: 2 });
expect(out).toContain("a");
expect(out).toContain("1");
});
it("uses custom separator", { timeout: 5000 }, () => {
expect(keyValue({ x: "y" }, { separator: " = " })).toContain("x = y");
});
it("applies indent", { timeout: 5000 }, () => {
const out = keyValue({ k: "v" }, { indent: 4 });
expect(out.startsWith(" k")).toBe(true);
});
});
describe("list", { timeout: 5000 }, () => {
it("contains bullet markers", { timeout: 5000 }, () => {
const out = list(["x", "y"]);
expect(out).toContain("• x");
expect(out).toContain("• y");
});
it("uses custom bullet", { timeout: 5000 }, () => {
expect(list(["a"], { bullet: "-" })).toContain("- a");
});
it("empty list returns empty string", { timeout: 5000 }, () => {
expect(list([])).toBe("");
});
});
describe("tree", { timeout: 5000 }, () => {
it("contains root label", { timeout: 5000 }, () => {
expect(tree({ label: "root" })).toContain("root");
});
it("contains child labels", { timeout: 5000 }, () => {
const out = tree({ label: "root", children: [{ label: "child" }] });
expect(out).toContain("root");
expect(out).toContain("child");
});
it("handles nested children", { timeout: 5000 }, () => {
const out = tree({
label: "a",
children: [{ label: "b", children: [{ label: "c" }] }],
});
expect(out).toContain("a");
expect(out).toContain("b");
expect(out).toContain("c");
});
});
describe("json", { timeout: 5000 }, () => {
it("formats json with indent 2", { timeout: 5000 }, () => {
expect(json({ x: 1 })).toBe('{\n "x": 1\n}');
});
it("uses custom indent", { timeout: 5000 }, () => {
expect(json({ a: 1 }, { indent: 4 })).toContain(" ");
});
});
describe("columns", { timeout: 5000 }, () => {
it("returns empty string for no rows", { timeout: 5000 }, () => {
expect(columns([])).toBe("");
});
it("aligns columns", { timeout: 5000 }, () => {
const out = columns([
["a", "bb"],
["ccc", "d"],
]);
const lines = out.split("\n");
expect(lines.length).toBe(2);
});
it("column width >= max cell content width (property)", {
timeout: 30000,
}, () => {
fc.assert(
fc.property(
fc.array(
fc.array(fc.string({ maxLength: 20 }), {
minLength: 1,
maxLength: 4,
}),
{ minLength: 1, maxLength: 10 }
),
(rows) => {
const out = columns(rows);
const lines = out.split("\n");
expect(lines.length).toBe(rows.length);
}
)
);
});
});
describe("tree — multi-child (├ connector)", { timeout: 5000 }, () => {
it("uses ├ for non-last children", { timeout: 5000 }, () => {
const out = tree({
label: "root",
children: [{ label: "a" }, { label: "b" }],
});
expect(out).toContain("├");
expect(out).toContain("└");
});
it("uses │ prefix for non-last children with nested subtree", {
timeout: 5000,
}, () => {
const out = tree({
label: "root",
children: [
{ label: "first", children: [{ label: "child" }] },
{ label: "last" },
],
});
expect(out).toContain("│");
expect(out).toContain("first");
expect(out).toContain("child");
});
});
describe("heading", { timeout: 5000 }, () => {
it("contains text", { timeout: 5000 }, () => {
expect(heading("Title", 1)).toContain("Title");
});
it("uses default level 1 when no level arg given", {
timeout: 5000,
}, () => {
expect(heading("Hello")).toContain("═");
});
it("uses ═ for level 1", { timeout: 5000 }, () => {
expect(heading("X", 1)).toContain("═");
});
it("uses ─ for level 2", { timeout: 5000 }, () => {
expect(heading("X", 2)).toContain("─");
});
it("uses - for level 3+", { timeout: 5000 }, () => {
expect(heading("X", 3)).toContain("-");
});
});
describe("divider defaults", { timeout: 5000 }, () => {
it("uses ─ and width 40 by default", { timeout: 5000 }, () => {
expect(divider()).toBe("─".repeat(40));
});
});
describe("indent defaults", { timeout: 5000 }, () => {
it("uses 2 spaces by default", { timeout: 5000 }, () => {
expect(indent("x")).toBe(" x");
});
});
describe("divider", { timeout: 5000 }, () => {
it("returns repeated char", { timeout: 5000 }, () => {
expect(divider("=", 5)).toBe("=====");
});
});
describe("indent", { timeout: 5000 }, () => {
it("indents each line", { timeout: 5000 }, () => {
const out = indent("a\nb", 2);
expect(out).toBe(" a\n b");
});
});

View File

@@ -0,0 +1,82 @@
import type {
JsonOptions,
KeyValueOptions,
ListOptions,
TreeNode,
TreeOptions,
} from "./format.types.js";
export function keyValue(
obj: Record<string, unknown>,
opts?: KeyValueOptions
): string {
const sep = opts?.separator ?? ": ";
const pad = " ".repeat(opts?.indent ?? 0);
return Object.entries(obj)
.map(([k, v]) => `${pad}${k}${sep}${String(v)}`)
.join("\n");
}
export function list(items: string[], opts?: ListOptions): string {
const bullet = opts?.bullet ?? "•";
const pad = " ".repeat(opts?.indent ?? 0);
return items.map((item) => `${pad}${bullet} ${item}`).join("\n");
}
export function tree(node: TreeNode, opts?: TreeOptions, _prefix = ""): string {
const indent = opts?.indent ?? 2;
const lines: string[] = [node.label];
if (node.children) {
for (let i = 0; i < node.children.length; i++) {
const child = node.children[i]!;
const isLast = i === node.children.length - 1;
const connector = isLast ? "└" : "├";
const childPrefix = _prefix + " ".repeat(indent);
const childLines = tree(child, opts, childPrefix).split("\n");
lines.push(`${_prefix}${connector}─ ${childLines[0]}`);
for (let j = 1; j < childLines.length; j++) {
lines.push(`${_prefix}${isLast ? " " : "│"} ${childLines[j]}`);
}
}
}
return lines.join("\n");
}
export function json(value: unknown, opts?: JsonOptions): string {
return JSON.stringify(value, null, opts?.indent ?? 2);
}
export function columns(rows: string[][], separator = " "): string {
if (rows.length === 0) return "";
const colCount = Math.max(...rows.map((r) => r.length));
const widths = Array.from({ length: colCount }, (_, c) =>
Math.max(...rows.map((r) => (r[c] ?? "").length))
);
return rows
.map((row) =>
row
.map((cell, c) =>
c < row.length - 1 ? cell.padEnd(widths[c]!) : cell
)
.join(separator)
)
.join("\n");
}
export function heading(text: string, level = 1): string {
const char = level === 1 ? "═" : level === 2 ? "─" : "-";
const line = char.repeat(text.length + 4);
return `${line}\n ${text} \n${line}`;
}
export function divider(char = "─", width = 40): string {
return char.repeat(width);
}
export function indent(text: string, spaces = 2): string {
const pad = " ".repeat(spaces);
return text
.split("\n")
.map((line) => `${pad}${line}`)
.join("\n");
}

View File

@@ -0,0 +1,22 @@
export interface KeyValueOptions {
separator?: string;
indent?: number;
}
export interface ListOptions {
bullet?: string;
indent?: number;
}
export interface TreeNode {
label: string;
children?: TreeNode[];
}
export interface TreeOptions {
indent?: number;
}
export interface JsonOptions {
indent?: number;
}

View File

@@ -0,0 +1,24 @@
export {
columns,
divider,
heading,
indent,
json,
keyValue,
list,
tree,
} from "./format.js";
export {
FormatOptionsSchema,
JsonOptionsSchema,
KeyValueOptionsSchema,
ListOptionsSchema,
TreeOptionsSchema,
} from "./format.schemas.js";
export type {
JsonOptions,
KeyValueOptions,
ListOptions,
TreeNode,
TreeOptions,
} from "./format.types.js";

View File

@@ -0,0 +1,3 @@
export { table } from "./table.js";
export { AlignmentSchema, TableOptionsSchema } from "./table.schemas.js";
export type { Alignment, TableOptions } from "./table.types.js";

View File

@@ -0,0 +1,81 @@
# output/table
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`table` renders structured data as a formatted ASCII table with header row, optional borders, and configurable column alignment. It computes column widths from the full data set — header and all rows — ensuring that every column is at least as wide as its widest cell. The result is a plain multi-line string ready for console output or piping.
The function is pure: it takes a `TableOptions` object and returns a `string`. No state, no I/O, no mutations.
## Features
- Automatic column width calculation: each column is as wide as its widest cell across all rows
- Three alignment modes per column: `"left"` (default), `"right"`, `"center"`
- Border mode (default on): header and data rows separated by `─┼─` divider
- Border-off mode: columns joined with two spaces
- Handles empty rows gracefully — returns header-only output
- Property-tested: column widths always `>=` max cell content width
## Usage: Basic Example
```ts
import { table } from "@suaveplan/cli/output/table/table";
const out = table({
headers: ["Name", "Role", "Since"],
rows: [
["Alice", "Admin", "2022"],
["Bob", "User", "2023"],
],
});
console.log(out);
// Name │ Role │ Since
// ──────┼───────┼──────
// Alice │ Admin │ 2022
// Bob │ User │ 2023
```
## Usage: Advanced Example
```ts
import { table } from "@suaveplan/cli/output/table/table";
const out = table({
headers: ["Package", "Version", "Size"],
rows: [
["@suaveplan/error", "0.1.0", "12 kB"],
["@suaveplan/cli", "0.1.0", "48 kB"],
],
alignment: ["left", "center", "right"],
border: true,
});
console.log(out);
```
## API Reference
### `table(opts: TableOptions): string`
Renders the table and returns the formatted string.
**`opts.headers: string[]`** — column header labels. Determines the number of columns.
**`opts.rows: string[][]`** — data rows. Each row is an array of string cells. Extra cells beyond `headers.length` are ignored; missing cells are treated as empty.
**`opts.alignment?: Alignment[]`** — per-column alignment (`"left"` | `"right"` | `"center"`). Defaults to `"left"` for any column not specified.
**`opts.border?: boolean`** — whether to render `│` column separators and `─┼─` divider line. Default `true`.
### `Alignment`
`"left" | "right" | "center"` — the alignment mode for a table column.
### `TableOptions`
The full options type. Corresponds to `TableOptionsSchema` for Zod validation.
## Implementation Notes
Column widths are computed with a single `Math.max` pass over `[headers, ...rows]`. Cells are padded with `String.prototype.padEnd` (left/center) or `String.prototype.padStart` (right via the `align` helper). The divider line joins `"─".repeat(width)` segments with `"─┼─"`. When `border` is false, the divider is omitted and segments are joined with `" "` instead of `" │ "`. Trailing whitespace on each row is trimmed to keep output clean.

View File

@@ -0,0 +1,10 @@
import z from "zod";
export const AlignmentSchema = z.enum(["left", "right", "center"]);
export const TableOptionsSchema = z.object({
headers: z.array(z.string()),
rows: z.array(z.array(z.string())),
alignment: z.array(AlignmentSchema).optional(),
border: z.boolean().optional(),
});

View File

@@ -0,0 +1,89 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import * as fc from "fast-check";
import { table } from "./table.js";
describe("table", { timeout: 5000 }, () => {
it("renders header row", { timeout: 5000 }, () => {
const out = table({ headers: ["Name", "Age"], rows: [] });
expect(out).toContain("Name");
expect(out).toContain("Age");
});
it("renders data rows", { timeout: 5000 }, () => {
const out = table({
headers: ["Name", "Age"],
rows: [
["Alice", "30"],
["Bob", "25"],
],
});
expect(out).toContain("Alice");
expect(out).toContain("Bob");
});
it("handles empty rows", { timeout: 5000 }, () => {
const out = table({ headers: ["Col"], rows: [] });
expect(out).toContain("Col");
});
it("aligns columns to max width (property)", { timeout: 30000 }, () => {
const printable = fc
.stringMatching(/^[a-zA-Z0-9_-]+$/)
.filter((s) => s.length >= 1 && s.length <= 15);
const cell = fc
.stringMatching(/^[a-zA-Z0-9 _-]*$/)
.filter((s) => s.length <= 15);
fc.assert(
fc.property(
fc.array(printable, { minLength: 1, maxLength: 4 }),
fc.array(fc.array(cell, { minLength: 1, maxLength: 4 }), {
maxLength: 5,
}),
(headers, rows) => {
const out = table({
headers,
rows: rows.map((r) => r.slice(0, headers.length)),
});
const lines = out.split("\n");
expect(lines.length).toBeGreaterThanOrEqual(1);
for (const h of headers) {
expect(lines[0]).toContain(h);
}
}
)
);
});
it("right-aligns numbers", { timeout: 5000 }, () => {
const out = table({
headers: ["Name", "Score"],
rows: [["Alice", "100"]],
alignment: ["left", "right"],
});
expect(out).toContain("Alice");
expect(out).toContain("100");
});
it("center-aligns when specified", { timeout: 5000 }, () => {
const out = table({
headers: ["Col"],
rows: [["x"]],
alignment: ["center"],
});
expect(out).toContain("Col");
});
it("border:false uses double-space separator and no divider", {
timeout: 5000,
}, () => {
const out = table({
headers: ["Name", "Score"],
rows: [["Alice", "100"]],
border: false,
});
expect(out).not.toContain("│");
expect(out).not.toContain("─┼─");
expect(out).toContain("Name");
expect(out).toContain("Alice");
});
});

View File

@@ -0,0 +1,42 @@
import type { Alignment, TableOptions } from "./table.types.js";
function align(text: string, width: number, alignment: Alignment): string {
const pad = width - text.length;
if (pad <= 0) return text;
if (alignment === "right") return " ".repeat(pad) + text;
if (alignment === "center") {
const left = Math.floor(pad / 2);
const right = pad - left;
return " ".repeat(left) + text + " ".repeat(right);
}
return text + " ".repeat(pad);
}
export function table(opts: TableOptions): string {
const { headers, rows, alignment = [], border = true } = opts;
const colCount = headers.length;
const allRows = [headers, ...rows];
const widths = Array.from({ length: colCount }, (_, c) =>
Math.max(...allRows.map((r) => (r[c] ?? "").length))
);
const sep = border ? " │ " : " ";
const rowToStr = (row: string[], _isHeader = false): string =>
row
.slice(0, colCount)
.map((cell, c) => align(cell, widths[c]!, alignment[c] ?? "left"))
.join(sep)
.trimEnd();
const divLine = border ? widths.map((w) => "─".repeat(w)).join("─┼─") : "";
const lines: string[] = [];
lines.push(rowToStr(headers, true));
if (border) lines.push(divLine);
for (const row of rows) {
lines.push(rowToStr(row));
}
return lines.join("\n");
}

View File

@@ -0,0 +1,8 @@
export type Alignment = "left" | "right" | "center";
export interface TableOptions {
headers: string[];
rows: string[][];
alignment?: Alignment[];
border?: boolean;
}

View File

@@ -0,0 +1,14 @@
export { parseArgs, parseArgumentSpec, parseOptionFlags } from "./parser.js";
export {
ArgumentConfigSchema,
OptionConfigSchema,
ParsedArgsSchema,
ParserConfigSchema,
} from "./parser.schemas.js";
export type {
ArgumentConfig,
OptionConfig,
OptionType,
ParsedArgs,
ParserConfig,
} from "./parser.types.js";

View File

@@ -0,0 +1,92 @@
# parser
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `parser` module turns a raw `argv` token array into a structured `ParsedArgs` object with typed options, named positional arguments, and a `rest` passthrough channel. It is the low-level argument-parsing engine that higher-level command definitions build on, designed to model the behaviour CLI users expect from mature tools like `git`, `npm`, and `cargo`.
The module is deliberately imperative and framework-free: no reflection, no decorators, no hidden state. Every behaviour — type coercion, `--no-*` negation, short-flag resolution, choice validation, required-option checking — is enforced in a single linear pass over `argv`, and every failure mode raises a `CliError` with a stable code drawn from `CLI_CODES.PARSE.*` so callers can branch on machine-readable codes instead of message strings.
## Features
- Boolean flags (`--verbose`) and negation (`--no-verbose`)
- String/number/array options via `--key value`, `--key=value`, or short-flag (`-v`) forms
- Short-flag mapping via `short: "v"` on the option config
- Passthrough `rest` channel after `--`
- Variadic positionals (`<files...>`) collected as arrays
- Required positionals and required options with dedicated error codes
- Choice validation with `INVALID_VALUE`
- `allowUnknown: true` to silently ignore unrecognised flags
- Defaults for options and positionals
- Helper parsers for flag specs (`parseOptionFlags`) and positional specs (`parseArgumentSpec`)
## Usage: Basic Example
```ts
import { parseArgs } from "@suaveplan/cli/parser";
const result = parseArgs(["--verbose", "src/file.ts"], {
options: { verbose: { type: "boolean" } },
arguments: [{ name: "file", required: true, variadic: false }],
});
console.log(result.options.verbose); // true
console.log(result.positionals.file); // "src/file.ts"
```
## Usage: Advanced Example
```ts
import { parseArgs, parseOptionFlags, parseArgumentSpec } from "@suaveplan/cli/parser";
const flag = parseOptionFlags("-o, --output <file>", "Output file");
const posArg = parseArgumentSpec("<sources...>", "Source files");
const result = parseArgs(
["-o", "out.txt", "--format=json", "a.ts", "b.ts", "--", "--not-a-flag"],
{
options: {
[flag.name]: flag.config,
format: { type: "string", choices: ["json", "text"], default: "text" },
retries: { type: "number", default: 3 },
},
arguments: [posArg],
}
);
// result.options.output === "out.txt"
// result.options.format === "json"
// result.positionals.sources === ["a.ts", "b.ts"]
// result.rest === ["--not-a-flag"]
```
## API Reference
### `parseArgs(argv, config?)`
- `argv: string[]` — raw CLI tokens (without the node/bun prefix)
- `config?: ParserConfig` — optional options/arguments definitions, `allowUnknown`, `stopEarly`
- Returns `ParsedArgs` with `options`, `positionals`, and `rest`
- Throws `CliError` with codes `UNKNOWN_OPTION`, `MISSING_VALUE`, `INVALID_VALUE`, `MISSING_ARGUMENT`, `MISSING_REQUIRED_OPTION`
### `parseOptionFlags(flags, description?, defaultValue?)`
- `flags: string` — commander-style spec, e.g. `"-v, --verbose"` or `"--output <file>"`
- Returns `{ name, config }` where `name` is the camelCased long-flag name
- Throws `CliError(UNKNOWN_OPTION)` if no long flag is provided
### `parseArgumentSpec(spec, description?)`
- `spec: string` — `"<name>"`, `"[name]"`, `"<name...>"`, or `"[name...]"`
- Returns an `ArgumentConfig` with `required`, `variadic`, `name`, and optional `description`
## Implementation Notes
- Single-pass state machine: tokens are consumed left-to-right with a small `restMode` flag that flips when `--` is encountered. There are no lookaheads beyond peeking at `argv[i + 1]` to decide whether a flag's value is the next token.
- `--no-*` handling strips the literal `--no-` prefix (not the camelCased form) and then camelCases the remainder, so `--no-dry-run` correctly maps to the `dryRun` option.
- Values are only consumed as the next token when that token does not start with `-`, preventing `--foo --bar` from stealing `--bar` as the value of `--foo`.
- `number` type uses `Number(raw)` and rejects `NaN` so strings like `"abc"` raise `INVALID_VALUE` rather than silently becoming `NaN`.
- All errors are `CliError` instances carrying `context` metadata (option name, offending value, allowed choices) so UIs and logs can render actionable diagnostics without re-parsing message strings.
- Schemas mirror the TypeScript types exactly and are exported separately so callers can validate untrusted `ParserConfig` inputs at the boundary without pulling Zod into hot paths.

View File

@@ -0,0 +1,38 @@
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(z.string(), OptionConfigSchema).optional(),
arguments: z.array(ArgumentConfigSchema).optional(),
allowUnknown: z.boolean().optional(),
stopEarly: z.boolean().optional(),
});
export const ParsedArgsSchema = z.object({
options: z.record(z.string(), z.unknown()),
positionals: z.record(z.string(), z.unknown()),
rest: z.array(z.string()),
});

View File

@@ -0,0 +1,320 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import * as fc from "fast-check";
import { CLI_CODES, CliError } from "../errors/errors.js";
import { parseArgs, parseArgumentSpec, parseOptionFlags } from "./parser.js";
describe("parseArgs", { timeout: 5000 }, () => {
it("parses a boolean flag", { timeout: 5000 }, () => {
const result = parseArgs(["--verbose"], {
options: { verbose: { type: "boolean" } },
});
expect(result.options.verbose).toBe(true);
});
it("parses --no-* to set boolean false", { timeout: 5000 }, () => {
const result = parseArgs(["--no-verbose"], {
options: { verbose: { type: "boolean", default: true } },
});
expect(result.options.verbose).toBe(false);
});
it("parses a string option with = syntax", { timeout: 5000 }, () => {
const result = parseArgs(["--name=world"], {
options: { name: { type: "string" } },
});
expect(result.options.name).toBe("world");
});
it("parses a string option as next token", { timeout: 5000 }, () => {
const result = parseArgs(["--output", "file.txt"], {
options: { output: { type: "string" } },
});
expect(result.options.output).toBe("file.txt");
});
it("parses a number option", { timeout: 5000 }, () => {
const result = parseArgs(["--count", "42"], {
options: { count: { type: "number" } },
});
expect(result.options.count).toBe(42);
});
it("parses short flags", { timeout: 5000 }, () => {
const result = parseArgs(["-v"], {
options: { verbose: { type: "boolean", short: "v" } },
});
expect(result.options.verbose).toBe(true);
});
it("captures rest after --", { timeout: 5000 }, () => {
const result = parseArgs(["--", "foo", "bar"]);
expect(result.rest).toEqual(["foo", "bar"]);
});
it("throws CliError(UNKNOWN_OPTION) for unknown flags", {
timeout: 5000,
}, () => {
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", {
timeout: 5000,
}, () => {
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", {
timeout: 5000,
}, () => {
expect(() =>
parseArgs([], {
arguments: [{ name: "file", required: true, variadic: false }],
})
).toThrow(
expect.objectContaining({ code: CLI_CODES.PARSE.MISSING_ARGUMENT })
);
});
it("parses variadic positional as array", { timeout: 5000 }, () => {
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", {
timeout: 5000,
}, () => {
expect(() =>
parseArgs(["--format", "xml"], {
options: {
format: { type: "string", choices: ["json", "text"] },
},
})
).toThrow(
expect.objectContaining({ code: CLI_CODES.PARSE.INVALID_VALUE })
);
});
it("applies defaults for missing options", { timeout: 5000 }, () => {
const result = parseArgs([], {
options: { verbose: { type: "boolean", default: false } },
});
expect(result.options.verbose).toBe(false);
});
it("throws MISSING_REQUIRED_OPTION when required option is absent", {
timeout: 5000,
}, () => {
expect(() =>
parseArgs([], {
options: { token: { type: "string", required: true } },
})
).toThrow(
expect.objectContaining({
code: CLI_CODES.PARSE.MISSING_REQUIRED_OPTION,
})
);
});
it("throws INVALID_VALUE for non-numeric number option", {
timeout: 5000,
}, () => {
expect(() =>
parseArgs(["--count", "abc"], {
options: { count: { type: "number" } },
})
).toThrow(
expect.objectContaining({ code: CLI_CODES.PARSE.INVALID_VALUE })
);
});
it("parses non-flag tokens as positionals", { timeout: 5000 }, () => {
const result = parseArgs(["src/file.ts"], {
arguments: [{ name: "file", required: false, variadic: false }],
});
expect(result.positionals.file).toBe("src/file.ts");
});
it("allowUnknown: true silently ignores unknown flags", {
timeout: 5000,
}, () => {
expect(() =>
parseArgs(["--unknown-flag"], { allowUnknown: true })
).not.toThrow();
});
it("throws UNKNOWN_OPTION for unknown short flag", {
timeout: 5000,
}, () => {
expect(() => parseArgs(["-z"])).toThrow(
expect.objectContaining({ code: CLI_CODES.PARSE.UNKNOWN_OPTION })
);
});
it("allowUnknown ignores unknown short flag", { timeout: 5000 }, () => {
expect(() => parseArgs(["-z"], { allowUnknown: true })).not.toThrow();
});
it("parses short flag with string value via next token", {
timeout: 5000,
}, () => {
const result = parseArgs(["-o", "out.txt"], {
options: { output: { type: "string", short: "o" } },
});
expect(result.options.output).toBe("out.txt");
});
it("throws MISSING_VALUE for short flag without value", {
timeout: 5000,
}, () => {
expect(() =>
parseArgs(["-o"], {
options: { output: { type: "string", short: "o" } },
})
).toThrow(
expect.objectContaining({ code: CLI_CODES.PARSE.MISSING_VALUE })
);
});
it("uses default value for missing optional positional argument", {
timeout: 5000,
}, () => {
const result = parseArgs([], {
arguments: [
{
name: "env",
required: false,
variadic: false,
default: "development",
},
],
});
expect(result.positionals.env).toBe("development");
});
it("treats --no-flag as unknown when base flag not defined", {
timeout: 5000,
}, () => {
expect(() => parseArgs(["--no-unknown"])).toThrow(
expect.objectContaining({ code: CLI_CODES.PARSE.UNKNOWN_OPTION })
);
});
it("boolean short flag without explicit type defaults to boolean", {
timeout: 5000,
}, () => {
const result = parseArgs(["-v"], {
options: { verbose: { short: "v" } },
});
expect(result.options.verbose).toBe(true);
});
it("optional positional absent with no default is not in positionals", {
timeout: 5000,
}, () => {
const result = parseArgs([], {
arguments: [{ name: "output", required: false, variadic: false }],
});
expect(result.positionals["output"]).toBeUndefined();
});
});
describe("parseOptionFlags", { timeout: 5000 }, () => {
it("extracts long flag name", { timeout: 5000 }, () => {
const { name } = parseOptionFlags("--verbose");
expect(name).toBe("verbose");
});
it("extracts short flag", { timeout: 5000 }, () => {
const { config } = parseOptionFlags("-v, --verbose");
expect(config.short).toBe("v");
});
it("marks as string type when value placeholder present", {
timeout: 5000,
}, () => {
const { config } = parseOptionFlags("--output <file>");
expect(config.type).toBe("string");
});
it("throws when no long flag provided", { timeout: 5000 }, () => {
expect(() => parseOptionFlags("-v")).toThrow(CliError);
});
it("sets description", { timeout: 5000 }, () => {
const { config } = parseOptionFlags("--verbose", "Enable verbose mode");
expect(config.description).toBe("Enable verbose mode");
});
it("marks as string type for optional value placeholder [file]", {
timeout: 5000,
}, () => {
const { config } = parseOptionFlags("--output [file]");
expect(config.type).toBe("string");
});
it("sets default value in config", { timeout: 5000 }, () => {
const { config } = parseOptionFlags("--count", "count", 42);
expect(config.default).toBe(42);
});
it("ignores unrecognized parts in flags string", { timeout: 5000 }, () => {
const { name } = parseOptionFlags("--name VALUE");
expect(name).toBe("name");
});
});
describe("parseArgumentSpec", { timeout: 5000 }, () => {
it("marks required for <name>", { timeout: 5000 }, () => {
expect(parseArgumentSpec("<file>").required).toBe(true);
});
it("marks optional for [name]", { timeout: 5000 }, () => {
expect(parseArgumentSpec("[file]").required).toBe(false);
});
it("marks variadic for <files...>", { timeout: 5000 }, () => {
expect(parseArgumentSpec("<files...>").variadic).toBe(true);
});
it("sets name without brackets", { timeout: 5000 }, () => {
expect(parseArgumentSpec("<file>").name).toBe("file");
});
it("sets description", { timeout: 5000 }, () => {
expect(parseArgumentSpec("<file>", "The input file").description).toBe(
"The input file"
);
});
});
describe("parseArgs property tests", { timeout: 30000 }, () => {
it("never throws with allowUnknown: true", { timeout: 30000 }, () => {
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();
}
)
);
});
});

View File

@@ -0,0 +1,266 @@
import { CLI_CODES, CliError } from "../errors/errors.js";
import type {
ArgumentConfig,
OptionConfig,
ParsedArgs,
ParserConfig,
} from "./parser.types.js";
function toCamelCase(str: string): string {
return str.replace(/-([a-z])/g, (_, c: string) => c.toUpperCase());
}
export function parseOptionFlags(
flags: string,
description?: string,
defaultValue?: unknown
): { name: string; config: OptionConfig } {
const parts = flags.split(/[\s,]+/).filter(Boolean);
let longFlag: string | undefined;
let shortFlag: string | undefined;
let hasValue = false;
for (const part of parts) {
if (part.startsWith("--")) {
longFlag = part.slice(2);
} else if (part.startsWith("-") && part.length === 2) {
shortFlag = part.slice(1);
} else if (part.startsWith("<") || part.startsWith("[")) {
hasValue = true;
}
}
if (/<[^>]+>|\[[^\]]+\]/.test(flags)) {
hasValue = true;
}
if (!longFlag) {
throw new CliError(
CLI_CODES.PARSE.UNKNOWN_OPTION,
"cli.parse.unknown_option",
{ context: { flags } }
);
}
const name = toCamelCase(longFlag);
const config: OptionConfig = {};
if (hasValue) config.type = "string";
if (shortFlag) config.short = shortFlag;
if (description) config.description = description;
if (defaultValue !== undefined) config.default = defaultValue;
return { name, config };
}
export function parseArgumentSpec(
spec: string,
description?: string
): ArgumentConfig {
const required = spec.startsWith("<");
const inner = spec.slice(1, -1);
const variadic = inner.endsWith("...");
const name = variadic ? inner.slice(0, -3) : inner;
return {
name,
required,
variadic,
...(description ? { description } : {}),
};
}
export function parseArgs(argv: string[], config?: ParserConfig): ParsedArgs {
const optionDefs = config?.options ?? {};
const argDefs = config?.arguments ?? [];
const allowUnknown = config?.allowUnknown ?? false;
const shortMap: Record<string, string> = {};
for (const [name, def] of Object.entries(optionDefs)) {
if (def.short) shortMap[def.short] = name;
}
const options: Record<string, unknown> = {};
for (const [name, def] of Object.entries(optionDefs)) {
if (def.default !== undefined) options[name] = def.default;
}
const positionals: string[] = [];
const rest: string[] = [];
let i = 0;
let restMode = false;
while (i < argv.length) {
const arg = argv[i]!;
if (restMode) {
rest.push(arg);
i++;
continue;
}
if (arg === "--") {
restMode = true;
i++;
continue;
}
if (arg.startsWith("--")) {
const eqIdx = arg.indexOf("=");
let key: string;
let inlineValue: string | undefined;
if (eqIdx !== -1) {
key = toCamelCase(arg.slice(2, eqIdx));
inlineValue = arg.slice(eqIdx + 1);
} else {
key = toCamelCase(arg.slice(2));
}
// Handle --no-* boolean negation
if (arg.startsWith("--no-")) {
const rawKey = arg.slice(5);
const camelRaw = toCamelCase(rawKey);
if (camelRaw in optionDefs) {
options[camelRaw] = false;
i++;
continue;
}
}
const def = optionDefs[key];
if (!def) {
if (allowUnknown) {
if (
inlineValue === undefined &&
i + 1 < argv.length &&
!argv[i + 1]!.startsWith("-")
) {
i += 2;
} else {
i++;
}
continue;
}
throw new CliError(
CLI_CODES.PARSE.UNKNOWN_OPTION,
"cli.parse.unknown_option",
{ context: { option: key } }
);
}
const type = def.type ?? "boolean";
if (type === "boolean") {
options[key] = true;
i++;
} else {
let raw: string;
if (inlineValue !== undefined) {
raw = inlineValue;
i++;
} else if (
i + 1 < argv.length &&
!argv[i + 1]!.startsWith("-")
) {
raw = argv[i + 1]!;
i += 2;
} else {
throw new CliError(
CLI_CODES.PARSE.MISSING_VALUE,
"cli.parse.missing_value",
{ context: { option: key } }
);
}
if (type === "number") {
const num = Number(raw);
if (Number.isNaN(num)) {
throw new CliError(
CLI_CODES.PARSE.INVALID_VALUE,
"cli.parse.invalid_value",
{ context: { option: key, value: raw } }
);
}
options[key] = num;
} else if (def.choices && !def.choices.includes(raw)) {
throw new CliError(
CLI_CODES.PARSE.INVALID_VALUE,
"cli.parse.invalid_value",
{
context: {
option: key,
value: raw,
choices: def.choices,
},
}
);
} else {
options[key] = raw;
}
}
} else if (arg.startsWith("-") && arg.length === 2) {
const shortKey = arg.slice(1);
const longName = shortMap[shortKey];
if (!longName) {
if (allowUnknown) {
i++;
continue;
}
throw new CliError(
CLI_CODES.PARSE.UNKNOWN_OPTION,
"cli.parse.unknown_option",
{ context: { option: shortKey } }
);
}
const def = optionDefs[longName]!;
const type = def.type ?? "boolean";
if (type === "boolean") {
options[longName] = true;
i++;
} else {
if (i + 1 >= argv.length) {
throw new CliError(
CLI_CODES.PARSE.MISSING_VALUE,
"cli.parse.missing_value",
{ context: { option: longName } }
);
}
options[longName] = argv[i + 1];
i += 2;
}
} else {
positionals.push(arg);
i++;
}
}
const namedPositionals: Record<string, unknown> = {};
let posIdx = 0;
for (const def of argDefs) {
if (def.variadic) {
namedPositionals[def.name] = positionals.slice(posIdx);
posIdx = positionals.length;
} else if (posIdx < positionals.length) {
namedPositionals[def.name] = positionals[posIdx++];
} else if (def.default !== undefined) {
namedPositionals[def.name] = def.default;
} else if (def.required) {
throw new CliError(
CLI_CODES.PARSE.MISSING_ARGUMENT,
"cli.parse.missing_argument",
{ context: { argument: def.name } }
);
}
}
for (const [name, def] of Object.entries(optionDefs)) {
if (def.required && !(name in options)) {
throw new CliError(
CLI_CODES.PARSE.MISSING_REQUIRED_OPTION,
"cli.parse.missing_required_option",
{ context: { option: name } }
);
}
}
return { options, positionals: namedPositionals, rest };
}

View File

@@ -0,0 +1,31 @@
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[];
}

View File

@@ -0,0 +1,7 @@
export { createProgress } from "./progress.js";
export { ProgressOptionsSchema } from "./progress.schemas.js";
export type {
ProgressInstance,
ProgressOptions,
WritableStreamLike,
} from "./progress.types.js";

View File

@@ -0,0 +1,92 @@
# progress
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`createProgress` renders a fixed-width ASCII progress bar for tracking long-running operations with a known total. It writes bar updates to any `WritableStreamLike` output — TTY or non-TTY — using in-place line clearing on TTY terminals and newline-separated lines on non-TTY streams (CI, pipes). The bar updates are driven by explicit `update(current)` calls from the caller; no internal timer is used.
The instance is `AbortSignal`-aware: aborting the signal silently makes all subsequent `update()` calls no-ops, allowing calling code to cancel a long operation without crashing.
## Features
- Fixed-width block bar with configurable `width` (default 40 characters) using `█` fill and `░` empty characters
- Percentage and `current/total` count displayed after the bar: `[████████████░░░░░░░░] 60% (60/100)`
- TTY mode: clears the current line and re-renders in-place via `clearLine` / `cursorTo`
- Non-TTY mode: writes each update on its own line for readable CI output
- `render()` returns the bar string without writing to the stream — useful for embedding in larger output
- `complete()` forces 100% and appends a newline; idempotent (safe to call multiple times)
- `AbortSignal` support: abort event listener marks the instance done; pre-fired signal respected via guard check in `update()`
## Usage: Basic Example
```ts
import { createProgress } from "@suaveplan/cli/progress/progress";
const p = createProgress({ total: 100 });
for (let i = 0; i <= 100; i++) {
p.update(i);
await sleep(20);
}
p.complete();
```
## Usage: Advanced Example
```ts
import { createProgress } from "@suaveplan/cli/progress/progress";
import { Writable } from "node:stream";
const controller = new AbortController();
const p = createProgress({
total: files.length,
width: 60,
signal: controller.signal,
});
let processed = 0;
for (const file of files) {
if (controller.signal.aborted) break;
await processFile(file);
p.update(++processed);
}
p.complete();
```
## API Reference
### `createProgress(opts: ProgressOptions): ProgressInstance`
Creates a progress bar instance. No output is written until `update()` or `complete()` is called.
**`opts.total: number`** — the maximum value (100%). Must be positive.
**`opts.width?: number`** — width of the bar in characters (default `40`).
**`opts.stream?: WritableStreamLike`** — output target (default `process.stdout`). TTY in-place rendering requires `clearLine` and `cursorTo` methods plus `isTTY === true`.
**`opts.signal?: AbortSignal`** — when aborted, future `update()` calls become no-ops and the bar stops advancing.
### `ProgressInstance`
**`update(current: number): void`** — updates the displayed progress to `current`. Values are clamped to `[0, total]`. No-op if `complete()` was already called or the signal was aborted.
**`complete(): void`** — writes a 100% bar with a trailing newline and marks the instance done. Idempotent.
**`render(): string`** — returns the current bar string without writing to the stream.
### `ProgressOptionsSchema`
Zod schema for `ProgressOptions` — validates `total` (positive number) and `width` (optional positive number). Excludes `stream` and `signal`.
### `WritableStreamLike`
Re-exported from `../spinner/spinner.types.js`. Any object with `write(text: string): void` satisfies this interface; `clearLine`, `cursorTo`, and `isTTY` are optional and enable TTY mode when present.
## Implementation Notes
Progress percentage is computed as `Math.min(1, current / total)` to handle over-total updates gracefully. The filled block count is `Math.round(pct * width)` — rounding avoids a full-bar display at 99% on small widths. The `complete()` guard (`done` flag) prevents double-writes when called multiple times. The `AbortSignal` listener uses `{ once: true }` so it self-removes after the first event.

View File

@@ -0,0 +1,6 @@
import z from "zod";
export const ProgressOptionsSchema = z.object({
total: z.number().positive(),
width: z.number().positive().optional(),
});

View File

@@ -0,0 +1,145 @@
import { Writable } from "node:stream";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { createProgress } from "./progress.js";
type TtyWritable = Writable & {
isTTY: boolean;
clearLine: (dir: number) => void;
cursorTo: (x: number) => void;
};
function makeStream(): { stream: TtyWritable; output: () => string } {
const chunks: string[] = [];
const stream = new Writable({
write(chunk, _enc, cb) {
chunks.push(chunk.toString());
cb();
},
}) as TtyWritable;
stream.isTTY = false;
stream.clearLine = () => {};
stream.cursorTo = () => {};
return { stream, output: () => chunks.join("") };
}
function makeTtyStream(): {
stream: TtyWritable;
output: () => string;
cleared: () => number;
} {
const chunks: string[] = [];
let clearCount = 0;
const stream = new Writable({
write(chunk, _enc, cb) {
chunks.push(chunk.toString());
cb();
},
}) as TtyWritable;
stream.isTTY = true;
stream.clearLine = () => {
clearCount++;
};
stream.cursorTo = () => {};
return { stream, output: () => chunks.join(""), cleared: () => clearCount };
}
describe("createProgress", { timeout: 5000 }, () => {
it("render() returns a bar string for 0%", () => {
const { stream } = makeStream();
const p = createProgress({ total: 100, stream });
expect(p.render()).toContain("0%");
expect(p.render()).toContain("0/100");
});
it("update() advances the bar and writes to stream", () => {
const { stream, output } = makeStream();
const p = createProgress({ total: 100, stream });
p.update(50);
expect(output()).toContain("50%");
expect(output()).toContain("50/100");
});
it("update() clamps to total", () => {
const { stream } = makeStream();
const p = createProgress({ total: 100, stream });
p.update(200);
expect(p.render()).toContain("100%");
});
it("update() clamps to 0 for negative values", () => {
const { stream } = makeStream();
const p = createProgress({ total: 100, stream });
p.update(-10);
expect(p.render()).toContain("0%");
});
it("complete() writes 100% bar with newline", () => {
const { stream, output } = makeStream();
const p = createProgress({ total: 100, stream });
p.update(50);
p.complete();
expect(output()).toContain("100%");
expect(output()).toContain("100/100");
});
it("complete() is idempotent", () => {
const { stream, output } = makeStream();
const p = createProgress({ total: 10, stream });
p.complete();
p.complete();
const count = output().split("100%").length - 1;
expect(count).toBe(1);
});
it("update() is no-op after complete()", () => {
const { stream, output } = makeStream();
const p = createProgress({ total: 10, stream });
p.complete();
const before = output();
p.update(5);
expect(output()).toBe(before);
});
it("respects custom width", () => {
const { stream } = makeStream();
const p = createProgress({ total: 100, width: 20, stream });
const bar = p.render();
const match = bar.match(/\[([█░]+)\]/);
expect(match?.[1]?.length).toBe(20);
});
it("aborts via AbortSignal — update becomes no-op", async () => {
const { stream, output } = makeStream();
const controller = new AbortController();
const p = createProgress({
total: 100,
stream,
signal: controller.signal,
});
p.update(30);
controller.abort();
await new Promise((r) => setTimeout(r, 10));
const before = output();
p.update(60);
expect(output()).toBe(before);
});
it("uses TTY clear on update", () => {
const { stream, cleared } = makeTtyStream();
const p = createProgress({ total: 100, stream });
p.update(10);
expect(cleared()).toBeGreaterThan(0);
});
it("uses TTY clear on complete", () => {
const { stream, cleared } = makeTtyStream();
const p = createProgress({ total: 100, stream });
p.complete();
expect(cleared()).toBeGreaterThan(0);
});
it("defaults stream to process.stdout when omitted", () => {
const p = createProgress({ total: 100 });
expect(p.render()).toContain("0%");
});
});

View File

@@ -0,0 +1,77 @@
import type {
ProgressInstance,
ProgressOptions,
WritableStreamLike,
} from "./progress.types.js";
const DEFAULT_WIDTH = 40;
const FILL_CHAR = "█";
const EMPTY_CHAR = "░";
export function createProgress(opts: ProgressOptions): ProgressInstance {
const { total } = opts;
const width = opts.width ?? DEFAULT_WIDTH;
const stream: WritableStreamLike = opts.stream ?? process.stdout;
const isTTY = stream.isTTY ?? false;
let current = 0;
let done = false;
function buildBar(value: number): string {
const pct = Math.min(1, value / total);
const filled = Math.round(pct * width);
const empty = width - filled;
const bar = FILL_CHAR.repeat(filled) + EMPTY_CHAR.repeat(empty);
const percent = Math.round(pct * 100);
return `[${bar}] ${percent}% (${value}/${total})`;
}
function write(value: number): void {
const line = buildBar(value);
if (isTTY && stream.clearLine && stream.cursorTo) {
stream.clearLine(0);
stream.cursorTo(0);
stream.write(line);
} else {
stream.write(`${line}\n`);
}
}
const instance: ProgressInstance = {
render(): string {
return buildBar(current);
},
update(value: number): void {
if (opts.signal?.aborted || done) return;
current = Math.min(total, Math.max(0, value));
write(current);
},
complete(): void {
if (done) return;
done = true;
current = total;
const line = buildBar(total);
if (isTTY && stream.clearLine && stream.cursorTo) {
stream.clearLine(0);
stream.cursorTo(0);
stream.write(`${line}\n`);
} else {
stream.write(`${line}\n`);
}
},
};
if (opts.signal) {
opts.signal.addEventListener(
"abort",
() => {
done = true;
},
{ once: true }
);
}
return instance;
}

View File

@@ -0,0 +1,16 @@
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;
}

View File

@@ -0,0 +1,22 @@
export {
confirm,
createDefaultReader,
multiselect,
password,
select,
text,
} from "./prompt.js";
export {
ConfirmPromptOptionsSchema,
SelectChoiceSchema,
TextPromptOptionsSchema,
} from "./prompt.schemas.js";
export type {
ConfirmPromptOptions,
InputReader,
MultiSelectPromptOptions,
PasswordPromptOptions,
SelectChoice,
SelectPromptOptions,
TextPromptOptions,
} from "./prompt.types.js";

View File

@@ -0,0 +1,95 @@
# prompt
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`prompt` provides a collection of pure, injectable interactive prompt functions for CLI applications: `text`, `password`, `confirm`, `select`, and `multiselect`. All five functions accept an `InputReader` as their last argument for testing and embedding — callers supply any object with `question(prompt): Promise<string>` and `close(): void`. In production, `createDefaultReader()` builds an `InputReader` backed by `@suaveplan/readline/interface`, which in turn reads from `process.stdin`.
Prompt functions are asynchronous, loop-safe, and validation-aware. They re-prompt on invalid input rather than throwing, keeping the user experience coherent across all terminal types.
## Features
- `text(opts, reader?)` — free-form text input with optional default and validation callback
- `password(opts, reader?)` — masked input (structurally identical to text; masking is delegated to the terminal emulator or TTY mode)
- `confirm(opts, reader?)` — yes/no boolean with optional default; accepts `y`, `yes`, `n`, `no` (case-insensitive)
- `select(opts, reader?)` — numbered single-choice list; re-prompts on out-of-range input
- `multiselect(opts, reader?)` — comma-separated multi-choice with configurable `min`/`max` and deduplication
- `createDefaultReader()` — async factory returning an `InputReader` wired to `process.stdin` via `@suaveplan/readline`
- All functions accept both a plain `string` message or a full options object for brevity
## Usage: Basic Example
```ts
import { confirm, select, text } from "@suaveplan/cli/prompt/prompt";
const name = await text("Your name?");
const confirmed = await confirm({ message: "Continue?", default: true });
const role = await select({ message: "Pick a role:", choices: ["admin", "user", "guest"] });
console.log(name, confirmed, role);
```
## Usage: Advanced Example
```ts
import { multiselect, text } from "@suaveplan/cli/prompt/prompt";
import type { InputReader } from "@suaveplan/cli/prompt/prompt";
// Inject a reader for testing
function mockReader(lines: string[]): InputReader {
let i = 0;
return { question: async () => lines[i++] ?? "", close: () => {} };
}
const username = await text(
{ message: "Username?", validate: (v) => v.length >= 3 || "at least 3 chars" },
mockReader(["ab", "alice"]),
);
const features = await multiselect(
{ message: "Enable features:", choices: ["logging", "metrics", "tracing"], min: 1 },
mockReader(["1,2"]),
);
console.log(username, features);
```
## API Reference
### `text(opts: TextPromptOptions | string, reader?: InputReader): Promise<string>`
Prompts for free-form text. When `opts` is a string it is used as the `message`. Returns the user's input or `opts.default` if the input is empty and a default is set. Re-prompts if `opts.validate` returns an error string.
### `password(opts: PasswordPromptOptions | string, reader?: InputReader): Promise<string>`
Identical interface to `text` but semantically for secret input. The caller is responsible for disabling echo on the actual terminal if needed.
### `confirm(opts: ConfirmPromptOptions | string, reader?: InputReader): Promise<boolean>`
Prompts for a yes/no answer. Accepts `y`, `yes`, `n`, `no` (case-insensitive). Returns `opts.default` when the user submits an empty line and a default is configured. Re-prompts on unrecognized input.
### `select<T>(opts: SelectPromptOptions<T>, reader?: InputReader): Promise<T>`
Displays a numbered list of choices and returns the `value` of the selected entry. Choices may be plain strings (label = value) or `SelectChoice<T>` objects. Re-prompts when the entered number is out of range.
### `multiselect<T>(opts: MultiSelectPromptOptions<T>, reader?: InputReader): Promise<T[]>`
Displays a numbered list and accepts comma-separated indices. Deduplicates repeated indices. Enforces `opts.min` / `opts.max` bounds and re-prompts when any index is invalid or the count is out of bounds.
### `createDefaultReader(): Promise<InputReader>`
Async factory that lazily imports `@suaveplan/readline/interface` and wires it to `process.stdin` / `process.stdout`. Returns an `InputReader`. The caller is responsible for calling `close()` when done.
### `InputReader`
Injectable interface: `{ question(prompt: string): Promise<string>; close(): void; }`. Pass a mock for tests.
### `TextPromptOptionsSchema`, `ConfirmPromptOptionsSchema`, `SelectChoiceSchema`
Zod schemas for the corresponding option types — useful for config-file driven prompts.
## Implementation Notes
All prompt functions manage the reader lifecycle when no reader is injected: they call `createDefaultReader()` in a `try/finally` block that calls `close()` after the prompt resolves. When a reader is injected, the caller owns the lifecycle. Validation callbacks return either `true` (pass) or an error string (fail); the error is written to `process.stderr` before re-prompting. The `multiselect` function considers any out-of-range index a hard error and re-prompts rather than silently discarding it.

View File

@@ -0,0 +1,17 @@
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(),
});

View File

@@ -0,0 +1,201 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
_setReaderFactory,
confirm,
createDefaultReader,
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("withReader — no reader provided path", { timeout: 5000 }, () => {
it("creates and closes reader when no reader arg given", async () => {
let closed = false;
const fakeReader: InputReader = {
question: async () => "Alice",
close: () => {
closed = true;
},
};
const restore = _setReaderFactory(async () => fakeReader);
try {
const result = await text("Name?");
expect(result).toBe("Alice");
expect(closed).toBe(true);
} finally {
restore();
}
});
});
describe("createDefaultReader()", { timeout: 5000 }, () => {
it("returns an InputReader with question and close methods", async () => {
const reader = await createDefaultReader();
expect(typeof reader.question).toBe("function");
expect(typeof reader.close).toBe("function");
// Call question() and close immediately to exercise the function body;
// race with a timeout so the test never blocks on stdin
const result = await Promise.race([
reader.question("?").catch(() => "closed"),
new Promise<string>((r) => setTimeout(() => r("timeout"), 50)),
]);
reader.close();
expect(typeof result).toBe("string");
});
});
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");
});
it("returns answer when no default and empty input", async () => {
expect(await text({ message: "Name?" }, makeReader([""]))).toBe("");
});
});
describe("password()", { timeout: 5000 }, () => {
it("returns user input", async () => {
expect(await password("Pass?", makeReader(["secret"]))).toBe("secret");
});
it("re-prompts when validation fails then passes", async () => {
const result = await password(
{
message: "Pass?",
validate: (v) => (v.length >= 6 ? true : "too short"),
},
makeReader(["abc", "longpass"])
);
expect(result).toBe("longpass");
});
});
describe("confirm()", { timeout: 5000 }, () => {
it("returns true for 'y'", async () => {
expect(await confirm("Continue?", makeReader(["y"]))).toBe(true);
});
it("returns true for 'yes'", async () => {
expect(await confirm("Continue?", makeReader(["yes"]))).toBe(true);
});
it("returns false for 'n'", async () => {
expect(await confirm("Continue?", makeReader(["n"]))).toBe(false);
});
it("returns false for 'no'", async () => {
expect(await confirm("Continue?", makeReader(["no"]))).toBe(false);
});
it("returns default true when empty", async () => {
expect(
await confirm({ message: "Go?", default: true }, makeReader([""]))
).toBe(true);
});
it("returns default false when empty", async () => {
expect(
await confirm({ message: "Go?", default: false }, makeReader([""]))
).toBe(false);
});
it("re-prompts on invalid input then accepts valid", async () => {
expect(await confirm("Go?", makeReader(["maybe", "y"]))).toBe(true);
});
});
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");
});
it("handles object choices", async () => {
const result = await select(
{ message: "Pick:", choices: [{ label: "Option A", value: 42 }] },
makeReader(["1"])
);
expect(result).toBe(42);
});
});
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);
});
it("enforces max selection", async () => {
const result = await multiselect(
{ message: "Pick:", choices: ["a", "b", "c"], max: 1 },
makeReader(["1,2", "1"])
);
expect(result.length).toBeLessThanOrEqual(1);
});
it("deduplicates repeated indices", async () => {
const result = await multiselect(
{ message: "Pick:", choices: ["a", "b"] },
makeReader(["1,1,1"])
);
expect(result).toEqual(["a"]);
});
it("re-prompts on invalid indices", async () => {
const result = await multiselect(
{ message: "Pick:", choices: ["a", "b"] },
makeReader(["99", "1"])
);
expect(result).toEqual(["a"]);
});
});

View File

@@ -0,0 +1,189 @@
import type {
ConfirmPromptOptions,
InputReader,
MultiSelectPromptOptions,
PasswordPromptOptions,
SelectChoice,
SelectPromptOptions,
TextPromptOptions,
} from "./prompt.types.js";
function normalizeChoice<T>(choice: SelectChoice<T> | string): SelectChoice<T> {
if (typeof choice === "string")
return { label: choice, value: choice as unknown as T };
return choice;
}
export async function createDefaultReader(): Promise<InputReader> {
const { createInterface } = await import(
"@suaveplan/readline/interface/interface"
);
const { stdin, stdout } = process;
const input = stdin as Parameters<typeof createInterface>[0]["input"];
const output = stdout as NonNullable<
Parameters<typeof createInterface>[0]["output"]
>;
const rl = createInterface({ input, output });
return {
question: (prompt: string) => rl.question(prompt),
close: () => rl.close(),
};
}
let _readerFactory: () => Promise<InputReader> = createDefaultReader;
/** @internal Override the default reader factory (for testing). */
export function _setReaderFactory(
factory: () => Promise<InputReader>
): () => void {
_readerFactory = factory;
return () => {
_readerFactory = createDefaultReader;
};
}
async function withReader<T>(
reader: InputReader | undefined,
fn: (rl: InputReader) => Promise<T>
): Promise<T> {
if (reader) return fn(reader);
const rl = await _readerFactory();
try {
return await fn(rl);
} finally {
rl.close();
}
}
export async function text(
opts: TextPromptOptions | string,
reader?: InputReader
): Promise<string> {
const options: TextPromptOptions =
typeof opts === "string" ? { message: opts } : opts;
return withReader(reader, async (rl) => {
while (true) {
const answer = await rl.question(`${options.message} `);
const value =
answer === "" && options.default !== undefined
? options.default
: answer;
if (options.validate) {
const result = options.validate(value);
if (result !== true) {
process.stderr.write(`${result}\n`);
continue;
}
}
return value;
}
});
}
export async function password(
opts: PasswordPromptOptions | string,
reader?: InputReader
): Promise<string> {
const options: PasswordPromptOptions =
typeof opts === "string" ? { message: opts } : opts;
return withReader(reader, async (rl) => {
while (true) {
const value = await rl.question(`${options.message} `);
if (options.validate) {
const result = options.validate(value);
if (result !== true) {
process.stderr.write(`${result}\n`);
continue;
}
}
return value;
}
});
}
export async function confirm(
opts: ConfirmPromptOptions | string,
reader?: InputReader
): Promise<boolean> {
const options: ConfirmPromptOptions =
typeof opts === "string" ? { message: opts } : opts;
return withReader(reader, async (rl) => {
while (true) {
const hint =
options.default === true
? " [Y/n]"
: options.default === false
? " [y/N]"
: " [y/n]";
const answer = await rl.question(`${options.message}${hint} `);
if (answer === "" && options.default !== undefined)
return options.default;
if (/^y(es)?$/i.test(answer)) return true;
if (/^no?$/i.test(answer)) return false;
process.stderr.write("Please enter y or n.\n");
}
});
}
export async function select<T = string>(
opts: SelectPromptOptions<T>,
reader?: InputReader
): Promise<T> {
const choices = opts.choices.map(normalizeChoice<T>);
return withReader(reader, async (rl) => {
while (true) {
const list = choices
.map((c, i) => ` ${i + 1}) ${c.label}`)
.join("\n");
const answer = await rl.question(
`${opts.message}\n${list}\nChoice: `
);
const index = Number.parseInt(answer, 10);
if (!Number.isNaN(index) && index >= 1 && index <= choices.length) {
return choices[index - 1]!.value;
}
process.stderr.write(
`Please enter a number between 1 and ${choices.length}.\n`
);
}
});
}
export async function multiselect<T = string>(
opts: MultiSelectPromptOptions<T>,
reader?: InputReader
): Promise<T[]> {
const choices = opts.choices.map(normalizeChoice<T>);
const min = opts.min ?? 0;
const max = opts.max ?? choices.length;
return withReader(reader, async (rl) => {
while (true) {
const list = choices
.map((c, i) => ` ${i + 1}) ${c.label}`)
.join("\n");
const answer = await rl.question(
`${opts.message}\n${list}\nChoices (comma-separated): `
);
const parsed = answer
.split(",")
.map((s) => Number.parseInt(s.trim(), 10));
const valid = parsed.filter(
(n) => !Number.isNaN(n) && n >= 1 && n <= choices.length
);
if (valid.length !== parsed.length) {
process.stderr.write(
`Please enter numbers between 1 and ${choices.length}.\n`
);
continue;
}
const unique = [...new Set(valid)];
if (unique.length < min || unique.length > max) {
process.stderr.write(
`Please select between ${min} and ${max} items.\n`
);
continue;
}
return unique.map((i) => choices[i - 1]!.value);
}
});
}

View File

@@ -0,0 +1,39 @@
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;
}

View File

@@ -0,0 +1,7 @@
export { createSpinner } from "./spinner.js";
export { SpinnerOptionsSchema } from "./spinner.schemas.js";
export type {
SpinnerInstance,
SpinnerOptions,
WritableStreamLike,
} from "./spinner.types.js";

View File

@@ -0,0 +1,99 @@
# spinner
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`createSpinner` renders an animated terminal spinner for long-running operations. It writes a spinning frame animation to any `WritableStreamLike` output — TTY or non-TTY — and exposes a chainable API to start, stop, succeed, fail, or warn when the operation resolves. The spinner integrates with `AbortSignal` so it can be cancelled programmatically without leaking timers.
The spinner is entirely synchronous after creation. No promises are used internally — the animation is driven by a `setInterval` timer that is always cancelled on `stop()`.
## Features
- Animated frame loop with configurable frames array and interval (default 80ms, 10-frame braille spinner)
- Chainable API: `start().succeed()`, `start().fail()`, `start().warn()`, `start().stop()`
- TTY mode: clears current line and re-renders in-place via `clearLine`/`cursorTo`
- Non-TTY mode (CI/pipes): writes each frame on its own line — no escape codes
- `AbortSignal` support: live abort event listener (`"abort"`) stops the spinner; pre-fired abort prevents `start()` entirely
- `succeed` / `fail` / `warn` write a terminal symbol (`✔` / `✖` / `⚠`) followed by the text
- `update(text)` changes the displayed text without restarting the animation
## Usage: Basic Example
```ts
import { createSpinner } from "@suaveplan/cli/spinner/spinner";
const sp = createSpinner({ text: "Fetching data…" });
sp.start();
await fetchData();
sp.succeed("Done!");
```
## Usage: Advanced Example
```ts
import { createSpinner } from "@suaveplan/cli/spinner/spinner";
const controller = new AbortController();
const sp = createSpinner({
text: "Building…",
frames: ["-", "\\", "|", "/"],
interval: 120,
signal: controller.signal,
});
sp.start();
setTimeout(() => controller.abort(), 5000); // cancel after 5 s
try {
await runBuild();
sp.succeed("Build complete");
} catch {
sp.fail("Build failed");
}
```
## API Reference
### `createSpinner(opts?: SpinnerOptions): SpinnerInstance`
Creates and returns a spinner instance. No animation starts until `start()` is called.
**`opts.text?: string`** — initial text displayed next to the spinner frame (default `""`).
**`opts.frames?: string[]`** — animation frame sequence (default: 10-step braille pattern).
**`opts.interval?: number`** — milliseconds between frame advances (default `80`).
**`opts.stream?: WritableStreamLike`** — output target (default `process.stdout`). Any object with a `write()` method is accepted; TTY-specific methods (`clearLine`, `cursorTo`) are optional.
**`opts.signal?: AbortSignal`** — when aborted, calls `stop()` immediately. If already aborted at construction time, `start()` becomes a no-op.
### `SpinnerInstance`
**`isSpinning: boolean`** — `true` between `start()` and `stop()`.
**`start(text?: string): SpinnerInstance`** — begins animation. If a `text` argument is provided, updates the displayed text before starting. No-op if already spinning or if the signal is pre-aborted.
**`stop(): SpinnerInstance`** — stops animation and clears the line (TTY only). No-op if not spinning.
**`succeed(text?: string): SpinnerInstance`** — calls `stop()` then writes `✔ <text>` (falls back to current text).
**`fail(text?: string): SpinnerInstance`** — calls `stop()` then writes `✖ <text>`.
**`warn(text?: string): SpinnerInstance`** — calls `stop()` then writes `⚠ <text>`.
**`update(text: string): SpinnerInstance`** — replaces the displayed text without interrupting animation.
### `SpinnerOptionsSchema`
Zod schema for `SpinnerOptions` — validates `text`, `frames`, and `interval` (excludes `stream` and `signal` which are not Zod-serialisable).
### `WritableStreamLike`
Minimal interface for the `stream` option: any object with `write(text: string): void`. The optional `clearLine`, `cursorTo`, and `isTTY` members enable TTY in-place rendering when present.
## Implementation Notes
The TTY branch detects `stream.isTTY === true` at construction time and uses `clearLine(0)` + `cursorTo(0)` + `write(frame + " " + text)` on each tick. The non-TTY branch appends a newline to each frame write so CI logs remain readable. The `setInterval` is stored and cleared in `stop()` to prevent timer leaks; calling `stop()` when not spinning is a safe no-op. The `AbortSignal` listener uses `{ once: true }` so it self-removes after the first abort event.

View File

@@ -0,0 +1,7 @@
import z from "zod";
export const SpinnerOptionsSchema = z.object({
text: z.string().optional(),
frames: z.array(z.string()).optional(),
interval: z.number().positive().optional(),
});

View File

@@ -0,0 +1,263 @@
import { Writable } from "node:stream";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { createSpinner } from "./spinner.js";
type TtyWritable = Writable & {
isTTY: boolean;
clearLine: (dir: number) => void;
cursorTo: (x: number) => void;
};
function makeStream(): { stream: TtyWritable; output: () => string } {
const chunks: string[] = [];
const stream = new Writable({
write(chunk, _enc, cb) {
chunks.push(chunk.toString());
cb();
},
}) as TtyWritable;
stream.isTTY = false;
stream.clearLine = () => {};
stream.cursorTo = () => {};
return { stream, output: () => chunks.join("") };
}
function makeTtyStream(): {
stream: TtyWritable;
output: () => string;
cleared: () => number;
} {
const chunks: string[] = [];
let clearCount = 0;
const stream = new Writable({
write(chunk, _enc, cb) {
chunks.push(chunk.toString());
cb();
},
}) as TtyWritable;
stream.isTTY = true;
stream.clearLine = () => {
clearCount++;
};
stream.cursorTo = () => {};
return { stream, output: () => chunks.join(""), cleared: () => clearCount };
}
describe("createSpinner", { timeout: 5000 }, () => {
it("starts and is spinning", () => {
const { stream } = makeStream();
const sp = createSpinner({ text: "Loading", stream });
sp.start();
expect(sp.isSpinning).toBe(true);
sp.stop();
});
it("is not spinning after stop()", () => {
const { stream } = makeStream();
const sp = createSpinner({ text: "x", stream });
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 });
sp.start();
expect(output()).toContain("Working");
sp.stop();
});
it("succeed writes success symbol", () => {
const { stream, output } = makeStream();
const sp = createSpinner({ text: "done", stream });
sp.start().succeed("Completed");
expect(output()).toContain("Completed");
expect(output()).toContain("✔");
});
it("fail writes fail symbol", () => {
const { stream, output } = makeStream();
const sp = createSpinner({ text: "task", stream });
sp.start().fail("Failed");
expect(output()).toContain("Failed");
expect(output()).toContain("✖");
});
it("warn writes warn symbol", () => {
const { stream, output } = makeStream();
const sp = createSpinner({ text: "task", stream });
sp.start().warn("Warning");
expect(output()).toContain("Warning");
expect(output()).toContain("⚠");
});
it("update changes the spinner text", () => {
const { stream } = makeStream();
const sp = createSpinner({ text: "old", stream });
sp.update("new");
sp.start();
sp.stop();
expect(sp.isSpinning).toBe(false);
});
it("aborts via AbortSignal", async () => {
const { stream } = makeStream();
const controller = new AbortController();
const sp = createSpinner({
text: "work",
stream,
signal: controller.signal,
});
sp.start();
controller.abort();
await new Promise((r) => setTimeout(r, 10));
expect(sp.isSpinning).toBe(false);
});
it("start() is a no-op when signal is already aborted", () => {
const { stream } = makeStream();
const controller = new AbortController();
controller.abort();
const sp = createSpinner({
text: "work",
stream,
signal: controller.signal,
});
sp.start();
expect(sp.isSpinning).toBe(false);
});
it("stop() is a no-op when not spinning", () => {
const { stream } = makeStream();
const sp = createSpinner({ text: "x", stream });
expect(() => sp.stop()).not.toThrow();
expect(sp.isSpinning).toBe(false);
});
it("start() is a no-op when already spinning", () => {
const { stream } = makeStream();
const sp = createSpinner({ text: "x", stream });
sp.start();
sp.start();
expect(sp.isSpinning).toBe(true);
sp.stop();
});
it("clears line on TTY stop", () => {
const { stream, cleared } = makeTtyStream();
const sp = createSpinner({ text: "tty", stream });
sp.start();
sp.stop();
expect(cleared()).toBeGreaterThan(0);
});
it("renders TTY frame on start", () => {
const { stream, output } = makeTtyStream();
const sp = createSpinner({ text: "tty-work", stream });
sp.start();
expect(output()).toContain("tty-work");
sp.stop();
});
it("succeed/fail/warn use current text when no arg given", () => {
const { stream, output } = makeStream();
const sp = createSpinner({ text: "mytask", stream });
sp.start().succeed();
expect(output()).toContain("mytask");
});
it("interval callback advances the frame", async () => {
const { stream, output } = makeTtyStream();
const sp = createSpinner({
text: "tick",
stream,
interval: 1,
frames: [">", "-"],
});
sp.start();
await new Promise((r) => setTimeout(r, 20));
sp.stop();
expect(output().length).toBeGreaterThan(0);
});
it("start() with text arg updates text", () => {
const { stream, output } = makeStream();
const sp = createSpinner({ stream });
sp.start("dynamic text");
expect(output()).toContain("dynamic text");
sp.stop();
});
it("fail() without arg uses current text", () => {
const { stream, output } = makeStream();
const sp = createSpinner({ text: "job", stream });
sp.start().fail();
expect(output()).toContain("job");
expect(output()).toContain("✖");
});
it("warn() without arg uses current text", () => {
const { stream, output } = makeStream();
const sp = createSpinner({ text: "job", stream });
sp.start().warn();
expect(output()).toContain("job");
expect(output()).toContain("⚠");
});
it("uses process.stdout when stream is omitted", () => {
const sp = createSpinner({ text: "test" });
expect(sp.isSpinning).toBe(false);
});
it("isTTY defaults to false when stream has no isTTY prop", () => {
const chunks: string[] = [];
const stream = {
write: (t: string) => {
chunks.push(t);
},
};
const sp = createSpinner({ text: "test", stream });
sp.start();
expect(chunks.join("")).toContain("test");
sp.stop();
});
it("uses default opts when called with no arguments", () => {
const sp = createSpinner();
expect(sp.isSpinning).toBe(false);
});
it("clearLine is no-op when isTTY=true but clearLine/cursorTo absent", () => {
const chunks: string[] = [];
const stream = {
write: (t: string) => {
chunks.push(t);
},
isTTY: true,
};
const sp = createSpinner({ text: "task", stream });
sp.start();
sp.stop();
expect(sp.isSpinning).toBe(false);
});
it("interval callback on non-TTY stream does not renderFrame", async () => {
const chunks: string[] = [];
const stream = {
write: (t: string) => {
chunks.push(t);
},
};
const sp = createSpinner({
text: "x",
stream,
interval: 1,
frames: ["a", "b"],
});
sp.start();
await new Promise((r) => setTimeout(r, 20));
sp.stop();
// Only initial frame written (non-TTY interval does nothing)
expect(chunks.length).toBe(1);
});
});

View File

@@ -0,0 +1,111 @@
import type {
SpinnerInstance,
SpinnerOptions,
WritableStreamLike,
} from "./spinner.types.js";
const DEFAULT_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
const DEFAULT_INTERVAL = 80;
function clearLine(stream: WritableStreamLike): void {
if (stream.isTTY && stream.clearLine && stream.cursorTo) {
stream.clearLine(0);
stream.cursorTo(0);
}
}
function renderFrame(
stream: WritableStreamLike,
frame: string,
text: string,
isTTY: boolean
): void {
if (isTTY && stream.clearLine && stream.cursorTo) {
stream.clearLine(0);
stream.cursorTo(0);
stream.write(`${frame} ${text}`);
} else {
stream.write(`${frame} ${text}\n`);
}
}
export function createSpinner(opts: SpinnerOptions = {}): SpinnerInstance {
const frames = opts.frames ?? DEFAULT_FRAMES;
const interval = opts.interval ?? DEFAULT_INTERVAL;
const stream: WritableStreamLike = opts.stream ?? process.stdout;
const isTTY = stream.isTTY ?? false;
let text = opts.text ?? "";
let spinning = false;
let frameIndex = 0;
let timer: ReturnType<typeof setInterval> | undefined;
const instance: SpinnerInstance = {
get isSpinning() {
return spinning;
},
start(newText?: string): SpinnerInstance {
if (opts.signal?.aborted) return instance;
if (spinning) return instance;
if (newText !== undefined) text = newText;
spinning = true;
renderFrame(
stream,
frames[frameIndex % frames.length]!,
text,
isTTY
);
timer = setInterval(() => {
frameIndex = (frameIndex + 1) % frames.length;
if (isTTY)
renderFrame(stream, frames[frameIndex]!, text, isTTY);
}, interval);
return instance;
},
stop(): SpinnerInstance {
if (!spinning) return instance;
spinning = false;
clearInterval(timer);
timer = undefined;
if (isTTY) clearLine(stream);
return instance;
},
succeed(newText?: string): SpinnerInstance {
instance.stop();
stream.write(`✔ ${newText ?? text}\n`);
return instance;
},
fail(newText?: string): SpinnerInstance {
instance.stop();
stream.write(`✖ ${newText ?? text}\n`);
return instance;
},
warn(newText?: string): SpinnerInstance {
instance.stop();
stream.write(`⚠ ${newText ?? text}\n`);
return instance;
},
update(newText: string): SpinnerInstance {
text = newText;
return instance;
},
};
if (opts.signal) {
opts.signal.addEventListener(
"abort",
() => {
instance.stop();
},
{ once: true }
);
}
return instance;
}

View File

@@ -0,0 +1,24 @@
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;
}

View File

@@ -0,0 +1,15 @@
export {
createStyler,
getStyleNames,
isColorSupported,
stripAnsi,
style,
} from "./style.js";
export { StyleCodeSchema, StyleOptionsSchema } from "./style.schemas.js";
export type {
BgColor,
Color,
Modifier,
StyleCode,
Styler,
} from "./style.types.js";

View File

@@ -0,0 +1,82 @@
# style
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`style` provides terminal ANSI colour and text-decoration utilities for `@suaveplan/cli`. It wraps text in ANSI escape sequences for modifiers (bold, dim, italic, underline, strikethrough) and colours (foreground and background). The module is colour-support-aware: when the terminal does not support colour, all styling is bypassed and plain text is returned unchanged.
The design uses a `Proxy`-based fluent builder that allows infinite chaining of style codes — `style.bold.red.underline("text")` — without allocating intermediate strings until the final call.
## Features
- 24 style codes: 6 modifiers, 9 foreground colours, 1 gray, 8 background colours
- `createStyler(enabled?)` — factory for a fresh styler instance, injectable colour flag for testing
- `style` — singleton styler wired to `isColorSupported()` at module load time
- `stripAnsi(text)` — removes all ANSI escape sequences from a string
- `isColorSupported()` — returns `true` when stdout is a TTY or `FORCE_COLOR` is set
- `getStyleNames()` — returns the list of all valid style code names
- Proxy-based chaining: `style.bold.red.underline("x")` composes all codes in a single call
- Disabled mode: `createStyler(false)` returns identity function — no allocation overhead
- Zero runtime dependencies outside the package
## Usage: Basic Example
```ts
import { style } from "@suaveplan/cli/style/style";
console.log(style.bold("Build complete"));
console.log(style.red("Error occurred"));
console.log(style.green.bold("✓ Tests passed"));
```
## Usage: Advanced Example
```ts
import { createStyler, isColorSupported, stripAnsi } from "@suaveplan/cli/style/style";
// Inject colour support for testing
const s = createStyler(process.env.CI !== "true");
function label(text: string, type: "info" | "warn" | "error"): string {
if (type === "error") return s.bold.red(`[ERROR] ${text}`);
if (type === "warn") return s.yellow(`[WARN] ${text}`);
return s.cyan(`[INFO] ${text}`);
}
console.log(label("Connection timed out", "error"));
// Strip for log files
const plain = stripAnsi(label("hello", "info"));
```
## API Reference
### `createStyler(enabled?: boolean): Styler`
Creates a new styler instance. `enabled` defaults to `isColorSupported()`. Returns a `Styler` (Proxy) that composes ANSI codes on chained property access and applies them on call.
### `style: Styler`
Pre-built singleton styler bound to the current terminal's colour support. Use this for production output.
### `stripAnsi(text: string): string`
Strips all ANSI escape sequences from `text`. Safe for log files and length calculations.
### `isColorSupported(): boolean`
Returns `true` when `process.stdout.isTTY` is true or `FORCE_COLOR` env var is set.
### `getStyleNames(): StyleCode[]`
Returns the array of all known style code names in order.
### `Styler`
A callable type with all style codes as chainable properties. Calling it with a string applies all accumulated codes and returns the styled string.
## Implementation Notes
The `Proxy` trap intercepts property access on the styler function itself, returning a new `Proxy`-wrapped function with the accessed code appended to the accumulated stack. This avoids the class-per-style-combination explosion of naive implementations. Opening and closing ANSI codes are concatenated from the stack at call time. The `reset` code (`\x1b[0m`) is handled like any other code — callers can chain `.reset` to reset all styles.

View File

@@ -0,0 +1,31 @@
import z from "zod";
export const StyleCodeSchema = z.enum([
"bold",
"dim",
"italic",
"underline",
"strikethrough",
"reset",
"black",
"red",
"green",
"yellow",
"blue",
"magenta",
"cyan",
"white",
"gray",
"bgBlack",
"bgRed",
"bgGreen",
"bgYellow",
"bgBlue",
"bgMagenta",
"bgCyan",
"bgWhite",
]);
export const StyleOptionsSchema = z.object({
enabled: z.boolean().optional(),
});

View File

@@ -0,0 +1,108 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import * as fc from "fast-check";
import {
createStyler,
getStyleNames,
isColorSupported,
stripAnsi,
} from "./style.js";
describe("stripAnsi", { timeout: 5000 }, () => {
it("strips ANSI escape codes", { timeout: 5000 }, () => {
expect(stripAnsi("\x1b[31mred\x1b[39m")).toBe("red");
});
it("returns plain text unchanged", { timeout: 5000 }, () => {
expect(stripAnsi("hello")).toBe("hello");
});
});
describe("isColorSupported", { timeout: 5000 }, () => {
it("returns a boolean", { timeout: 5000 }, () => {
expect(typeof isColorSupported()).toBe("boolean");
});
it("returns true when isTTY is true", { timeout: 5000 }, () => {
const saved = process.stdout.isTTY;
process.stdout.isTTY = true;
expect(isColorSupported()).toBe(true);
process.stdout.isTTY = saved;
});
it("returns true when FORCE_COLOR is set", { timeout: 5000 }, () => {
const saved = process.env["FORCE_COLOR"];
process.env["FORCE_COLOR"] = "1";
try {
expect(isColorSupported()).toBe(true);
} finally {
if (saved === undefined) delete process.env["FORCE_COLOR"];
else process.env["FORCE_COLOR"] = saved;
}
});
});
describe("getStyleNames", { timeout: 5000 }, () => {
it("returns non-empty array", { timeout: 5000 }, () => {
const names = getStyleNames();
expect(names.length).toBeGreaterThan(0);
expect(names).toContain("bold");
expect(names).toContain("red");
});
});
describe("createStyler", { timeout: 5000 }, () => {
it("returns a callable styler", { timeout: 5000 }, () => {
const s = createStyler(true);
expect(typeof s).toBe("function");
});
it("wraps text with ANSI codes when enabled", { timeout: 5000 }, () => {
const s = createStyler(true);
const result = s.red("hello");
expect(result).toContain("\x1b[");
expect(result).toContain("hello");
});
it("returns plain text when disabled", { timeout: 5000 }, () => {
const s = createStyler(false);
expect(s.bold.red("text")).toBe("text");
});
it("chains styles", { timeout: 5000 }, () => {
const s = createStyler(true);
const result = s.bold.red("hi");
expect(stripAnsi(result)).toBe("hi");
});
});
describe("createStyler — proxy unknown prop", { timeout: 5000 }, () => {
it("returns undefined for unknown style property", {
timeout: 5000,
}, () => {
const s = createStyler(true);
// Access a property that is not a valid style code
expect((s as Record<string, unknown>)["notAStyle"]).toBeUndefined();
});
});
describe("createStyler — default parameter", { timeout: 5000 }, () => {
it("uses isColorSupported() as default when called without args", {
timeout: 5000,
}, () => {
const s = createStyler(); // triggers default parameter branch
expect(typeof s).toBe("function");
});
});
describe("createStyler property tests", { timeout: 30000 }, () => {
it("stripAnsi(style.bold.red(s)) === s for all strings", {
timeout: 30000,
}, () => {
const s = createStyler(true);
fc.assert(
fc.property(fc.string(), (str) => {
expect(stripAnsi(s.bold.red(str))).toBe(str);
})
);
});
});

View File

@@ -0,0 +1,73 @@
import type { StyleCode, Styler } from "./style.types.js";
const CODES: Record<StyleCode, [number, number]> = {
reset: [0, 0],
bold: [1, 22],
dim: [2, 22],
italic: [3, 23],
underline: [4, 24],
strikethrough: [9, 29],
black: [30, 39],
red: [31, 39],
green: [32, 39],
yellow: [33, 39],
blue: [34, 39],
magenta: [35, 39],
cyan: [36, 39],
white: [37, 39],
gray: [90, 39],
bgBlack: [40, 49],
bgRed: [41, 49],
bgGreen: [42, 49],
bgYellow: [43, 49],
bgBlue: [44, 49],
bgMagenta: [45, 49],
bgCyan: [46, 49],
bgWhite: [47, 49],
};
// biome-ignore lint/suspicious/noControlCharactersInRegex: ESC is intentional for ANSI stripping
const ANSI_RE = /\x1b\[[0-9;]*m/g;
export function stripAnsi(text: string): string {
return text.replace(ANSI_RE, "");
}
export function isColorSupported(): boolean {
return (
process.stdout?.isTTY === true ||
process.env["FORCE_COLOR"] !== undefined
);
}
export function getStyleNames(): StyleCode[] {
return Object.keys(CODES) as StyleCode[];
}
export function createStyler(enabled = isColorSupported()): Styler {
function makeStyler(appliedCodes: StyleCode[]): Styler {
const fn = (text: string): string => {
if (!enabled) return text;
let open = "";
let close = "";
for (const code of appliedCodes) {
const [o, c] = CODES[code];
open += `\x1b[${o}m`;
close = `\x1b[${c}m${close}`;
}
return `${open}${text}${close}`;
};
return new Proxy(fn as Styler, {
get(_target, prop: string) {
if (prop in CODES) {
return makeStyler([...appliedCodes, prop as StyleCode]);
}
return undefined;
},
});
}
return makeStyler([]);
}
export const style: Styler = createStyler(isColorSupported());

View File

@@ -0,0 +1,54 @@
export type Modifier =
| "bold"
| "dim"
| "italic"
| "underline"
| "strikethrough"
| "reset";
export type Color =
| "black"
| "red"
| "green"
| "yellow"
| "blue"
| "magenta"
| "cyan"
| "white"
| "gray";
export type BgColor =
| "bgBlack"
| "bgRed"
| "bgGreen"
| "bgYellow"
| "bgBlue"
| "bgMagenta"
| "bgCyan"
| "bgWhite";
export type StyleCode = Modifier | Color | BgColor;
export interface Styler {
(text: string): string;
bold: Styler;
dim: Styler;
italic: Styler;
underline: Styler;
strikethrough: Styler;
reset: Styler;
black: Styler;
red: Styler;
green: Styler;
yellow: Styler;
blue: Styler;
magenta: Styler;
cyan: Styler;
white: Styler;
gray: Styler;
bgBlack: Styler;
bgRed: Styler;
bgGreen: Styler;
bgYellow: Styler;
bgBlue: Styler;
bgMagenta: Styler;
bgCyan: Styler;
bgWhite: Styler;
}

View File

@@ -0,0 +1 @@
export {};

View File

@@ -0,0 +1,12 @@
{
"$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"]
}

View File

@@ -0,0 +1,39 @@
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\//,
"@suaveplan/error",
"@suaveplan/types",
"tslib",
"zod",
"@opentelemetry/api",
/^node:/,
],
},
},
});

View File

@@ -0,0 +1,31 @@
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,
},
});