hacksh 0.12.0 → 0.14.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,7 +2,7 @@
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`, and use its `logger` for progress and interactive prompts.
5
+ with the context’s `exec`, use its `logger` for progress, and request interactive input with `prompt`.
6
6
 
7
7
  The name comes from 拍手 (_hakushu_), the Japanese word for "clap", as a nod to
8
8
  [clap](https://github.com/clap-rs/clap), the command-line argument parser for Rust. It is spelled
@@ -35,12 +35,13 @@ const main = defineCommand({
35
35
  version: "0.1.0",
36
36
  description: "Show a commit summary",
37
37
  args: z.object({
38
- _: z.tuple([z.string().default("HEAD").describe("Commit to inspect").meta({ title: "REF" })]),
39
- repo: z.string().optional().describe("Repository directory"),
38
+ positionals: z.tuple([
39
+ z.string().default("HEAD").meta({ title: "REF", description: "Commit to inspect" }),
40
+ ]),
41
+ repo: z.string().optional().meta({ description: "Repository directory", alias: "C" }),
40
42
  }),
41
- argOptions: { repo: { alias: "C" } },
42
43
  async run({ args, exec }) {
43
- const [ref] = args._;
44
+ const [ref] = args.positionals;
44
45
  const { stdout } = await exec(
45
46
  "git",
46
47
  ["show", "--no-patch", "--format=%h %s", "--end-of-options", ref],
@@ -85,27 +86,27 @@ Boolean flags also accept the explicit forms `--verbose=true` and
85
86
 
86
87
  ## Positional arguments
87
88
 
88
- Declare positional arguments as a `z.tuple` in the `_` field. Tuple items are assigned in order,
89
+ Declare positional arguments as a `z.tuple` in the `positionals` field. Tuple items are assigned in order,
89
90
  and the tuple rest collects all remaining values. Optional or defaulted items must come after
90
- required ones, and items cannot be booleans or arrays. The `_` field is never a named option.
91
+ required ones, and items cannot be booleans or arrays. The `positionals` field is never a named option.
91
92
 
92
93
  ```ts
