hacksh 0.13.0 → 0.15.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,40 @@ 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
+ Importing hacksh types `alias`, `env`, and `valueName` on Zod's global metadata; any other key stays
172
+ allowed. Metadata does not change schema keys, parsed output, or validation error paths. `z.enum` choices
173
+ and descriptions appear in generated help, including inside supported schema wrappers and tuple items.
172
174
 
173
175
  ```ts
174
176
  const convert = defineCommand({
175
177
  name: "convert",
176
178
  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" }),
179
+ positionals: z.tuple(
180
+ [z.enum(["small", "large"]).meta({ title: "SIZE", description: "Item size" })],
181
+ z.string().meta({ title: "INPUT", description: "Input files" }),
180
182
  ),
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"),
183
+ format: z
184
+ .enum(["json", "yaml"])
185
+ .optional()
186
+ .meta({ description: "Output format", alias: "f", valueName: "FORMAT" }),
187
+ overwrite: z.boolean().default(true).meta({ description: "Replace existing files" }),
188
+ verbose: z
189
+ .boolean()
190
+ .default(false)
191
+ .meta({ description: "Show details", alias: ["v", "V"] }),
184
192
  }),
185
- argOptions: {
186
- format: { alias: "f", valueName: "FORMAT" },
187
- verbose: { alias: ["v", "V"] },
188
- },
189
193
  });
190
194
  ```
191
195
 
192
196
  ```sh
193
197
  convert large -f yaml -v a.txt b.txt
194
- # { _: ["large", "a.txt", "b.txt"], format: "yaml", overwrite: true, verbose: true }
198
+ # { positionals: ["large", "a.txt", "b.txt"], format: "yaml", overwrite: true, verbose: true }
195
199
  ```
196
200
 
197
201
  ```text
@@ -211,18 +215,24 @@ OPTIONS
211
215
 
212
216
  - `alias` gives a named option one or more single-character short forms.
213
217
  - `valueName` sets the value label of a named option.
218
+ - `env` overrides an option's automatic environment variable name, or disables it with `false`.
219
+ - `title` labels a positional argument; `description` supplies its help text.
220
+
221
+ Place metadata on the outer schema to override metadata inherited through wrappers. Zod's native
222
+ `.describe(text)` remains equivalent to description metadata; new code should use `.meta({ description: text })`.
223
+ With `zod/mini`, use `.check(mini.meta({ ... }))`.
214
224
 
215
225
  Boolean flags are shown as `--[no-]name` because they also accept the `--no-` form. The
216
226
  `negatable` field of `UsageOptionSummary` carries the same information for machine-readable
217
227
  help, where `flags` keeps the literal `--name` spelling.
218
228
 
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
229
+ `defineCommand` validates metadata types and throws a `TypeError` for empty or multi-character
230
+ 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
222
232
  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
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
226
236
  any additional command-line flags, so names such as `--json`, `--output`, and `--log-format` remain
227
237
  available to application schemas.
228
238
 
@@ -234,8 +244,11 @@ upper snake case.
234
244
 
235
245
  ```ts
236
246
  const deploy = defineCommand({
237
- args: z.object({ target: z.string(), dryRun: z.boolean().default(false), token: z.string() }),
238
- argOptions: { token: { env: "GITHUB_TOKEN" } },
247
+ args: z.object({
248
+ target: z.string(),
249
+ dryRun: z.boolean().default(false),
250
+ token: z.string().meta({ env: "GITHUB_TOKEN" }),
251
+ }),
239
252
  run: ({ args }) => args,
240
253
  });
241
254
  const main = defineCommand({ name: "my-cli", subCommands: { deploy } });
@@ -254,7 +267,7 @@ MY_CLI_DEPLOY_TARGET=prod MY_CLI_DEPLOY_DRY_RUN=true GITHUB_TOKEN=t my-cli deplo
254
267
  positional arguments, and commands under an unnamed root get no derived name.
255
268
  - Help shows the name as `(Env: MY_CLI_DEPLOY_TARGET)`, and `UsageOptionSummary.env` carries it.
