hacksh 0.14.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,61 +142,11 @@ 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
- Metadata does not change schema keys, parsed output, or validation error paths. `z.enum` choices
148
+ Importing hacksh types `alias`, `env`, and `valueName` on Zod's global metadata; any other key stays
149
+ allowed. Metadata does not change schema keys, parsed output, or validation error paths. `z.enum` choices
172
150
  and descriptions appear in generated help, including inside supported schema wrappers and tuple items.
173
151
 
174
152
  ```ts
@@ -212,7 +190,8 @@ OPTIONS
212
190
  -v, -V, --[no-]verbose Show details (Default: false)
213
191
  ```
214
192
 
215
- - `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`.
216
195
  - `valueName` sets the value label of a named option.
217
196
  - `env` overrides an option's automatic environment variable name, or disables it with `false`.
218
197
  - `title` labels a positional argument; `description` supplies its help text.
@@ -221,19 +200,9 @@ Place metadata on the outer schema to override metadata inherited through wrappe
221
200
  `.describe(text)` remains equivalent to description metadata; new code should use `.meta({ description: text })`.
222
201
  With `zod/mini`, use `.check(mini.meta({ ... }))`.
223
202
 
224
- Boolean flags are shown as `--[no-]name` because they also accept the `--no-` form. The
225
- `negatable` field of `UsageOptionSummary` carries the same information for machine-readable
226
- help, where `flags` keeps the literal `--name` spelling.
227
-
228
203
  `defineCommand` validates metadata types and throws a `TypeError` for empty or multi-character
229
204
  aliases, duplicate aliases, and option-only metadata (`alias`, `env`, `valueName`) on positional
230
- schemas. Without an alias of the same letter, `-h` and `-v` are shorthands
231
- for `--help` and `--version`. Defining an `h` or `v` alias replaces the shorthand, so `convert -v`
232
- above sets `verbose`; `--help` and `--version` remain available. Both `help` and `version` option
233
- names are reserved and throw a `TypeError` from `defineCommand` on every command.
234
- Output selection does not reserve
235
- any additional command-line flags, so names such as `--json`, `--output`, and `--log-format` remain
236
- available to application schemas.
205
+ schemas.
237
206
 
238
207
  ### Environment variables
239
208
 
@@ -271,6 +240,53 @@ MY_CLI_DEPLOY_TARGET=prod MY_CLI_DEPLOY_DRY_RUN=true GITHUB_TOKEN=t my-cli deplo
271
240
  - Two options that resolve to the same name on one route, such as a root `deployTarget` and a
272
241
  `deploy` subcommand's `target`, make the invocation throw a `TypeError`.
273
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
+
274
290
  ## Subcommands
275
291
 