93
94
  const copy = defineCommand({
94
95
  name: "copy",
95
96
  args: z.object({
96
- _: z.tuple(
97
- [z.string().describe("Output directory").meta({ title: "DESTINATION" })],
98
- z.string().describe("Input files").meta({ title: "FILES" }),
97
+ positionals: z.tuple(
98
+ [z.string().meta({ title: "DESTINATION", description: "Output directory" })],
99
+ z.string().meta({ title: "FILES", description: "Input files" }),
99
100
  ),
100
101
  }),
101
102
  run({ args }) {
102
- const [destination, ...files] = args._; // [string, ...string[]]
103
+ const [destination, ...files] = args.positionals; // [string, ...string[]]
103
104
  },
104
105
  });
105
106
  ```
106
107
 
107
108
  ```sh
108
- copy out a.txt b.txt # { _: ["out", "a.txt", "b.txt"] }
109
+ copy out a.txt b.txt # { positionals: ["out", "a.txt", "b.txt"] }
109
110
  ```
110
111
 
111
112
  Help and validation errors label each item with its `.meta({ title })`, such as `<DESTINATION>` and
@@ -117,17 +118,17 @@ additional positional arguments are rejected by default, as are unknown options.
117
118
 
118
119
  A command rejects unknown options and excess positional arguments before its `run` handler or
119
120
  schema transforms execute. For ordinary commands, declare the accepted arguments in `args`;
120
- there is no need to inspect `extraArgs` manually. Errors are `HackshValidationError` instances,
121
+ there is no need to inspect `input.unknown` manually. Errors are `HackshValidationError` instances,
121
122
  with each rejected token in `issues[].arg`.
122
123
 
123
- Everything after `--` is always collected unchanged in `passthrough`, independently of this policy.
124
+ Everything after `--` is always collected unchanged in `input.passthrough`, independently of this policy.
124
125
  Use that explicit boundary when forwarding arguments to another executable:
125
126
 
126
127
  ```ts
127
128
  const forward = defineCommand({
128
129
  name: "git-forward",
129
- async run({ exec, passthrough }) {
130
- const { stdout } = await exec("git", passthrough);
130
+ async run({ exec, input }) {
131
+ const { stdout } = await exec("git", input.passthrough);
131
132
  console.log(stdout);
132
133
  },
133
134
  });
@@ -139,17 +140,17 @@ For a wrapper that also accepts undeclared arguments before `--`, set `allowUnkn
139
140
  ```ts
140
141
  const wrapper = defineCommand({
141
142
  allowUnknownArgs: true,
142
- async run({ extraArgs, passthrough, exec }) {
143
- await exec("some-tool", [...extraArgs, ...passthrough]);
143
+ async run({ input, exec }) {
144
+ await exec("some-tool", [...input.unknown, ...input.passthrough]);
144
145
  },
145
146
  });
146
147
  ```
147
148
 
148
- `extraArgs` preserves the order of unknown option tokens and excess positional values. Unknown
149
+ `input.unknown` preserves the order of unknown option tokens and excess positional values. Unknown
149
150
  options do not consume a following value: if the schema has an unfilled positional slot, that
150
151
  value fills it. Known options are still parsed and validated. Use `--` when a child's arguments
151
- must not be interpreted by this command. A handler that does not use `passthrough` ignores those
152
- arguments; commands that require rejecting them can check `passthrough.length` explicitly.
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.
153
154
 
154
155
  `allowUnknownArgs` defaults to `false`, inherits down the selected subcommand route, and can be
155
156
  overridden by each child. A child's setting does not change validation of its parent's arguments.
@@ -161,37 +162,39 @@ including unknown arguments. Routing errors such as an unknown subcommand still
161
162
  undeclared `--version` is an unknown argument, since the version builtin belongs to the root.
162
163
 
163
164
  **Migrating from 0.11.0:** unknown input was previously accepted by default. Add
164
- `allowUnknownArgs: true` to commands that intentionally use `extraArgs`, or to their shared parent.
165
+ `allowUnknownArgs: true` to commands that intentionally use `input.unknown`, or to their shared parent.
165
166
  No migration is needed for `--` forwarding.
166
167
 
167
- ## Argument options
168
+ ## Argument metadata
168
169
 
169
- Use `argOptions` to set short aliases and help labels for each named option without changing
170
- schema keys, parsed output, or validation error paths. `z.enum` choices and schema descriptions
171
- appear in generated help, including enums inside the supported schema wrappers and tuple items.
170
+ 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
172
+ and descriptions appear in generated help, including inside supported schema wrappers and tuple items.
172
173
 
173
174
  ```ts
174
175
  const convert = defineCommand({
175
176
  name: "convert",
176
177
  args: z.object({
177
- _: z.tuple(
178
- [z.enum(["small", "large"]).describe("Item size").meta({ title: "SIZE" })],
179
- z.string().describe("Input files").meta({ title: "INPUT" }),
178
+ positionals: z.tuple(
179
+ [z.enum(["small", "large"]).meta({ title: "SIZE", description: "Item size" })],
180
+ z.string().meta({ title: "INPUT", description: "Input files" }),
180
181
  ),
181
- format: z.enum(["json", "yaml"]).optional().describe("Output format"),
182
- overwrite: z.boolean().default(true).describe("Replace existing files"),
183
- verbose: z.boolean().default(false).describe("Show details"),
182
+ format: z
183
+ .enum(["json", "yaml"])
184
+ .optional()
185
+ .meta({ description: "Output format", alias: "f", valueName: "FORMAT" }),
186
+ overwrite: z.boolean().default(true).meta({ description: "Replace existing files" }),
187
+ verbose: z
188
+ .boolean()
189
+ .default(false)
190
+ .meta({ description: "Show details", alias: ["v", "V"] }),
184
191
  }),
185
- argOptions: {
186
- format: { alias: "f", valueName: "FORMAT" },
187
- verbose: { alias: ["v", "V"] },
188
- },
189
192
  });
190
193
  ```
191
194
 
192
195
  ```sh
193
196
  convert large -f yaml -v a.txt b.txt
194
- # { _: ["large", "a.txt", "b.txt"], format: "yaml", overwrite: true, verbose: true }
197
+ # { positionals: ["large", "a.txt", "b.txt"], format: "yaml", overwrite: true, verbose: true }
195
198
  ```
196
199
 
197
200
  ```text
@@ -211,18 +214,24 @@ OPTIONS
211
214
 
212
215
  - `alias` gives a named option one or more single-character short forms.
213
216
  - `valueName` sets the value label of a named option.
217
+ - `env` overrides an option's automatic environment variable name, or disables it with `false`.
218
+ - `title` labels a positional argument; `description` supplies its help text.
219
+
220
+ Place metadata on the outer schema to override metadata inherited through wrappers. Zod's native
221
+ `.describe(text)` remains equivalent to description metadata; new code should use `.meta({ description: text })`.
222
+ With `zod/mini`, use `.check(mini.meta({ ... }))`.
214
223
 
215
224
  Boolean flags are shown as `--[no-]name` because they also accept the `--no-` form. The
216
225
  `negatable` field of `UsageOptionSummary` carries the same information for machine-readable
217
226
  help, where `flags` keeps the literal `--name` spelling.
218
227
 
219
- `argOptions` keys are checked against the argument schema. `defineCommand` throws a `TypeError`
220
- when an alias is longer than one character or is used by more than one option, and when
221
- `argOptions` has a `_` key. Without an alias of the same letter, `-h` and `-v` are shorthands
228
+ `defineCommand` validates metadata types and throws a `TypeError` for empty or multi-character
229
+ 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
222
231
  for `--help` and `--version`. Defining an `h` or `v` alias replaces the shorthand, so `convert -v`
223
- above sets `verbose`; `--help` and `--version` remain available. A `help` option throws a
224
- `TypeError` from `defineCommand`. A `version` option on the root command throws a `TypeError` when
225
- `runMain` or `runCommand` starts; subcommands may define one. Output selection does not reserve
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
226
235
  any additional command-line flags, so names such as `--json`, `--output`, and `--log-format` remain
227
236
  available to application schemas.
228
237
 
@@ -234,8 +243,11 @@ upper snake case.
234
243
 
235
244
  ```ts
236
245
  const deploy = defineCommand({
237
- args: z.object({ target: z.string(), dryRun: z.boolean().default(false), token: z.string() }),
238
- argOptions: { token: { env: "GITHUB_TOKEN" } },
246
+ args: z.object({
247
+ target: z.string(),
248
+ dryRun: z.boolean().default(false),
249
+ token: z.string().meta({ env: "GITHUB_TOKEN" }),
250
+ }),
239
251
  run: ({ args }) => args,
240
252
  });
241
253
  const main = defineCommand({ name: "my-cli", subCommands: { deploy } });
@@ -254,7 +266,7 @@ MY_CLI_DEPLOY_TARGET=prod MY_CLI_DEPLOY_DRY_RUN=true GITHUB_TOKEN=t my-cli deplo
254
266
  positional arguments, and commands under an unnamed root get no derived name.
255
267
  - Help shows the name as `(Env: MY_CLI_DEPLOY_TARGET)`, and `UsageOptionSummary.env` carries it.
256
268
  Errors name the variable but never include its value.
257
- - `runCommand`, `runMain`, and `createMain` read the `env` option, defaulting to `process.env`.
269
+ - `runCommand` and `runMain` read the `env` option, defaulting to `process.env`.
258
270
  `parseArgs(command, argv, { env })` reads the environment only when `env` is given.
259
271
  - Two options that resolve to the same name on one route, such as a root `deployTarget` and a
260
272
  `deploy` subcommand's `target`, make the invocation throw a `TypeError`.
@@ -327,15 +339,15 @@ finishes. A printer failure rejects the invocation without changing a successful
327
339
 
328
340
  ## Interactive input
329
341
 
330
- The logger also owns the invocation's interactive input. Use `select`, `confirm`, and `text` from
331
- the same logger (including loggers returned by `with` and `getChild`) so prompts use the selected
332
- printer and output mode.
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.
333
345
 
334
346
  ```ts
335
347
  const deploy = defineCommand({
336
348
  name: "deploy",
337
- async run({ logger }) {
338
- const environment = await logger.select({
349
+ async run({ logger, prompt }) {
350
+ const environment = await prompt.select({
339
351
  message: "Environment",
340
352
  options: [
341
353
  { value: "staging", hint: "safe default" },
@@ -343,9 +355,9 @@ const deploy = defineCommand({
343
355
  ] as const,
344
356
  initialValue: "staging",
345
357
  }); // "staging" | "production"
346
- const confirmed = await logger.confirm({ message: `Deploy to ${environment}?` });
358
+ const confirmed = await prompt.confirm({ message: `Deploy to ${environment}?` });
347
359
  if (!confirmed) return;
348
- const release = await logger.text({
360
+ const release = await prompt.text({
349
361
  message: "Release name",
350
362
  placeholder: "v1.2.3",
351
363
  validate: (value) => (value.length === 0 ? "A release name is required" : undefined),
@@ -436,9 +448,9 @@ parse Git records or infer whether stdout is text, JSON, or a list.
436
448
  ### Execution options
437
449
 
438
450
  Options are `cwd`, `env`, `input`, `reject`, `timeout` (milliseconds), `signal` (an
439
- `AbortSignal`), `maxBuffer` (bytes per stream, default 100,000,000), and `forceKillAfterDelay`
440
- (milliseconds, default 5,000). Every call also stops with `aborted` when the invocation's `signal`
441
- aborts. `input` is written to stdin and closed; otherwise stdin is empty.
451
+ `AbortSignal`), `maxBuffer` (bytes per stream, default 100,000,000), `forceKillAfterDelay`
452
+ (milliseconds, default 5,000), and `dryRun`. Every call also stops with `aborted` when the
453
+ invocation's `signal` aborts. `input` is written to stdin and closed; otherwise stdin is empty.
442
454
  Output beyond `maxBuffer` is truncated to that many bytes. On timeout, abort, or output limit,
443
455
  hacksh sends `SIGTERM` to the child it started and `SIGKILL` after `forceKillAfterDelay`; it does
444
456
  not stop grandchildren, and it does not wait for them to close inherited pipes. Invalid arguments
@@ -458,6 +470,18 @@ JSON event has `phase: "start"`, `command` (`file`, `args`, and `cwd` when set),
458
470
  in the event, the result's `command`, the `HackshExecError` message, and a `spawn` failure's
459
471
  `error`. The child still receives the real values, and its captured output is returned unchanged.
460
472
 
473
+ `dryRun: true` does not start the child and does not write `input`. The result is
474
+ `{ status: "skipped", command }` with the redacted command and no `stdout`, `stderr`, or `exitCode`.
475
+ The `exec` event uses `phase: "skip"` and the same command, stdio, and `interactive` flag.
476
+ Pretty output marks that line with `#` instead of `$`. Omit `dryRun`, or pass `false`, to keep
477
+ today's success or failure result. A boolean `dryRun` returns the union of a skip and the executed
478
+ result, so narrow `status` before reading output. `reject` does not apply to a skip. An invocation
479
+ signal that is already aborted still fails as `aborted` before any skip event, and `reject` applies
480
+ to that failure. Option checks still run, including `input` together with `stdin: "inherit"`.
481
+ JSON mode, a non-interactive environment, and a busy terminal still show the plan and return
482
+ a skip. `display: false` hides the skip event. `dryRun` is not read from CLI flags or
483
+ `context.args`.
484
+
461
485
  ```ts
462
486
  await exec("git", ["worktree", "add", path, branch], { cwd: repository });
463
487
  await exec("gh", ["auth", "login", "--with-token"], { input: token, display: false });
@@ -562,7 +586,8 @@ to its caller, and returns the selected `run` handler's result. Pass a `printer`
562
586
  receive all output produced by that invocation as structured events:
563
587
 
564
588
  ```ts
565
- import { runCommand, type PrintEvent, type Printer } from "hacksh";
589
+ import { runCommand } from "hacksh";
590
+ import type { PrintEvent, Printer } from "hacksh/printer";
566
591
 
567
592
  const events: PrintEvent[] = [];
568
593
  const printer: Printer = {
@@ -575,7 +600,7 @@ await runCommand(main, ["--help"], { printer });
575
600
  // events[0] is { type: "usage", usage, text, stream: "stdout" }
576
601
  ```
577
602
 
578
- The current events are `usage`, `version`, `error`, `log`, and `prompt`. Usage events include the resolved
603
+ The current events are `usage`, `version`, `error`, `log`, `prompt`, and `exec`. Usage events include the resolved
579
604
  command's schema-free name, version, description, synopsis, arguments, options, and visible child
580
605
  commands, plus rendered text and the intended `stdout` or `stderr` stream. Version events include
581
606
  the version string. Error events contain a schema-free summary with an explicit `kind`, structured
@@ -585,68 +610,74 @@ the prompt kind, status, current value or choices, placeholder, and validation m
585
610
  drained before `runCommand` or `runMain` completes or exits. A printer rejection rejects
586
611
  `runCommand`.
587
612
 
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
+
588
618
  Use `createPrettyPrinter({ color, unicode, stdout, stderr })` to reuse the built-in human-readable
589
619
  renderer with custom writable targets, or `createJsonPrinter({ stdout, stderr })` to reuse the
590
620
  built-in JSON-lines behavior. The pretty renderer has no public theme API; replacing the fixed
591
- presentation requires a custom `Printer`. `consolePrinter` and `createStreamPrinter` remain the
592
- undecorated compatibility adapters. `summarizeUsage(command)` returns the same canonical help
621
+ presentation requires a custom `Printer`. `summarizeUsage(command)` returns the same canonical help
593
622
  metadata carried by a usage event. `resolveOutputMode(...)` exposes the selection contract for
594
- wrappers and the later presentation or prompting layers.
623
+ wrappers and presentation or prompting layers.
595
624
 
596
- For parsing and usage rendering without execution, use the async composition APIs. `parseArgs`
625
+ For parsing and usage rendering without execution, use the composition APIs. `parseArgs`
597
626
  parses exactly the supplied command definition and argument segment, returns its schema output only,
598
- and throws `HackshValidationError` on failure. It does not route subcommands, act on help or version
599
- tokens, or bypass validation; callers that resolve commands themselves must pass the selected
600
- command and its segment. `renderUsage` has no output side effects; `showUsage` sends that same text
601
- through an explicit printer or the same automatic pretty/JSON selection used by command
602
- invocations. Its `output`, `env`, `stdin`, `stdout`, `stderr`, and `unicode` options configure that
603
- automatic path. The existing synchronous or asynchronous `write` option remains supported and
604
- takes precedence over printer selection; an explicit `printer` also retains its existing behavior.
605
- These functions are async so future command-resolution features can be added without changing
606
- their return types.
627
+ and throws `HackshValidationError` on failure. It remains asynchronous to support async Zod
628
+ refinements and transformations. It does not route subcommands, act on help or version tokens, or
629
+ bypass validation; callers that resolve commands themselves must pass the selected command and
630
+ its segment. `renderUsage` returns a string synchronously with no output side effects. `showUsage`
631
+ 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"`.
607
633
 
608
634
  ```ts
609
- import { consolePrinter, parseArgs, renderUsage, showUsage } from "hacksh";
635
+ import { parseArgs, renderUsage, showUsage } from "hacksh";
636
+ import { createPrettyPrinter } from "hacksh/printer";
610
637
 
611
638
  const args = await parseArgs(main, ["42", "--repo", "owner/repo"]);
612
639
  // args is inferred from the schema as:
613
- // { pr?: number; repo?: string; verbose: boolean }
640
+ // { positionals: [string]; repo?: string }
614
641
 
615
- const text: string = await renderUsage(main); // does not write output
642
+ const text: string = renderUsage(main); // does not write output
616
643
  await showUsage(main, {
617
- printer: consolePrinter,
644
+ printer: createPrettyPrinter({ color: false, unicode: true }),
618
645
  stream: "stderr",
619
646
  });
620
647
  ```
621
648
 
622
- The `run` context includes `cmd`, the actual resolved command definition. An unnamed subcommand is
623
- represented by a completed copy whose `name` is its key; the original definition is not mutated.
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.
624
659
 
625
660
  `runMain(command)` reads `process.argv`, prints validation or usage errors, and exits with status 1.
626
- Use `argv` to inject arguments, or `createMain` to make a reusable entry point. Invocation arguments
627
- passed to the returned function take precedence over configured `argv`; omitting both falls back to
628
- `process.argv.slice(2)`. Both functions return `Promise<void>`.
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:
629
664
 
630
665
  ```ts
631
- import { createMain } from "hacksh";
666
+ import { runMain } from "hacksh";
632
667
 
633
- const mainEntry = createMain(main, { argv: ["--verbose"] });
634
- await mainEntry(); // uses the configured argv
635
- await mainEntry([]); // an explicit empty invocation overrides it
668
+ const mainEntry = (argv: string[] = ["--verbose"]) => runMain(main, { argv });
669
+ await mainEntry(); // uses the local default
670
+ await mainEntry([]); // explicitly supplies no arguments
636
671
  ```
637
672
 
638
- The legacy `RunMainOptions.showUsage` callback still customizes both ordinary help and the usage
639
- appended to validation or routing errors. Its supplied `write` function targets stdout for help and
640
- stderr for errors by emitting usage events through the invocation's selected printer, and the
641
- callback may be async. Pass `onError` to replace the entire default error display, including usage;
642
- output written by this application callback remains its own responsibility:
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:
643
677
 
644
678
  ```ts
645
679
  await runMain(main, {
646
680
  argv: ["--help"],
647
- async showUsage(command, { write }) {
648
- await write(await renderUsage(command));
649
- },
650
681
  async onError(error) {
651
682
  console.error(`gh-pr-commits: ${error instanceof Error ? error.message : String(error)}`);
652
683
  },