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