276
292
  ```ts
@@ -304,105 +320,30 @@ listing, including usage shown for an unknown command. Hidden commands remain av
304
320
  their own `--help` output remains available. If every subcommand is hidden, help keeps the generic
305
321
  `<COMMAND>` syntax but omits the empty `COMMANDS` section.
306
322
 
307
- ## Structured logging
308
-
309
- Every `run` context has a `logger`. Its `trace`, `debug`, `info`, `warn`, `error`, and `fatal`
310
- methods emit structured records through the invocation's printer. `step` and `success` are
311
- info-level records with a distinct semantic kind for presentation. Placeholders in braces use the
312
- record's properties. Every level is emitted and displayed by the default printer; there is no
313
- built-in severity threshold or verbose flag.
314
-
315
- ```ts
316
- const deploy = defineCommand({
317
- name: "deploy",
318
- async run({ logger }) {
319
- const log = logger.with({ app: "web", env: "prod" });
320
- log.step("Deploying {app} to {env}");
321
- log.getChild("upload").debug("Uploaded {files} files", { files: 12 });
322
- log.success("Deployed {app}");
323
- },
324
- });
325
- ```
326
-
327
- Properties passed to an individual call override properties bound by `with`. `getChild` appends a
328
- category segment; hacksh automatically starts the category with the named root and each resolved
329
- subcommand. Missing placeholders, and placeholders whose values cannot be converted to text,
330
- remain unchanged without discarding their properties. Passing an `Error` to `error` or `fatal`
331
- uses its message and adds a canonical, schema-free error summary as `properties.error`. Error
332
- values nested in other log properties are summarized the same way before custom printers receive
333
- the event.
334
-
335
- Each `log` print event contains a `record` with `level`, `kind`, `category`, interpolated `message`,
336
- template `rawMessage`, merged `properties`, and a numeric `timestamp`. Logger calls do not need to
337
- be awaited: they use the invocation's serialized output queue, which is drained before execution
338
- finishes. A printer failure rejects the invocation without changing a successful `run` value.
323
+ ## The run context
339
324
 
340
- ## Interactive input
325
+ Every `run` handler receives a `CommandContext` with:
341
326
 
342
- Every `run` context has a `prompt` with `select`, `confirm`, and `text` methods. Interactive input
343
- is separate from `logger`: prompts share the invocation's selected printer, serialized output queue,
344
- 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.
345
338
 
346
- ```ts
347
- const deploy = defineCommand({
348
- name: "deploy",
349
- async run({ logger, prompt }) {
350
- const environment = await prompt.select({
351
- message: "Environment",
352
- options: [
353
- { value: "staging", hint: "safe default" },
354
- { value: "production", label: "Production" },
355
- ] as const,
356
- initialValue: "staging",
357
- }); // "staging" | "production"
358
- const confirmed = await prompt.confirm({ message: `Deploy to ${environment}?` });
359
- if (!confirmed) return;
360
- const release = await prompt.text({
361
- message: "Release name",
362
- placeholder: "v1.2.3",
363
- validate: (value) => (value.length === 0 ? "A release name is required" : undefined),
364
- });
365
- logger.success("Deploying {release}", { release });
366
- },
367
- });
368
- ```
369
-
370
- Select values retain their literal union type. Arrow keys change the selected choice, Enter accepts
371
- it, and `initialValue` controls the initial selection. Confirm accepts `y`, `n`, or Enter; Enter
372
- uses `initialValue` when supplied and otherwise means no. Text displays its placeholder without
373
- using it as input, supports editing, and repeats the prompt only when `validate` returns a nonempty
374
- message; `""` means valid. Backspace removes one Unicode code point. Input pasted after a selection
375
- or confirmation is retained for the next sequential prompt instead of being discarded.
376
- The built-in prompt API intentionally stops at select, confirm, and text; it does not provide a
377
- multiselect prompt.
378
-
379
- Prompts are allowed only when the resolved mode is interactive: pretty output outside CI with TTY
380
- stdin and stdout. JSON, CI, and non-TTY invocations throw `HackshPromptError` immediately even if a
381
- default is available. Its `reason` and `prompt` fields identify why input failed and the prompt's
382
- `kind`, `message`, and select `choices`. Reasons distinguish `non-interactive`, `cancelled`,
383
- `aborted`, `eof`, `overlapping`, `invalid-options`, `input-error`, and `printer-error`. Ctrl-C and
384
- Escape cancel; `runMain` reports cancellation and exits with status 130. A prompt that starts or is
385
- waiting after the invocation's `signal` aborts fails with `aborted` and the signal's reason as its
386
- `cause`. Only one prompt may be active in an invocation. Input listeners and raw terminal mode are
387
- restored after every outcome.
388
- Failed prompts emit a non-visible `closed` prompt state through the same printer before rejecting,
389
- so callers that catch cancellation, EOF, input, or printer errors do not leave stale terminal UI.
390
-
391
- Active pretty prompts redraw in place rather than appending a block for every key. A completed
392
- prompt collapses to one result line. If a logger event arrives while a prompt is active, the
393
- printer removes the prompt, renders the event, and then redraws the current prompt state.
394
- Physical rows are calculated from explicit newlines, ANSI-free Unicode display width, and
395
- `stdout.columns`, including terminal wrapping. When a custom output writer does not expose a
396
- positive `columns` value, the renderer conservatively counts explicit lines only.
397
-
398
- ## External commands
339
+ ### External commands
399
340
 
400
341
  Every `run` context has an `exec` function that starts an executable with an argument array. It
401
342
  never uses a shell, so spaces, quotes, `$`, `;`, `|`, and `*` in arguments reach the child
402
343
  unchanged. By default it resolves with the captured output and rejects with `HackshExecError`
403
344
  when the command fails. Pass `reject: false` to receive failures as results instead. Prefer this
404
- context API to `spawnSync` or a custom subprocess wrapper inside a command handler so execution
405
- 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.
406
347
 
407
348
  ```ts
