termcraft
v0.2.0
Published
A type-safe CLI framework for Bun with declarative argument parsing, subcommands, and lifecycle hooks.
Downloads
208
Maintainers
Readme
termcraft
Build type-safe CLIs with a single function call. Parsing, help, subcommands, and lifecycle hooks, all inferred from your definition.
Note: termcraft is in early development (v0.1.0). The API may change between minor versions until v1.0.0.
Quick Start
bun add termcraftimport { defineCommand, parseAsString, parseAsBoolean, runMain } from "termcraft";
const command = defineCommand({
meta: { name: "greet", version: "1.0.0" },
args: {
name: parseAsString.positional(0).withDescription("Name to greet."),
loud: parseAsBoolean.withDefault(false).withDescription("Print in uppercase."),
},
run: ({ args }) => {
const line = `Hello ${args.name}!`;
console.log(args.loud ? line.toUpperCase() : line);
},
});
await runMain(command);$ bun run greet.ts world --loud
HELLO WORLD!
$ bun run greet.ts --help
greet
USAGE
greet <name> [options]
ARGUMENTS
<name> Name to greet.
OPTIONS
--loud Print in uppercase. (default: false)
-h, --help Show helpWhy termcraft?
- Bun-native Designed for Bun from the ground up, no Node baggage.
- Fully type-safe Argument types are inferred from your definition.
args.portisnumber, notany..withDefault()and.withRequired()narrow the type fromT | nulltoT. - Lifecycle hooks
setup/run/cleanup/onErrorwith guaranteed cleanup, even on SIGINT. No other lightweight CLI framework handles resource teardown this well. - Flag-or-prompt pattern Combine CLI flags with interactive prompts:
args.name ?? await text({ message: "Name?" }). Ship CLIs that work both interactively and in scripts. - Batteries included Prompts, colors, and banners as sub-path imports. One
bun add, no juggling packages.
Features
- Declarative commands Define CLIs with
defineCommand(). - Type-safe parsers
parseAsString,parseAsInteger,parseAsFloat,parseAsBoolean,parseAsEnum. - Fluent API
.withAlias(),.withDefault(),.withRequired(),.withDescription(),.positional(),.variadic(). - Subcommand routing Nested commands with lazy-loaded imports via
() => import(). - Auto-generated help
--help/-hwith aligned columns, usage lines, and examples. - Signal handling Graceful SIGINT (exit 130) / SIGTERM (exit 143) with cleanup.
- Interactive prompts via
termcraft/prompts(@clack/prompts). - Terminal colors via
termcraft/colors(picocolors). - ASCII banners via
termcraft/banner(figlet). - Pass-through args. Everything after
--is available ascontext.restfor forwarding to child processes. - Boolean negation.
--no-verboseautomatically negates--verbosefor any boolean flag. - Repeated options.
--file a.txt --file b.txtcollects values into an array via.repeated(). - Custom parsers from parse/serialize functions or any Zod-compatible schema.
Argument Parsers
Parsers start as optional (T | null). Chain methods to refine:
import { parseAsString, parseAsInteger, parseAsEnum } from "termcraft";
parseAsString.positional(0) // string | null
parseAsString.positional(0).withRequired() // string (non-null)
parseAsInteger.withAlias("c").withDefault(1) // number (non-null)
parseAsString.withRequired().withDescription("Output file.") // string (non-null)
parseAsEnum(["stable", "beta", "canary"] as const) // "stable" | "beta" | "canary" | null
parseAsString.positional(0).variadic() // string[] (collects remaining)Built-in Parsers
| Parser | Type | Description |
|--------|------|-------------|
| parseAsString | string | Pass-through string |
| parseAsInteger | number | Whole numbers only |
| parseAsFloat | number | Any valid number |
| parseAsBoolean | boolean | true/yes/1 or false/no/0 |
| parseAsEnum(values) | values[number] | One of the provided values |
Custom Parsers
Create parsers from functions or any object with safeParse (e.g., Zod):
import { createParser } from "termcraft";
// From parse/serialize functions
const parseAsDate = createParser({
parse: (value: string) => {
const date = new Date(value);
return isNaN(date.getTime()) ? null : date;
},
serialize: (value: Date) => value.toISOString(),
});
// From a Zod schema (requires zod as a peer dependency)
import { z } from "zod";
const parseAsPort = createParser(z.coerce.number().int().min(1).max(65535));Subcommands
Nest commands with the commands field. Supports deeply nested routing and lazy-loaded imports:
import { defineCommand, parseAsInteger, runMain } from "termcraft";
const command = defineCommand({
meta: { name: "tasks", version: "1.0.0" },
commands: {
list: defineCommand({
meta: { name: "list", description: "List tasks." },
args: {
limit: parseAsInteger.withDefault(10).withDescription("Max tasks."),
},
run: ({ args }) => console.log(`Listing ${args.limit} tasks`),
}),
// Lazy-loaded subcommand
deploy: () => import("./commands/deploy"),
},
});
await runMain(command);bun run cli.ts list --limit 5
bun run cli.ts deployLifecycle Hooks
Commands support setup, run, cleanup, and onError hooks. Cleanup is guaranteed to run even if setup or run throws:
const command = defineCommand({
meta: { name: "release", version: "1.0.0" },
args: {
tag: parseAsString.withRequired().withDescription("Version tag."),
},
setup: async ({ args }) => {
console.log(`Preparing release ${args.tag}`);
},
run: ({ args }) => {
console.log(`Publishing ${args.tag}`);
},
cleanup: () => {
console.log("Cleaning up temporary files");
},
onError: (error) => {
console.error(`Release failed: ${error}`);
process.exitCode = 1;
},
});Interactive Prompts
Use termcraft/prompts for interactive input. The flag-or-prompt pattern lets your CLI work both interactively and in CI:
import { defineCommand, parseAsString, runMain } from "termcraft";
import { text, select, intro, outro } from "termcraft/prompts";
const command = defineCommand({
meta: { name: "init", version: "1.0.0" },
args: {
name: parseAsString.withDescription("Project name."),
},
run: async ({ args }) => {
intro("Project Setup");
// Use CLI flag if provided, prompt otherwise
const name = args.name ?? await text({ message: "Project name?" });
const template = await select({
message: "Template?",
options: [
{ value: "web", label: "Web App" },
{ value: "api", label: "API Server" },
],
});
outro(`Created ${name} with ${template} template.`);
},
});
await runMain(command);Colors and Banners
import { colors } from "termcraft/colors";
import { banner } from "termcraft/banner";
console.log(await banner("my-app"));
console.log(colors.green("Success!"));
console.log(colors.bold(colors.red("Error:")), "something went wrong");Error Handling
termcraft exports error classes for programmatic error handling:
import { TermcraftError, MissingArgError, InvalidValueError } from "termcraft";MissingArgErroris thrown when a required argument or option is missing.InvalidValueErroris thrown when a value doesn't match the parser's expected type.TermcraftErroris the base class for all termcraft errors.
Signal handling is automatic: SIGINT exits with code 130, SIGTERM with 143. Cleanup hooks always run before exit.
Documentation
Full guides and API reference in docs/:
- Getting Started
- Defining Commands
- Argument Parsers
- Custom Parsers
- Subcommands
- Lifecycle Hooks
- Prompts, Colors, and Banners
- Error Handling
- API Reference
Examples
See examples/ for complete working CLIs:
- greet.ts positional args, flags, aliases
- init.ts interactive prompts with flag-or-prompt pattern
- release.ts lifecycle hooks, banners, error handling
- tasks.ts subcommands and nested commands
- build.ts repeated options,
--no-flagnegation,--pass-through - workspace.ts full app-like CLI with grouped subcommands
bun run examples/greet.ts world --loudDevelopment
bun install # install dependencies
bun test # run tests
bun run lint # check for lint/format issues
bun run lint:fix # auto-fix lint/format issues
bun run typecheck # run TypeScript type checking
bun run check # lint + typecheck
bun run build # build dist/ for publishingLicense
MIT
