argsbarg 3.3.0 → 3.3.2
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 +25 -5
- package/README.md +1 -1
- package/docs/ai-skills.md +2 -0
- package/docs/cli-program.md +209 -0
- package/docs/install.md +6 -4
- package/docs/mcp.md +7 -3
- package/docs/templates/cursor/rules/cli-program.mdc +29 -0
- package/index.d.ts +0 -2
- package/package.json +1 -1
- package/src/builtins/install.ts +3 -3
- package/src/headless.test.ts +0 -6
- package/src/headless.ts +0 -5
- package/src/index.ts +0 -1
- package/src/install/detect-installed.ts +0 -1
- package/src/install/index.ts +7 -9
- package/src/install/install.test.ts +64 -1
- package/src/install/mcp-config.ts +0 -1
- package/src/install/plan.ts +8 -8
- package/src/install/uninstall.ts +17 -15
package/CHANGELOG.md
CHANGED
|
@@ -7,14 +7,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
-
## [3.3.
|
|
10
|
+
## [3.3.2] - 2026-06-21
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
## [3.3.2] - 2026-06-21
|
|
14
|
+
|
|
15
|
+
### Changed
|
|
16
|
+
|
|
17
|
+
- **`install --uninstall`** — symmetric with install: requires `--all` or scoped flags; `--uninstall --all` removes everything argsbarg installed; empty scope succeeds without error.
|
|
18
|
+
|
|
19
|
+
## [3.3.1] - 2026-06-21
|
|
11
20
|
|
|
21
|
+
### Added
|
|
22
|
+
|
|
23
|
+
- **`docs/cli-program.md`** — authoring guide for `CliProgram` and leaves (MCP-free defaults); **headless-capable handlers** and **inline schema by default**.
|
|
24
|
+
- **`docs/templates/cursor/rules/cli-program.mdc`** — concise copy-paste Cursor rule for consumer apps.
|
|
25
|
+
|
|
26
|
+
### Removed
|
|
12
27
|
|
|
13
|
-
|
|
28
|
+
- **`mcpToolSchemaHints`** — redundant with MCP `inputSchema` option descriptions.
|
|
29
|
+
- **`wantsDryRun`** — use `ctx.hasFlag("dry-run")` instead.
|
|
30
|
+
|
|
31
|
+
## [3.3.0] - 2026-06-21
|
|
14
32
|
|
|
15
33
|
### Added
|
|
16
34
|
|
|
17
|
-
- **Headless helpers** — `shouldRunHeadless`, `shouldRunHeadlessWithPositionals`, `shouldRunHeadlessWithYes`, `
|
|
35
|
+
- **Headless helpers** — `shouldRunHeadless`, `shouldRunHeadlessWithPositionals`, `shouldRunHeadlessWithYes`, `wantsExplicitJson`, `requireYesInNonTty`, `formatDryRunMessage` for Ink/MCP CLIs.
|
|
18
36
|
- **`ghReleaseUpdateGetLatest`** — optional `install.updateGetLatest` factory for GitHub releases via `gh`.
|
|
19
37
|
- **`createGhVersionCheck`** — version-check cache, update notices, and background refresh helpers.
|
|
20
38
|
|
|
@@ -234,8 +252,10 @@ const cli = { ... } satisfies CliProgram; // or : CliProgram
|
|
|
234
252
|
- 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`).
|
|
235
253
|
- Imports: use `CliPositional` where needed; replace `CliOptionDef` with `CliOption` or `CliPositional` as appropriate.
|
|
236
254
|
|
|
237
|
-
[Unreleased]: https://github.com/bdombro/bun-argsbarg/compare/v3.3.
|
|
238
|
-
[3.3.
|
|
255
|
+
[Unreleased]: https://github.com/bdombro/bun-argsbarg/compare/v3.3.2...HEAD
|
|
256
|
+
[3.3.2]: https://github.com/bdombro/bun-argsbarg/releases/tag/v3.3.2
|
|
257
|
+
[3.3.2]: https://github.com/bdombro/bun-argsbarg/releases/tag/v3.3.2
|
|
258
|
+
[3.3.1]: https://github.com/bdombro/bun-argsbarg/releases/tag/v3.3.1
|
|
239
259
|
[3.3.0]: https://github.com/bdombro/bun-argsbarg/releases/tag/v3.3.0
|
|
240
260
|
[3.2.0]: https://github.com/bdombro/bun-argsbarg/releases/tag/v3.2.0
|
|
241
261
|
[3.1.0]: https://github.com/bdombro/bun-argsbarg/releases/tag/v3.1.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
|
|
@@ -0,0 +1,209 @@
|
|
|
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
|
+
| `commands/<name>/command.tsx` module | 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
|
+
When you extract a leaf or router, prefer a **plain exported object** — not a zero-arg wrapper function:
|
|
61
|
+
|
|
62
|
+
```typescript
|
|
63
|
+
// commands/reserve/command.tsx
|
|
64
|
+
export const reserveCommand = {
|
|
65
|
+
key: "reserve",
|
|
66
|
+
description: "Reserve a QA environment.",
|
|
67
|
+
options: [YES_OPTION, DRY_RUN_OPTION],
|
|
68
|
+
positionals: [/* … */],
|
|
69
|
+
handler: async (ctx) => { /* … */ },
|
|
70
|
+
} satisfies CliLeaf;
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Use a **parameterized factory** only when the schema truly depends on inputs (e.g. `createUpsertCommand(deps)` for tests or injected config). A `reserveCommand()` that returns a static literal adds indirection without benefit.
|
|
74
|
+
|
|
75
|
+
**`satisfies CliProgram`** on the root (or **`satisfies CliLeaf`** / router type on extracted modules) preserves type-checking whether inline or not.
|
|
76
|
+
|
|
77
|
+
## Descriptions
|
|
78
|
+
|
|
79
|
+
Write for **what the command does**, not how the UI works:
|
|
80
|
+
|
|
81
|
+
- **Good:** `Reserve a QA environment.`
|
|
82
|
+
- **Weak:** `Opens the reservation wizard.`
|
|
83
|
+
|
|
84
|
+
Option and positional `description` strings appear in `-h`, MCP `inputSchema`, and generated skills — keep them concrete (`Environment name (e.g. qa2).`).
|
|
85
|
+
|
|
86
|
+
Use root **`notes`** for cross-cutting hints shown in help (install commands, docs topics, VPN requirements).
|
|
87
|
+
|
|
88
|
+
## Well-known option names
|
|
89
|
+
|
|
90
|
+
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.").
|
|
91
|
+
|
|
92
|
+
## When to use `mcpTool` (escape hatches only)
|
|
93
|
+
|
|
94
|
+
**Omit `mcpTool` unless you have a specific reason.**
|
|
95
|
+
|
|
96
|
+
### Fix the CLI first
|
|
97
|
+
|
|
98
|
+
Many "MCP problems" are schema or handler gaps. Prefer these over escape hatches:
|
|
99
|
+
|
|
100
|
+
| Problem | Fix (not `mcpTool`) |
|
|
101
|
+
| --- | --- |
|
|
102
|
+
| Agents don't know which flags to pass | Use standard option names (`yes`, `dry-run`, `json`); improve option `description` strings |
|
|
103
|
+
| MCP calls hang on prompts | Add `--yes` and a headless code path; use `shouldRunHeadlessWithYes` |
|
|
104
|
+
| Help text describes Ink UI | Rewrite leaf `description` as the action ("Reserve an environment.") |
|
|
105
|
+
| MCP needs different args than humans | Expose the same flags; resolve defaults in the handler for both `cli` and `mcp` |
|
|
106
|
+
| Command "doesn't work" over MCP | Branch on `ctx.invocation === "mcp"` in the handler (stdio is the wire) |
|
|
107
|
+
|
|
108
|
+
### When escape hatches are appropriate
|
|
109
|
+
|
|
110
|
+
| Field | Use when |
|
|
111
|
+
| --- | --- |
|
|
112
|
+
| `enabled: false` | Command is **genuinely** CLI-only (open browser, Ink-only flow with no scriptable equivalent) |
|
|
113
|
+
| `requiresEnv: [...]` | Runtime secrets; appended to MCP description and enforced at `tools/call` |
|
|
114
|
+
| `description: "..."` | **Irreducible** MCP limitation (e.g. live tail / `--watch` cannot be streamed on the MCP wire yet) |
|
|
115
|
+
|
|
116
|
+
Do **not** use `mcpTool.description` to paper over missing `--yes`, non-standard flag names, or handlers that only work interactively — fix those instead.
|
|
117
|
+
|
|
118
|
+
If help text and MCP behavior match after your fixes, **omit `mcpTool` entirely**.
|
|
119
|
+
|
|
120
|
+
## Headless-capable handlers
|
|
121
|
+
|
|
122
|
+
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:
|
|
123
|
+
|
|
124
|
+
- **MCP** (`ctx.invocation === "mcp"` — always non-interactive)
|
|
125
|
+
- **Non-TTY CLI** (pipes, CI, `myapp cmd --yes` in a script)
|
|
126
|
+
- **Explicit flags** (`--json`, `--dry-run`)
|
|
127
|
+
|
|
128
|
+
Use **one headless implementation** for all three; do not fork separate "MCP handlers."
|
|
129
|
+
|
|
130
|
+
### When to branch
|
|
131
|
+
|
|
132
|
+
| Command kind | Headless trigger | Helpers |
|
|
133
|
+
| --- | --- | --- |
|
|
134
|
+
| Read / query | MCP, `--json`, or non-TTY | `shouldRunHeadless`, `wantsExplicitJson` |
|
|
135
|
+
| Mutate | MCP with args + `yes`/`dry-run`, or non-TTY with `--yes` | `shouldRunHeadlessWithYes`, `requireYesInNonTty` |
|
|
136
|
+
| Mutate with positionals | Same, but avoid auto-headless on empty argv | `shouldRunHeadlessWithPositionals` |
|
|
137
|
+
|
|
138
|
+
### Recommended handler shape
|
|
139
|
+
|
|
140
|
+
**Mutating command** (wizard optional, script path required):
|
|
141
|
+
|
|
142
|
+
```typescript
|
|
143
|
+
import {
|
|
144
|
+
requireYesInNonTty,
|
|
145
|
+
shouldRunHeadlessWithYes,
|
|
146
|
+
} from "argsbarg";
|
|
147
|
+
|
|
148
|
+
handler: async (ctx) => {
|
|
149
|
+
const dryRun = ctx.hasFlag("dry-run");
|
|
150
|
+
const yes = ctx.hasFlag("yes");
|
|
151
|
+
const env = ctx.args[0];
|
|
152
|
+
|
|
153
|
+
requireYesInNonTty(yes, "Example: myapp reserve qa2 --yes", dryRun);
|
|
154
|
+
|
|
155
|
+
if (shouldRunHeadlessWithYes(ctx, { yes, hasRequiredArgs: !!env, dryRun })) {
|
|
156
|
+
const result = await executeReserve({ dryRun, env, yes });
|
|
157
|
+
process.stdout.write(`${result.message}\n`);
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
await renderInteractiveWizard({ env, yes, dryRun });
|
|
162
|
+
};
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
**Read / query command** (optional `--json`):
|
|
166
|
+
|
|
167
|
+
```typescript
|
|
168
|
+
import { shouldRunHeadless, wantsExplicitJson } from "argsbarg";
|
|
169
|
+
|
|
170
|
+
handler: async (ctx) => {
|
|
171
|
+
const json = ctx.hasFlag("json");
|
|
172
|
+
|
|
173
|
+
if (shouldRunHeadless(ctx, json)) {
|
|
174
|
+
const data = await fetchStatus(ctx.args[0]);
|
|
175
|
+
if (wantsExplicitJson(ctx, json)) {
|
|
176
|
+
process.stdout.write(`${JSON.stringify(data, null, 2)}\n`);
|
|
177
|
+
} else {
|
|
178
|
+
process.stdout.write(formatStatusHuman(data));
|
|
179
|
+
}
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
await renderPage(<StatusPage env={ctx.args[0]} />);
|
|
184
|
+
};
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
### Rules of thumb
|
|
188
|
+
|
|
189
|
+
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.
|
|
190
|
+
2. **`--yes` on mutators** — required for non-TTY CLI scripts; MCP should pass `yes: true` in tool arguments when the schema exposes `yes`.
|
|
191
|
+
3. **Stdout is the contract** — headless paths write results to stdout (or JSON with `--json`); stderr for errors. No Ink on the MCP wire.
|
|
192
|
+
4. **`ctx.invocation === "mcp"`** — use only for wire-specific behavior (pipe child stdout, reject `--watch`, etc.), not to duplicate business logic.
|
|
193
|
+
5. **Hide only when impossible** — `mcpTool.enabled: false` after confirming no headless path exists (browser-only, irreducible streaming).
|
|
194
|
+
|
|
195
|
+
Basic synchronous handlers do not need this structure — only commands with an interactive branch.
|
|
196
|
+
|
|
197
|
+
## Reserved names
|
|
198
|
+
|
|
199
|
+
Do not declare user commands named `completion`, `install`, `mcp`, `version`, `docs`, or `update` at the root — ArgsBarg injects these when configured.
|
|
200
|
+
|
|
201
|
+
## Cursor rule for consumer repos
|
|
202
|
+
|
|
203
|
+
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.
|
|
204
|
+
|
|
205
|
+
## See also
|
|
206
|
+
|
|
207
|
+
- [MCP server](mcp.md) — tools, schema resource, env bootstrapping
|
|
208
|
+
- [Agent skills](ai-skills.md) — `install --skill`
|
|
209
|
+
- [Bundled docs](bundled-docs.md) — `docs` topics and `docs mcp`
|
package/docs/install.md
CHANGED
|
@@ -17,8 +17,8 @@ myapp update
|
|
|
17
17
|
# See what is installed
|
|
18
18
|
myapp install --status
|
|
19
19
|
|
|
20
|
-
# Remove everything
|
|
21
|
-
myapp install --uninstall --yes
|
|
20
|
+
# Remove everything installed with --all
|
|
21
|
+
myapp install --uninstall --all --yes
|
|
22
22
|
```
|
|
23
23
|
|
|
24
24
|
## What gets installed
|
|
@@ -33,7 +33,9 @@ myapp install --uninstall --yes
|
|
|
33
33
|
| Claude skill | `--skill` | `~/.claude/skills/<dir>/` when `~/.claude` exists |
|
|
34
34
|
| MCP config | `--mcp` | `~/.cursor/mcp.json` and `~/.claude.json` when MCP is enabled |
|
|
35
35
|
|
|
36
|
-
`--all` expands to `--bin`, `--completions`, `--skill`, and `--mcp` (when `mcpServer.enabled` is `true`).
|
|
36
|
+
`--all` expands to `--bin`, `--completions`, `--skill`, and `--mcp` (when `mcpServer.enabled` is `true`) for both install and uninstall. Missing targets are skipped silently (no error if nothing is on disk or a shell/agent directory does not exist).
|
|
37
|
+
|
|
38
|
+
`install --uninstall` requires the same target flags as install (`--all`, `--bin`, etc.) — bare `--uninstall` alone is an error.
|
|
37
39
|
|
|
38
40
|
Shells not on PATH are skipped silently (no warnings).
|
|
39
41
|
|
|
@@ -105,7 +107,7 @@ Environment:
|
|
|
105
107
|
| `--reinstall` | Reinstall artifacts already on disk (implies `--bin` + `--yes`) |
|
|
106
108
|
| `--from <path>` | Binary to copy with `--reinstall` (default: running executable) |
|
|
107
109
|
| `--status` | Read-only inventory |
|
|
108
|
-
| `--uninstall` | Remove
|
|
110
|
+
| `--uninstall` | Remove artifacts in scope (`--all`, `--bin`, `--completions`, `--skill`, `--mcp`); skips targets not installed |
|
|
109
111
|
|
|
110
112
|
`--update` is accepted as a deprecated alias for `--reinstall`.
|
|
111
113
|
|
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 to `export const …Command = { … } satisfies CliLeaf` (or router type) when shared flags, `mcpTool` spreads, or large handlers justify a module — not zero-arg wrapper functions
|
|
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/index.d.ts
CHANGED
|
@@ -307,8 +307,6 @@ export declare function cliErrWithHelp(ctx: CliContext, msg: string): never;
|
|
|
307
307
|
export declare const isInteractiveTty: boolean;
|
|
308
308
|
/** Minimal context for headless routing helpers. */
|
|
309
309
|
export type HeadlessContext = Pick<CliContext, "invocation">;
|
|
310
|
-
/** True when `--dry-run` was passed. */
|
|
311
|
-
export declare function wantsDryRun(hasDryRunFlag: boolean): boolean;
|
|
312
310
|
/** True when `--json` was passed or the handler was invoked via MCP. */
|
|
313
311
|
export declare function wantsExplicitJson(ctx: HeadlessContext, hasJsonFlag: boolean): boolean;
|
|
314
312
|
/**
|
package/package.json
CHANGED
package/src/builtins/install.ts
CHANGED
|
@@ -42,7 +42,7 @@ export function installBuiltinOptions(root: CliProgram): CliOption[] {
|
|
|
42
42
|
},
|
|
43
43
|
{
|
|
44
44
|
name: "uninstall",
|
|
45
|
-
description: "Remove installed artifacts (all
|
|
45
|
+
description: "Remove installed artifacts (use --all or scoped flags; skips targets not on disk).",
|
|
46
46
|
kind: CliOptionKind.Presence,
|
|
47
47
|
},
|
|
48
48
|
{
|
|
@@ -96,8 +96,8 @@ export function cliBuiltinInstallCommand(root: CliProgram): CliLeaf {
|
|
|
96
96
|
` {app} update\n\n` +
|
|
97
97
|
"See what is installed:\n" +
|
|
98
98
|
` {app} install --status\n\n` +
|
|
99
|
-
"Remove everything:\n" +
|
|
100
|
-
` {app} install --uninstall --yes\n\n` +
|
|
99
|
+
"Remove everything installed with --all:\n" +
|
|
100
|
+
` {app} install --uninstall --all --yes\n\n` +
|
|
101
101
|
"Use --dry to preview changes, --json for machine-readable output.",
|
|
102
102
|
options: installBuiltinOptions(root),
|
|
103
103
|
handler: () => {},
|
package/src/headless.test.ts
CHANGED
|
@@ -5,15 +5,9 @@ import {
|
|
|
5
5
|
shouldRunHeadless,
|
|
6
6
|
shouldRunHeadlessWithPositionals,
|
|
7
7
|
shouldRunHeadlessWithYes,
|
|
8
|
-
wantsDryRun,
|
|
9
8
|
wantsExplicitJson,
|
|
10
9
|
} from "./headless.ts";
|
|
11
10
|
|
|
12
|
-
test("wantsDryRun detects flag", () => {
|
|
13
|
-
expect(wantsDryRun(true)).toBe(true);
|
|
14
|
-
expect(wantsDryRun(false)).toBe(false);
|
|
15
|
-
});
|
|
16
|
-
|
|
17
11
|
test("wantsExplicitJson includes MCP invocation", () => {
|
|
18
12
|
expect(wantsExplicitJson({ invocation: "cli" }, false)).toBe(false);
|
|
19
13
|
expect(wantsExplicitJson({ invocation: "mcp" }, false)).toBe(true);
|
package/src/headless.ts
CHANGED
|
@@ -4,11 +4,6 @@ import { isInteractiveTty } from "./utils.ts";
|
|
|
4
4
|
/** Minimal context for headless routing helpers. */
|
|
5
5
|
export type HeadlessContext = Pick<CliContext, "invocation">;
|
|
6
6
|
|
|
7
|
-
/** True when `--dry-run` was passed. */
|
|
8
|
-
export function wantsDryRun(hasDryRunFlag: boolean): boolean {
|
|
9
|
-
return hasDryRunFlag;
|
|
10
|
-
}
|
|
11
|
-
|
|
12
7
|
/** True when `--json` was passed or the handler was invoked via MCP. */
|
|
13
8
|
export function wantsExplicitJson(ctx: HeadlessContext, hasJsonFlag: boolean): boolean {
|
|
14
9
|
return hasJsonFlag || ctx.invocation === "mcp";
|
package/src/index.ts
CHANGED
package/src/install/index.ts
CHANGED
|
@@ -13,7 +13,7 @@ import { resolveInstallPaths } from "./paths.ts";
|
|
|
13
13
|
import { installErr, installInfo, installOut, printInstallStatus } from "./status.ts";
|
|
14
14
|
import { buildUninstallPlan, uninstallSkillDir, type UninstallAction } from "./uninstall.ts";
|
|
15
15
|
|
|
16
|
-
function parseInstallOpts(raw: Record<string, string>): InstallOpts {
|
|
16
|
+
export function parseInstallOpts(raw: Record<string, string>): InstallOpts {
|
|
17
17
|
const flag = (name: string) => raw[name] === "1";
|
|
18
18
|
const reinstall = flag("reinstall") || flag("update");
|
|
19
19
|
return {
|
|
@@ -34,7 +34,7 @@ function parseInstallOpts(raw: Record<string, string>): InstallOpts {
|
|
|
34
34
|
};
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
function
|
|
37
|
+
export function validateInstallOpts(opts: InstallOpts): string | null {
|
|
38
38
|
if (opts.quiet && opts.dry) {
|
|
39
39
|
return "--quiet cannot be combined with --dry.";
|
|
40
40
|
}
|
|
@@ -60,10 +60,10 @@ function validateOpts(opts: InstallOpts): string | null {
|
|
|
60
60
|
) {
|
|
61
61
|
return "--reinstall cannot be combined with other target flags.";
|
|
62
62
|
}
|
|
63
|
-
if (opts.uninstall && (opts.
|
|
64
|
-
return "--uninstall cannot be combined with --
|
|
63
|
+
if (opts.uninstall && (opts.reinstall || opts.status)) {
|
|
64
|
+
return "--uninstall cannot be combined with --reinstall or --status.";
|
|
65
65
|
}
|
|
66
|
-
if (!opts.status && !opts.reinstall
|
|
66
|
+
if (!opts.status && !opts.reinstall) {
|
|
67
67
|
const hasTarget = opts.all || opts.bin || opts.completions || opts.skill || opts.mcp;
|
|
68
68
|
if (!hasTarget) {
|
|
69
69
|
return "Specify at least one target: --all, --bin, --completions, --skill, or --mcp.";
|
|
@@ -126,7 +126,7 @@ export async function runInstallMutation(
|
|
|
126
126
|
rawOpts: Record<string, string>,
|
|
127
127
|
): Promise<{ changed: string[]; opts: InstallOpts; paths: ReturnType<typeof resolveInstallPaths> }> {
|
|
128
128
|
const opts = parseInstallOpts(rawOpts);
|
|
129
|
-
const err =
|
|
129
|
+
const err = validateInstallOpts(opts);
|
|
130
130
|
if (err) {
|
|
131
131
|
throw new Error(err);
|
|
132
132
|
}
|
|
@@ -159,7 +159,7 @@ export async function runInstallMutation(
|
|
|
159
159
|
}
|
|
160
160
|
|
|
161
161
|
if (actions.length === 0) {
|
|
162
|
-
|
|
162
|
+
return { changed: [], opts, paths };
|
|
163
163
|
}
|
|
164
164
|
|
|
165
165
|
if (!opts.quiet && !opts.json) {
|
|
@@ -218,5 +218,3 @@ export async function cliInstall(root: CliProgram, rawOpts: Record<string, strin
|
|
|
218
218
|
|
|
219
219
|
process.exit(0);
|
|
220
220
|
}
|
|
221
|
-
|
|
222
|
-
export { parseInstallOpts };
|
|
@@ -6,8 +6,9 @@ import { CliProgram } from "../types.ts";
|
|
|
6
6
|
import { detectInstalledArtifacts } from "./detect-installed.ts";
|
|
7
7
|
import { resolveInstallPaths } from "./paths.ts";
|
|
8
8
|
import { buildInstallPlan } from "./plan.ts";
|
|
9
|
+
import { buildUninstallPlan } from "./uninstall.ts";
|
|
9
10
|
import { printInstallStatus } from "./status.ts";
|
|
10
|
-
import { parseInstallOpts } from "./index.ts";
|
|
11
|
+
import { parseInstallOpts, runInstallMutation, validateInstallOpts } from "./index.ts";
|
|
11
12
|
|
|
12
13
|
const fixture: CliProgram = {
|
|
13
14
|
key: "testapp",
|
|
@@ -123,3 +124,65 @@ describe("parseInstallOpts", () => {
|
|
|
123
124
|
expect(opts.all).toBe(true);
|
|
124
125
|
});
|
|
125
126
|
});
|
|
127
|
+
|
|
128
|
+
describe("validateInstallOpts", () => {
|
|
129
|
+
test("uninstall requires a target flag", () => {
|
|
130
|
+
const opts = parseInstallOpts({ uninstall: "1", yes: "1" });
|
|
131
|
+
expect(validateInstallOpts(opts)).toContain("Specify at least one target");
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("uninstall allows --all", () => {
|
|
135
|
+
const opts = parseInstallOpts({ uninstall: "1", all: "1", yes: "1" });
|
|
136
|
+
expect(validateInstallOpts(opts)).toBeNull();
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("uninstall rejects --reinstall", () => {
|
|
140
|
+
const opts = parseInstallOpts({ uninstall: "1", reinstall: "1", all: "1" });
|
|
141
|
+
expect(validateInstallOpts(opts)).toContain("--reinstall");
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
describe("install mutation", () => {
|
|
146
|
+
test("uninstall --all with nothing installed succeeds", async () => {
|
|
147
|
+
const result = await runInstallMutation(fixture, { uninstall: "1", all: "1", yes: "1" });
|
|
148
|
+
expect(result.changed).toEqual([]);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("install --skill with no agent dirs succeeds", async () => {
|
|
152
|
+
const result = await runInstallMutation(fixture, { skill: "1", yes: "1", dry: "1" });
|
|
153
|
+
expect(result.changed).toEqual([]);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("uninstall --all removes detected binary", async () => {
|
|
157
|
+
const paths = resolveInstallPaths(fixture, {});
|
|
158
|
+
mkdirSync(paths.bindir, { recursive: true });
|
|
159
|
+
writeFileSync(paths.binaryPath, "fake", "utf8");
|
|
160
|
+
|
|
161
|
+
const result = await runInstallMutation(fixture, { uninstall: "1", all: "1", yes: "1" });
|
|
162
|
+
expect(result.changed).toContain(paths.binaryPath);
|
|
163
|
+
expect(existsSync(paths.binaryPath)).toBe(false);
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
describe("uninstall plan", () => {
|
|
168
|
+
test("buildUninstallPlan --all scopes like install --all", () => {
|
|
169
|
+
const paths = resolveInstallPaths(fixture, {});
|
|
170
|
+
mkdirSync(paths.bindir, { recursive: true });
|
|
171
|
+
writeFileSync(paths.binaryPath, "fake", "utf8");
|
|
172
|
+
|
|
173
|
+
const plan = buildUninstallPlan(fixture, paths, parseInstallOpts({ uninstall: "1", all: "1" }));
|
|
174
|
+
expect(plan.some((a) => a.summary.startsWith("binary:"))).toBe(true);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test("buildUninstallPlan --bin ignores completions", () => {
|
|
178
|
+
const paths = resolveInstallPaths(fixture, {});
|
|
179
|
+
mkdirSync(paths.bindir, { recursive: true });
|
|
180
|
+
writeFileSync(paths.binaryPath, "fake", "utf8");
|
|
181
|
+
mkdirSync(join(home, ".bash_completion.d"), { recursive: true });
|
|
182
|
+
writeFileSync(paths.bashCompletion, "# bash", "utf8");
|
|
183
|
+
|
|
184
|
+
const plan = buildUninstallPlan(fixture, paths, parseInstallOpts({ uninstall: "1", bin: "1" }));
|
|
185
|
+
expect(plan).toHaveLength(1);
|
|
186
|
+
expect(plan[0]!.summary.startsWith("binary:")).toBe(true);
|
|
187
|
+
});
|
|
188
|
+
});
|
package/src/install/plan.ts
CHANGED
|
@@ -35,19 +35,19 @@ export interface InstallAction {
|
|
|
35
35
|
run: () => string[];
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
-
function
|
|
38
|
+
export function wantsInstallBin(opts: InstallOpts): boolean {
|
|
39
39
|
return !!(opts.all || opts.bin || opts.reinstall);
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
-
function
|
|
42
|
+
export function wantsInstallCompletions(opts: InstallOpts): boolean {
|
|
43
43
|
return !!(opts.all || opts.completions);
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
-
function
|
|
46
|
+
export function wantsInstallSkill(opts: InstallOpts): boolean {
|
|
47
47
|
return !!(opts.all || opts.skill);
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
-
function
|
|
50
|
+
export function wantsInstallMcp(opts: InstallOpts, root: CliProgram): boolean {
|
|
51
51
|
return !!(opts.mcp || opts.all) && resolveCapabilities(root).mcp;
|
|
52
52
|
}
|
|
53
53
|
|
|
@@ -56,7 +56,7 @@ export function buildInstallPlan(root: CliProgram, paths: InstallPaths, opts: In
|
|
|
56
56
|
const actions: InstallAction[] = [];
|
|
57
57
|
const dry = !!opts.dry;
|
|
58
58
|
|
|
59
|
-
if (
|
|
59
|
+
if (wantsInstallBin(opts)) {
|
|
60
60
|
const sourcePath = opts.from ?? process.execPath;
|
|
61
61
|
actions.push({
|
|
62
62
|
kind: "binary",
|
|
@@ -66,7 +66,7 @@ export function buildInstallPlan(root: CliProgram, paths: InstallPaths, opts: In
|
|
|
66
66
|
});
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
-
if (
|
|
69
|
+
if (wantsInstallCompletions(opts)) {
|
|
70
70
|
const shells = detectShells();
|
|
71
71
|
if (shells.bash) {
|
|
72
72
|
actions.push({
|
|
@@ -103,7 +103,7 @@ export function buildInstallPlan(root: CliProgram, paths: InstallPaths, opts: In
|
|
|
103
103
|
}
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
-
if (
|
|
106
|
+
if (wantsInstallSkill(opts)) {
|
|
107
107
|
const home = userHome();
|
|
108
108
|
if (existsSync(join(home, ".cursor"))) {
|
|
109
109
|
actions.push({
|
|
@@ -123,7 +123,7 @@ export function buildInstallPlan(root: CliProgram, paths: InstallPaths, opts: In
|
|
|
123
123
|
}
|
|
124
124
|
}
|
|
125
125
|
|
|
126
|
-
if (
|
|
126
|
+
if (wantsInstallMcp(opts, root)) {
|
|
127
127
|
const entry = expectedMcpEntry(root);
|
|
128
128
|
if (existsSync(join(userHome(), ".cursor"))) {
|
|
129
129
|
actions.push({
|
package/src/install/uninstall.ts
CHANGED
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
import { existsSync, rmSync } from "node:fs";
|
|
2
|
-
import { join } from "node:path";
|
|
3
|
-
import { resolveCapabilities } from "../capabilities.ts";
|
|
4
2
|
import { CliProgram } from "../types.ts";
|
|
5
3
|
import { uninstallBinary } from "./binary.ts";
|
|
6
4
|
import { uninstallCompletions } from "./completions.ts";
|
|
7
5
|
import { detectInstalledArtifacts } from "./detect-installed.ts";
|
|
8
6
|
import { removeMcpConfig } from "./mcp-config.ts";
|
|
9
|
-
import { InstallPaths
|
|
10
|
-
import
|
|
7
|
+
import { InstallPaths } from "./paths.ts";
|
|
8
|
+
import {
|
|
9
|
+
wantsInstallBin,
|
|
10
|
+
wantsInstallCompletions,
|
|
11
|
+
wantsInstallMcp,
|
|
12
|
+
wantsInstallSkill,
|
|
13
|
+
type InstallOpts,
|
|
14
|
+
} from "./plan.ts";
|
|
11
15
|
|
|
12
16
|
export interface UninstallAction {
|
|
13
17
|
summary: string;
|
|
@@ -15,22 +19,17 @@ export interface UninstallAction {
|
|
|
15
19
|
run: () => string[];
|
|
16
20
|
}
|
|
17
21
|
|
|
18
|
-
|
|
19
|
-
return !opts.bin && !opts.completions && !opts.skill && !opts.mcp;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
/** Builds uninstall actions from detected artifacts. */
|
|
22
|
+
/** Builds uninstall actions for scoped targets (--all mirrors install --all). */
|
|
23
23
|
export function buildUninstallPlan(
|
|
24
24
|
root: CliProgram,
|
|
25
25
|
paths: InstallPaths,
|
|
26
26
|
opts: InstallOpts,
|
|
27
27
|
): UninstallAction[] {
|
|
28
28
|
const detected = detectInstalledArtifacts(paths);
|
|
29
|
-
const all = scopeAll(opts);
|
|
30
29
|
const dry = !!opts.dry;
|
|
31
30
|
const actions: UninstallAction[] = [];
|
|
32
31
|
|
|
33
|
-
if ((
|
|
32
|
+
if (wantsInstallBin(opts) && detected.binary) {
|
|
34
33
|
actions.push({
|
|
35
34
|
summary: `binary: ${paths.binaryPath}`,
|
|
36
35
|
message: `Removing binary ${paths.binaryPath}`,
|
|
@@ -38,7 +37,10 @@ export function buildUninstallPlan(
|
|
|
38
37
|
});
|
|
39
38
|
}
|
|
40
39
|
|
|
41
|
-
if (
|
|
40
|
+
if (
|
|
41
|
+
wantsInstallCompletions(opts) &&
|
|
42
|
+
(detected.bashCompletion || detected.zshCompletion || detected.fishCompletion)
|
|
43
|
+
) {
|
|
42
44
|
if (detected.bashCompletion) {
|
|
43
45
|
actions.push({
|
|
44
46
|
summary: `bash completion: ${paths.bashCompletion}`,
|
|
@@ -62,7 +64,7 @@ export function buildUninstallPlan(
|
|
|
62
64
|
}
|
|
63
65
|
}
|
|
64
66
|
|
|
65
|
-
if ((
|
|
67
|
+
if (wantsInstallSkill(opts) && detected.cursorSkill) {
|
|
66
68
|
actions.push({
|
|
67
69
|
summary: `cursor skill: ${paths.cursorSkillDir}/`,
|
|
68
70
|
message: `Removing Cursor skill ${paths.cursorSkillDir}/`,
|
|
@@ -70,7 +72,7 @@ export function buildUninstallPlan(
|
|
|
70
72
|
});
|
|
71
73
|
}
|
|
72
74
|
|
|
73
|
-
if ((
|
|
75
|
+
if (wantsInstallSkill(opts) && detected.claudeSkill) {
|
|
74
76
|
actions.push({
|
|
75
77
|
summary: `claude skill: ${paths.claudeSkillDir}/`,
|
|
76
78
|
message: `Removing Claude Code skill ${paths.claudeSkillDir}/`,
|
|
@@ -78,7 +80,7 @@ export function buildUninstallPlan(
|
|
|
78
80
|
});
|
|
79
81
|
}
|
|
80
82
|
|
|
81
|
-
if ((
|
|
83
|
+
if (wantsInstallMcp(opts, root)) {
|
|
82
84
|
if (detected.cursorMcp) {
|
|
83
85
|
actions.push({
|
|
84
86
|
summary: `cursor mcp: ${paths.cursorMcpPath}`,
|