408
349
  const check = defineCommand({
@@ -433,7 +374,7 @@ UTF-8 `stdout` and `stderr` with one final newline removed. A failure's `reason`
433
374
  gives `ExecSuccess`, while `false` or a `boolean` variable gives `ExecResult`, which must be
434
375
  narrowed before reading `exitCode`.
435
376
 
436
- ### Working with captured output
377
+ #### Working with captured output
437
378
 
438
379
  Use `stdout` directly for a single value such as a commit ID or repository path. Do not call
439
380
  `.trim()` on paths: surrounding whitespace can be part of the name. `exec` already removes one
@@ -445,7 +386,7 @@ that format and parse it with `JSON.parse` (and a Zod schema when validation is
445
386
  helper. Splitting that format on newlines would break paths containing newlines. hacksh does not
446
387
  parse Git records or infer whether stdout is text, JSON, or a list.
447
388
 
448
- ### Execution options
389
+ #### Execution options
449
390
 
450
391
  Options are `cwd`, `env`, `input`, `reject`, `timeout` (milliseconds), `signal` (an
451
392
  `AbortSignal`), `maxBuffer` (bytes per stream, default 100,000,000), `forceKillAfterDelay`
@@ -516,6 +457,164 @@ reads the whole environment to start a process.
516
457
  To test a command without starting processes, call its `run` with a context whose `exec` is your own
517
458
  function of type `Exec`.
518
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
+
519
618
  ## Output formats
520
619
 
521
620
  `runMain` and `runCommand` resolve one output mode before command routing, parsing, or execution.
@@ -532,20 +631,25 @@ await runMain(main, { output: "auto" });
532
631
  // HACKSH_OUTPUT=json my-cli --help overrides the configured preference.
533
632
  ```
534
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
+
535
639
  The resolved `OutputMode` has `format`, `interactive`, and `color` fields. Interaction is enabled
536
- only for pretty output outside CI when both stdin and stdout are TTYs. Color is enabled only for
537
- pretty output to a stdout TTY when `NO_COLOR` is empty or absent. These decisions are independent: an
538
- 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`,
539
645
  and `stderr` can be supplied in execution options for embedded or tested invocations.
540
646
  Custom `stdin` values implement the readable-stream operations `on`, `removeListener`, `resume`,
541
647
  and `pause`; `isTTY`, `isRaw`, `readableFlowing`, and `setRawMode` describe optional terminal
542
648
  capabilities. A stream without those readable operations fails with an `input-error` if prompting
543
649
  is attempted.
544
650
 
545
- JSON output contains exactly one `PrintEvent` object per line. Help and version events and
546
- trace/debug/info logs go to stdout; errors, warning/error/fatal logs, and `exec` events go to
547
- stderr. Usage attached
548
- 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
549
653
  terminal control sequences removed, and the printer never serializes executable command or Zod
550
654
  schema objects.
551
655
 
@@ -574,16 +678,56 @@ block. All six log levels are displayed by default.
574
678
  ```
575
679
 
576
680
  Pretty output uses Unicode symbols by default. Set `unicode: false` on `runMain` or `runCommand` for
577
- ASCII marks and bars. Color is still resolved from the selected output mode: it requires pretty
578
- 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
579
683
  JSON output. An invocation does not add automatic start or end messages; the renderer presents only
580
684
  the semantic events that the command emits.
581
685
 
582
- ## 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.
583
729
 
584
- `runCommand(command, argv, options)` handles help and version output without exiting, throws errors
585
- to its caller, and returns the selected `run` handler's result. Pass a `printer` in `options` to
586
- 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:
587
731
 
588
732
  ```ts
589
733
  import { runCommand } from "hacksh";
@@ -600,27 +744,24 @@ await runCommand(main, ["--help"], { printer });
600
744
  // events[0] is { type: "usage", usage, text, stream: "stdout" }
601
745
  ```
602
746
 
603
- 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
604
748
  command's schema-free name, version, description, synopsis, arguments, options, and visible child
605
749
  commands, plus rendered text and the intended `stdout` or `stderr` stream. Version events include
606
750
  the version string. Error events contain a schema-free summary with an explicit `kind`, structured
607
751
  validation issues, standard `Error` fields, custom enumerable properties, and recursively
608
752
  canonical causes; log events contain the structured record described above. Prompt events contain
609
- 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
610
755
  drained before `runCommand` or `runMain` completes or exits. A printer rejection rejects
611
756
  `runCommand`.
612
757
 
613
- The root `hacksh` entry exposes command-definition and execution APIs. Import printer factories,
614
- output-mode helpers, `Printer`, `PrintEvent`, and renderer-facing event and summary types from
615
- `hacksh/printer`. A custom printer can narrow `PrintEvent` on `event.type` without importing
616
- each individual event type.
617
-
618
758
  Use `createPrettyPrinter({ color, unicode, stdout, stderr })` to reuse the built-in human-readable
619
- 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
620
760
  built-in JSON-lines behavior. The pretty renderer has no public theme API; replacing the fixed
621
- presentation requires a custom `Printer`. `summarizeUsage(command)` returns the same canonical help
622
- metadata carried by a usage event. `resolveOutputMode(...)` exposes the selection contract for
623
- 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
624
765
 
625
766
  For parsing and usage rendering without execution, use the composition APIs. `parseArgs`
626
767
  parses exactly the supplied command definition and argument segment, returns its schema output only,
@@ -629,7 +770,8 @@ refinements and transformations. It does not route subcommands, act on help or v
629
770
  bypass validation; callers that resolve commands themselves must pass the selected command and
630
771
  its segment. `renderUsage` returns a string synchronously with no output side effects. `showUsage`
631
772
  requires an explicit `printer`, sends the same text and structured help summary as a usage event,
632
- 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.
633
775
 
634
776
  ```ts
635
777
  import { parseArgs, renderUsage, showUsage } from "hacksh";
@@ -646,83 +788,16 @@ await showUsage(main, {
646
788
  });
647
789
  ```
648
790
 
649
- The `run` context includes `command`, the actual resolved command definition. An unnamed subcommand
650
- is represented by a completed copy whose `name` is its key; the original definition is not mutated.
651
- `args` contains validated schema output. `input` separates the original selected command segment
652
- (`argv`), accepted unknown tokens (`unknown`), and tokens after `--` (`passthrough`).
653
- For a routed child, `input.argv` excludes the parent options and subcommand names.
654
-
655
- `defineCommand` preserves the handler's return type. `runCommand` returns a promise of the awaited
656
- handler result, unioned with results from every possible routed descendant and `undefined` for
657
- help/version. Routing-only commands add no handler result. Async and void handlers retain their
658
- result types; `runMain` deliberately discards results.
659
-
660
- `runMain(command)` reads `process.argv`, prints validation or usage errors, and exits with status 1.
661
- Use `argv` to inject arguments. Omitting it uses `process.argv.slice(2)`; an empty array is an
662
- explicit empty invocation. `runMain` returns `Promise<void>`. A reusable entry point is an ordinary
663
- local function:
664
-
665
- ```ts
666
- import { runMain } from "hacksh";
667
-
668
- const mainEntry = (argv: string[] = ["--verbose"]) => runMain(main, { argv });
669
- await mainEntry(); // uses the local default
670
- await mainEntry([]); // explicitly supplies no arguments
671
- ```
791
+ ### Errors
672
792
 
673
- Help and the usage appended to validation or routing errors use the invocation's printer.
674
- Usage events target stdout for help and stderr for errors. Pass `onError` to replace the entire
675
- default error display, including usage; output written by this application callback remains its
676
- own responsibility:
677
-
678
- ```ts
679
- await runMain(main, {
680
- argv: ["--help"],
681
- async onError(error) {
682
- console.error(`gh-pr-commits: ${error instanceof Error ? error.message : String(error)}`);
683
- },
684
- });
685
- ```
686
-
687
- Usage failures are `HackshUsageError` instances. Argument failures are
688
- `HackshValidationError` instances whose `issues` identify CLI names such as `--repo` or `<PR>`.
689
- Prompt failures are `HackshPromptError` instances with structured `reason` and `prompt` fields.
690
-
691
- ## Cancellation and exit status
692
-
693
- Every `run` context has a `signal`, an `AbortSignal` for that invocation. `exec` calls and prompts
694
- already follow it; pass it to `fetch` and other APIs that accept one. Cancellation is cooperative:
695
- hacksh does not stop code that ignores the signal.
696
-
697
- ```ts
698
- import { defineCommand, HackshExitError, runCommand } from "hacksh";
699
-
700
- const check = defineCommand({
701
- name: "check",
702
- async run({ signal }) {
703
- const response = await fetch("https://example.com/health", { signal });
704
- if (!response.ok) throw new HackshExitError(2, `Health check returned ${response.status}`);
705
- },
706
- });
707
-
708
- const controller = new AbortController();
709
- await runCommand(check, [], { signal: controller.signal });
710
- ```
711
-
712
- `runCommand` accepts a `signal` option and never listens for process signals or exits. If that
713
- signal is already aborted, `runCommand` rejects with its reason without calling `run`; aborting it
714
- later aborts the context's `signal`.
715
-
716
- `runMain` listens for `SIGINT` and `SIGTERM` while it runs and removes those listeners before it
717
- returns or exits. The first signal aborts the invocation with an `AbortError` named after the
718
- signal; once `run` settles, output is drained and the process exits with 130 for `SIGINT` or 143 for
719
- `SIGTERM`, even if `run` threw. A second signal while `run` is still pending exits immediately with
720
- that signal's status.
721
-
722
- Throw `HackshExitError(exitCode, message?)` to choose the exit status, an integer from 0 to 255.
723
- `runMain` prints a non-empty message as an ordinary error and prints nothing when the message is
724
- omitted; `onError` still receives the error. `runCommand` rethrows it, so library callers read
725
- `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.
726
801
 
727
802
  ## Development
728
803
 
@@ -741,3 +816,15 @@ under that version, or supply an absolute executable path explicitly:
741
816
  ```sh
742
817
  HACKSH_MINIMUM_NODE=/absolute/path/to/node-v22.18.0 pnpm test:packed
743
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.