256
269
  Errors name the variable but never include its value.
257
- - `runCommand`, `runMain`, and `createMain` read the `env` option, defaulting to `process.env`.
270
+ - `runCommand` and `runMain` read the `env` option, defaulting to `process.env`.
258
271
  `parseArgs(command, argv, { env })` reads the environment only when `env` is given.
259
272
  - Two options that resolve to the same name on one route, such as a root `deployTarget` and a
260
273
  `deploy` subcommand's `target`, make the invocation throw a `TypeError`.
@@ -327,15 +340,15 @@ finishes. A printer failure rejects the invocation without changing a successful
327
340
 
328
341
  ## Interactive input
329
342
 
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.
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.
333
346
 
334
347
  ```ts
335
348
  const deploy = defineCommand({
336
349
  name: "deploy",
337
- async run({ logger }) {
338
- const environment = await logger.select({
350
+ async run({ logger, prompt }) {
351
+ const environment = await prompt.select({
339
352
  message: "Environment",
340
353
  options: [
341
354
  { value: "staging", hint: "safe default" },
@@ -343,9 +356,9 @@ const deploy = defineCommand({
343
356
  ] as const,
344
357
  initialValue: "staging",
345
358
  }); // "staging" | "production"
346
- const confirmed = await logger.confirm({ message: `Deploy to ${environment}?` });
359
+ const confirmed = await prompt.confirm({ message: `Deploy to ${environment}?` });
347
360
  if (!confirmed) return;
348
- const release = await logger.text({
361
+ const release = await prompt.text({
349
362
  message: "Release name",
350
363
  placeholder: "v1.2.3",
351
364
  validate: (value) => (value.length === 0 ? "A release name is required" : undefined),
@@ -574,7 +587,8 @@ to its caller, and returns the selected `run` handler's result. Pass a `printer`
574
587
  receive all output produced by that invocation as structured events:
575
588
 
576
589
  ```ts
577
- import { runCommand, type PrintEvent, type Printer } from "hacksh";
590
+ import { runCommand } from "hacksh";
591
+ import type { PrintEvent, Printer } from "hacksh/printer";
578
592
 
579
593
  const events: PrintEvent[] = [];
