hacksh 0.15.0 → 0.17.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/README.md CHANGED
@@ -2,17 +2,33 @@
2
2
 
3
3
  Define a type-safe command-line interface with a Zod 4 object schema. The same schema parses and
4
4
  validates arguments, infers the value passed to `run`, and generates help. Run external commands
5
- with the context’s `exec`, use its `logger` for progress, and request interactive input with `prompt`.
6
-
7
- The name comes from 拍手 (_hakushu_), the Japanese word for "clap", as a nod to
8
- [clap](https://github.com/clap-rs/clap), the command-line argument parser for Rust. It is spelled
9
- with _hack_ and _sh_, as in shell.
10
-
11
- hacksh draws heavily on [Citty](https://github.com/unjs/citty) for its command API design and
12
- [Clack](https://github.com/bombshell-dev/clack) for its interactive prompts and terminal output.
13
- Many thanks to both projects and their contributors. hacksh uses Zod schemas for argument validation
14
- and type inference, including tuples for positional arguments; it does not guarantee API or behavior
15
- compatibility with Citty or Clack.
5
+ with the context’s `exec`, write results with `output`, use its `logger` for progress, and request
6
+ interactive input with `prompt`.
7
+
8
+ - [Install](#install)
9
+ - [Quick start](#quick-start)
10
+ - [Arguments](#arguments)
11
+ - [Options and flags](#options-and-flags)
12
+ - [Positional arguments](#positional-arguments)
13
+ - [Argument metadata](#argument-metadata)
14
+ - [Environment variables](#environment-variables)
15
+ - [Unknown arguments and forwarding](#unknown-arguments-and-forwarding)
16
+ - [Subcommands](#subcommands)
17
+ - [The run context](#the-run-context)
18
+ - [External commands](#external-commands)
19
+ - [Command output](#command-output)
20
+ - [Structured logging](#structured-logging)
21
+ - [Interactive input](#interactive-input)
22
+ - [Cancellation and exit status](#cancellation-and-exit-status)
23
+ - [Output formats](#output-formats)
24
+ - [Programmatic API and errors](#programmatic-api-and-errors)
25
+ - [Entry points](#entry-points)
26
+ - [Composable run functions](#composable-run-functions)
27
+ - [Printers and events](#printers-and-events)
28
+ - [Standalone parsing and help](#standalone-parsing-and-help)
29
+ - [Errors](#errors)
30
+ - [Development](#development)
31
+ - [Name and acknowledgments](#name-and-acknowledgments)
16
32
 
17
33
  ## Install
18
34
 
@@ -24,7 +40,7 @@ hacksh is an ESM package and supports Node.js 22.18 or newer. Zod is a peer depe
24
40
  application controls the Zod 4 version it uses. The public schema types and schema inspection use
25
41
  the shared `zod/v4/core` API and accept schemas created with either `zod` or `zod/mini`.
26
42
 
27
- ## Define and run a command
43
+ ## Quick start
28
44
 
29
45
  ```ts
30
46
  import { z } from "zod";
@@ -40,14 +56,14 @@ const main = defineCommand({
40
56
  ]),
41
57
  repo: z.string().optional().meta({ description: "Repository directory", alias: "C" }),
42
58
  }),
43
- async run({ args, exec }) {
59
+ async run({ args, exec, output }) {
44
60
  const [ref] = args.positionals;
45
61
  const { stdout } = await exec(
46
62
  "git",
47
63
  ["show", "--no-patch", "--format=%h %s", "--end-of-options", ref],
48
64
  { cwd: args.repo },
49
65
  );
50
- console.log(stdout);
66
+ output.println(stdout);
51
67
  },
52
68
  });
53
69
 
@@ -61,20 +77,15 @@ git-summary --help
61
77
  git-summary --version
62
78
  ```
63
79
 
64
- Use an `async run` handler and its `exec` for external commands. It captures output, removes one
65
- final newline, throws on failure, and observes invocation cancellation. Pass arguments as an array;
66
- there is no command string to quote or split. You do not need a `node:child_process` import or a
67
- custom process wrapper for these behaviors. See [External commands](#external-commands) for expected
68
- nonzero exits, stdin, and terminal access.
80
+ `args` is inferred from the Zod schema, and `exec` runs external commands with an argument array,
81
+ captures output with one final newline removed, throws on failure, and observes invocation
82
+ cancellation. See [Arguments](#arguments) for option and positional rules,
83
+ [The run context](#the-run-context) for `exec`, `output`, `logger`, `prompt`, and `signal`, and
84
+ [Output formats](#output-formats) for how results and logs are split between stdout and stderr.
69
85
 
70
- `console.log` above writes the command’s data to stdout. Direct stdout writes are not converted
71
- to JSON by hacksh’s output mode. Logger events go through the printer; their stream depends on
72
- the level, so informational logs also share stdout by default.
86
+ ## Arguments
73
87
 
74
- `--version` is a root-only builtin. It prints the root command's `version`. When the root has
75
- none, `runMain` resolves the executable (`process.argv[1]`) through symlinks and prints the
76
- `version` of the nearest ancestor `package.json` that declares one, so a published CLI reports its
77
- package version without repeating it in code. `runCommand` does not search the filesystem.
88
+ ### Options and flags
78
89
 
79
90
  Boolean fields are flags and other fields take a value. Both a schema key and its kebab-case form
80
91
  are accepted (`--dryRun` and `--dry-run`). Every boolean flag also accepts a `--no-` prefixed form
@@ -84,7 +95,25 @@ Boolean flags also accept the explicit forms `--verbose=true` and
84
95
  `--no-verbose=false`) are validation errors. Value options accept `--repo ./my-repo` and
85
96
  `--repo=./my-repo`; values such as `false` remain strings for non-boolean fields.
86
97
 
87
- ## Positional arguments
98
+ Boolean flags are shown in help as `--[no-]name` because they also accept the `--no-` form. The
99
+ `negatable` field of `UsageOptionSummary` carries the same information for machine-readable
100
+ help, where `flags` keeps the literal `--name` spelling.
101
+
102
+ `--help` and `--version` are built in:
103
+
104
+ - `--version` is a root-only builtin. It prints the root command's `version`. When the root has
105
+ none, `runMain` resolves the executable (`process.argv[1]`) through symlinks and prints the
106
+ `version` of the nearest ancestor `package.json` that declares one, so a published CLI reports its
107
+ package version without repeating it in code. `runCommand` does not search the filesystem.
108
+ - Without an alias of the same letter, `-h` and `-v` are shorthands for `--help` and `--version`.
109
+ Defining an `h` or `v` alias on an option replaces the shorthand while keeping `--help` and
110
+ `--version` available.
111
+ - Both `help` and `version` option names are reserved and throw a `TypeError` from `defineCommand`
112
+ on every command.
113
+ - Output selection does not reserve any additional command-line flags, so names such as `--json`,
114
+ `--output`, and `--log-format` remain available to application schemas.
115
+
116
+ ### Positional arguments
88
117
 
89
118
  Declare positional arguments as a `z.tuple` in the `positionals` field. Tuple items are assigned in order,
90
119
  and the tuple rest collects all remaining values. Optional or defaulted items must come after
@@ -101,6 +130,7 @@ const copy = defineCommand({
101
130
  }),
102
131
  run({ args }) {
103
132
  const [destination, ...files] = args.positionals; // [string, ...string[]]
133
+ return { destination, files };
104
134
  },
105
135
  });
106
136
  ```
@@ -114,58 +144,7 @@ Help and validation errors label each item with its `.meta({ title })`, such as
114
144
  labeled `ARGS`. Use `z.array` instead of a tuple to accept only variadic values. Without a rest,
115
145
  additional positional arguments are rejected by default, as are unknown options.
116
146
 
117
- ### Unknown arguments and forwarding
118
-
119
- A command rejects unknown options and excess positional arguments before its `run` handler or
120
- schema transforms execute. For ordinary commands, declare the accepted arguments in `args`;
121
- there is no need to inspect `input.unknown` manually. Errors are `HackshValidationError` instances,
122
- with each rejected token in `issues[].arg`.
123
-
124
- Everything after `--` is always collected unchanged in `input.passthrough`, independently of this policy.
125
- Use that explicit boundary when forwarding arguments to another executable:
126
-
127
- ```ts
128
- const forward = defineCommand({
129
- name: "git-forward",
130
- async run({ exec, input }) {
131
- const { stdout } = await exec("git", input.passthrough);
132
- console.log(stdout);
133
- },
134
- });
135
- // git-forward -- status --short
136
- ```
137
-
138
- For a wrapper that also accepts undeclared arguments before `--`, set `allowUnknownArgs: true`:
139
-
140
- ```ts
141
- const wrapper = defineCommand({
142
- allowUnknownArgs: true,
143
- async run({ input, exec }) {
144
- await exec("some-tool", [...input.unknown, ...input.passthrough]);
145
- },
146
- });
147
- ```
148
-
149
- `input.unknown` preserves the order of unknown option tokens and excess positional values. Unknown
150
- options do not consume a following value: if the schema has an unfilled positional slot, that
151
- value fills it. Known options are still parsed and validated. Use `--` when a child's arguments
152
- must not be interpreted by this command. A handler that does not use `input.passthrough` ignores those
153
- arguments; commands that require rejecting them can check `input.passthrough.length` explicitly.
154
-
155
- `allowUnknownArgs` defaults to `false`, inherits down the selected subcommand route, and can be
156
- overridden by each child. A child's setting does not change validation of its parent's arguments.
157
- Definitions are not mutated, so reusing a child under another root does not retain inherited settings.
158
- Standalone `parseArgs` uses the supplied command's setting and does not resolve a parent route.
159
-
160
- Once routing succeeds, help and root version output take precedence over argument validation,
161
- including unknown arguments. Routing errors such as an unknown subcommand still fail. A child's
162
- undeclared `--version` is an unknown argument, since the version builtin belongs to the root.
163
-
164
- **Migrating from 0.11.0:** unknown input was previously accepted by default. Add
165
- `allowUnknownArgs: true` to commands that intentionally use `input.unknown`, or to their shared parent.
166
- No migration is needed for `--` forwarding.
167
-
168
- ## Argument metadata
147
+ ### Argument metadata
169
148
 
170
149
  Use Zod 4 `.meta({ alias, valueName, env, title, description })` on each argument schema.
171
150
  Importing hacksh types `alias`, `env`, and `valueName` on Zod's global metadata; any other key stays
@@ -213,7 +192,8 @@ OPTIONS
213
192
  -v, -V, --[no-]verbose Show details (Default: false)
214
193
  ```
215
194
 
216
- - `alias` gives a named option one or more single-character short forms.
195
+ - `alias` gives a named option one or more single-character short forms. Defining `v` above replaces
196
+ the default `-v` version shorthand, so `convert -v` sets `verbose`.
217
197
  - `valueName` sets the value label of a named option.
218
198
  - `env` overrides an option's automatic environment variable name, or disables it with `false`.
219
199
  - `title` labels a positional argument; `description` supplies its help text.
@@ -222,19 +202,9 @@ Place metadata on the outer schema to override metadata inherited through wrappe
222
202
  `.describe(text)` remains equivalent to description metadata; new code should use `.meta({ description: text })`.
223
203
  With `zod/mini`, use `.check(mini.meta({ ... }))`.
224
204
 
225
- Boolean flags are shown as `--[no-]name` because they also accept the `--no-` form. The
226
- `negatable` field of `UsageOptionSummary` carries the same information for machine-readable
227
- help, where `flags` keeps the literal `--name` spelling.
228
-
229
205
  `defineCommand` validates metadata types and throws a `TypeError` for empty or multi-character
230
206
  aliases, duplicate aliases, and option-only metadata (`alias`, `env`, `valueName`) on positional
231
- schemas. Without an alias of the same letter, `-h` and `-v` are shorthands
232
- for `--help` and `--version`. Defining an `h` or `v` alias replaces the shorthand, so `convert -v`
233
- above sets `verbose`; `--help` and `--version` remain available. Both `help` and `version` option
234
- names are reserved and throw a `TypeError` from `defineCommand` on every command.
235
- Output selection does not reserve
236
- any additional command-line flags, so names such as `--json`, `--output`, and `--log-format` remain
237
- available to application schemas.
207
+ schemas.
238
208
 
239
209
  ### Environment variables
240
210
 
@@ -272,6 +242,53 @@ MY_CLI_DEPLOY_TARGET=prod MY_CLI_DEPLOY_DRY_RUN=true GITHUB_TOKEN=t my-cli deplo
272
242
  - Two options that resolve to the same name on one route, such as a root `deployTarget` and a
273
243
  `deploy` subcommand's `target`, make the invocation throw a `TypeError`.
274
244
 
245
+ ### Unknown arguments and forwarding
246
+
247
+ A command rejects unknown options and excess positional arguments before its `run` handler or
248
+ schema transforms execute. For ordinary commands, declare the accepted arguments in `args`;
249
+ there is no need to inspect `input.unknown` manually. Errors are `HackshValidationError` instances,
250
+ with each rejected token in `issues[].arg`.
251
+
252
+ Everything after `--` is always collected unchanged in `input.passthrough`, independently of this policy.
253
+ Use that explicit boundary when forwarding arguments to another executable:
254
+
255
+ ```ts
256
+ const forward = defineCommand({
257
+ name: "git-forward",
258
+ async run({ exec, input, output }) {
259
+ const { stdout } = await exec("git", input.passthrough);
260
+ output.println(stdout);
261
+ },
262
+ });
263
+ // git-forward -- status --short
264
+ ```
265
+
266
+ For a wrapper that also accepts undeclared arguments before `--`, set `allowUnknownArgs: true`:
267
+
268
+ ```ts
269
+ const wrapper = defineCommand({
270
+ allowUnknownArgs: true,
271
+ async run({ input, exec }) {
272
+ await exec("some-tool", [...input.unknown, ...input.passthrough]);
273
+ },
274
+ });
275
+ ```
276
+
277
+ `input.unknown` preserves the order of unknown option tokens and excess positional values. Unknown
278
+ options do not consume a following value: if the schema has an unfilled positional slot, that
279
+ value fills it. Known options are still parsed and validated. Use `--` when a child's arguments
280
+ must not be interpreted by this command. A handler that does not use `input.passthrough` ignores those
281
+ arguments; commands that require rejecting them can check `input.passthrough.length` explicitly.
282
+
283
+ `allowUnknownArgs` defaults to `false`, inherits down the selected subcommand route, and can be
284
+ overridden by each child. A child's setting does not change validation of its parent's arguments.
285
+ Definitions are not mutated, so reusing a child under another root does not retain inherited settings.
286
+ Standalone `parseArgs` uses the supplied command's setting and does not resolve a parent route.
287
+
288
+ Once routing succeeds, help and root version output take precedence over argument validation,
289
+ including unknown arguments. Routing errors such as an unknown subcommand still fail. A child's
290
+ undeclared `--version` is an unknown argument, since the version builtin belongs to the root.
291
+
275
292
  ## Subcommands
276
293
 
277
294
  ```ts
@@ -305,107 +322,34 @@ listing, including usage shown for an unknown command. Hidden commands remain av
305
322
  their own `--help` output remains available. If every subcommand is hidden, help keeps the generic
306
323
  `<COMMAND>` syntax but omits the empty `COMMANDS` section.
307
324
 
308
- ## Structured logging
309
-
310
- Every `run` context has a `logger`. Its `trace`, `debug`, `info`, `warn`, `error`, and `fatal`
311
- methods emit structured records through the invocation's printer. `step` and `success` are
312
- info-level records with a distinct semantic kind for presentation. Placeholders in braces use the
313
- record's properties. Every level is emitted and displayed by the default printer; there is no
314
- built-in severity threshold or verbose flag.
315
-
316
- ```ts
317
- const deploy = defineCommand({
318
- name: "deploy",
319
- async run({ logger }) {
320
- const log = logger.with({ app: "web", env: "prod" });
321
- log.step("Deploying {app} to {env}");
322
- log.getChild("upload").debug("Uploaded {files} files", { files: 12 });
323
- log.success("Deployed {app}");
324
- },
325
- });
326
- ```
327
-
328
- Properties passed to an individual call override properties bound by `with`. `getChild` appends a
329
- category segment; hacksh automatically starts the category with the named root and each resolved
330
- subcommand. Missing placeholders, and placeholders whose values cannot be converted to text,
331
- remain unchanged without discarding their properties. Passing an `Error` to `error` or `fatal`
332
- uses its message and adds a canonical, schema-free error summary as `properties.error`. Error
333
- values nested in other log properties are summarized the same way before custom printers receive
334
- the event.
335
-
336
- Each `log` print event contains a `record` with `level`, `kind`, `category`, interpolated `message`,
337
- template `rawMessage`, merged `properties`, and a numeric `timestamp`. Logger calls do not need to
338
- be awaited: they use the invocation's serialized output queue, which is drained before execution
339
- finishes. A printer failure rejects the invocation without changing a successful `run` value.
340
-
341
- ## Interactive input
342
-
343
- Every `run` context has a `prompt` with `select`, `confirm`, and `text` methods. Interactive input
344
- is separate from `logger`: prompts share the invocation's selected printer, serialized output queue,
345
- terminal lock, output mode, and abort signal.
346
-
347
- ```ts
348
- const deploy = defineCommand({
349
- name: "deploy",
350
- async run({ logger, prompt }) {
351
- const environment = await prompt.select({
352
- message: "Environment",
353
- options: [
354
- { value: "staging", hint: "safe default" },
355
- { value: "production", label: "Production" },
356
- ] as const,
357
- initialValue: "staging",
358
- }); // "staging" | "production"
359
- const confirmed = await prompt.confirm({ message: `Deploy to ${environment}?` });
360
- if (!confirmed) return;
361
- const release = await prompt.text({
362
- message: "Release name",
363
- placeholder: "v1.2.3",
364
- validate: (value) => (value.length === 0 ? "A release name is required" : undefined),
365
- });
366
- logger.success("Deploying {release}", { release });
367
- },
368
- });
369
- ```
325
+ ## The run context
370
326
 
371
- Select values retain their literal union type. Arrow keys change the selected choice, Enter accepts
372
- it, and `initialValue` controls the initial selection. Confirm accepts `y`, `n`, or Enter; Enter
373
- uses `initialValue` when supplied and otherwise means no. Text displays its placeholder without
374
- using it as input, supports editing, and repeats the prompt only when `validate` returns a nonempty
375
- message; `""` means valid. Backspace removes one Unicode code point. Input pasted after a selection
376
- or confirmation is retained for the next sequential prompt instead of being discarded.
377
- The built-in prompt API intentionally stops at select, confirm, and text; it does not provide a
378
- multiselect prompt.
327
+ Every `run` handler receives a `CommandContext` with:
379
328
 
380
- Prompts are allowed only when the resolved mode is interactive: pretty output outside CI with TTY
381
- stdin and stdout. JSON, CI, and non-TTY invocations throw `HackshPromptError` immediately even if a
382
- default is available. Its `reason` and `prompt` fields identify why input failed and the prompt's
383
- `kind`, `message`, and select `choices`. Reasons distinguish `non-interactive`, `cancelled`,
384
- `aborted`, `eof`, `overlapping`, `invalid-options`, `input-error`, and `printer-error`. Ctrl-C and
385
- Escape cancel; `runMain` reports cancellation and exits with status 130. A prompt that starts or is
386
- waiting after the invocation's `signal` aborts fails with `aborted` and the signal's reason as its
387
- `cause`. Only one prompt may be active in an invocation. Input listeners and raw terminal mode are
388
- restored after every outcome.
389
- Failed prompts emit a non-visible `closed` prompt state through the same printer before rejecting,
390
- so callers that catch cancellation, EOF, input, or printer errors do not leave stale terminal UI.
329
+ - `args` — validated output inferred from the command's `args` schema.
330
+ - `input` — the raw selected command segment (`argv`), accepted unknown tokens (`unknown`), and
331
+ tokens after `--` (`passthrough`). For a routed child, `input.argv` excludes parent options and
332
+ subcommand names.
333
+ - `command` — the resolved command definition. An unnamed subcommand is represented by a completed
334
+ copy whose `name` is its key; the original definition is not mutated.
335
+ - `exec` — runs external commands under the invocation's environment, printer, and cancellation.
336
+ - `output` — writes the command's results to stdout through the invocation's printer.
337
+ - `logger` — emits structured log records to stderr through the invocation's printer.
338
+ - `prompt` — requests interactive `select`, `confirm`, or `text` input.
339
+ - `signal` — an `AbortSignal` for the invocation.
391
340
 
392
- Active pretty prompts redraw in place rather than appending a block for every key. A completed
393
- prompt collapses to one result line. If a logger event arrives while a prompt is active, the
394
- printer removes the prompt, renders the event, and then redraws the current prompt state.
395
- Physical rows are calculated from explicit newlines, ANSI-free Unicode display width, and
396
- `stdout.columns`, including terminal wrapping. When a custom output writer does not expose a
397
- positive `columns` value, the renderer conservatively counts explicit lines only.
398
-
399
- ## External commands
341
+ ### External commands
400
342
 
401
343
  Every `run` context has an `exec` function that starts an executable with an argument array. It
402
344
  never uses a shell, so spaces, quotes, `$`, `;`, `|`, and `*` in arguments reach the child
403
345
  unchanged. By default it resolves with the captured output and rejects with `HackshExecError`
404
346
  when the command fails. Pass `reject: false` to receive failures as results instead. Prefer this
405
- context API to `spawnSync` or a custom subprocess wrapper inside a command handler so execution
406
- uses the invocation’s environment, cancellation, and printer.
347
+ context API to `node:child_process`, `spawnSync`, or a custom subprocess wrapper inside a command
348
+ handler so execution uses the invocation’s environment, cancellation, and printer.
407
349
 
408
350
  ```ts
351
+ import { HackshExecError } from "hacksh";
352
+
409
353
  const check = defineCommand({
410
354
  name: "check-head",
411
355
  async run({ exec, logger }) {
@@ -419,7 +363,7 @@ const check = defineCommand({
419
363
  return;
420
364
  }
421
365
  // Missing executables, cancellation, and other failures must still fail the command.
422
- throw new Error("Could not inspect HEAD", { cause: head });
366
+ throw new HackshExecError("Could not inspect HEAD", head);
423
367
  }
424
368
  logger.info("HEAD is {commit}", { commit: head.stdout });
425
369
  },
@@ -434,7 +378,7 @@ UTF-8 `stdout` and `stderr` with one final newline removed. A failure's `reason`
434
378
  gives `ExecSuccess`, while `false` or a `boolean` variable gives `ExecResult`, which must be
435
379
  narrowed before reading `exitCode`.
436
380
 
437
- ### Working with captured output
381
+ #### Working with captured output
438
382
 
439
383
  Use `stdout` directly for a single value such as a commit ID or repository path. Do not call
440
384
  `.trim()` on paths: surrounding whitespace can be part of the name. `exec` already removes one
@@ -446,7 +390,7 @@ that format and parse it with `JSON.parse` (and a Zod schema when validation is
446
390
  helper. Splitting that format on newlines would break paths containing newlines. hacksh does not
447
391
  parse Git records or infer whether stdout is text, JSON, or a list.
448
392
 
449
- ### Execution options
393
+ #### Execution options
450
394
 
451
395
  Options are `cwd`, `env`, `input`, `reject`, `timeout` (milliseconds), `signal` (an
452
396
  `AbortSignal`), `maxBuffer` (bytes per stream, default 100,000,000), `forceKillAfterDelay`
@@ -517,36 +461,200 @@ reads the whole environment to start a process.
517
461
  To test a command without starting processes, call its `run` with a context whose `exec` is your own
518
462
  function of type `Exec`.
519
463
 
464
+ ### Command output
465
+
466
+ Every `run` context has an `output` for the data the command produces, kept apart from progress
467
+ and diagnostics on `logger`. `print(text)` writes text exactly as given, `println(text)` adds a
468
+ newline, and `json(value)` writes a JSON value.
469
+
470
+ ```ts
471
+ const report = defineCommand({
472
+ name: "report",
473
+ async run({ logger, output }) {
474
+ logger.step("Collecting commits");
475
+ output.print(renderTable(rows)); // already ends with a newline
476
+ output.println(`${rows.length} rows`);
477
+ output.json({ rows });
478
+ },
479
+ });
480
+ ```
481
+
482
+ In pretty output, text goes to stdout unchanged and `json` values are indented with two spaces.
483
+ In JSON output, each call writes one line on stdout: `{"type":"output","kind":"text","text":...}`
484
+ holding the exact text, including the newline `println` adds, or
485
+ `{"type":"output","kind":"json","data":...}`. Concatenating the `text` values reproduces the
486
+ pretty stdout, except that terminal control sequences are removed as in every JSON string. `json`
487
+ values are normalized like log properties.
488
+
489
+ hacksh never prints a `run` return value; it is returned to `runCommand` callers only.
490
+ `runMain` reports thrown `HackshError` instances on stderr and rethrows other exceptions.
491
+ Output that was written before a failure stays written. Like logger calls,
492
+ `output` calls do not need to be awaited and are drained before execution finishes. Writing to
493
+ `process.stdout` or `console.log` directly bypasses the printer, the output mode, and any `stdout`
494
+ writer given to `runMain` or `runCommand`.
495
+
496
+ ### Structured logging
497
+
498
+ Every `run` context has a `logger`. Its `trace`, `debug`, `info`, `warn`, `error`, and `fatal`
499
+ methods emit structured records through the invocation's printer. `step` and `success` are
500
+ info-level records with a distinct semantic kind for presentation. Placeholders in braces use the
501
+ record's properties. Every level is emitted and displayed by the default printer; there is no
502
+ built-in severity threshold or verbose flag.
503
+
504
+ ```ts
505
+ const deploy = defineCommand({
506
+ name: "deploy",
507
+ async run({ logger }) {
508
+ const log = logger.with({ app: "web", env: "prod" });
509
+ log.step("Deploying {app} to {env}");
510
+ log.getChild("upload").debug("Uploaded {files} files", { files: 12 });
511
+ log.success("Deployed {app}");
512
+ },
513
+ });
514
+ ```
515
+
516
+ Properties passed to an individual call override properties bound by `with`. `getChild` appends a
517
+ category segment; hacksh automatically starts the category with the named root and each resolved
518
+ subcommand. Missing placeholders, and placeholders whose values cannot be converted to text,
519
+ remain unchanged without discarding their properties. Passing an `Error` to `error` or `fatal`
520
+ uses its message and adds a canonical, schema-free error summary as `properties.error`. Error
521
+ values nested in other log properties are summarized the same way before custom printers receive
522
+ the event.
523
+
524
+ Each `log` print event contains a `record` with `level`, `kind`, `category`, interpolated `message`,
525
+ template `rawMessage`, merged `properties`, and a numeric `timestamp`. Logger calls do not need to
526
+ be awaited: they use the invocation's serialized output queue, which is drained before execution
527
+ finishes. A printer failure rejects the invocation without changing a successful `run` value.
528
+
529
+ ### Interactive input
530
+
531
+ Every `run` context has a `prompt` with `select`, `confirm`, and `text` methods. Interactive input
532
+ is separate from `logger`: prompts share the invocation's selected printer, serialized output queue,
533
+ terminal lock, output mode, and abort signal.
534
+
535
+ ```ts
536
+ const deploy = defineCommand({
537
+ name: "deploy",
538
+ async run({ logger, prompt }) {
539
+ const environment = await prompt.select({
540
+ message: "Environment",
541
+ options: [
542
+ { value: "staging", hint: "safe default" },
543
+ { value: "production", label: "Production" },
544
+ ] as const,
545
+ initialValue: "staging",
546
+ }); // "staging" | "production"
547
+ const confirmed = await prompt.confirm({ message: `Deploy to ${environment}?` });
548
+ if (!confirmed) return;
549
+ const release = await prompt.text({
550
+ message: "Release name",
551
+ placeholder: "v1.2.3",
552
+ validate: (value) => (value.length === 0 ? "A release name is required" : undefined),
553
+ });
554
+ logger.success("Deploying {release}", { release });
555
+ },
556
+ });
557
+ ```
558
+
559
+ Select values retain their literal union type. Arrow keys change the selected choice, Enter accepts
560
+ it, and `initialValue` controls the initial selection. Confirm accepts `y`, `n`, or Enter; Enter
561
+ uses `initialValue` when supplied and otherwise means no. Text displays its placeholder without
562
+ using it as input, supports editing, and repeats the prompt only when `validate` returns a nonempty
563
+ message; `""` means valid. Backspace removes one Unicode code point. Input pasted after a selection
564
+ or confirmation is retained for the next sequential prompt instead of being discarded.
565
+ The built-in prompt API intentionally stops at select, confirm, and text; it does not provide a
566
+ multiselect prompt.
567
+
568
+ Prompts are allowed only when the resolved mode is interactive: pretty output outside CI with TTY
569
+ stdin and stdout. JSON, CI, and non-TTY invocations throw `HackshPromptError` immediately even if a
570
+ default is available. Its `reason` and `prompt` fields identify why input failed and the prompt's
571
+ `kind`, `message`, and select `choices`. Reasons distinguish `non-interactive`, `cancelled`,
572
+ `aborted`, `eof`, `overlapping`, `invalid-options`, `input-error`, and `printer-error`. Ctrl-C and
573
+ Escape cancel; `runMain` reports cancellation and exits with status 130. A prompt that starts or is
574
+ waiting after the invocation's `signal` aborts fails with `aborted` and the signal's reason as its
575
+ `cause`. Only one prompt may be active in an invocation. Input listeners and raw terminal mode are
576
+ restored after every outcome.
577
+ Failed prompts emit a non-visible `closed` prompt state through the same printer before rejecting,
578
+ so callers that catch cancellation, EOF, input, or printer errors do not leave stale terminal UI.
579
+
580
+ Active pretty prompts redraw in place rather than appending a block for every key. A completed
581
+ prompt collapses to one result line. If a logger or output event arrives while a prompt is active, the
582
+ printer removes the prompt, renders the event, and then redraws the current prompt state.
583
+ Physical rows are calculated from explicit newlines, ANSI-free Unicode display width, and
584
+ `stdout.columns`, including terminal wrapping. When a custom output writer does not expose a
585
+ positive `columns` value, the renderer conservatively counts explicit lines only.
586
+
587
+ ### Cancellation and exit status
588
+
589
+ Every `run` context has a `signal`, an `AbortSignal` for that invocation. `exec` calls and prompts
590
+ already follow it; pass it to `fetch` and other APIs that accept one. Cancellation is cooperative:
591
+ hacksh does not stop code that ignores the signal.
592
+
593
+ ```ts
594
+ import { defineCommand, HackshExitError, runCommand } from "hacksh";
595
+
596
+ const check = defineCommand({
597
+ name: "check",
598
+ async run({ signal }) {
599
+ const response = await fetch("https://example.com/health", { signal });
600
+ if (!response.ok) throw new HackshExitError(2, `Health check returned ${response.status}`);
601
+ },
602
+ });
603
+
604
+ const controller = new AbortController();
605
+ await runCommand(check, [], { signal: controller.signal });
606
+ ```
607
+
608
+ `runCommand` accepts a `signal` option and never listens for process signals or exits. If that
609
+ signal is already aborted, `runCommand` rejects with its reason without calling `run`; aborting it
610
+ later aborts the context's `signal`.
611
+
612
+ `runMain` listens for `SIGINT` and `SIGTERM` while it runs and removes those listeners before it
613
+ returns or exits. The first signal aborts the invocation with an `AbortError` named after the
614
+ signal. When `run` returns, throws a `HackshError`, or rejects with that exact signal reason,
615
+ output is drained and the process exits with 130 for `SIGINT` or 143 for `SIGTERM`. Unrelated
616
+ exceptions and output failures still reject instead of being converted to a signal exit.
617
+ A second signal while the invocation is pending exits immediately with that signal's status.
618
+
619
+ Throw `HackshExitError(exitCode, message?)` to choose the exit status, an integer from 0 to 255.
620
+ `runMain` prints a non-empty message as an ordinary CLI error and prints nothing when the message
621
+ is omitted; `onError` still receives the error. `runCommand` rethrows it unchanged.
622
+ Other `HackshError` instances exit with 1, or 130 for a cancelled prompt.
623
+
520
624
  ## Output formats
521
625
 
522
626
  `runMain` and `runCommand` resolve one output mode before command routing, parsing, or execution.
523
627
  Their `output` option accepts `"auto"` (the default), `"pretty"`, or `"json"`. A valid
524
628
  `HACKSH_OUTPUT` environment value takes precedence over that option. `auto` selects JSON only when
525
629
  `CI` has a non-empty value; redirecting or piping stdout alone retains pretty output. Invalid
526
- explicit option or environment values never execute the command. `runCommand` rejects with a
527
- `TypeError`. `runMain` gives that error to `onError` when configured; otherwise it emits the error
528
- through a supplied printer or as a dedicated JSON error line, drains output, and exits with status
529
- code `1`. Output values are case-sensitive.
630
+ explicit option or environment values never execute the command. Both entry points reject with a
631
+ `TypeError`; `runMain` does not call `onError`, print a fallback error event, or force an exit.
632
+ Output values are case-sensitive.
530
633
 
531
634
  ```ts
532
635
  await runMain(main, { output: "auto" });
533
636
  // HACKSH_OUTPUT=json my-cli --help overrides the configured preference.
534
637
  ```
535
638
 
639
+ stdout carries the command's results: `output` events, help, and version. Every log level, errors,
640
+ and `exec` events go to stderr, so a pipe such as `my-cli | jq` receives only results while logs
641
+ remain visible on the terminal. Direct writes such as `console.log` bypass the printer and are not
642
+ converted to JSON.
643
+
536
644
  The resolved `OutputMode` has `format`, `interactive`, and `color` fields. Interaction is enabled
537
- only for pretty output outside CI when both stdin and stdout are TTYs. Color is enabled only for
538
- pretty output to a stdout TTY when `NO_COLOR` is empty or absent. These decisions are independent: an
539
- explicit pretty format in CI can use color, but remains noninteractive. `env`, `stdin`, `stdout`,
645
+ only for pretty output outside CI when both stdin and stdout are TTYs. `color` has a `stdout` and a
646
+ `stderr` flag; each is enabled only for pretty output when that stream is a TTY and `NO_COLOR` is
647
+ empty or absent, so logs stay colored on the terminal when stdout is redirected. These decisions
648
+ are independent: an explicit pretty format in CI can use color, but remains noninteractive. `env`,
649
+ `stdin`, `stdout`,
540
650
  and `stderr` can be supplied in execution options for embedded or tested invocations.
541
651
  Custom `stdin` values implement the readable-stream operations `on`, `removeListener`, `resume`,
542
652
  and `pause`; `isTTY`, `isRaw`, `readableFlowing`, and `setRawMode` describe optional terminal
543
653
  capabilities. A stream without those readable operations fails with an `input-error` if prompting
544
654
  is attempted.
545
655
 
546
- JSON output contains exactly one `PrintEvent` object per line. Help and version events and
547
- trace/debug/info logs go to stdout; errors, warning/error/fatal logs, and `exec` events go to
548
- stderr. Usage attached
549
- to an error retains its `stderr` stream. Every log level is emitted by default. JSON strings have
656
+ JSON output contains exactly one `PrintEvent` object per line, on the streams described above.
657
+ Usage attached to an error retains its `stderr` stream. Every log level is emitted by default. JSON strings have
550
658
  terminal control sequences removed, and the printer never serializes executable command or Zod
551
659
  schema objects.
552
660
 
@@ -575,16 +683,117 @@ block. All six log levels are displayed by default.
575
683
  ```
576
684
 
577
685
  Pretty output uses Unicode symbols by default. Set `unicode: false` on `runMain` or `runCommand` for
578
- ASCII marks and bars. Color is still resolved from the selected output mode: it requires pretty
579
- output and a stdout TTY, and any non-empty `NO_COLOR` disables it. Unicode selection does not affect
686
+ ASCII marks and bars. Color is still resolved from the selected output mode: each event is colored
687
+ only when its stream is a TTY, and any non-empty `NO_COLOR` disables it. Unicode selection does not affect
580
688
  JSON output. An invocation does not add automatic start or end messages; the renderer presents only
581
689
  the semantic events that the command emits.
582
690
 
583
- ## Programmatic execution and errors
691
+ ## Programmatic API and errors
692
+
693
+ ### Entry points
694
+
695
+ `defineCommand` preserves the handler's exact return type. Handlers are ordinary functions that
696
+ return values or `void`, synchronously or asynchronously. `runCommand(command, argv, options)`
697
+ returns a promise of the awaited result, unioned with results from every possible routed
698
+ descendant and `undefined` for help/version. Routing-only commands add no handler result.
699
+ Thrown exceptions, argument errors, and output failures reject the promise. `runCommand` never
700
+ exits or automatically prints a return value or error.
701
+
702
+ `runMain(command)` reads `process.argv`, discards return values, and only handles thrown
703
+ `HackshError` instances as normal CLI failures. Other exceptions reject without automatic error
704
+ display, `onError`, or process exit. Pending output is drained and signal listeners are removed
705
+ on both success and rejection. Output failures also reject, including while reporting a CLI
706
+ error; they are not hidden by a forced exit. See
707
+ [Cancellation and exit status](#cancellation-and-exit-status) for exit codes.
708
+ Use `argv` to inject arguments. Omitting it uses `process.argv.slice(2)`; an empty array is an
709
+ explicit empty invocation. `runMain` returns `Promise<void>`. A reusable entry point is an ordinary
710
+ local function:
711
+
712
+ ```ts
713
+ import { runMain } from "hacksh";
714
+
715
+ const mainEntry = (argv: string[] = ["--verbose"]) => runMain(main, { argv });
716
+ await mainEntry(); // uses the local default
717
+ await mainEntry([]); // explicitly supplies no arguments
718
+ ```
719
+
720
+ Help and the usage appended to validation or routing errors use the invocation's printer.
721
+ Usage events target stdout for help and stderr for errors. Pass `onError` to replace the entire
722
+ default display of a `HackshError`, including usage. It receives the original error instance;
723
+ output written by this application callback remains its own responsibility. If the callback
724
+ throws or rejects, `runMain` drains output, removes its listeners, and rejects without exiting:
725
+
726
+ ```ts
727
+ await runMain(main, {
728
+ argv: ["--help"],
729
+ async onError(error) {
730
+ console.error(`gh-pr-commits: ${error.message}`);
731
+ },
732
+ });
733
+ ```
734
+
735
+ ### Composable run functions
584
736
 
585
- `runCommand(command, argv, options)` handles help and version output without exiting, throws errors
586
- to its caller, and returns the selected `run` handler's result. Pass a `printer` in `options` to
587
- receive all output produced by that invocation as structured events:
737
+ Use ordinary functions and `await`; no handler factory or result envelope is required.
738
+ Throw a `HackshError` subclass for an expected CLI failure. Catch only the specific failures
739
+ the caller can recover from and rethrow everything else:
740
+
741
+ ```ts
742
+ import { z } from "zod";
743
+ import { defineCommand, HackshError, type CommandContext } from "hacksh";
744
+
745
+ class AppError extends HackshError {}
746
+
747
+ class NotFoundError extends AppError {
748
+ readonly id: string;
749
+
750
+ constructor(id: string) {
751
+ super(`User ${id} was not found`);
752
+ this.id = id;
753
+ }
754
+ }
755
+
756
+ const users = new Map([["alice", { id: "alice", displayName: "Alice" }]]);
757
+
758
+ function loadUser(id: string) {
759
+ const user = users.get(id);
760
+ if (!user) throw new NotFoundError(id);
761
+ return user;
762
+ }
763
+
764
+ const schema = z.object({ id: z.string() });
765
+
766
+ function run({ args }: CommandContext<typeof schema>) {
767
+ try {
768
+ return loadUser(args.id);
769
+ } catch (error) {
770
+ if (error instanceof NotFoundError) {
771
+ return { id: error.id, displayName: "Guest" };
772
+ }
773
+ throw error;
774
+ }
775
+ }
776
+
777
+ const command = defineCommand({ args: schema, run });
778
+ ```
779
+
780
+ The context's `args` is the validated, transformed schema output, not raw argv. Domain functions
781
+ such as `loadUser` need not depend on `CommandContext`. Omit the recovery branch when an expected
782
+ failure should reach `runMain` and be displayed there.
783
+
784
+ `HackshError(message?, options?)` supports the standard `ErrorOptions.cause` and uses the concrete
785
+ subclass name as `error.name`. Application subclasses can add their own fields. A plain `Error`,
786
+ `TypeError`, or non-error thrown value is not classified as an expected CLI failure. Do not
787
+ blanket-wrap unknown exceptions in `HackshError`: doing so would hide that distinction.
788
+
789
+ ### Printers and events
790
+
791
+ The root `hacksh` entry exposes command-definition and execution APIs. Import printer factories,
792
+ output-mode helpers, `Printer`, `PrintEvent`, and renderer-facing event and summary types from
793
+ `hacksh/printer`. A custom printer can narrow `PrintEvent` on `event.type` without importing
794
+ each individual event type.
795
+
796
+ Pass a `printer` in `options` to receive all output produced by an invocation as structured events:
588
797
 
589
798
  ```ts
590
799
  import { runCommand } from "hacksh";
@@ -601,27 +810,24 @@ await runCommand(main, ["--help"], { printer });
601
810
  // events[0] is { type: "usage", usage, text, stream: "stdout" }
602
811
  ```
603
812
 
604
- The current events are `usage`, `version`, `error`, `log`, `prompt`, and `exec`. Usage events include the resolved
813
+ The current events are `usage`, `version`, `error`, `log`, `prompt`, `output`, and `exec`. Usage events include the resolved
605
814
  command's schema-free name, version, description, synopsis, arguments, options, and visible child
606
815
  commands, plus rendered text and the intended `stdout` or `stderr` stream. Version events include
607
816
  the version string. Error events contain a schema-free summary with an explicit `kind`, structured
608
817
  validation issues, standard `Error` fields, custom enumerable properties, and recursively
609
818
  canonical causes; log events contain the structured record described above. Prompt events contain
610
- the prompt kind, status, current value or choices, placeholder, and validation message. Printer calls are serialized, may be asynchronous, and are
819
+ the prompt kind, status, current value or choices, placeholder, and validation message. Output
820
+ events carry `kind: "text"` with `text` or `kind: "json"` with `data`. Printer calls are serialized, may be asynchronous, and are
611
821
  drained before `runCommand` or `runMain` completes or exits. A printer rejection rejects
612
822
  `runCommand`.
613
823
 
614
- The root `hacksh` entry exposes command-definition and execution APIs. Import printer factories,
615
- output-mode helpers, `Printer`, `PrintEvent`, and renderer-facing event and summary types from
616
- `hacksh/printer`. A custom printer can narrow `PrintEvent` on `event.type` without importing
617
- each individual event type.
618
-
619
824
  Use `createPrettyPrinter({ color, unicode, stdout, stderr })` to reuse the built-in human-readable
620
- renderer with custom writable targets, or `createJsonPrinter({ stdout, stderr })` to reuse the
825
+ renderer with custom writable targets; `color` is one boolean or a `{ stdout, stderr }` pair, or `createJsonPrinter({ stdout, stderr })` to reuse the
621
826
  built-in JSON-lines behavior. The pretty renderer has no public theme API; replacing the fixed
622
- presentation requires a custom `Printer`. `summarizeUsage(command)` returns the same canonical help
623
- metadata carried by a usage event. `resolveOutputMode(...)` exposes the selection contract for
624
- wrappers and presentation or prompting layers.
827
+ presentation requires a custom `Printer`. `resolveOutputMode(...)` exposes the selection contract
828
+ for wrappers and presentation or prompting layers.
829
+
830
+ ### Standalone parsing and help
625
831
 
626
832
  For parsing and usage rendering without execution, use the composition APIs. `parseArgs`
627
833
  parses exactly the supplied command definition and argument segment, returns its schema output only,
@@ -630,7 +836,8 @@ refinements and transformations. It does not route subcommands, act on help or v
630
836
  bypass validation; callers that resolve commands themselves must pass the selected command and
631
837
  its segment. `renderUsage` returns a string synchronously with no output side effects. `showUsage`
632
838
  requires an explicit `printer`, sends the same text and structured help summary as a usage event,
633
- and awaits the printer. Its optional `stream` defaults to `"stdout"`.
839
+ and awaits the printer. Its optional `stream` defaults to `"stdout"`. `summarizeUsage(command)`
840
+ returns the same canonical help metadata carried by a usage event.
634
841
 
635
842
  ```ts
636
843
  import { parseArgs, renderUsage, showUsage } from "hacksh";
@@ -647,83 +854,18 @@ await showUsage(main, {
647
854
  });
648
855
  ```
649
856
 
650
- The `run` context includes `command`, the actual resolved command definition. An unnamed subcommand
651
- is represented by a completed copy whose `name` is its key; the original definition is not mutated.
652
- `args` contains validated schema output. `input` separates the original selected command segment
653
- (`argv`), accepted unknown tokens (`unknown`), and tokens after `--` (`passthrough`).
654
- For a routed child, `input.argv` excludes the parent options and subcommand names.
655
-
656
- `defineCommand` preserves the handler's return type. `runCommand` returns a promise of the awaited
657
- handler result, unioned with results from every possible routed descendant and `undefined` for
658
- help/version. Routing-only commands add no handler result. Async and void handlers retain their
659
- result types; `runMain` deliberately discards results.
660
-
661
- `runMain(command)` reads `process.argv`, prints validation or usage errors, and exits with status 1.
662
- Use `argv` to inject arguments. Omitting it uses `process.argv.slice(2)`; an empty array is an
663
- explicit empty invocation. `runMain` returns `Promise<void>`. A reusable entry point is an ordinary
664
- local function:
665
-
666
- ```ts
667
- import { runMain } from "hacksh";
668
-
669
- const mainEntry = (argv: string[] = ["--verbose"]) => runMain(main, { argv });
670
- await mainEntry(); // uses the local default
671
- await mainEntry([]); // explicitly supplies no arguments
672
- ```
673
-
674
- Help and the usage appended to validation or routing errors use the invocation's printer.
675
- Usage events target stdout for help and stderr for errors. Pass `onError` to replace the entire
676
- default error display, including usage; output written by this application callback remains its
677
- own responsibility:
678
-
679
- ```ts
680
- await runMain(main, {
681
- argv: ["--help"],
682
- async onError(error) {
683
- console.error(`gh-pr-commits: ${error instanceof Error ? error.message : String(error)}`);
684
- },
685
- });
686
- ```
687
-
688
- Usage failures are `HackshUsageError` instances. Argument failures are
689
- `HackshValidationError` instances whose `issues` identify CLI names such as `--repo` or `<PR>`.
690
- Prompt failures are `HackshPromptError` instances with structured `reason` and `prompt` fields.
691
-
692
- ## Cancellation and exit status
693
-
694
- Every `run` context has a `signal`, an `AbortSignal` for that invocation. `exec` calls and prompts
695
- already follow it; pass it to `fetch` and other APIs that accept one. Cancellation is cooperative:
696
- hacksh does not stop code that ignores the signal.
857
+ ### Errors
697
858
 
698
- ```ts
699
- import { defineCommand, HackshExitError, runCommand } from "hacksh";
700
-
701
- const check = defineCommand({
702
- name: "check",
703
- async run({ signal }) {
704
- const response = await fetch("https://example.com/health", { signal });
705
- if (!response.ok) throw new HackshExitError(2, `Health check returned ${response.status}`);
706
- },
707
- });
708
-
709
- const controller = new AbortController();
710
- await runCommand(check, [], { signal: controller.signal });
711
- ```
712
-
713
- `runCommand` accepts a `signal` option and never listens for process signals or exits. If that
714
- signal is already aborted, `runCommand` rejects with its reason without calling `run`; aborting it
715
- later aborts the context's `signal`.
716
-
717
- `runMain` listens for `SIGINT` and `SIGTERM` while it runs and removes those listeners before it
718
- returns or exits. The first signal aborts the invocation with an `AbortError` named after the
719
- signal; once `run` settles, output is drained and the process exits with 130 for `SIGINT` or 143 for
720
- `SIGTERM`, even if `run` threw. A second signal while `run` is still pending exits immediately with
721
- that signal's status.
722
-
723
- Throw `HackshExitError(exitCode, message?)` to choose the exit status, an integer from 0 to 255.
724
- `runMain` prints a non-empty message as an ordinary error and prints nothing when the message is
725
- omitted; `onError` still receives the error. `runCommand` rethrows it, so library callers read
726
- `exitCode` from the caught error. Other failures exit with 1, or 130 for a cancelled prompt.
859
+ - `HackshError` — base class for expected CLI failures. Extend it for application-specific errors;
860
+ all error classes below inherit from it.
861
+ - `HackshUsageError` — routing and usage failures (such as an unknown subcommand).
862
+ - `HackshValidationError` — argument and option validation failures, whose `issues` identify CLI
863
+ names such as `--repo` or `<PR>` and rejected unknown tokens in `issues[].arg`.
864
+ - `HackshPromptError` — interactive prompt failures, with structured `reason` and `prompt` fields.
865
+ - `HackshExecError` — external command failures when `reject` is not `false`, with the full failure
866
+ in `error.result`.
867
+ - `HackshExitError` — thrown by a command handler to exit with a specific status code (`0`–`255`)
868
+ and optional message.
727
869
 
728
870
  ## Development
729
871
 
@@ -742,3 +884,15 @@ under that version, or supply an absolute executable path explicitly:
742
884
  ```sh
743
885
  HACKSH_MINIMUM_NODE=/absolute/path/to/node-v22.18.0 pnpm test:packed
744
886
  ```
887
+
888
+ ## Name and acknowledgments
889
+
890
+ The name comes from 拍手 (_hakushu_), the Japanese word for "clap", as a nod to
891
+ [clap](https://github.com/clap-rs/clap), the command-line argument parser for Rust. It is spelled
892
+ with _hack_ and _sh_, as in shell.
893
+
894
+ hacksh draws heavily on [Citty](https://github.com/unjs/citty) for its command API design and
895
+ [Clack](https://github.com/bombshell-dev/clack) for its interactive prompts and terminal output.
896
+ Many thanks to both projects and their contributors. hacksh uses Zod schemas for argument validation
897
+ and type inference, including tuples for positional arguments; it does not guarantee API or behavior
898
+ compatibility with Citty or Clack.