argsbarg 3.2.0 → 3.3.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/CHANGELOG.md CHANGED
@@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [3.3.0] - 2026-06-21
11
+
12
+
13
+ ## [3.3.0] - 2026-06-20
14
+
15
+ ### Added
16
+
17
+ - **Headless helpers** — `shouldRunHeadless`, `shouldRunHeadlessWithPositionals`, `shouldRunHeadlessWithYes`, `wantsDryRun`, `wantsExplicitJson`, `requireYesInNonTty`, `formatDryRunMessage` for Ink/MCP CLIs.
18
+ - **`ghReleaseUpdateGetLatest`** — optional `install.updateGetLatest` factory for GitHub releases via `gh`.
19
+ - **`createGhVersionCheck`** — version-check cache, update notices, and background refresh helpers.
20
+
10
21
  ## [3.2.0] - 2026-06-20
11
22
 
12
23
  ### Added
@@ -223,7 +234,9 @@ const cli = { ... } satisfies CliProgram; // or : CliProgram
223
234
  - Migrate schemas: rename every `children` property to **`commands`**; move positional definitions to **`CliPositional`** objects on `positionals` and strip `positional` / `argMin` / `argMax` from flag definitions under `options` (flags only carry `name`, `description`, `kind`, and optional `shortName`).
224
235
  - Imports: use `CliPositional` where needed; replace `CliOptionDef` with `CliOption` or `CliPositional` as appropriate.
225
236
 
226
- [Unreleased]: https://github.com/bdombro/bun-argsbarg/compare/v3.2.0...HEAD
237
+ [Unreleased]: https://github.com/bdombro/bun-argsbarg/compare/v3.3.0...HEAD
238
+ [3.3.0]: https://github.com/bdombro/bun-argsbarg/releases/tag/v3.3.0
239
+ [3.3.0]: https://github.com/bdombro/bun-argsbarg/releases/tag/v3.3.0
227
240
  [3.2.0]: https://github.com/bdombro/bun-argsbarg/releases/tag/v3.2.0
228
241
  [3.1.0]: https://github.com/bdombro/bun-argsbarg/releases/tag/v3.1.0
229
242
  [3.0.0]: https://github.com/bdombro/bun-argsbarg/releases/tag/v3.0.0
@@ -57,7 +57,7 @@ import readmeText from "../README.md" with { type: "text" };
57
57
 
58
58
  Bun embeds the file when you `bun build --compile`. ArgsBarg does not read the filesystem at runtime.
59
59
 
60
- For several topics, use a barrel file (e.g. `src/docs/topics.ts`) so `index.tsx` stays small.
60
+ Inline topics in your program root when the set is small; use a separate module only if the import map grows enough to clutter `index.tsx`.
61
61
 
62
62
  ## Schema, API, and skill (`docs schema`, `docs api`, `docs skill`)
63
63
 
package/docs/install.md CHANGED
@@ -54,6 +54,41 @@ install: {
54
54
 
55
55
  When `updateGetLatest` is set, ArgsBarg also registers the **`update`** built-in (`myapp update`).
56
56
 
57
+ ### GitHub releases (`ghReleaseUpdateGetLatest`)
58
+
59
+ For compiled binaries published via `gh release`, wire a hook without hand-rolling download logic:
60
+
61
+ ```typescript
62
+ import {
63
+ createGhFetchLatest,
64
+ createGhVersionCheck,
65
+ ghReleaseUpdateGetLatest,
66
+ } from "argsbarg";
67
+
68
+ const cachePath = path.join(configDir, "version-check.json");
69
+
70
+ install: {
71
+ updateGetLatest: ghReleaseUpdateGetLatest({
72
+ repo: "owner/repo",
73
+ asset: "myapp",
74
+ tempPrefix: "myapp-update.",
75
+ cachePath,
76
+ }),
77
+ }
78
+
79
+ // Optional: summary notice + background refresh
80
+ const versionCheck = createGhVersionCheck({
81
+ currentVersion: "1.0.0",
82
+ commandName: "myapp",
83
+ cachePath,
84
+ fetchLatest: createGhFetchLatest({ repo: "owner/repo" }),
85
+ });
86
+ versionCheck.getUpdateNotice();
87
+ versionCheck.refreshIfStale();
88
+ ```
89
+
90
+ Requires `gh` on PATH and `gh auth login`. Consumers keep app-specific config only (~15 lines).
91
+
57
92
  Environment:
58
93
 
59
94
  - `INSTALL_PREFIX` — same as `install.prefix` / `--prefix`
@@ -21,6 +21,9 @@ const cli = {
21
21
  readme: { text: "# nested.ts\n\nNested groups demo.\n" },
22
22
  },
23
23
  },
