termcraft 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Mythie
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,276 @@
1
+ # termcraft
2
+
3
+ [![CI](https://github.com/Mythie/termcraft/actions/workflows/ci.yml/badge.svg)](https://github.com/Mythie/termcraft/actions/workflows/ci.yml)
4
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](./LICENSE)
5
+
6
+ Build type-safe CLIs with a single function call. Parsing, help, subcommands, and lifecycle hooks, all inferred from your definition.
7
+
8
+ > **Note:** termcraft is in early development (v0.1.0). The API may change between minor versions until v1.0.0.
9
+
10
+ ## Quick Start
11
+
12
+ ```sh
13
+ bun add termcraft
14
+ ```
15
+
16
+ ```ts
17
+ import { defineCommand, parseAsString, parseAsBoolean, runMain } from "termcraft";
18
+
19
+ const command = defineCommand({
20
+ meta: { name: "greet", version: "1.0.0" },
21
+ args: {
22
+ name: parseAsString.positional(0).withDescription("Name to greet."),
23
+ loud: parseAsBoolean.withDefault(false).withDescription("Print in uppercase."),
24
+ },
25
+ run: ({ args }) => {
26
+ const line = `Hello ${args.name}!`;
27
+ console.log(args.loud ? line.toUpperCase() : line);
28
+ },
29
+ });
30
+
31
+ await runMain(command);
32
+ ```
33
+
34
+ ```
35
+ $ bun run greet.ts world --loud
36
+ HELLO WORLD!
37
+
38
+ $ bun run greet.ts --help
39
+ greet
40
+
41
+ USAGE
42
+ greet <name> [options]
43
+
44
+ ARGUMENTS
45
+ <name> Name to greet.
46
+
47
+ OPTIONS
48
+ --loud Print in uppercase. (default: false)
49
+ -h, --help Show help
50
+ ```
51
+
52
+ ## Why termcraft?
53
+
54
+ - **Bun-native** Designed for Bun from the ground up, no Node baggage.
55
+ - **Fully type-safe** Argument types are inferred from your definition. `args.port` is `number`, not `any`. `.withDefault()` and `.withRequired()` narrow the type from `T | null` to `T`.
56
+ - **Lifecycle hooks** `setup` / `run` / `cleanup` / `onError` with guaranteed cleanup, even on SIGINT. No other lightweight CLI framework handles resource teardown this well.
57
+ - **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.
58
+ - **Batteries included** Prompts, colors, and banners as sub-path imports. One `bun add`, no juggling packages.
59
+
60
+ ## Features
61
+
62
+ - **Declarative commands** Define CLIs with `defineCommand()`.
63
+ - **Type-safe parsers** `parseAsString`, `parseAsInteger`, `parseAsFloat`, `parseAsBoolean`, `parseAsEnum`.
64
+ - **Fluent API** `.withAlias()`, `.withDefault()`, `.withRequired()`, `.withDescription()`, `.positional()`, `.variadic()`.
65
+ - **Subcommand routing** Nested commands with lazy-loaded imports via `() => import()`.
66
+ - **Auto-generated help** `--help` / `-h` with aligned columns, usage lines, and examples.
67
+ - **Signal handling** Graceful SIGINT (exit 130) / SIGTERM (exit 143) with cleanup.
68
+ - **Interactive prompts** via `termcraft/prompts` ([`@clack/prompts`](https://github.com/bombshell-dev/clack)).
69
+ - **Terminal colors** via `termcraft/colors` ([`picocolors`](https://github.com/alexeyraspopov/picocolors)).
70
+ - **ASCII banners** via `termcraft/banner` ([`figlet`](https://github.com/patorjk/figlet.js)).
71
+ - **Custom parsers** from parse/serialize functions or any Zod-compatible schema.
72
+
73
+ ## Argument Parsers
74
+
75
+ Parsers start as optional (`T | null`). Chain methods to refine:
76
+
77
+ ```ts
78
+ import { parseAsString, parseAsInteger, parseAsEnum } from "termcraft";
79
+
80
+ parseAsString.positional(0) // string | null
81
+ parseAsString.positional(0).withRequired() // string (non-null)
82
+ parseAsInteger.withAlias("c").withDefault(1) // number (non-null)
83
+ parseAsString.withRequired().withDescription("Output file.") // string (non-null)
84
+ parseAsEnum(["stable", "beta", "canary"] as const) // "stable" | "beta" | "canary" | null
85
+ parseAsString.positional(0).variadic() // string[] (collects remaining)
86
+ ```
87
+
88
+ ### Built-in Parsers
89
+
90
+ | Parser | Type | Description |
91
+ |--------|------|-------------|
92
+ | `parseAsString` | `string` | Pass-through string |
93
+ | `parseAsInteger` | `number` | Whole numbers only |
94
+ | `parseAsFloat` | `number` | Any valid number |
95
+ | `parseAsBoolean` | `boolean` | `true/yes/1` or `false/no/0` |
96
+ | `parseAsEnum(values)` | `values[number]` | One of the provided values |
97
+
98
+ ### Custom Parsers
99
+
100
+ Create parsers from functions or any object with `safeParse` (e.g., Zod):
101
+
102
+ ```ts
103
+ import { createParser } from "termcraft";
104
+
105
+ // From parse/serialize functions
106
+ const parseAsDate = createParser({
107
+ parse: (value: string) => {
108
+ const date = new Date(value);
109
+ return isNaN(date.getTime()) ? null : date;
110
+ },
111
+ serialize: (value: Date) => value.toISOString(),
112
+ });
113
+
114
+ // From a Zod schema (requires zod as a peer dependency)
115
+ import { z } from "zod";
116
+ const parseAsPort = createParser(z.coerce.number().int().min(1).max(65535));
117
+ ```
118
+
119
+ ## Subcommands
120
+
121
+ Nest commands with the `commands` field. Supports deeply nested routing and lazy-loaded imports:
122
+
123
+ ```ts
124
+ import { defineCommand, parseAsInteger, runMain } from "termcraft";
125
+
126
+ const command = defineCommand({
127
+ meta: { name: "tasks", version: "1.0.0" },
128
+ commands: {
129
+ list: defineCommand({
130
+ meta: { name: "list", description: "List tasks." },
131
+ args: {
132
+ limit: parseAsInteger.withDefault(10).withDescription("Max tasks."),
133
+ },
134
+ run: ({ args }) => console.log(`Listing ${args.limit} tasks`),
135
+ }),
136
+ // Lazy-loaded subcommand
137
+ deploy: () => import("./commands/deploy"),
138
+ },
139
+ });
140
+
141
+ await runMain(command);
142
+ ```
143
+
144
+ ```sh
145
+ bun run cli.ts list --limit 5
146
+ bun run cli.ts deploy
147
+ ```
148
+
149
+ ## Lifecycle Hooks
150
+
151
+ Commands support `setup`, `run`, `cleanup`, and `onError` hooks. Cleanup is guaranteed to run even if `setup` or `run` throws:
152
+
153
+ ```ts
154
+ const command = defineCommand({
155
+ meta: { name: "release", version: "1.0.0" },
156
+ args: {
157
+ tag: parseAsString.withRequired().withDescription("Version tag."),
158
+ },
159
+ setup: async ({ args }) => {
160
+ console.log(`Preparing release ${args.tag}`);
161
+ },
162
+ run: ({ args }) => {
163
+ console.log(`Publishing ${args.tag}`);
164
+ },
165
+ cleanup: () => {
166
+ console.log("Cleaning up temporary files");
167
+ },
168
+ onError: (error) => {
169
+ console.error(`Release failed: ${error}`);
170
+ process.exitCode = 1;
171
+ },
172
+ });
173
+ ```
174
+
175
+ ## Interactive Prompts
176
+
177
+ Use `termcraft/prompts` for interactive input. The flag-or-prompt pattern lets your CLI work both interactively and in CI:
178
+
179
+ ```ts
180
+ import { defineCommand, parseAsString, runMain } from "termcraft";
181
+ import { text, select, intro, outro } from "termcraft/prompts";
182
+
183
+ const command = defineCommand({
184
+ meta: { name: "init", version: "1.0.0" },
185
+ args: {
186
+ name: parseAsString.withDescription("Project name."),
187
+ },
188
+ run: async ({ args }) => {
189
+ intro("Project Setup");
190
+
191
+ // Use CLI flag if provided, prompt otherwise
192
+ const name = args.name ?? await text({ message: "Project name?" });
193
+
194
+ const template = await select({
195
+ message: "Template?",
196
+ options: [
197
+ { value: "web", label: "Web App" },
198
+ { value: "api", label: "API Server" },
199
+ ],
200
+ });
201
+
202
+ outro(`Created ${name} with ${template} template.`);
203
+ },
204
+ });
205
+
206
+ await runMain(command);
207
+ ```
208
+
209
+ ## Colors and Banners
210
+
211
+ ```ts
212
+ import { colors } from "termcraft/colors";
213
+ import { banner } from "termcraft/banner";
214
+
215
+ console.log(await banner("my-app"));
216
+ console.log(colors.green("Success!"));
217
+ console.log(colors.bold(colors.red("Error:")), "something went wrong");
218
+ ```
219
+
220
+ ## Error Handling
221
+
222
+ termcraft exports error classes for programmatic error handling:
223
+
224
+ ```ts
225
+ import { TermcraftError, MissingArgError, InvalidValueError } from "termcraft";
226
+ ```
227
+
228
+ - `MissingArgError` is thrown when a required argument or option is missing.
229
+ - `InvalidValueError` is thrown when a value doesn't match the parser's expected type.
230
+ - `TermcraftError` is the base class for all termcraft errors.
231
+
232
+ Signal handling is automatic: SIGINT exits with code 130, SIGTERM with 143. Cleanup hooks always run before exit.
233
+
234
+ ## Documentation
235
+
236
+ Full guides and API reference in [`docs/`](./docs/):
237
+
238
+ - [Getting Started](./docs/getting-started.md)
239
+ - [Defining Commands](./docs/defining-commands.md)
240
+ - [Argument Parsers](./docs/argument-parsers.md)
241
+ - [Custom Parsers](./docs/custom-parsers.md)
242
+ - [Subcommands](./docs/subcommands.md)
243
+ - [Lifecycle Hooks](./docs/lifecycle-hooks.md)
244
+ - [Prompts, Colors, and Banners](./docs/prompts.md)
245
+ - [Error Handling](./docs/error-handling.md)
246
+ - [API Reference](./docs/api-reference.md)
247
+
248
+ ## Examples
249
+
250
+ See [`examples/`](./examples/) for complete working CLIs:
251
+
252
+ - **[greet.ts](./examples/greet.ts)** positional args, flags, aliases
253
+ - **[init.ts](./examples/init.ts)** interactive prompts with flag-or-prompt pattern
254
+ - **[release.ts](./examples/release.ts)** lifecycle hooks, banners, error handling
255
+ - **[tasks.ts](./examples/tasks.ts)** subcommands and nested commands
256
+ - **[workspace.ts](./examples/workspace.ts)** full app-like CLI with grouped subcommands
257
+
258
+ ```sh
259
+ bun run examples/greet.ts world --loud
260
+ ```
261
+
262
+ ## Development
263
+
264
+ ```sh
265
+ bun install # install dependencies
266
+ bun test # run tests
267
+ bun run lint # check for lint/format issues
268
+ bun run lint:fix # auto-fix lint/format issues
269
+ bun run typecheck # run TypeScript type checking
270
+ bun run check # lint + typecheck
271
+ bun run build # build dist/ for publishing
272
+ ```
273
+
274
+ ## License
275
+
276
+ MIT
@@ -0,0 +1,7 @@
1
+ //#region src/banner/index.d.ts
2
+ interface BannerOptions {
3
+ font?: "1Row" | "3-D" | "3D Diagonal" | "3D-ASCII" | "3x5" | "4Max" | "5 Line Oblique" | "Standard" | "Ghost" | "Big" | "Block" | "Bubble" | "Digital" | "Ivrit" | "Mini" | "Script" | "Shadow" | "Slant" | "Small" | "Speed" | "Tinker-Toy" | (string & {});
4
+ }
5
+ declare function banner(text: string, options?: BannerOptions): Promise<string>;
6
+ //#endregion
7
+ export { BannerOptions, banner };
@@ -0,0 +1,15 @@
1
+ import figlet from "figlet";
2
+ //#region src/banner/index.ts
3
+ function banner(text, options) {
4
+ return new Promise((resolve, reject) => {
5
+ figlet.text(text, options, (error, result) => {
6
+ if (error) {
7
+ reject(error);
8
+ return;
9
+ }
10
+ resolve(result ?? "");
11
+ });
12
+ });
13
+ }
14
+ //#endregion
15
+ export { banner };
@@ -0,0 +1,108 @@
1
+ //#region src/parsers/types.d.ts
2
+ type ParserFunctions<T> = {
3
+ parse: (val: string) => T | null;
4
+ serialize: (val: T) => string;
5
+ };
6
+ type ParserMeta = {
7
+ alias?: string;
8
+ description?: string;
9
+ defaultValue?: unknown;
10
+ isRequired: boolean;
11
+ isPositional: boolean;
12
+ positionalIndex?: number;
13
+ isVariadic: boolean;
14
+ typeName?: string;
15
+ isBooleanFlag: boolean;
16
+ };
17
+ type Parser<T, D extends boolean = false, V extends boolean = false> = {
18
+ _meta: ParserMeta;
19
+ parse: (val: string) => T | null;
20
+ serialize: (val: T) => string;
21
+ withAlias: (alias: string) => Parser<T, D, V>;
22
+ withDescription: (description: string) => Parser<T, D, V>;
23
+ withRequired: () => Parser<T, true, V>;
24
+ withDefault: (defaultValue: T) => Parser<T, true, V>;
25
+ positional: (index?: number) => Parser<T, true, V>;
26
+ variadic: () => Parser<T, D, true>;
27
+ };
28
+ //#endregion
29
+ //#region src/command/types.d.ts
30
+ type CommandMeta = {
31
+ name: string;
32
+ version?: string;
33
+ description?: string;
34
+ };
35
+ type ArgsRecord = Record<string, Parser<any, boolean, boolean>>;
36
+ type CommandContext<T extends ArgsRecord> = {
37
+ args: InferArgs<T>;
38
+ };
39
+ type HelpConfig = {
40
+ header?: string;
41
+ examples?: string[];
42
+ render?: (command: CommandDef<any>, meta: CommandMeta) => string;
43
+ };
44
+ type InferArgs<T extends ArgsRecord> = { [K in keyof T]: T[K] extends Parser<infer V, infer D, infer IsVariadic> ? IsVariadic extends true ? V[] : D extends true ? V : V | null : never };
45
+ type LazyCommandRef = {
46
+ description?: string;
47
+ load: () => Promise<{
48
+ default: CommandDef<any>;
49
+ }>;
50
+ };
51
+ type CommandRef = CommandDef<any> | (() => Promise<{
52
+ default: CommandDef<any>;
53
+ }>) | LazyCommandRef;
54
+ type CommandDef<T extends ArgsRecord> = {
55
+ meta: CommandMeta;
56
+ args?: T;
57
+ commands?: Record<string, CommandRef>;
58
+ help?: HelpConfig;
59
+ setup?: (context: CommandContext<T>) => void | Promise<void>;
60
+ run?: (context: CommandContext<T>) => unknown | Promise<unknown>;
61
+ cleanup?: (context: CommandContext<T>) => void | Promise<void>;
62
+ onError?: (error: unknown) => void | Promise<void>;
63
+ };
64
+ //#endregion
65
+ //#region src/command/define.d.ts
66
+ declare const defineCommand: <T extends ArgsRecord = ArgsRecord>(definition: CommandDef<T>) => CommandDef<T>;
67
+ //#endregion
68
+ //#region src/command/errors.d.ts
69
+ declare class TermcraftError extends Error {
70
+ constructor(message: string);
71
+ }
72
+ declare class MissingArgError extends TermcraftError {
73
+ constructor(name: string, isPositional: boolean);
74
+ }
75
+ declare class InvalidValueError extends TermcraftError {
76
+ constructor(name: string, value: string, typeName: string, isPositional: boolean);
77
+ }
78
+ //#endregion
79
+ //#region src/command/help.d.ts
80
+ declare const renderHelp: <T extends ArgsRecord>(command: CommandDef<T>) => string;
81
+ //#endregion
82
+ //#region src/command/run.d.ts
83
+ declare const runCommand: <T extends ArgsRecord>(command: CommandDef<T>, options: {
84
+ argv: string[];
85
+ }) => Promise<void>;
86
+ declare const routeAndRun: <T extends ArgsRecord>(command: CommandDef<T>, argv: string[]) => Promise<void>;
87
+ declare const runMain: <T extends ArgsRecord>(command: CommandDef<T>) => Promise<void>;
88
+ //#endregion
89
+ //#region src/parsers/built-in.d.ts
90
+ declare const parseAsString: Parser<string, false>;
91
+ declare const parseAsInteger: Parser<number, false>;
92
+ declare const parseAsFloat: Parser<number, false>;
93
+ declare const parseAsBoolean: Parser<boolean, false>;
94
+ declare const parseAsEnum: <const TValues extends readonly [string, ...string[]]>(values: TValues) => Parser<TValues[number], false>;
95
+ //#endregion
96
+ //#region src/parsers/create-parser.d.ts
97
+ type ZodLikeSchema<T = unknown> = {
98
+ safeParse: (value: unknown) => {
99
+ success: true;
100
+ data: T;
101
+ } | {
102
+ success: false;
103
+ };
104
+ };
105
+ declare function createParser<T>(functions: ParserFunctions<T>, meta?: Partial<ParserMeta>): Parser<T, false>;
106
+ declare function createParser<T>(schema: ZodLikeSchema<T>, meta?: Partial<ParserMeta>): Parser<T, false>;
107
+ //#endregion
108
+ export { type ArgsRecord, type CommandContext, type CommandDef, type CommandMeta, type CommandRef, type HelpConfig, type InferArgs, InvalidValueError, type LazyCommandRef, MissingArgError, type Parser, type ParserFunctions, type ParserMeta, TermcraftError, createParser, defineCommand, parseAsBoolean, parseAsEnum, parseAsFloat, parseAsInteger, parseAsString, renderHelp, routeAndRun, runCommand, runMain };
package/dist/index.mjs ADDED
@@ -0,0 +1,500 @@
1
+ import { colors } from "./utils/color.mjs";
2
+ import { parseArgs } from "node:util";
3
+ //#region src/command/utils.ts
4
+ const getOrderedPositionals = (args) => {
5
+ return Object.entries(args).filter(([, parser]) => parser._meta.isPositional).map(([name, parser], order) => ({
6
+ name,
7
+ parser,
8
+ order,
9
+ index: parser._meta.positionalIndex ?? Number.MAX_SAFE_INTEGER
10
+ })).sort((left, right) => {
11
+ if (left.index !== right.index) return left.index - right.index;
12
+ return left.order - right.order;
13
+ });
14
+ };
15
+ //#endregion
16
+ //#region src/command/define.ts
17
+ const validateArgs = (args) => {
18
+ const aliases = /* @__PURE__ */ new Map();
19
+ for (const [name, parser] of Object.entries(args)) {
20
+ const { alias } = parser._meta;
21
+ if (alias) {
22
+ const existing = aliases.get(alias);
23
+ if (existing) throw new Error(`Duplicate alias "${alias}" for arguments "${existing}" and "${name}".`);
24
+ aliases.set(alias, name);
25
+ }
26
+ }
27
+ const explicitIndices = /* @__PURE__ */ new Map();
28
+ for (const [name, parser] of Object.entries(args)) {
29
+ const positionalIndex = parser._meta.isPositional ? parser._meta.positionalIndex : void 0;
30
+ if (positionalIndex === void 0) continue;
31
+ const existing = explicitIndices.get(positionalIndex);
32
+ if (existing) throw new Error(`Duplicate positional index ${positionalIndex} for arguments "${existing}" and "${name}".`);
33
+ explicitIndices.set(positionalIndex, name);
34
+ }
35
+ const orderedPositionals = getOrderedPositionals(args);
36
+ const variadics = orderedPositionals.filter(({ parser }) => parser._meta.isVariadic);
37
+ if (variadics.length > 1) throw new Error("Only one variadic positional argument is allowed.");
38
+ const variadic = variadics[0];
39
+ if (variadic && orderedPositionals[orderedPositionals.length - 1]?.name !== variadic.name) throw new Error(`Variadic positional argument "${variadic.name}" must be the last positional argument.`);
40
+ };
41
+ const defineCommand = (definition) => {
42
+ if (definition.args) validateArgs(definition.args);
43
+ return definition;
44
+ };
45
+ //#endregion
46
+ //#region src/command/errors.ts
47
+ var TermcraftError = class extends Error {
48
+ constructor(message) {
49
+ super(message);
50
+ this.name = new.target.name;
51
+ }
52
+ };
53
+ var MissingArgError = class extends TermcraftError {
54
+ constructor(name, isPositional) {
55
+ super(isPositional ? `Missing required argument: ${name}` : `Missing required option: --${name}`);
56
+ }
57
+ };
58
+ var InvalidValueError = class extends TermcraftError {
59
+ constructor(name, value, typeName, isPositional) {
60
+ super(`Invalid value for ${isPositional ? name : `--${name}`}: ${JSON.stringify(value)} (expected ${typeName})`);
61
+ }
62
+ };
63
+ //#endregion
64
+ //#region src/command/help.ts
65
+ const isPositionalRequired = (parser) => parser._meta.defaultValue === void 0;
66
+ const isNamedOptionRequired = (parser) => {
67
+ return parser._meta.isRequired && parser._meta.defaultValue === void 0;
68
+ };
69
+ const formatPositionalUsage = (name, parser) => {
70
+ const displayName = parser._meta.isVariadic ? `${name}...` : name;
71
+ return isPositionalRequired(parser) ? `<${displayName}>` : `[${displayName}]`;
72
+ };
73
+ const formatOptionUsage = (name, parser) => {
74
+ return `--${name}${parser._meta.isBooleanFlag ? "" : ` <${parser._meta.typeName ?? "value"}>`}`;
75
+ };
76
+ const formatDefaultValue = (parser) => {
77
+ if (parser._meta.defaultValue === void 0) return null;
78
+ return parser.serialize(parser._meta.defaultValue);
79
+ };
80
+ const formatOptionLabel = (name, parser) => {
81
+ return `${parser._meta.alias ? `-${parser._meta.alias}, ` : ""}--${name}${parser._meta.isBooleanFlag ? "" : ` <${parser._meta.typeName ?? "value"}>`}`;
82
+ };
83
+ const formatOptionDescription = (parser) => {
84
+ const parts = [];
85
+ if (parser._meta.description) parts.push(parser._meta.description);
86
+ const defaultValue = formatDefaultValue(parser);
87
+ if (defaultValue !== null) parts.push(`(default: ${defaultValue})`);
88
+ return parts.join(" ");
89
+ };
90
+ const isLazyCommandRef$1 = (ref) => typeof ref === "object" && "load" in ref;
91
+ const formatCommandDescription = (commandRef) => {
92
+ if (typeof commandRef === "function") return "";
93
+ if (isLazyCommandRef$1(commandRef)) return commandRef.description ?? "";
94
+ return commandRef.meta.description ?? "";
95
+ };
96
+ const renderSection = (title, lines) => {
97
+ if (lines.length === 0) return [];
98
+ return [colors.bold(colors.cyan(title)), ...lines];
99
+ };
100
+ const renderHelp = (command) => {
101
+ if (command.help?.render) return command.help.render(command, command.meta);
102
+ const args = command.args ?? {};
103
+ const argEntries = Object.entries(args);
104
+ const positionals = getOrderedPositionals(args);
105
+ const options = argEntries.filter(([, parser]) => !parser._meta.isPositional);
106
+ const requiredOptions = options.filter(([, parser]) => isNamedOptionRequired(parser));
107
+ const optionalOptions = options.filter(([, parser]) => !isNamedOptionRequired(parser));
108
+ const commandEntries = Object.entries(command.commands ?? {});
109
+ const sections = [];
110
+ const header = command.help?.header ?? colors.bold(command.meta.name);
111
+ sections.push(header);
112
+ if (command.meta.description) sections.push(command.meta.description);
113
+ const usage = [command.meta.name];
114
+ if (commandEntries.length > 0) usage.push("<command>");
115
+ usage.push(...requiredOptions.map(([name, parser]) => formatOptionUsage(name, parser)));
116
+ usage.push(...positionals.map(({ name, parser }) => formatPositionalUsage(name, parser)));
117
+ if (optionalOptions.length > 0) usage.push("[options]");
118
+ sections.push("", ...renderSection("USAGE", [` ${usage.join(" ")}`]));
119
+ if (positionals.length > 0) {
120
+ const labels = positionals.map(({ name, parser }) => formatPositionalUsage(name, parser));
121
+ const maxLabelWidth = Math.max(...labels.map((l) => l.length));
122
+ sections.push("", ...renderSection("ARGUMENTS", positionals.map(({ parser }, i) => {
123
+ const description = formatOptionDescription(parser);
124
+ const label = labels[i];
125
+ return description ? ` ${label.padEnd(maxLabelWidth)} ${description}` : ` ${label}`;
126
+ })));
127
+ }
128
+ {
129
+ const labels = options.map(([name, parser]) => formatOptionLabel(name, parser));
130
+ const implicitLabels = ["-h, --help"];
131
+ if (command.meta.version) implicitLabels.push("-v, --version");
132
+ const maxLabelWidth = Math.max(...labels.map((l) => l.length), ...implicitLabels.map((l) => l.length));
133
+ const optionLines = options.map(([, parser], i) => {
134
+ const description = formatOptionDescription(parser);
135
+ const label = labels[i];
136
+ return description ? ` ${label.padEnd(maxLabelWidth)} ${description}` : ` ${label}`;
137
+ });
138
+ const implicitFlags = [` ${"-h, --help".padEnd(maxLabelWidth)} Show help`];
139
+ if (command.meta.version) implicitFlags.push(` ${"-v, --version".padEnd(maxLabelWidth)} Show version`);
140
+ sections.push("", ...renderSection("OPTIONS", [...optionLines, ...implicitFlags]));
141
+ }
142
+ if (commandEntries.length > 0) {
143
+ const names = commandEntries.map(([name]) => name);
144
+ const maxNameWidth = Math.max(...names.map((n) => n.length));
145
+ sections.push("", ...renderSection("COMMANDS", commandEntries.map(([name, commandRef]) => {
146
+ const description = formatCommandDescription(commandRef);
147
+ return description ? ` ${name.padEnd(maxNameWidth)} ${description}` : ` ${name}`;
148
+ })));
149
+ }
150
+ if (command.help?.examples?.length) sections.push("", ...renderSection("EXAMPLES", command.help.examples.map((example) => ` ${colors.dim(example)}`)));
151
+ return sections.join("\n");
152
+ };
153
+ //#endregion
154
+ //#region src/command/resolve-args.ts
155
+ const parseValue = (name, parser, rawValue) => {
156
+ const parsed = parser.parse(rawValue);
157
+ if (parsed === null) throw new InvalidValueError(name, rawValue, parser._meta.typeName ?? "value", parser._meta.isPositional);
158
+ return parsed;
159
+ };
160
+ const resolveArgs = (args, argv) => {
161
+ const namedEntries = Object.entries(args).filter(([, parser]) => !parser._meta.isPositional);
162
+ const positionals = getOrderedPositionals(args);
163
+ const options = Object.fromEntries(namedEntries.map(([name, parser]) => {
164
+ return [name, {
165
+ type: parser._meta.isBooleanFlag ? "boolean" : "string",
166
+ ...parser._meta.alias ? { short: parser._meta.alias } : {}
167
+ }];
168
+ }));
169
+ let parsedArgs;
170
+ try {
171
+ parsedArgs = parseArgs({
172
+ args: argv,
173
+ options,
174
+ allowPositionals: true,
175
+ strict: true
176
+ });
177
+ } catch (error) {
178
+ if (error instanceof Error) {
179
+ const missingValue = error.message.match(/^Option '(?:-[^,]+, )?--([^ ]+) <value>' argument missing$/);
180
+ if (missingValue) throw new MissingArgError(missingValue[1], false);
181
+ throw new TermcraftError(error.message);
182
+ }
183
+ throw error;
184
+ }
185
+ const resolved = {};
186
+ for (const [name, parser] of namedEntries) {
187
+ const rawValue = parsedArgs.values[name];
188
+ if (rawValue === void 0) {
189
+ if (parser._meta.defaultValue !== void 0) {
190
+ resolved[name] = parser._meta.defaultValue;
191
+ continue;
192
+ }
193
+ if (parser._meta.isRequired) throw new MissingArgError(name, false);
194
+ resolved[name] = null;
195
+ continue;
196
+ }
197
+ resolved[name] = parseValue(name, parser, String(rawValue));
198
+ }
199
+ let positionalOffset = 0;
200
+ for (const { name, parser } of positionals) {
201
+ if (parser._meta.isVariadic) {
202
+ const remaining = parsedArgs.positionals.slice(positionalOffset);
203
+ if (remaining.length === 0) {
204
+ if (parser._meta.defaultValue !== void 0) {
205
+ resolved[name] = parser._meta.defaultValue;
206
+ continue;
207
+ }
208
+ throw new MissingArgError(name, true);
209
+ }
210
+ resolved[name] = remaining.map((value) => parseValue(name, parser, value));
211
+ positionalOffset = parsedArgs.positionals.length;
212
+ continue;
213
+ }
214
+ const rawValue = parsedArgs.positionals[positionalOffset];
215
+ if (rawValue === void 0) {
216
+ if (parser._meta.defaultValue !== void 0) {
217
+ resolved[name] = parser._meta.defaultValue;
218
+ continue;
219
+ }
220
+ throw new MissingArgError(name, true);
221
+ }
222
+ resolved[name] = parseValue(name, parser, rawValue);
223
+ positionalOffset += 1;
224
+ }
225
+ const unexpectedArg = parsedArgs.positionals[positionalOffset];
226
+ if (unexpectedArg !== void 0) throw new TermcraftError(`Unexpected argument: ${unexpectedArg}`);
227
+ return resolved;
228
+ };
229
+ //#endregion
230
+ //#region src/command/run.ts
231
+ var SignalExitError = class extends Error {
232
+ constructor(code) {
233
+ super(`Process interrupted with exit code ${code}`);
234
+ this.code = code;
235
+ this.name = "SignalExitError";
236
+ }
237
+ };
238
+ let activeSignal = null;
239
+ const formatError = (error) => {
240
+ if (error instanceof Error) return error.message;
241
+ return String(error);
242
+ };
243
+ const throwIfSignaled = () => {
244
+ const error = activeSignal?.error;
245
+ if (error) throw error;
246
+ };
247
+ const awaitWithSignal = async (value) => {
248
+ const signal = activeSignal;
249
+ if (!signal) return await value;
250
+ return await Promise.race([Promise.resolve(value), signal.promise.then((error) => {
251
+ throw error;
252
+ })]);
253
+ };
254
+ const isLazyCommandRef = (ref) => typeof ref === "object" && "load" in ref;
255
+ const resolveCommandRef = async (commandRef) => {
256
+ if (typeof commandRef === "function") {
257
+ const module = await awaitWithSignal(commandRef());
258
+ throwIfSignaled();
259
+ return module.default;
260
+ }
261
+ if (isLazyCommandRef(commandRef)) {
262
+ const module = await awaitWithSignal(commandRef.load());
263
+ throwIfSignaled();
264
+ return module.default;
265
+ }
266
+ throwIfSignaled();
267
+ return commandRef;
268
+ };
269
+ const hasRequiredPositionalsForEmptyArgvHelp = (command) => {
270
+ const args = command.args;
271
+ if (!args) return false;
272
+ return Object.values(args).some((parser) => parser._meta.isPositional && parser._meta.defaultValue === void 0);
273
+ };
274
+ const runCommand = async (command, options) => {
275
+ const context = { args: command.args ? resolveArgs(command.args, options.argv) : {} };
276
+ let error;
277
+ try {
278
+ throwIfSignaled();
279
+ await awaitWithSignal(command.setup?.(context));
280
+ throwIfSignaled();
281
+ await awaitWithSignal(command.run?.(context));
282
+ } catch (caughtError) {
283
+ error = caughtError;
284
+ }
285
+ try {
286
+ await command.cleanup?.(context);
287
+ throwIfSignaled();
288
+ } catch (caughtError) {
289
+ if (caughtError instanceof SignalExitError || error === void 0) error = caughtError;
290
+ }
291
+ if (error === void 0) return;
292
+ if (error instanceof SignalExitError) throw error;
293
+ if (command.onError) {
294
+ await command.onError(error);
295
+ return;
296
+ }
297
+ throw error;
298
+ };
299
+ const routeAndRun = async (command, argv) => {
300
+ throwIfSignaled();
301
+ const [subcommandName, ...rest] = argv;
302
+ if (subcommandName && command.commands?.[subcommandName]) {
303
+ const subcommand = await resolveCommandRef(command.commands[subcommandName]);
304
+ throwIfSignaled();
305
+ await routeAndRun(subcommand, rest);
306
+ return;
307
+ }
308
+ if (Boolean(command.commands && Object.keys(command.commands).length > 0) && !subcommandName || !subcommandName && hasRequiredPositionalsForEmptyArgvHelp(command)) {
309
+ throwIfSignaled();
310
+ console.log(renderHelp(command));
311
+ return;
312
+ }
313
+ const dashDashIndex = argv.indexOf("--");
314
+ const flagsArgv = dashDashIndex === -1 ? argv : argv.slice(0, dashDashIndex);
315
+ if (flagsArgv.includes("--help") || flagsArgv.includes("-h")) {
316
+ throwIfSignaled();
317
+ console.log(renderHelp(command));
318
+ return;
319
+ }
320
+ if (flagsArgv.includes("--version") || flagsArgv.includes("-v")) {
321
+ throwIfSignaled();
322
+ console.log(command.meta.version ?? "");
323
+ return;
324
+ }
325
+ throwIfSignaled();
326
+ await runCommand(command, { argv });
327
+ };
328
+ const runMain = async (command) => {
329
+ const argv = process.argv.slice(2);
330
+ let exitCode = null;
331
+ let stderrOutput = null;
332
+ const onSigint = () => {
333
+ const signal = activeSignal;
334
+ if (signal && !signal.error) {
335
+ const error = new SignalExitError(130);
336
+ signal.error = error;
337
+ signal.resolve(error);
338
+ }
339
+ };
340
+ const onSigterm = () => {
341
+ const signal = activeSignal;
342
+ if (signal && !signal.error) {
343
+ const error = new SignalExitError(143);
344
+ signal.error = error;
345
+ signal.resolve(error);
346
+ }
347
+ };
348
+ const previousSignal = activeSignal;
349
+ activeSignal = {
350
+ ...Promise.withResolvers(),
351
+ error: null
352
+ };
353
+ process.on("SIGINT", onSigint);
354
+ process.on("SIGTERM", onSigterm);
355
+ try {
356
+ await routeAndRun(command, argv);
357
+ } catch (error) {
358
+ if (error instanceof SignalExitError) exitCode = error.code;
359
+ else {
360
+ stderrOutput = `${formatError(error)}\n`;
361
+ exitCode = 1;
362
+ }
363
+ } finally {
364
+ process.off("SIGINT", onSigint);
365
+ process.off("SIGTERM", onSigterm);
366
+ activeSignal = previousSignal;
367
+ }
368
+ if (stderrOutput) process.stderr.write(stderrOutput);
369
+ if (exitCode !== null) process.exit(exitCode);
370
+ };
371
+ //#endregion
372
+ //#region src/parsers/create-parser.ts
373
+ const createParserMeta = (overrides = {}) => ({
374
+ isRequired: false,
375
+ isPositional: false,
376
+ isVariadic: false,
377
+ isBooleanFlag: false,
378
+ ...overrides
379
+ });
380
+ const buildParser = (config) => {
381
+ const meta = createParserMeta(config.meta);
382
+ return {
383
+ _meta: meta,
384
+ parse: config.parse,
385
+ serialize: config.serialize,
386
+ withAlias: (alias) => buildParser({
387
+ ...config,
388
+ meta: {
389
+ ...meta,
390
+ alias
391
+ }
392
+ }),
393
+ withDescription: (description) => buildParser({
394
+ ...config,
395
+ meta: {
396
+ ...meta,
397
+ description
398
+ }
399
+ }),
400
+ withRequired: () => buildParser({
401
+ parse: config.parse,
402
+ serialize: config.serialize,
403
+ meta: {
404
+ ...meta,
405
+ isRequired: true
406
+ }
407
+ }),
408
+ withDefault: (defaultValue) => buildParser({
409
+ parse: config.parse,
410
+ serialize: config.serialize,
411
+ meta: {
412
+ ...meta,
413
+ defaultValue
414
+ }
415
+ }),
416
+ positional: (index) => buildParser({
417
+ parse: config.parse,
418
+ serialize: config.serialize,
419
+ meta: {
420
+ ...meta,
421
+ isPositional: true,
422
+ positionalIndex: index
423
+ }
424
+ }),
425
+ variadic: () => buildParser({
426
+ ...config,
427
+ meta: {
428
+ ...meta,
429
+ isVariadic: true
430
+ }
431
+ })
432
+ };
433
+ };
434
+ function createParser(input, meta) {
435
+ if (typeof input === "object" && "parse" in input && "serialize" in input) {
436
+ const functions = input;
437
+ return buildParser({
438
+ parse: functions.parse,
439
+ serialize: functions.serialize,
440
+ meta
441
+ });
442
+ }
443
+ const schema = input;
444
+ return buildParser({
445
+ parse: (value) => {
446
+ const result = schema.safeParse(value);
447
+ return result.success ? result.data : null;
448
+ },
449
+ serialize: (value) => String(value),
450
+ meta
451
+ });
452
+ }
453
+ //#endregion
454
+ //#region src/parsers/built-in.ts
455
+ const parseAsString = createParser({
456
+ parse: (value) => value,
457
+ serialize: (value) => value
458
+ }, { typeName: "string" });
459
+ const parseAsInteger = createParser({
460
+ parse: (value) => {
461
+ if (value.trim() === "") return null;
462
+ const parsed = Number(value);
463
+ return Number.isInteger(parsed) ? parsed : null;
464
+ },
465
+ serialize: (value) => value.toString()
466
+ }, { typeName: "integer" });
467
+ const parseAsFloat = createParser({
468
+ parse: (value) => {
469
+ if (value.trim() === "") return null;
470
+ const parsed = Number(value);
471
+ return Number.isNaN(parsed) ? null : parsed;
472
+ },
473
+ serialize: (value) => value.toString()
474
+ }, { typeName: "number" });
475
+ const parseAsBoolean = createParser({
476
+ parse: (value) => {
477
+ const normalized = value.trim().toLowerCase();
478
+ if ([
479
+ "true",
480
+ "yes",
481
+ "1"
482
+ ].includes(normalized)) return true;
483
+ if ([
484
+ "false",
485
+ "no",
486
+ "0"
487
+ ].includes(normalized)) return false;
488
+ return null;
489
+ },
490
+ serialize: (value) => value.toString()
491
+ }, {
492
+ typeName: "boolean",
493
+ isBooleanFlag: true
494
+ });
495
+ const parseAsEnum = (values) => createParser({
496
+ parse: (value) => values.includes(value) ? value : null,
497
+ serialize: (value) => value
498
+ }, { typeName: values.join("|") });
499
+ //#endregion
500
+ export { InvalidValueError, MissingArgError, TermcraftError, createParser, defineCommand, parseAsBoolean, parseAsEnum, parseAsFloat, parseAsInteger, parseAsString, renderHelp, routeAndRun, runCommand, runMain };
@@ -0,0 +1,2 @@
1
+ import { cancel, confirm, group, intro, isCancel, log, multiselect, note, outro, select, spinner, text } from "@clack/prompts";
2
+ export { cancel, confirm, group, intro, isCancel, log, multiselect, note, outro, select, spinner, text };
@@ -0,0 +1,2 @@
1
+ import { cancel, confirm, group, intro, isCancel, log, multiselect, note, outro, select, spinner, text } from "@clack/prompts";
2
+ export { cancel, confirm, group, intro, isCancel, log, multiselect, note, outro, select, spinner, text };
@@ -0,0 +1,2 @@
1
+ import colors from "picocolors";
2
+ export { colors };
@@ -0,0 +1,2 @@
1
+ import colors from "picocolors";
2
+ export { colors };
package/package.json ADDED
@@ -0,0 +1,89 @@
1
+ {
2
+ "name": "termcraft",
3
+ "version": "0.1.0",
4
+ "description": "A type-safe CLI framework for Bun with declarative argument parsing, subcommands, and lifecycle hooks.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "Mythie",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/Mythie/termcraft.git"
11
+ },
12
+ "homepage": "https://github.com/Mythie/termcraft",
13
+ "bugs": "https://github.com/Mythie/termcraft/issues",
14
+ "keywords": [
15
+ "cli",
16
+ "command-line",
17
+ "framework",
18
+ "bun",
19
+ "typescript",
20
+ "parser",
21
+ "arguments"
22
+ ],
23
+ "engines": {
24
+ "bun": ">=1.0.0"
25
+ },
26
+ "files": [
27
+ "dist",
28
+ "LICENSE",
29
+ "README.md"
30
+ ],
31
+ "exports": {
32
+ ".": {
33
+ "types": "./dist/index.d.mts",
34
+ "import": "./dist/index.mjs"
35
+ },
36
+ "./prompts": {
37
+ "types": "./dist/prompts/index.d.mts",
38
+ "import": "./dist/prompts/index.mjs"
39
+ },
40
+ "./banner": {
41
+ "types": "./dist/banner/index.d.mts",
42
+ "import": "./dist/banner/index.mjs"
43
+ },
44
+ "./colors": {
45
+ "types": "./dist/utils/color.d.mts",
46
+ "import": "./dist/utils/color.mjs"
47
+ }
48
+ },
49
+ "main": "./dist/index.mjs",
50
+ "types": "./dist/index.d.mts",
51
+ "scripts": {
52
+ "build": "tsdown",
53
+ "lint": "biome check .",
54
+ "lint:fix": "biome check --write .",
55
+ "format": "biome format --write .",
56
+ "typecheck": "tsc --noEmit",
57
+ "test": "bun test",
58
+ "check": "biome check . && tsc --noEmit",
59
+ "prepublishOnly": "bun run check && bun run build",
60
+ "prepare": "husky || true"
61
+ },
62
+ "lint-staged": {
63
+ "*.{ts,tsx,js,jsx,json,css,md}": [
64
+ "biome check --write --no-errors-on-unmatched"
65
+ ]
66
+ },
67
+ "peerDependencies": {
68
+ "zod": "^4.0.0"
69
+ },
70
+ "peerDependenciesMeta": {
71
+ "zod": {
72
+ "optional": true
73
+ }
74
+ },
75
+ "dependencies": {
76
+ "@clack/prompts": "^1.1.0",
77
+ "figlet": "^1.11.0",
78
+ "picocolors": "^1.1.1"
79
+ },
80
+ "devDependencies": {
81
+ "@biomejs/biome": "^2.4.7",
82
+ "@types/bun": "latest",
83
+ "@types/figlet": "^1.7.0",
84
+ "husky": "^9.1.7",
85
+ "lint-staged": "^16.3.4",
86
+ "tsdown": "^0.21.2",
87
+ "typescript": "latest"
88
+ }
89
+ }