argsbarg 3.2.0 → 3.3.1
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 +23 -1
- package/README.md +1 -1
- package/docs/ai-skills.md +2 -0
- package/docs/bundled-docs.md +1 -1
- package/docs/cli-program.md +194 -0
- package/docs/install.md +35 -0
- package/docs/mcp.md +7 -3
- package/docs/templates/cursor/rules/cli-program.mdc +29 -0
- package/examples/nested.ts +3 -0
- package/index.d.ts +73 -0
- package/package.json +1 -1
- package/plan.md +10 -185
- package/src/headless.test.ts +80 -0
- package/src/headless.ts +81 -0
- package/src/index.ts +17 -0
- package/src/install/gh-release-update.test.ts +22 -0
- package/src/install/gh-release-update.ts +229 -0
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [3.3.1] - 2026-06-21
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **`docs/cli-program.md`** — authoring guide for `CliProgram` and leaves (MCP-free defaults); **headless-capable handlers** and **inline schema by default**.
|
|
15
|
+
- **`docs/templates/cursor/rules/cli-program.mdc`** — concise copy-paste Cursor rule for consumer apps.
|
|
16
|
+
|
|
17
|
+
### Removed
|
|
18
|
+
|
|
19
|
+
- **`mcpToolSchemaHints`** — redundant with MCP `inputSchema` option descriptions.
|
|
20
|
+
- **`wantsDryRun`** — use `ctx.hasFlag("dry-run")` instead.
|
|
21
|
+
|
|
22
|
+
## [3.3.0] - 2026-06-21
|
|
23
|
+
|
|
24
|
+
### Added
|
|
25
|
+
|
|
26
|
+
- **Headless helpers** — `shouldRunHeadless`, `shouldRunHeadlessWithPositionals`, `shouldRunHeadlessWithYes`, `wantsExplicitJson`, `requireYesInNonTty`, `formatDryRunMessage` for Ink/MCP CLIs.
|
|
27
|
+
- **`ghReleaseUpdateGetLatest`** — optional `install.updateGetLatest` factory for GitHub releases via `gh`.
|
|
28
|
+
- **`createGhVersionCheck`** — version-check cache, update notices, and background refresh helpers.
|
|
29
|
+
|
|
10
30
|
## [3.2.0] - 2026-06-20
|
|
11
31
|
|
|
12
32
|
### Added
|
|
@@ -223,7 +243,9 @@ const cli = { ... } satisfies CliProgram; // or : CliProgram
|
|
|
223
243
|
- 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
244
|
- Imports: use `CliPositional` where needed; replace `CliOptionDef` with `CliOption` or `CliPositional` as appropriate.
|
|
225
245
|
|
|
226
|
-
[Unreleased]: https://github.com/bdombro/bun-argsbarg/compare/v3.
|
|
246
|
+
[Unreleased]: https://github.com/bdombro/bun-argsbarg/compare/v3.3.1...HEAD
|
|
247
|
+
[3.3.1]: https://github.com/bdombro/bun-argsbarg/releases/tag/v3.3.1
|
|
248
|
+
[3.3.0]: https://github.com/bdombro/bun-argsbarg/releases/tag/v3.3.0
|
|
227
249
|
[3.2.0]: https://github.com/bdombro/bun-argsbarg/releases/tag/v3.2.0
|
|
228
250
|
[3.1.0]: https://github.com/bdombro/bun-argsbarg/releases/tag/v3.1.0
|
|
229
251
|
[3.0.0]: https://github.com/bdombro/bun-argsbarg/releases/tag/v3.0.0
|
package/README.md
CHANGED
|
@@ -110,7 +110,7 @@ When **`docs.enabled`** is `true`, do not declare a top-level command named **`d
|
|
|
110
110
|
|
|
111
111
|
Opt in on the program root with `mcpServer: { enabled: true }`, then run `myapp mcp` for a stdio MCP server. Each leaf command becomes a tool; the CLI tree is available as resource `<sanitized-key>://schema` (same as `myapp docs schema`). Handlers can read `ctx.invocation` and use `cliInvoke` for headless testing.
|
|
112
112
|
|
|
113
|
-
See **[docs/mcp.md](docs/mcp.md)** for configuration, env bootstrapping, custom resources, Cursor setup, and protocol details.
|
|
113
|
+
See **[docs/mcp.md](docs/mcp.md)** for configuration, env bootstrapping, custom resources, Cursor setup, and protocol details. See **[docs/cli-program.md](docs/cli-program.md)** for schema authoring (consumer apps: copy **`docs/templates/cursor/rules/cli-program.mdc`** to **`.cursor/rules/cli-program.mdc`**).
|
|
114
114
|
|
|
115
115
|
### Install
|
|
116
116
|
|
package/docs/ai-skills.md
CHANGED
|
@@ -44,6 +44,8 @@ Skills describe **shell invocation only** — no MCP setup, `mcp.json`, or `tool
|
|
|
44
44
|
| **`myapp install --skill`** | Static shell command catalog for agents |
|
|
45
45
|
| **`myapp docs`** (requires `docs`) | Bundled markdown on stdout (`docs mcp` when MCP enabled) |
|
|
46
46
|
|
|
47
|
+
Command catalog lines reuse MCP tool descriptions. See [cli-program.md](cli-program.md).
|
|
48
|
+
|
|
47
49
|
See also:
|
|
48
50
|
|
|
49
51
|
- [Bundled docs](bundled-docs.md) — `docs` config and compile-time imports
|
package/docs/bundled-docs.md
CHANGED
|
@@ -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
|
-
|
|
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
|
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
# Writing `CliProgram` and leaf commands
|
|
2
|
+
|
|
3
|
+
ArgsBarg turns your schema into help, shell completions, MCP tools, and agent skills. **The same `description` fields you write for humans are the agent contract** for basic apps.
|
|
4
|
+
|
|
5
|
+
## Minimal app (MCP is free)
|
|
6
|
+
|
|
7
|
+
```typescript
|
|
8
|
+
const cli = {
|
|
9
|
+
key: "myapp",
|
|
10
|
+
version: "1.0.0",
|
|
11
|
+
description: "One-line summary of what the CLI does.",
|
|
12
|
+
mcpServer: { enabled: true },
|
|
13
|
+
commands: [
|
|
14
|
+
{
|
|
15
|
+
key: "greet",
|
|
16
|
+
description: "Greet someone by name.",
|
|
17
|
+
positionals: [
|
|
18
|
+
{ name: "name", description: "Who to greet.", kind: CliOptionKind.String },
|
|
19
|
+
],
|
|
20
|
+
handler: async (ctx) => { /* ... */ },
|
|
21
|
+
},
|
|
22
|
+
],
|
|
23
|
+
} satisfies CliProgram;
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
No `mcpTool` blocks required. Every leaf becomes an MCP tool; `inputSchema` comes from options and positionals.
|
|
27
|
+
|
|
28
|
+
## Inline schema by default
|
|
29
|
+
|
|
30
|
+
ArgsBarg is **schema-first** — the program tree is the product. **Keep `CliProgram` and leaf fields inline** (`key`, `description`, `options`, `positionals`, `handler`) so a reader sees the full command contract in one place.
|
|
31
|
+
|
|
32
|
+
**Inline by default:**
|
|
33
|
+
|
|
34
|
+
```typescript
|
|
35
|
+
{
|
|
36
|
+
key: "reserve",
|
|
37
|
+
description: "Reserve a QA environment.",
|
|
38
|
+
options: [
|
|
39
|
+
{ name: "yes", description: "Skip confirmation; use for non-interactive runs.", kind: CliOptionKind.Presence },
|
|
40
|
+
{ name: "dry-run", description: "Preview without mutating.", kind: CliOptionKind.Presence },
|
|
41
|
+
],
|
|
42
|
+
positionals: [
|
|
43
|
+
{ name: "env", description: "Environment name.", kind: CliOptionKind.String, argMin: 0, argMax: 1 },
|
|
44
|
+
],
|
|
45
|
+
handler: async (ctx) => { /* … */ },
|
|
46
|
+
}
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
**Extract only when well justified:**
|
|
50
|
+
|
|
51
|
+
| Extract | When |
|
|
52
|
+
| --- | --- |
|
|
53
|
+
| Shared option objects (`DRY_RUN_OPTION`, `JSON_OPTION`) | Identical flag reused on many leaves |
|
|
54
|
+
| Shared spreads (`...MCP_TOOL_MUTATOR`) | Same `mcpTool` metadata on a family of commands |
|
|
55
|
+
| `somethingCommand()` factory | Entry file is large; handler/body is substantial (Ink page, headless dispatch) |
|
|
56
|
+
| `docs.topics` text imports | Compile-time markdown bundling — not schema shape |
|
|
57
|
+
|
|
58
|
+
**Avoid extracting** thin indirection: a file that only re-exports `{ key, description, options }` with no logic, or splitting every leaf into its own module when the handler is a few lines. If extraction does not reduce duplication or file size materially, keep it inline.
|
|
59
|
+
|
|
60
|
+
**`satisfies CliProgram`** on the root (or on extracted command factories) preserves type-checking whether inline or not.
|
|
61
|
+
|
|
62
|
+
## Descriptions
|
|
63
|
+
|
|
64
|
+
Write for **what the command does**, not how the UI works:
|
|
65
|
+
|
|
66
|
+
- **Good:** `Reserve a QA environment.`
|
|
67
|
+
- **Weak:** `Opens the reservation wizard.`
|
|
68
|
+
|
|
69
|
+
Option and positional `description` strings appear in `-h`, MCP `inputSchema`, and generated skills — keep them concrete (`Environment name (e.g. qa2).`).
|
|
70
|
+
|
|
71
|
+
Use root **`notes`** for cross-cutting hints shown in help (install commands, docs topics, VPN requirements).
|
|
72
|
+
|
|
73
|
+
## Well-known option names
|
|
74
|
+
|
|
75
|
+
Prefer **`yes`**, **`dry-run`**, and **`json`** when semantics match. They appear in `-h`, MCP `inputSchema`, and generated skills — write clear option `description` strings (e.g. "Skip confirmation; use for non-interactive runs.").
|
|
76
|
+
|
|
77
|
+
## When to use `mcpTool` (escape hatches only)
|
|
78
|
+
|
|
79
|
+
**Omit `mcpTool` unless you have a specific reason.**
|
|
80
|
+
|
|
81
|
+
### Fix the CLI first
|
|
82
|
+
|
|
83
|
+
Many "MCP problems" are schema or handler gaps. Prefer these over escape hatches:
|
|
84
|
+
|
|
85
|
+
| Problem | Fix (not `mcpTool`) |
|
|
86
|
+
| --- | --- |
|
|
87
|
+
| Agents don't know which flags to pass | Use standard option names (`yes`, `dry-run`, `json`); improve option `description` strings |
|
|
88
|
+
| MCP calls hang on prompts | Add `--yes` and a headless code path; use `shouldRunHeadlessWithYes` |
|
|
89
|
+
| Help text describes Ink UI | Rewrite leaf `description` as the action ("Reserve an environment.") |
|
|
90
|
+
| MCP needs different args than humans | Expose the same flags; resolve defaults in the handler for both `cli` and `mcp` |
|
|
91
|
+
| Command "doesn't work" over MCP | Branch on `ctx.invocation === "mcp"` in the handler (stdio is the wire) |
|
|
92
|
+
|
|
93
|
+
### When escape hatches are appropriate
|
|
94
|
+
|
|
95
|
+
| Field | Use when |
|
|
96
|
+
| --- | --- |
|
|
97
|
+
| `enabled: false` | Command is **genuinely** CLI-only (open browser, Ink-only flow with no scriptable equivalent) |
|
|
98
|
+
| `requiresEnv: [...]` | Runtime secrets; appended to MCP description and enforced at `tools/call` |
|
|
99
|
+
| `description: "..."` | **Irreducible** MCP limitation (e.g. live tail / `--watch` cannot be streamed on the MCP wire yet) |
|
|
100
|
+
|
|
101
|
+
Do **not** use `mcpTool.description` to paper over missing `--yes`, non-standard flag names, or handlers that only work interactively — fix those instead.
|
|
102
|
+
|
|
103
|
+
If help text and MCP behavior match after your fixes, **omit `mcpTool` entirely**.
|
|
104
|
+
|
|
105
|
+
## Headless-capable handlers
|
|
106
|
+
|
|
107
|
+
Simple leaves (read args, print stdout) are already headless — no extra work. **Any handler that might mount Ink, prompt, or open a browser should also implement a scriptable fast path** for:
|
|
108
|
+
|
|
109
|
+
- **MCP** (`ctx.invocation === "mcp"` — always non-interactive)
|
|
110
|
+
- **Non-TTY CLI** (pipes, CI, `myapp cmd --yes` in a script)
|
|
111
|
+
- **Explicit flags** (`--json`, `--dry-run`)
|
|
112
|
+
|
|
113
|
+
Use **one headless implementation** for all three; do not fork separate "MCP handlers."
|
|
114
|
+
|
|
115
|
+
### When to branch
|
|
116
|
+
|
|
117
|
+
| Command kind | Headless trigger | Helpers |
|
|
118
|
+
| --- | --- | --- |
|
|
119
|
+
| Read / query | MCP, `--json`, or non-TTY | `shouldRunHeadless`, `wantsExplicitJson` |
|
|
120
|
+
| Mutate | MCP with args + `yes`/`dry-run`, or non-TTY with `--yes` | `shouldRunHeadlessWithYes`, `requireYesInNonTty` |
|
|
121
|
+
| Mutate with positionals | Same, but avoid auto-headless on empty argv | `shouldRunHeadlessWithPositionals` |
|
|
122
|
+
|
|
123
|
+
### Recommended handler shape
|
|
124
|
+
|
|
125
|
+
**Mutating command** (wizard optional, script path required):
|
|
126
|
+
|
|
127
|
+
```typescript
|
|
128
|
+
import {
|
|
129
|
+
requireYesInNonTty,
|
|
130
|
+
shouldRunHeadlessWithYes,
|
|
131
|
+
} from "argsbarg";
|
|
132
|
+
|
|
133
|
+
handler: async (ctx) => {
|
|
134
|
+
const dryRun = ctx.hasFlag("dry-run");
|
|
135
|
+
const yes = ctx.hasFlag("yes");
|
|
136
|
+
const env = ctx.args[0];
|
|
137
|
+
|
|
138
|
+
requireYesInNonTty(yes, "Example: myapp reserve qa2 --yes", dryRun);
|
|
139
|
+
|
|
140
|
+
if (shouldRunHeadlessWithYes(ctx, { yes, hasRequiredArgs: !!env, dryRun })) {
|
|
141
|
+
const result = await executeReserve({ dryRun, env, yes });
|
|
142
|
+
process.stdout.write(`${result.message}\n`);
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
await renderInteractiveWizard({ env, yes, dryRun });
|
|
147
|
+
};
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
**Read / query command** (optional `--json`):
|
|
151
|
+
|
|
152
|
+
```typescript
|
|
153
|
+
import { shouldRunHeadless, wantsExplicitJson } from "argsbarg";
|
|
154
|
+
|
|
155
|
+
handler: async (ctx) => {
|
|
156
|
+
const json = ctx.hasFlag("json");
|
|
157
|
+
|
|
158
|
+
if (shouldRunHeadless(ctx, json)) {
|
|
159
|
+
const data = await fetchStatus(ctx.args[0]);
|
|
160
|
+
if (wantsExplicitJson(ctx, json)) {
|
|
161
|
+
process.stdout.write(`${JSON.stringify(data, null, 2)}\n`);
|
|
162
|
+
} else {
|
|
163
|
+
process.stdout.write(formatStatusHuman(data));
|
|
164
|
+
}
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
await renderPage(<StatusPage env={ctx.args[0]} />);
|
|
169
|
+
};
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
### Rules of thumb
|
|
173
|
+
|
|
174
|
+
1. **Resolvable from flags** — if a human can complete the action with flags, an agent can too (`--env qa2 --yes`). Wizards are optional sugar on TTY.
|
|
175
|
+
2. **`--yes` on mutators** — required for non-TTY CLI scripts; MCP should pass `yes: true` in tool arguments when the schema exposes `yes`.
|
|
176
|
+
3. **Stdout is the contract** — headless paths write results to stdout (or JSON with `--json`); stderr for errors. No Ink on the MCP wire.
|
|
177
|
+
4. **`ctx.invocation === "mcp"`** — use only for wire-specific behavior (pipe child stdout, reject `--watch`, etc.), not to duplicate business logic.
|
|
178
|
+
5. **Hide only when impossible** — `mcpTool.enabled: false` after confirming no headless path exists (browser-only, irreducible streaming).
|
|
179
|
+
|
|
180
|
+
Basic synchronous handlers do not need this structure — only commands with an interactive branch.
|
|
181
|
+
|
|
182
|
+
## Reserved names
|
|
183
|
+
|
|
184
|
+
Do not declare user commands named `completion`, `install`, `mcp`, `version`, `docs`, or `update` at the root — ArgsBarg injects these when configured.
|
|
185
|
+
|
|
186
|
+
## Cursor rule for consumer repos
|
|
187
|
+
|
|
188
|
+
Copy `node_modules/argsbarg/docs/templates/cursor/rules/cli-program.mdc` to `.cursor/rules/cli-program.mdc` so agents editing your CLI schema follow these conventions.
|
|
189
|
+
|
|
190
|
+
## See also
|
|
191
|
+
|
|
192
|
+
- [MCP server](mcp.md) — tools, schema resource, env bootstrapping
|
|
193
|
+
- [Agent skills](ai-skills.md) — `install --skill`
|
|
194
|
+
- [Bundled docs](bundled-docs.md) — `docs` topics and `docs mcp`
|
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`
|
package/docs/mcp.md
CHANGED
|
@@ -103,9 +103,9 @@ Tool names are derived from the command path, with each segment sanitized (non-a
|
|
|
103
103
|
|
|
104
104
|
### Tool descriptions
|
|
105
105
|
|
|
106
|
-
Each tool’s `description` includes the human CLI path and the leaf’s help text, separated by an em dash:
|
|
106
|
+
Each tool’s `description` includes the human CLI path and the leaf’s help text, separated by an em dash. Tool arguments are defined in `inputSchema` (options and positionals with their descriptions). `mcpTool.requiresEnv` is appended as `[requires env: …]`.
|
|
107
107
|
|
|
108
|
-
| CLI path | MCP `description` |
|
|
108
|
+
| CLI path | MCP `description` (example) |
|
|
109
109
|
| --- | --- |
|
|
110
110
|
| `stat owner lookup` | `stat owner lookup — Resolve owner info.` |
|
|
111
111
|
| `read` | `read — Print the first line of each file.` |
|
|
@@ -126,6 +126,8 @@ Set `mcpTool: { enabled: false }` on a **leaf command** to hide it from `tools/l
|
|
|
126
126
|
|
|
127
127
|
Omitted or `enabled: true` exposes the command (default). `mcpTool` is only valid on leaves — not on the program root or routing groups.
|
|
128
128
|
|
|
129
|
+
**Prefer fixing schema and handlers over `mcpTool` overrides** — standard option names (`yes`, `dry-run`, `json`), headless paths, and clear descriptions usually make MCP work without per-leaf config. See [cli-program.md](cli-program.md).
|
|
130
|
+
|
|
129
131
|
### Per-leaf tool metadata
|
|
130
132
|
|
|
131
133
|
```typescript
|
|
@@ -207,7 +209,9 @@ URIs must be unique and must not equal `schemaResourceUri`. `load()` runs synchr
|
|
|
207
209
|
|
|
208
210
|
Handlers receive `ctx.invocation`: `"cli"` for normal `cliRun` dispatch, `"mcp"` for MCP `tools/call`.
|
|
209
211
|
|
|
210
|
-
|
|
212
|
+
MCP is always non-interactive. Commands that can mount Ink or prompts should implement a **headless fast path** (same path as non-TTY CLI with `--yes` / `--json`) — see [cli-program.md — Headless-capable handlers](cli-program.md#headless-capable-handlers).
|
|
213
|
+
|
|
214
|
+
Use `ctx.invocation` to branch subprocess behavior — MCP stdout is the JSON-RPC wire, so child processes must not inherit it:
|
|
211
215
|
|
|
212
216
|
```typescript
|
|
213
217
|
handler: async (ctx) => {
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Argsbarg CliProgram authoring; copy to .cursor/rules/cli-program.mdc
|
|
3
|
+
globs: "**/*.{ts,tsx}"
|
|
4
|
+
alwaysApply: false
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
**argsbarg** — `CliProgram` drives help, MCP, and skills. Full guide: `node_modules/argsbarg/docs/cli-program.md`.
|
|
8
|
+
|
|
9
|
+
## Defaults
|
|
10
|
+
- `mcpServer: { enabled: true }` for MCP; **omit `mcpTool`** unless an escape hatch is required
|
|
11
|
+
- `description` on root, commands, options, and positionals (→ `-h`, MCP `inputSchema`, skills)
|
|
12
|
+
- `satisfies CliProgram`; root: `key`, `version`, `description`
|
|
13
|
+
- Reserved root commands (do not declare): `completion`, `install`, `mcp`, `version`, `docs`, `update`
|
|
14
|
+
|
|
15
|
+
## Schema
|
|
16
|
+
- **Inline** options, positionals, and handler schema; extract only for shared flags, `mcpTool` spreads, or large handlers (Ink/dispatch)
|
|
17
|
+
- Leaf descriptions: action-oriented, not UI jargon
|
|
18
|
+
- Prefer option names `yes`, `dry-run`, `json` when semantics match; describe non-interactive use on the option
|
|
19
|
+
|
|
20
|
+
## `mcpTool` (rare)
|
|
21
|
+
Fix schema/handler first — not for missing `--yes`, wrong flag names, or unfinished headless paths.
|
|
22
|
+
Only: `enabled: false` (CLI-only), `requiresEnv`, or irreducible limits (e.g. streaming `--watch`). Comment why if used.
|
|
23
|
+
|
|
24
|
+
## Headless
|
|
25
|
+
Interactive commands need one fast path for MCP, non-TTY CLI, and `--yes` / `--dry-run` / `--json`.
|
|
26
|
+
- Mutators: `requireYesInNonTty`, `shouldRunHeadlessWithYes`
|
|
27
|
+
- Queries: `shouldRunHeadless`, `wantsExplicitJson`
|
|
28
|
+
- Varargs/positionals: `shouldRunHeadlessWithPositionals`
|
|
29
|
+
- `ctx.invocation === "mcp"` only for subprocess/TTY wire behavior; use argsbarg headless helpers, not raw `isTTY`
|
package/examples/nested.ts
CHANGED
package/index.d.ts
CHANGED
|
@@ -305,5 +305,78 @@ 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 `--json` was passed or the handler was invoked via MCP. */
|
|
311
|
+
export declare function wantsExplicitJson(ctx: HeadlessContext, hasJsonFlag: boolean): boolean;
|
|
312
|
+
/**
|
|
313
|
+
* Headless when MCP, `--json`, `--dry-run`, or stdin is not a TTY.
|
|
314
|
+
* Use for commands that should auto-emit JSON in pipelines.
|
|
315
|
+
*/
|
|
316
|
+
export declare function shouldRunHeadless(ctx: HeadlessContext, hasJsonFlag: boolean, hasDryRunFlag?: boolean, interactive?: boolean): boolean;
|
|
317
|
+
/**
|
|
318
|
+
* Like {@link shouldRunHeadless}, but only auto-headless in non-TTY when positionals are present.
|
|
319
|
+
* Avoids turning empty invocations into JSON errors.
|
|
320
|
+
*/
|
|
321
|
+
export declare function shouldRunHeadlessWithPositionals(ctx: HeadlessContext, hasJsonFlag: boolean, positionals: string[], hasDryRunFlag?: boolean, interactive?: boolean): boolean;
|
|
322
|
+
/**
|
|
323
|
+
* Headless when MCP, `--dry-run` with required args, or non-TTY with `--yes` and required args.
|
|
324
|
+
* Use for mutating commands that require explicit `--yes` in scripts.
|
|
325
|
+
*/
|
|
326
|
+
export declare function shouldRunHeadlessWithYes(ctx: HeadlessContext, opts: {
|
|
327
|
+
yes: boolean;
|
|
328
|
+
hasRequiredArgs: boolean;
|
|
329
|
+
dryRun?: boolean;
|
|
330
|
+
}, interactive?: boolean): boolean;
|
|
331
|
+
/**
|
|
332
|
+
* Exits when non-interactive mode is used without `--yes`.
|
|
333
|
+
* @param hint - Command-specific guidance appended to the error
|
|
334
|
+
*/
|
|
335
|
+
export declare function requireYesInNonTty(yes: boolean, hint: string, dryRun?: boolean, interactive?: boolean): void;
|
|
336
|
+
/** Prefixes a success message when running in dry-run mode. */
|
|
337
|
+
export declare function formatDryRunMessage(message: string, dryRun: boolean): string;
|
|
338
|
+
/** Config for {@link ghReleaseUpdateGetLatest}. */
|
|
339
|
+
export interface GhReleaseUpdateConfig {
|
|
340
|
+
/** GitHub `owner/repo` slug. */
|
|
341
|
+
repo: string;
|
|
342
|
+
/** Release asset filename (e.g. `myapp`). */
|
|
343
|
+
asset: string;
|
|
344
|
+
/** Temp directory name prefix for downloads. */
|
|
345
|
+
tempPrefix: string;
|
|
346
|
+
/** Path to the on-disk version-check cache JSON file. */
|
|
347
|
+
cachePath: string;
|
|
348
|
+
/** Optional hint when `gh auth` fails or no releases exist. */
|
|
349
|
+
repoEnvHint?: string;
|
|
350
|
+
}
|
|
351
|
+
/** Config for {@link createGhVersionCheck}. */
|
|
352
|
+
export interface GhVersionCheckConfig {
|
|
353
|
+
/** Installed semver string. */
|
|
354
|
+
currentVersion: string;
|
|
355
|
+
/** CLI command name for update notices (e.g. `qa`). */
|
|
356
|
+
commandName: string;
|
|
357
|
+
/** Path to the on-disk version-check cache JSON file. */
|
|
358
|
+
cachePath: string;
|
|
359
|
+
/** Cache TTL in milliseconds (default 24h). */
|
|
360
|
+
ttlMs?: number;
|
|
361
|
+
/** When true, skip background refresh (e.g. test subprocess). */
|
|
362
|
+
skipRefresh?: () => boolean;
|
|
363
|
+
/** When true, skip refresh because `gh` is unavailable. */
|
|
364
|
+
ghAvailable?: () => boolean;
|
|
365
|
+
/** Fetches latest release version via `gh`. */
|
|
366
|
+
fetchLatest: () => Promise<string>;
|
|
367
|
+
}
|
|
368
|
+
/** Returns whether the installed version matches the latest release. */
|
|
369
|
+
export declare function isAlreadyCurrent(current: string, latest: string): boolean;
|
|
370
|
+
/** Strips a leading `v` from a release tag. */
|
|
371
|
+
export declare function parseReleaseTag(tag: string): string;
|
|
372
|
+
/** Builds a `CliUpdateGetLatest` hook that downloads a release via `gh`. */
|
|
373
|
+
export declare function ghReleaseUpdateGetLatest(config: GhReleaseUpdateConfig): CliUpdateGetLatest;
|
|
374
|
+
/** Version-check cache helpers for summary notices and background refresh. */
|
|
375
|
+
export declare function createGhVersionCheck(config: GhVersionCheckConfig): {
|
|
376
|
+
getUpdateNotice: () => string | null;
|
|
377
|
+
refreshIfStale: () => void;
|
|
378
|
+
};
|
|
379
|
+
/** Shared `gh release view` fetcher for hooks and version-check refresh. */
|
|
380
|
+
export declare function createGhFetchLatest(config: Pick<GhReleaseUpdateConfig, "repo" | "repoEnvHint">): () => Promise<string>;
|
|
308
381
|
|
|
309
382
|
export {};
|
package/package.json
CHANGED
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**:
|
|
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
|
-
###
|
|
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
|
-
|
|
166
|
-
-
|
|
167
|
-
-
|
|
168
|
-
-
|
|
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
|
-
|
|
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
|
-
**
|
|
183
|
-
- **
|
|
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
|
-
|
|
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,80 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
formatDryRunMessage,
|
|
4
|
+
requireYesInNonTty,
|
|
5
|
+
shouldRunHeadless,
|
|
6
|
+
shouldRunHeadlessWithPositionals,
|
|
7
|
+
shouldRunHeadlessWithYes,
|
|
8
|
+
wantsExplicitJson,
|
|
9
|
+
} from "./headless.ts";
|
|
10
|
+
|
|
11
|
+
test("wantsExplicitJson includes MCP invocation", () => {
|
|
12
|
+
expect(wantsExplicitJson({ invocation: "cli" }, false)).toBe(false);
|
|
13
|
+
expect(wantsExplicitJson({ invocation: "mcp" }, false)).toBe(true);
|
|
14
|
+
expect(wantsExplicitJson({ invocation: "cli" }, true)).toBe(true);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("shouldRunHeadless is true for MCP and json", () => {
|
|
18
|
+
expect(shouldRunHeadless({ invocation: "mcp" }, false)).toBe(true);
|
|
19
|
+
expect(shouldRunHeadless({ invocation: "cli" }, true)).toBe(true);
|
|
20
|
+
expect(shouldRunHeadless({ invocation: "cli" }, false, true)).toBe(true);
|
|
21
|
+
expect(shouldRunHeadless({ invocation: "cli" }, false, false, false)).toBe(true);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("shouldRunHeadlessWithPositionals requires positionals in non-tty", () => {
|
|
25
|
+
expect(
|
|
26
|
+
shouldRunHeadlessWithPositionals({ invocation: "cli" }, false, [], false, false),
|
|
27
|
+
).toBe(false);
|
|
28
|
+
expect(
|
|
29
|
+
shouldRunHeadlessWithPositionals({ invocation: "cli" }, false, ["a"], false, false),
|
|
30
|
+
).toBe(true);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("shouldRunHeadlessWithYes requires yes in non-tty", () => {
|
|
34
|
+
expect(
|
|
35
|
+
shouldRunHeadlessWithYes(
|
|
36
|
+
{ invocation: "cli" },
|
|
37
|
+
{ yes: true, hasRequiredArgs: true },
|
|
38
|
+
false,
|
|
39
|
+
),
|
|
40
|
+
).toBe(true);
|
|
41
|
+
expect(
|
|
42
|
+
shouldRunHeadlessWithYes(
|
|
43
|
+
{ invocation: "cli" },
|
|
44
|
+
{ yes: false, hasRequiredArgs: true },
|
|
45
|
+
false,
|
|
46
|
+
),
|
|
47
|
+
).toBe(false);
|
|
48
|
+
expect(
|
|
49
|
+
shouldRunHeadlessWithYes(
|
|
50
|
+
{ invocation: "cli" },
|
|
51
|
+
{ yes: false, hasRequiredArgs: true, dryRun: true },
|
|
52
|
+
false,
|
|
53
|
+
),
|
|
54
|
+
).toBe(true);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("formatDryRunMessage prefixes dry-run output", () => {
|
|
58
|
+
expect(formatDryRunMessage("hello", false)).toBe("hello");
|
|
59
|
+
expect(formatDryRunMessage("hello", true)).toBe("[DRY RUN] hello");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("requireYesInNonTty exits without yes in non-tty", () => {
|
|
63
|
+
const originalExit = process.exit;
|
|
64
|
+
let code: number | undefined;
|
|
65
|
+
process.exit = ((c?: number) => {
|
|
66
|
+
code = c ?? 0;
|
|
67
|
+
throw new Error("exit");
|
|
68
|
+
}) as typeof process.exit;
|
|
69
|
+
|
|
70
|
+
try {
|
|
71
|
+
expect(() => {
|
|
72
|
+
requireYesInNonTty(false, "hint", false, false);
|
|
73
|
+
}).toThrow("exit");
|
|
74
|
+
expect(code).toBe(1);
|
|
75
|
+
requireYesInNonTty(false, "hint", true, false);
|
|
76
|
+
requireYesInNonTty(true, "hint", false, false);
|
|
77
|
+
} finally {
|
|
78
|
+
process.exit = originalExit;
|
|
79
|
+
}
|
|
80
|
+
});
|
package/src/headless.ts
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
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 `--json` was passed or the handler was invoked via MCP. */
|
|
8
|
+
export function wantsExplicitJson(ctx: HeadlessContext, hasJsonFlag: boolean): boolean {
|
|
9
|
+
return hasJsonFlag || ctx.invocation === "mcp";
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Headless when MCP, `--json`, `--dry-run`, or stdin is not a TTY.
|
|
14
|
+
* Use for commands that should auto-emit JSON in pipelines.
|
|
15
|
+
*/
|
|
16
|
+
export function shouldRunHeadless(
|
|
17
|
+
ctx: HeadlessContext,
|
|
18
|
+
hasJsonFlag: boolean,
|
|
19
|
+
hasDryRunFlag = false,
|
|
20
|
+
interactive: boolean = isInteractiveTty,
|
|
21
|
+
): boolean {
|
|
22
|
+
if (ctx.invocation === "mcp") return true;
|
|
23
|
+
if (hasJsonFlag || hasDryRunFlag) return true;
|
|
24
|
+
return !interactive;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Like {@link shouldRunHeadless}, but only auto-headless in non-TTY when positionals are present.
|
|
29
|
+
* Avoids turning empty invocations into JSON errors.
|
|
30
|
+
*/
|
|
31
|
+
export function shouldRunHeadlessWithPositionals(
|
|
32
|
+
ctx: HeadlessContext,
|
|
33
|
+
hasJsonFlag: boolean,
|
|
34
|
+
positionals: string[],
|
|
35
|
+
hasDryRunFlag = false,
|
|
36
|
+
interactive: boolean = isInteractiveTty,
|
|
37
|
+
): boolean {
|
|
38
|
+
if (ctx.invocation === "mcp") return true;
|
|
39
|
+
if (hasJsonFlag || hasDryRunFlag) return true;
|
|
40
|
+
return !interactive && positionals.length > 0;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Headless when MCP, `--dry-run` with required args, or non-TTY with `--yes` and required args.
|
|
45
|
+
* Use for mutating commands that require explicit `--yes` in scripts.
|
|
46
|
+
*/
|
|
47
|
+
export function shouldRunHeadlessWithYes(
|
|
48
|
+
ctx: HeadlessContext,
|
|
49
|
+
opts: { yes: boolean; hasRequiredArgs: boolean; dryRun?: boolean },
|
|
50
|
+
interactive: boolean = isInteractiveTty,
|
|
51
|
+
): boolean {
|
|
52
|
+
if (ctx.invocation === "mcp") {
|
|
53
|
+
return opts.hasRequiredArgs && (opts.yes || Boolean(opts.dryRun));
|
|
54
|
+
}
|
|
55
|
+
if (opts.dryRun && opts.hasRequiredArgs) return true;
|
|
56
|
+
if (!interactive) return opts.yes && opts.hasRequiredArgs;
|
|
57
|
+
return opts.yes && opts.hasRequiredArgs;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Exits when non-interactive mode is used without `--yes`.
|
|
62
|
+
* @param hint - Command-specific guidance appended to the error
|
|
63
|
+
*/
|
|
64
|
+
export function requireYesInNonTty(
|
|
65
|
+
yes: boolean,
|
|
66
|
+
hint: string,
|
|
67
|
+
dryRun = false,
|
|
68
|
+
interactive: boolean = isInteractiveTty,
|
|
69
|
+
): void {
|
|
70
|
+
if (dryRun) return;
|
|
71
|
+
if (!interactive && !yes) {
|
|
72
|
+
process.stderr.write(`Error: non-interactive mode requires --yes. ${hint}\n`);
|
|
73
|
+
process.exit(1);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Prefixes a success message when running in dry-run mode. */
|
|
78
|
+
export function formatDryRunMessage(message: string, dryRun: boolean): string {
|
|
79
|
+
if (!dryRun) return message;
|
|
80
|
+
return `[DRY RUN] ${message}`;
|
|
81
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -28,3 +28,20 @@ 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
|
+
wantsExplicitJson,
|
|
38
|
+
} from "./headless.ts";
|
|
39
|
+
export type { HeadlessContext } from "./headless.ts";
|
|
40
|
+
export {
|
|
41
|
+
createGhFetchLatest,
|
|
42
|
+
createGhVersionCheck,
|
|
43
|
+
ghReleaseUpdateGetLatest,
|
|
44
|
+
isAlreadyCurrent,
|
|
45
|
+
parseReleaseTag,
|
|
46
|
+
} from "./install/gh-release-update.ts";
|
|
47
|
+
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
|
+
}
|