24
+ install: {
25
+ updateGetLatest: async () => ({ path: process.execPath, version: pkg.version }),
26
+ },
24
27
  commands: [
25
28
  {
26
29
  key: "stat",
package/index.d.ts CHANGED
@@ -305,5 +305,80 @@ export declare function cliRun(program: CliProgram, argv?: string[]): Promise<ne
305
305
  export declare function cliErrWithHelp(ctx: CliContext, msg: string): never;
306
306
  /** True when stdin is a TTY. */
307
307
  export declare const isInteractiveTty: boolean;
308
+ /** Minimal context for headless routing helpers. */
309
+ export type HeadlessContext = Pick<CliContext, "invocation">;
310
+ /** True when `--dry-run` was passed. */
311
+ export declare function wantsDryRun(hasDryRunFlag: boolean): boolean;
312
+ /** True when `--json` was passed or the handler was invoked via MCP. */
313
+ export declare function wantsExplicitJson(ctx: HeadlessContext, hasJsonFlag: boolean): boolean;
314
+ /**
315
+ * Headless when MCP, `--json`, `--dry-run`, or stdin is not a TTY.
316
+ * Use for commands that should auto-emit JSON in pipelines.
317
+ */
318
+ export declare function shouldRunHeadless(ctx: HeadlessContext, hasJsonFlag: boolean, hasDryRunFlag?: boolean, interactive?: boolean): boolean;
319
+ /**
320
+ * Like {@link shouldRunHeadless}, but only auto-headless in non-TTY when positionals are present.
321
+ * Avoids turning empty invocations into JSON errors.
322
+ */
323
+ export declare function shouldRunHeadlessWithPositionals(ctx: HeadlessContext, hasJsonFlag: boolean, positionals: string[], hasDryRunFlag?: boolean, interactive?: boolean): boolean;
324
+ /**
325
+ * Headless when MCP, `--dry-run` with required args, or non-TTY with `--yes` and required args.
326
+ * Use for mutating commands that require explicit `--yes` in scripts.
327
+ */
328
+ export declare function shouldRunHeadlessWithYes(ctx: HeadlessContext, opts: {
329
+ yes: boolean;
330
+ hasRequiredArgs: boolean;
331
+ dryRun?: boolean;
332
+ }, interactive?: boolean): boolean;
333
+ /**
334
+ * Exits when non-interactive mode is used without `--yes`.
335
+ * @param hint - Command-specific guidance appended to the error
336
+ */
337
+ export declare function requireYesInNonTty(yes: boolean, hint: string, dryRun?: boolean, interactive?: boolean): void;
338
+ /** Prefixes a success message when running in dry-run mode. */
339
+ export declare function formatDryRunMessage(message: string, dryRun: boolean): string;
340
+ /** Config for {@link ghReleaseUpdateGetLatest}. */
341
+ export interface GhReleaseUpdateConfig {
342
+ /** GitHub `owner/repo` slug. */
343
+ repo: string;
344
+ /** Release asset filename (e.g. `myapp`). */
345
+ asset: string;
346
+ /** Temp directory name prefix for downloads. */
347
+ tempPrefix: string;
348
+ /** Path to the on-disk version-check cache JSON file. */
349
+ cachePath: string;
350
+ /** Optional hint when `gh auth` fails or no releases exist. */
351
+ repoEnvHint?: string;
352
+ }
353
+ /** Config for {@link createGhVersionCheck}. */
354
+ export interface GhVersionCheckConfig {
355
+ /** Installed semver string. */
356
+ currentVersion: string;
357
+ /** CLI command name for update notices (e.g. `qa`). */
358
+ commandName: string;
359
+ /** Path to the on-disk version-check cache JSON file. */
360
+ cachePath: string;
361
+ /** Cache TTL in milliseconds (default 24h). */
362
+ ttlMs?: number;
363
+ /** When true, skip background refresh (e.g. test subprocess). */
364
+ skipRefresh?: () => boolean;
365
+ /** When true, skip refresh because `gh` is unavailable. */
366
+ ghAvailable?: () => boolean;
367
+ /** Fetches latest release version via `gh`. */
368
+ fetchLatest: () => Promise<string>;
369
+ }
370
+ /** Returns whether the installed version matches the latest release. */
371
+ export declare function isAlreadyCurrent(current: string, latest: string): boolean;
372
+ /** Strips a leading `v` from a release tag. */
373
+ export declare function parseReleaseTag(tag: string): string;
374
+ /** Builds a `CliUpdateGetLatest` hook that downloads a release via `gh`. */
375
+ export declare function ghReleaseUpdateGetLatest(config: GhReleaseUpdateConfig): CliUpdateGetLatest;
376
+ /** Version-check cache helpers for summary notices and background refresh. */
377
+ export declare function createGhVersionCheck(config: GhVersionCheckConfig): {
378
+ getUpdateNotice: () => string | null;
379
+ refreshIfStale: () => void;
380
+ };
381
+ /** Shared `gh release view` fetcher for hooks and version-check refresh. */
382
+ export declare function createGhFetchLatest(config: Pick<GhReleaseUpdateConfig, "repo" | "repoEnvHint">): () => Promise<string>;
308
383
 
309
384
  export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "argsbarg",
3
- "version": "3.2.0",
3
+ "version": "3.3.0",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "bun": ">=1.3"
package/plan.md CHANGED
@@ -1,194 +1,19 @@
1
1
  # bun-argsbarg Plan
2
2
 
3
- ## Plan: bun-argsbarg — Bun CLI Argument Parser
4
-
5
- **TL;DR**: Convert the Swift `argsbarg` declarative CLI framework into a TypeScript/Bun library with the same CLI features (schema-driven parsing, help rendering, shell completion, subcommand routing, fallback commands) while leveraging TypeScript's type system for compile-time schema safety.
6
-
7
3
  ## Current Status
8
4
 
9
- **Overall**: Phases 1–5 (95%) complete; Phases 6–8 in progress.
10
-
11
- ### ✅ Completed
12
-
13
- - **Phase 1**: Core Schema Types (`src/types.ts`)
14
- - `CliOptionKind` enum (Presence, String, Number)
15
- - `CliFallbackMode` enum (MissingOnly, MissingOrUnknown, UnknownOnly)
16
- - `CliCommand`, `CliOption`, `CliPositional`, `CliHandler`, `CliContext` interfaces
17
- - `CliSchemaValidationError` class
18
- - `CliOption` on `options`, `CliPositional` on `positionals`, nested routes under `commands`
19
-
20
- - **Phase 2**: Argument Parser (`src/parse.ts`)
21
- - `parse(root, argv)` with long/short options, bundling, equals syntax
22
- - Option consumption logic with strict number validation
23
- - Subcommand routing and positional argument collection
24
- - Help token detection (`-h`, `--help`)
25
- - `postParseValidate()` for strict option validation
26
- - `cliValidateRoot()` for schema validation (root rules, child uniqueness, reserved names, positional ordering)
27
- - Support for all fallback modes
28
-
29
- - **Phase 3**: Runtime Context (`src/context.ts`)
30
- - `CliContext` class with typed accessors
31
- - `flag(name)` — boolean presence check
32
- - `stringOpt(name)` — string value or undefined
33
- - `numberOpt(name)` — strict double parsing or null
34
- - `typedOpt<T>(name, parse)` — generic typed accessor (TypeScript advantage)
35
-
36
- - **Phase 4**: Help Rendering (`src/help.ts`)
37
- - `cliHelpRender()` for formatted help output
38
- - TTY-aware ANSI colors (red, aqua, gray, bold)
39
- - Unicode box drawing (╭╮├┤╰╯)
40
- - Terminal width detection (`process.stdout.columns`)
41
- - Text wrapping with visible width calculation (strips ANSI)
42
- - Help sections: usage box, options table, positionals table, subcommands table, notes box
43
- - `cliOptionLabel()` for formatted option display
44
-
45
- - **Phase 5**: Shell Completion (`src/completion.ts`)
46
- - `completionBashScript(schema)` — bash tab-completion generator
47
- - `completionZshScript(schema)` — zsh tab-completion generator
48
- - Scope walking for command tree traversal
49
- - Option/command/positional matching logic for both shells
50
-
51
- ### 🚧 In Progress
52
-
53
- - **Phase 6**: Main Entry Point (`src/index.ts`)
54
- - **Status**: Placeholder `hello()` function remains
55
- - **Needs**: Implement `cliRun(root: CliCommand): Promise<void>`
56
- - Validate schema via `cliValidateRoot()`
57
- - Auto-merge built-in `completion` command with `bash`/`zsh` subcommands
58
- - Call `parse()` on `process.argv.slice(2)`
59
- - Call `postParseValidate()`
60
- - Handle `ParseKind.Help` → render via `cliHelpRender()` → exit(0)
61
- - Handle `ParseKind.Error` → render red error + contextual help → exit(1)
62
- - Route to handler function with `CliContext`
63
- - Support async handlers with `await`
64
-
65
- - **Phase 7**: Examples & Tests (`src/index.test.ts`, `examples/`)
66
- - **Status**: Only placeholder tests exist
67
- - **Needs**:
68
- - Replace `src/index.test.ts` with comprehensive test suite covering:
69
- - Option parsing (long, short, bundled, equals syntax)
70
- - Help detection and rendering
71
- - Subcommand routing and fallback modes
72
- - Positional argument collection and arity validation
73
- - Unknown options/commands
74
- - Schema validation errors
75
- - Async handler support
76
- - Typed option accessors
77
- - Create `examples/minimal.ts` — hello with `--name` and `--verbose`
78
- - Create `examples/nested.ts` — nested subcommands with positionals (mirroring Swift examples)
79
-
80
- ### ❌ Not Started
81
-
82
- - **Phase 8**: Project Polish
83
- - Add `biome.json` for linting/formatting
84
- - Add CLI binary entry point (`bin/argsbarg`)
85
- - Create `README.md` with API docs and usage examples
86
- - Update `package.json` scripts and bin entry
87
- - Verify all verification steps pass
88
-
89
- ## Next Immediate Steps
90
-
91
- 1. Implement `cliRun()` in `src/index.ts`
92
- 2. Rewrite `src/index.test.ts` with actual test coverage
93
- 3. Create working examples in `examples/`
94
- 4. Run `bun test` and verify all tests pass
95
- 5. Run examples and verify output matches expectations
96
-
97
- **Steps**
98
-
99
- ### Phase 1: Core Schema Types (types.ts)
100
- - Define TypeScript equivalents of Swift's `CliOptionKind`, `CliOption`, `CliCommand`, `CliFallbackMode`
101
- - Leverage TypeScript generics/union types for compile-time safety (e.g., typed option values instead of string-only)
102
- - Add `CliHandler` type as `(ctx: CliContext) => void | Promise<void>` (support async handlers)
103
- - Add `CliSchemaValidationError` enum/class
104
- - **Parallel with Phase 2**
105
-
106
- ### Phase 2: Argument Parser (parse.ts)
107
- - Implement `parse(root: CliCommand, argv: string[]): ParseResult` — same logic as Swift
108
- - Long options (`--name`, `--name=value`)
109
- - Short options (`-n`, bundled `-abc`)
110
- - Subcommand routing through nested `CliCommand` tree
111
- - Positional argument collection with arity validation (argMin/argMax)
112
- - Help token detection (`-h`/`--help`)
113
- - Root-level `fallbackCommand`/`fallbackMode` support
114
- - Implement `postParseValidate()` — strict number validation, option key verification
115
- - Implement `cliValidateRoot()` — schema validation (no handler on routing nodes, no positionals on root, unique short names, reserved `-h`, etc.)
116
- - **Depends on Phase 1**
117
-
118
- ### Phase 3: Context & Runtime (context.ts)
119
- - Implement `CliContext` class with typed accessors:
120
- - `flag(name)` — boolean presence check
121
- - `stringOpt(name)` — string value
122
- - `numberOpt(name)` — strict double parse
123
- - **TypeScript enhancement**: `typedOpt<T>(name, parseFn)` — generic typed accessor
124
- - Implement `cliErrWithHelp(ctx, msg)` — red error + contextual help on stderr
125
- - **Depends on Phase 1**
126
-
127
- ### Phase 4: Help Rendering (help.ts)
128
- - Terminal-aware help with rounded box drawing (same Unicode box chars as Swift)
129
- - TTY detection (Node's `process.stdout.isTTY` instead of Swift's `isatty`)
130
- - Terminal width detection (`TIOCGWINSZ` via `ioctl` or fallback to `process.stdout.columns`)
131
- - ANSI color support (TTY-aware, same palette: red/green/aqua/gray/bold)
132
- - Help sections: Usage box, Options table, Arguments table, Subcommands table, Notes box
133
- - `cliHelpRender(schema, helpPath, useStderr)` — full help for root or nested command
134
- - **Depends on Phase 1**
135
-
136
- ### Phase 5: Shell Completion (completion.ts)
137
- - `completionBashScript(schema)` — generate bash tab-completion script
138
- - `completionZshScript(schema)` — generate zsh tab-completion script
139
- - Same scope-walking algorithm as Swift (depth-first, per-node arrays)
140
- - **Depends on Phase 1**
141
-
142
- ### Phase 6: Main Entry Point (index.ts)
143
- - `cliRun(root: CliCommand)` — orchestrates: validate → merge builtins → parse → validate → dispatch
144
- - Auto-merge `completion`/`bash`/`zsh` reserved commands
145
- - Exit code handling (0 for help/success, 1 for errors)
146
- - **Depends on Phases 2-5**
147
-
148
- ### Phase 7: Examples & Tests
149
- - `examples/minimal.ts` — hello world with options (like Swift's Minimal example)
150
- - `examples/nested.ts` — deeply nested subcommands with positionals (like Swift's Nested example)
151
- - `src/index.test.ts` — comprehensive tests mirroring Swift's ParseTests
152
- - Bundled short flags, long option equals, fallback modes
153
- - Unknown command, implicit help, invalid number validation
154
- - Completion script generation verification
155
- - Schema validation (root handler, root positionals, nested fallback, reserved names)
156
- - **TypeScript-specific**: async handler support, typed option accessors
5
+ **Overall**: Core CLI, MCP, install, docs, and update built-ins are complete. Public API is stable at **3.x**.
157
6
 
158
- ### Phase 8: Project Polish
159
- - Add `biome.json` config (lint/format)
160
- - Add `bin/` entry in `package.json` for CLI executable
161
- - Add `README.md` with API docs and usage examples
162
- - Update `package.json` scripts
163
- - **Parallel with Phase 7**
7
+ ### Shipped
164
8
 
165
- **Relevant files**
166
- - `/Users/briandombrowski/dev/bdombro/bun-argsbarg/src/index.ts` replace placeholder `hello()` with `cliRun` + schema types
167
- - `/Users/briandombrowski/dev/bdombro/bun-argsbarg/src/index.test.ts` replace with comprehensive test suite
168
- - `/Users/briandombrowski/dev/bdombro/bun-argsbarg/package.json` add bin entry, biome config, README
169
- - `/Users/briandombrowski/dev/bdombro/bun-argsbarg/tsconfig.json` — keep as-is (already correct for Bun)
170
- - `/Users/briandombrowski/dev/bdombro/bun-argsbarg/examples/local-check.ts` — replace with minimal/nested examples
9
+ - Schema-driven parsing, help, completions, subcommand routing, fallback commands
10
+ - MCP server (`mcpServer: { enabled: true }`), `ctx.invocation`, `cliInvoke`
11
+ - `install` / `update` built-ins, agent skills, bundled `docs` (topics, schema, api, skill, mcp)
12
+ - Headless helpers and `ghReleaseUpdateGetLatest` for GitHub release consumers
171
13
 
172
- **Verification**
173
- 1. `bun test` — all tests pass
174
- 2. `bun ./examples/minimal.ts hello --name World --verbose` — outputs "hello World" with verbose mode
175
- 3. `bun ./examples/minimal.ts hello --help` — shows formatted help with options table
176
- 4. `bun ./examples/nested.ts stat owner lookup --user-name bob /etc/passwd` — outputs "lookup user=bob path=/etc/passwd"
177
- 5. `bun ./examples/minimal.ts completion bash` — outputs valid bash completion script
178
- 6. `bun ./examples/minimal.ts completion zsh` — outputs valid zsh completion script
179
- 7. `bun lint` and `bun check-types` — no errors
180
- 8. `bun ./examples/minimal.ts unknown-cmd` — shows fallback command help with red error
14
+ ### Consumers
181
15
 
182
- **Decisions**
183
- - **Single-file vs multi-file**: Multi-file (types/parse/context/help/completion) to match Swift's modular structure and keep files manageable
184
- - **Typed options**: Add `typedOpt<T>(name, parse: (s: string) => T)` to leverage TypeScript's type system — this is the key TypeScript advantage over Swift's string-only approach
185
- - **Async handlers**: Support `async (ctx) => Promise<void>` handlers — Bun/Node advantage over Swift's synchronous-only
186
- - **TTY detection**: Use `process.stdout.isTTY` and `process.stdout.columns` (Node built-in) instead of `ioctl`/`isatty` FFI
187
- - **No runtime dependencies**: Keep zero runtime deps, only `@types/bun` as dev dep
188
- - **CLI binary**: Add `bin/argsbarg` entry point so the library can also be used as a CLI tool
189
- - **Schema validation**: Same rules as Swift — root can't have handler/positionals, no duplicate shorts, `-h` reserved, etc.
16
+ - **qa-cli** — argsbarg program with Ink UI; commands under `src/commands/`
17
+ - **idp-trees** argsbarg program with headless JSON ops; `cli/dispatch.ts` pattern
190
18
 
191
- **Further Considerations**
192
- 1. Should we add a `@argsbarg()` decorator pattern for declarative schema definition (like Python's argparse decorators)? This would be a TypeScript-native enhancement.
193
- 2. Should the CLI binary (`bin/argsbarg`) support loading schema from a config file (JSON/YAML) for dynamic CLIs?
194
- 3. Error exit codes: Swift uses exit(1) for help (implicit) and exit(0) for explicit help. Should we match this or use more conventional exit codes?
19
+ See [README.md](README.md) and [CHANGELOG.md](CHANGELOG.md) for release history.
@@ -0,0 +1,86 @@
1
+ import { expect, test } from "bun:test";
2
+ import {
3
+ formatDryRunMessage,
4
+ requireYesInNonTty,
5
+ shouldRunHeadless,
6
+ shouldRunHeadlessWithPositionals,
7
+ shouldRunHeadlessWithYes,
8
+ wantsDryRun,
9
+ wantsExplicitJson,
10
+ } from "./headless.ts";
11
+
12
+ test("wantsDryRun detects flag", () => {
13
+ expect(wantsDryRun(true)).toBe(true);
14
+ expect(wantsDryRun(false)).toBe(false);
15
+ });
16
+
17
+ test("wantsExplicitJson includes MCP invocation", () => {
18
+ expect(wantsExplicitJson({ invocation: "cli" }, false)).toBe(false);
19
+ expect(wantsExplicitJson({ invocation: "mcp" }, false)).toBe(true);
20
+ expect(wantsExplicitJson({ invocation: "cli" }, true)).toBe(true);
21
+ });
22
+
23
+ test("shouldRunHeadless is true for MCP and json", () => {
24
+ expect(shouldRunHeadless({ invocation: "mcp" }, false)).toBe(true);
25
+ expect(shouldRunHeadless({ invocation: "cli" }, true)).toBe(true);
26
+ expect(shouldRunHeadless({ invocation: "cli" }, false, true)).toBe(true);
27
+ expect(shouldRunHeadless({ invocation: "cli" }, false, false, false)).toBe(true);
28
+ });
29
+
30
+ test("shouldRunHeadlessWithPositionals requires positionals in non-tty", () => {
31
+ expect(
32
+ shouldRunHeadlessWithPositionals({ invocation: "cli" }, false, [], false, false),
33
+ ).toBe(false);
34
+ expect(
35
+ shouldRunHeadlessWithPositionals({ invocation: "cli" }, false, ["a"], false, false),
36
+ ).toBe(true);
37
+ });
38
+
39
+ test("shouldRunHeadlessWithYes requires yes in non-tty", () => {
40
+ expect(
41
+ shouldRunHeadlessWithYes(
42
+ { invocation: "cli" },
43
+ { yes: true, hasRequiredArgs: true },
44
+ false,
45
+ ),
46
+ ).toBe(true);
47
+ expect(
48
+ shouldRunHeadlessWithYes(
49
+ { invocation: "cli" },
50
+ { yes: false, hasRequiredArgs: true },
51
+ false,
52
+ ),
53
+ ).toBe(false);
54
+ expect(
55
+ shouldRunHeadlessWithYes(
56
+ { invocation: "cli" },
57
+ { yes: false, hasRequiredArgs: true, dryRun: true },
58
+ false,
59
+ ),
60
+ ).toBe(true);
61
+ });
62
+
63
+ test("formatDryRunMessage prefixes dry-run output", () => {
64
+ expect(formatDryRunMessage("hello", false)).toBe("hello");
65
+ expect(formatDryRunMessage("hello", true)).toBe("[DRY RUN] hello");
66
+ });
67
+
68
+ test("requireYesInNonTty exits without yes in non-tty", () => {
69
+ const originalExit = process.exit;
70
+ let code: number | undefined;
71
+ process.exit = ((c?: number) => {
72
+ code = c ?? 0;
73
+ throw new Error("exit");
74
+ }) as typeof process.exit;
75
+
76
+ try {
77
+ expect(() => {
78
+ requireYesInNonTty(false, "hint", false, false);
79
+ }).toThrow("exit");
80
+ expect(code).toBe(1);
81
+ requireYesInNonTty(false, "hint", true, false);
82
+ requireYesInNonTty(true, "hint", false, false);
83
+ } finally {
84
+ process.exit = originalExit;
85
+ }
86
+ });
@@ -0,0 +1,86 @@
1
+ import type { CliContext } from "./context.ts";
2
+ import { isInteractiveTty } from "./utils.ts";
3
+
4
+ /** Minimal context for headless routing helpers. */
5
+ export type HeadlessContext = Pick<CliContext, "invocation">;
6
+
7
+ /** True when `--dry-run` was passed. */
8
+ export function wantsDryRun(hasDryRunFlag: boolean): boolean {
9
+ return hasDryRunFlag;
10
+ }
11
+
12
+ /** True when `--json` was passed or the handler was invoked via MCP. */
13
+ export function wantsExplicitJson(ctx: HeadlessContext, hasJsonFlag: boolean): boolean {
14
+ return hasJsonFlag || ctx.invocation === "mcp";
15
+ }
16
+
17
+ /**
18
+ * Headless when MCP, `--json`, `--dry-run`, or stdin is not a TTY.
19
+ * Use for commands that should auto-emit JSON in pipelines.
20
+ */
21
+ export function shouldRunHeadless(
22
+ ctx: HeadlessContext,
23
+ hasJsonFlag: boolean,
24
+ hasDryRunFlag = false,
25
+ interactive: boolean = isInteractiveTty,
26
+ ): boolean {
27
+ if (ctx.invocation === "mcp") return true;
28
+ if (hasJsonFlag || hasDryRunFlag) return true;
29
+ return !interactive;
30
+ }
31
+
32
+ /**
33
+ * Like {@link shouldRunHeadless}, but only auto-headless in non-TTY when positionals are present.
34
+ * Avoids turning empty invocations into JSON errors.
35
+ */
36
+ export function shouldRunHeadlessWithPositionals(
37
+ ctx: HeadlessContext,
38
+ hasJsonFlag: boolean,
39
+ positionals: string[],
40
+ hasDryRunFlag = false,
41
+ interactive: boolean = isInteractiveTty,
42
+ ): boolean {
43
+ if (ctx.invocation === "mcp") return true;
44
+ if (hasJsonFlag || hasDryRunFlag) return true;
45
+ return !interactive && positionals.length > 0;
46
+ }
47
+
48
+ /**
49
+ * Headless when MCP, `--dry-run` with required args, or non-TTY with `--yes` and required args.
50
+ * Use for mutating commands that require explicit `--yes` in scripts.
51
+ */
52
+ export function shouldRunHeadlessWithYes(
53
+ ctx: HeadlessContext,
54
+ opts: { yes: boolean; hasRequiredArgs: boolean; dryRun?: boolean },
55
+ interactive: boolean = isInteractiveTty,
56
+ ): boolean {
57
+ if (ctx.invocation === "mcp") {
58
+ return opts.hasRequiredArgs && (opts.yes || Boolean(opts.dryRun));
59
+ }
60
+ if (opts.dryRun && opts.hasRequiredArgs) return true;
61
+ if (!interactive) return opts.yes && opts.hasRequiredArgs;
62
+ return opts.yes && opts.hasRequiredArgs;
63
+ }
64
+
65
+ /**
66
+ * Exits when non-interactive mode is used without `--yes`.
67
+ * @param hint - Command-specific guidance appended to the error
68
+ */
69
+ export function requireYesInNonTty(
70
+ yes: boolean,
71
+ hint: string,
72
+ dryRun = false,
73
+ interactive: boolean = isInteractiveTty,
74
+ ): void {
75
+ if (dryRun) return;
76
+ if (!interactive && !yes) {
77
+ process.stderr.write(`Error: non-interactive mode requires --yes. ${hint}\n`);
78
+ process.exit(1);
79
+ }
80
+ }
81
+
82
+ /** Prefixes a success message when running in dry-run mode. */
83
+ export function formatDryRunMessage(message: string, dryRun: boolean): string {
84
+ if (!dryRun) return message;
85
+ return `[DRY RUN] ${message}`;
86
+ }
package/src/index.ts CHANGED
@@ -28,3 +28,21 @@ export type {
28
28
  CliPositional,
29
29
  } from "./types.ts";
30
30
  export { isInteractiveTty } from "./utils.ts";
31
+ export {
32
+ formatDryRunMessage,
33
+ requireYesInNonTty,
34
+ shouldRunHeadless,
35
+ shouldRunHeadlessWithPositionals,
36
+ shouldRunHeadlessWithYes,
37
+ wantsDryRun,
38
+ wantsExplicitJson,
39
+ } from "./headless.ts";
40
+ export type { HeadlessContext } from "./headless.ts";
41
+ export {
42
+ createGhFetchLatest,
43
+ createGhVersionCheck,
44
+ ghReleaseUpdateGetLatest,
45
+ isAlreadyCurrent,
46
+ parseReleaseTag,
47
+ } from "./install/gh-release-update.ts";
48
+ export type { GhReleaseUpdateConfig, GhVersionCheckConfig } from "./install/gh-release-update.ts";
@@ -0,0 +1,22 @@
1
+ import { expect, test } from "bun:test";
2
+ import { isAlreadyCurrent, parseReleaseTag } from "./gh-release-update.ts";
3
+
4
+ test("parseReleaseTag strips leading v", () => {
5
+ expect(parseReleaseTag("v1.4.3")).toBe("1.4.3");
6
+ });
7
+
8
+ test("parseReleaseTag leaves bare semver unchanged", () => {
9
+ expect(parseReleaseTag("1.4.3")).toBe("1.4.3");
10
+ });
11
+
12
+ test("parseReleaseTag throws on empty tag", () => {
13
+ expect(() => parseReleaseTag("")).toThrow("Release tag is empty");
14
+ });
15
+
16
+ test("isAlreadyCurrent returns true when versions match", () => {
17
+ expect(isAlreadyCurrent("1.4.3", "1.4.3")).toBe(true);
18
+ });
19
+
20
+ test("isAlreadyCurrent returns false when versions differ", () => {
21
+ expect(isAlreadyCurrent("1.4.2", "1.4.3")).toBe(false);
22
+ });
@@ -0,0 +1,229 @@
1
+ import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { dirname, join } from "node:path";
4
+ import type { CliUpdateGetLatest } from "../types.ts";
5
+
6
+ /** Config for {@link ghReleaseUpdateGetLatest}. */
7
+ export interface GhReleaseUpdateConfig {
8
+ /** GitHub `owner/repo` slug. */
9
+ repo: string;
10
+ /** Release asset filename (e.g. `myapp`). */
11
+ asset: string;
12
+ /** Temp directory name prefix for downloads. */
13
+ tempPrefix: string;
14
+ /** Path to the on-disk version-check cache JSON file. */
15
+ cachePath: string;
16
+ /** Optional hint when `gh auth` fails or no releases exist. */
17
+ repoEnvHint?: string;
18
+ }
19
+
20
+ /** Config for {@link createGhVersionCheck}. */
21
+ export interface GhVersionCheckConfig {
22
+ /** Installed semver string. */
23
+ currentVersion: string;
24
+ /** CLI command name for update notices (e.g. `qa`). */
25
+ commandName: string;
26
+ /** Path to the on-disk version-check cache JSON file. */
27
+ cachePath: string;
28
+ /** Cache TTL in milliseconds (default 24h). */
29
+ ttlMs?: number;
30
+ /** When true, skip background refresh (e.g. test subprocess). */
31
+ skipRefresh?: () => boolean;
32
+ /** When true, skip refresh because `gh` is unavailable. */
33
+ ghAvailable?: () => boolean;
34
+ /** Fetches latest release version via `gh`. */
35
+ fetchLatest: () => Promise<string>;
36
+ }
37
+
38
+ type VersionCheckCache = {
39
+ fetchedAt: number;
40
+ latest: string;
41
+ };
42
+
43
+ const DEFAULT_TTL_MS = 24 * 60 * 60 * 1000;
44
+
45
+ /** Returns whether the installed version matches the latest release. */
46
+ export function isAlreadyCurrent(current: string, latest: string): boolean {
47
+ return current === latest;
48
+ }
49
+
50
+ /** Strips a leading `v` from a release tag. */
51
+ export function parseReleaseTag(tag: string): string {
52
+ const trimmed = tag.trim();
53
+ if (!trimmed) {
54
+ throw new Error("Release tag is empty");
55
+ }
56
+ return trimmed.startsWith("v") ? trimmed.slice(1) : trimmed;
57
+ }
58
+
59
+ /** Builds a `CliUpdateGetLatest` hook that downloads a release via `gh`. */
60
+ export function ghReleaseUpdateGetLatest(config: GhReleaseUpdateConfig): CliUpdateGetLatest {
61
+ const fetchLatest = createGhFetchLatest(config);
62
+
63
+ return async ({ version }) => {
64
+ ensureGhAvailable();
65
+
66
+ const latestVersion = await fetchLatest();
67
+ if (isAlreadyCurrent(version, latestVersion)) {
68
+ return { path: process.execPath, version: latestVersion };
69
+ }
70
+
71
+ const tempDir = mkdtempSync(join(tmpdir(), config.tempPrefix));
72
+ try {
73
+ const downloadedPath = await downloadReleaseAsset(config, tempDir);
74
+ chmodSync(downloadedPath, 0o755);
75
+ writeVersionCheckCache(config.cachePath, { fetchedAt: Date.now(), latest: latestVersion });
76
+ return {
77
+ path: downloadedPath,
78
+ version: latestVersion,
79
+ cleanup: () => {
80
+ rmSync(tempDir, { recursive: true, force: true });
81
+ },
82
+ };
83
+ } catch (err) {
84
+ rmSync(tempDir, { recursive: true, force: true });
85
+ throw err;
86
+ }
87
+ };
88
+ }
89
+
90
+ /** Version-check cache helpers for summary notices and background refresh. */
91
+ export function createGhVersionCheck(config: GhVersionCheckConfig): {
92
+ getUpdateNotice: () => string | null;
93
+ refreshIfStale: () => void;
94
+ } {
95
+ const ttlMs = config.ttlMs ?? DEFAULT_TTL_MS;
96
+ const ghAvailable = config.ghAvailable ?? (() => Bun.which("gh") !== null);
97
+
98
+ return {
99
+ getUpdateNotice(): string | null {
100
+ const cached = readVersionCheckCache(config.cachePath);
101
+ if (cached === null || isAlreadyCurrent(config.currentVersion, cached.latest)) {
102
+ return null;
103
+ }
104
+ return `Update available: v${cached.latest} (you have v${config.currentVersion}). Run \`${config.commandName} update\``;
105
+ },
106
+
107
+ refreshIfStale(): void {
108
+ if (config.skipRefresh?.()) return;
109
+ if (!ghAvailable()) return;
110
+
111
+ const cached = readVersionCheckCache(config.cachePath);
112
+ if (cached !== null && Date.now() - cached.fetchedAt < ttlMs) {
113
+ return;
114
+ }
115
+
116
+ void config.fetchLatest()
117
+ .then((latest) => {
118
+ writeVersionCheckCache(config.cachePath, { fetchedAt: Date.now(), latest });
119
+ })
120
+ .catch(() => {
121
+ // Best-effort; summary must never fail because of a version check.
122
+ });
123
+ },
124
+ };
125
+ }
126
+
127
+ /** Shared `gh release view` fetcher for hooks and version-check refresh. */
128
+ export function createGhFetchLatest(config: Pick<GhReleaseUpdateConfig, "repo" | "repoEnvHint">): () => Promise<string> {
129
+ return async () => {
130
+ const result = await runGh([
131
+ "release",
132
+ "view",
133
+ "--repo",
134
+ config.repo,
135
+ "--json",
136
+ "tagName",
137
+ ]);
138
+
139
+ if (result.exitCode !== 0) {
140
+ const detail = result.stderr.trim() || result.stdout.trim();
141
+ const hint = detail.includes("auth") || detail.includes("401")
142
+ ? " Run `gh auth login` and try again."
143
+ : detail.includes("release not found") || detail.includes("Not Found")
144
+ ? config.repoEnvHint
145
+ ? ` ${config.repoEnvHint}`
146
+ : ` No releases found for ${config.repo}.`
147
+ : "";
148
+ throw new Error(
149
+ `Failed to fetch latest release from ${config.repo}: ${detail || "unknown error"}.${hint}`,
150
+ );
151
+ }
152
+
153
+ let parsed: { tagName?: string };
154
+ try {
155
+ parsed = JSON.parse(result.stdout) as { tagName?: string };
156
+ } catch {
157
+ throw new Error("Failed to parse release metadata from gh");
158
+ }
159
+
160
+ if (!parsed.tagName) {
161
+ throw new Error("No release tag found for this repository");
162
+ }
163
+
164
+ return parseReleaseTag(parsed.tagName);
165
+ };
166
+ }
167
+
168
+ function ensureGhAvailable(): void {
169
+ if (Bun.which("gh") === null) {
170
+ throw new Error(
171
+ "GitHub CLI (gh) is required. Install from https://cli.github.com/ and run `gh auth login`.",
172
+ );
173
+ }
174
+ }
175
+
176
+ async function downloadReleaseAsset(config: GhReleaseUpdateConfig, tempDir: string): Promise<string> {
177
+ const result = await runGh([
178
+ "release",
179
+ "download",
180
+ "--repo",
181
+ config.repo,
182
+ "--pattern",
183
+ config.asset,
184
+ "--dir",
185
+ tempDir,
186
+ ]);
187
+
188
+ if (result.exitCode !== 0) {
189
+ const detail = result.stderr.trim() || result.stdout.trim();
190
+ throw new Error(
191
+ `Failed to download release from ${config.repo}: ${detail || "unknown error"}`,
192
+ );
193
+ }
194
+
195
+ const downloadedPath = join(tempDir, config.asset);
196
+ if (!existsSync(downloadedPath)) {
197
+ throw new Error(`Release asset "${config.asset}" was not found in the download`);
198
+ }
199
+
200
+ return downloadedPath;
201
+ }
202
+
203
+ async function runGh(args: string[]): Promise<{ exitCode: number; stdout: string; stderr: string }> {
204
+ const proc = Bun.spawn(["gh", ...args], { stdout: "pipe", stderr: "pipe" });
205
+ const [exitCode, stdout, stderr] = await Promise.all([
206
+ proc.exited,
207
+ new Response(proc.stdout).text(),
208
+ new Response(proc.stderr).text(),
209
+ ]);
210
+ return { exitCode, stdout, stderr };
211
+ }
212
+
213
+ function readVersionCheckCache(cachePath: string): VersionCheckCache | null {
214
+ try {
215
+ const raw = readFileSync(cachePath, "utf8");
216
+ const parsed = JSON.parse(raw) as VersionCheckCache;
217
+ if (typeof parsed.fetchedAt !== "number" || typeof parsed.latest !== "string") {
218
+ return null;
219
+ }
220
+ return parsed;
221
+ } catch {
222
+ return null;
223
+ }
224
+ }
225
+
226
+ function writeVersionCheckCache(cachePath: string, cache: VersionCheckCache): void {
227
+ mkdirSync(dirname(cachePath), { recursive: true });
228
+ writeFileSync(cachePath, JSON.stringify(cache));
229
+ }