580
594
  const printer: Printer = {
@@ -587,7 +601,7 @@ await runCommand(main, ["--help"], { printer });
587
601
  // events[0] is { type: "usage", usage, text, stream: "stdout" }
588
602
  ```
589
603
 
590
- The current events are `usage`, `version`, `error`, `log`, and `prompt`. Usage events include the resolved
604
+ The current events are `usage`, `version`, `error`, `log`, `prompt`, and `exec`. Usage events include the resolved
591
605
  command's schema-free name, version, description, synopsis, arguments, options, and visible child
592
606
  commands, plus rendered text and the intended `stdout` or `stderr` stream. Version events include
593
607
  the version string. Error events contain a schema-free summary with an explicit `kind`, structured
@@ -597,68 +611,74 @@ the prompt kind, status, current value or choices, placeholder, and validation m
597
611
  drained before `runCommand` or `runMain` completes or exits. A printer rejection rejects
598
612
  `runCommand`.
599
613
 
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
+
600
619
  Use `createPrettyPrinter({ color, unicode, stdout, stderr })` to reuse the built-in human-readable
601
620
  renderer with custom writable targets, or `createJsonPrinter({ stdout, stderr })` to reuse the
602
621
  built-in JSON-lines behavior. The pretty renderer has no public theme API; replacing the fixed
603
- presentation requires a custom `Printer`. `consolePrinter` and `createStreamPrinter` remain the
604
- undecorated compatibility adapters. `summarizeUsage(command)` returns the same canonical help
622
+ presentation requires a custom `Printer`. `summarizeUsage(command)` returns the same canonical help
605
623
  metadata carried by a usage event. `resolveOutputMode(...)` exposes the selection contract for
606
- wrappers and the later presentation or prompting layers.
624
+ wrappers and presentation or prompting layers.
607
625
 
608
- For parsing and usage rendering without execution, use the async composition APIs. `parseArgs`
626
+ For parsing and usage rendering without execution, use the composition APIs. `parseArgs`
609
627
  parses exactly the supplied command definition and argument segment, returns its schema output only,
610
- and throws `HackshValidationError` on failure. It does not route subcommands, act on help or version
611
- tokens, or bypass validation; callers that resolve commands themselves must pass the selected
612
- command and its segment. `renderUsage` has no output side effects; `showUsage` sends that same text
613
- through an explicit printer or the same automatic pretty/JSON selection used by command
614
- invocations. Its `output`, `env`, `stdin`, `stdout`, `stderr`, and `unicode` options configure that
615
- automatic path. The existing synchronous or asynchronous `write` option remains supported and
616
- takes precedence over printer selection; an explicit `printer` also retains its existing behavior.
617
- These functions are async so future command-resolution features can be added without changing
618
- their return types.
628
+ and throws `HackshValidationError` on failure. It remains asynchronous to support async Zod
629
+ refinements and transformations. It does not route subcommands, act on help or version tokens, or
630
+ bypass validation; callers that resolve commands themselves must pass the selected command and
631
+ its segment. `renderUsage` returns a string synchronously with no output side effects. `showUsage`
632
+ 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"`.
619
634
 
620
635
  ```ts
621
- import { consolePrinter, parseArgs, renderUsage, showUsage } from "hacksh";
636
+ import { parseArgs, renderUsage, showUsage } from "hacksh";
637
+ import { createPrettyPrinter } from "hacksh/printer";
622
638
 
623
639
  const args = await parseArgs(main, ["42", "--repo", "owner/repo"]);
624
640
  // args is inferred from the schema as:
625
- // { pr?: number; repo?: string; verbose: boolean }
641
+ // { positionals: [string]; repo?: string }
626
642
 
627
- const text: string = await renderUsage(main); // does not write output
643
+ const text: string = renderUsage(main); // does not write output
628
644
  await showUsage(main, {
629
- printer: consolePrinter,
645
+ printer: createPrettyPrinter({ color: false, unicode: true }),
630
646
  stream: "stderr",
631
647
  });
632
648
  ```
633
649
 
634
- The `run` context includes `cmd`, the actual resolved command definition. An unnamed subcommand is
635
- represented by a completed copy whose `name` is its key; the original definition is not mutated.
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.
636
660
 
637
661
  `runMain(command)` reads `process.argv`, prints validation or usage errors, and exits with status 1.
638
- Use `argv` to inject arguments, or `createMain` to make a reusable entry point. Invocation arguments
639
- passed to the returned function take precedence over configured `argv`; omitting both falls back to
640
- `process.argv.slice(2)`. Both functions return `Promise<void>`.
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:
641
665
 
642
666
  ```ts
643
- import { createMain } from "hacksh";
667
+ import { runMain } from "hacksh";
644
668
 
645
- const mainEntry = createMain(main, { argv: ["--verbose"] });
646
- await mainEntry(); // uses the configured argv
647
- await mainEntry([]); // an explicit empty invocation overrides it
669
+ const mainEntry = (argv: string[] = ["--verbose"]) => runMain(main, { argv });
670
+ await mainEntry(); // uses the local default
671
+ await mainEntry([]); // explicitly supplies no arguments
648
672
  ```
649
673
 
650
- The legacy `RunMainOptions.showUsage` callback still customizes both ordinary help and the usage
651
- appended to validation or routing errors. Its supplied `write` function targets stdout for help and
652
- stderr for errors by emitting usage events through the invocation's selected printer, and the
653
- callback may be async. Pass `onError` to replace the entire default error display, including usage;
654
- output written by this application callback remains its own responsibility:
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:
655
678
 
656
679
  ```ts
657
680
  await runMain(main, {
658
681
  argv: ["--help"],
659
- async showUsage(command, { write }) {
660
- await write(await renderUsage(command));
661
- },
662
682
  async onError(error) {
663
683
  console.error(`gh-pr-commits: ${error instanceof Error ? error.message : String(error)}`);
664
684
  },