argsbarg 3.3.1 → 3.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [3.3.3] - 2026-06-21
11
+
12
+ ### Added
13
+
14
+ - **`docs --save`** — write bundled docs to `./docs/`; `docs all --save` writes one file per topic instead of combined stdout output. Argsbarg-generated markdown (`mcp`, `api`, `skill`) is prefixed with a `Generated by … docs … --save` HTML comment.
15
+
16
+ ## [3.3.2] - 2026-06-21
17
+
18
+ ### Changed
19
+
20
+ - **`install --uninstall`** — symmetric with install: requires `--all` or scoped flags; `--uninstall --all` removes everything argsbarg installed; empty scope succeeds without error.
21
+
10
22
  ## [3.3.1] - 2026-06-21
11
23
 
12
24
  ### Added
@@ -243,7 +255,9 @@ const cli = { ... } satisfies CliProgram; // or : CliProgram
243
255
  - 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`).
244
256
  - Imports: use `CliPositional` where needed; replace `CliOptionDef` with `CliOption` or `CliPositional` as appropriate.
245
257
 
246
- [Unreleased]: https://github.com/bdombro/bun-argsbarg/compare/v3.3.1...HEAD
258
+ [Unreleased]: https://github.com/bdombro/bun-argsbarg/compare/v3.3.3...HEAD
259
+ [3.3.3]: https://github.com/bdombro/bun-argsbarg/releases/tag/v3.3.3
260
+ [3.3.2]: https://github.com/bdombro/bun-argsbarg/releases/tag/v3.3.2
247
261
  [3.3.1]: https://github.com/bdombro/bun-argsbarg/releases/tag/v3.3.1
248
262
  [3.3.0]: https://github.com/bdombro/bun-argsbarg/releases/tag/v3.3.0
249
263
  [3.2.0]: https://github.com/bdombro/bun-argsbarg/releases/tag/v3.2.0
@@ -32,6 +32,8 @@ myapp docs api # command tree as markdown
32
32
  myapp docs skill # generated Cursor SKILL.md
33
33
  myapp docs all # all user topics; includes auto mcp when MCP enabled
34
34
  myapp docs mcp # auto-generated when mcpServer.enabled
35
+ myapp docs readme --save # write ./docs/readme.md
36
+ myapp docs all --save # write ./docs/<topic>.md for each bundled topic
35
37
  ```
36
38
 
37
39
  ## Configuration
@@ -89,3 +91,19 @@ All `docs` subcommands are hidden from MCP `tools/list` (`mcpTool: { enabled: fa
89
91
  | `mcp` | Callable tools + schema resource |
90
92
 
91
93
  Do not declare a top-level command named **`docs`** when `docs.enabled` is `true` — it is reserved.
94
+
95
+ ## Save to disk (`--save`)
96
+
97
+ Pass **`--save`** on `docs` or any docs subcommand to write files under **`./docs/`** (relative to the current working directory). Each saved path is printed on stdout.
98
+
99
+ | Command | Output |
100
+ | --- | --- |
101
+ | `docs readme --save` | `./docs/readme.md` |
102
+ | `docs schema --save` | `./docs/schema.json` |
103
+ | `docs api --save` | `./docs/api.md` |
104
+ | `docs skill --save` | `./docs/skill.md` |
105
+ | `docs all --save` | one file per bundled topic (`readme.md`, `mcp.md`, …) — not a combined file |
106
+
107
+ `docs all --save` uses the same topic set as stdout `docs all` (user topics plus auto `mcp` when enabled). It does not include `schema`, `api`, or `skill` unless you invoke those subcommands separately.
108
+
109
+ Argsbarg-generated markdown (`mcp`, `api`, `skill`) is prefixed with an HTML comment: `<!-- Generated by myapp docs api --save; do not edit. -->`. Consumer topic files and `schema.json` are written as-is.
@@ -52,12 +52,27 @@ ArgsBarg is **schema-first** — the program tree is the product. **Keep `CliPro
52
52
  | --- | --- |
53
53
  | Shared option objects (`DRY_RUN_OPTION`, `JSON_OPTION`) | Identical flag reused on many leaves |
54
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) |
55
+ | `commands/<name>/command.tsx` module | Entry file is large; handler/body is substantial (Ink page, headless dispatch) |
56
56
  | `docs.topics` text imports | Compile-time markdown bundling — not schema shape |
57
57
 
58
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
59
 
60
- **`satisfies CliProgram`** on the root (or on extracted command factories) preserves type-checking whether inline or not.
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.
61
76
 
62
77
  ## Descriptions
63
78
 
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 detected
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 detected artifacts (scope with `--bin`, `--completions`, `--skill`, `--mcp`) |
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
 
@@ -13,7 +13,7 @@ alwaysApply: false
13
13
  - Reserved root commands (do not declare): `completion`, `install`, `mcp`, `version`, `docs`, `update`
14
14
 
15
15
  ## Schema
16
- - **Inline** options, positionals, and handler schema; extract only for shared flags, `mcpTool` spreads, or large handlers (Ink/dispatch)
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
17
  - Leaf descriptions: action-oriented, not UI jargon
18
18
  - Prefer option names `yes`, `dry-run`, `json` when semantics match; describe non-interactive use on the option
19
19
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "argsbarg",
3
- "version": "3.3.1",
3
+ "version": "3.3.3",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "bun": ">=1.3"
@@ -42,7 +42,7 @@ export function installBuiltinOptions(root: CliProgram): CliOption[] {
42
42
  },
43
43
  {
44
44
  name: "uninstall",
45
- description: "Remove installed artifacts (all detected, or limit with other flags).",
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: () => {},
@@ -1,4 +1,4 @@
1
- import { CliFallbackMode, type CliLeaf, type CliProgram, type CliRouter } from "../types.ts";
1
+ import { CliFallbackMode, CliOptionKind, type CliLeaf, type CliOption, type CliProgram, type CliRouter } from "../types.ts";
2
2
  import {
3
3
  DOCS_ROUTER_DESCRIPTION,
4
4
  docsEffectiveDefaultTopic,
@@ -8,14 +8,32 @@ import {
8
8
  docsUserTopicKeys,
9
9
  printDocsTopic,
10
10
  } from "./resolve.ts";
11
+ import { saveDocsTopics } from "./save.ts";
12
+
13
+ const DOCS_SAVE_OPTION: CliOption = {
14
+ name: "save",
15
+ description: "Write documentation to ./docs/ (`docs all` writes one file per topic).",
16
+ kind: CliOptionKind.Presence,
17
+ };
18
+
19
+ function runDocsTopic(program: CliProgram, topic: string, ctx: { hasFlag(name: string): boolean }): void {
20
+ if (ctx.hasFlag("save")) {
21
+ for (const path of saveDocsTopics(program, topic)) {
22
+ process.stdout.write(`${path}\n`);
23
+ }
24
+ return;
25
+ }
26
+ printDocsTopic(program, topic);
27
+ }
11
28
 
12
29
  function docsLeaf(program: CliProgram, key: string, description: string): CliLeaf {
13
30
  return {
14
31
  key,
15
32
  description,
33
+ options: [DOCS_SAVE_OPTION],
16
34
  mcpTool: { enabled: false },
17
- handler: () => {
18
- printDocsTopic(program, key);
35
+ handler: (ctx) => {
36
+ runDocsTopic(program, key, ctx);
19
37
  },
20
38
  };
21
39
  }
@@ -46,6 +64,7 @@ export function cliBuiltinDocsGroup(program: CliProgram): CliRouter {
46
64
  return {
47
65
  key: "docs",
48
66
  description: docs.description ?? DOCS_ROUTER_DESCRIPTION,
67
+ options: [DOCS_SAVE_OPTION],
49
68
  fallbackCommand: docsEffectiveDefaultTopic(docs),
50
69
  fallbackMode: CliFallbackMode.MissingOnly,
51
70
  commands: leaves,
@@ -1,4 +1,7 @@
1
- import { expect, test } from "bun:test";
1
+ import { describe, expect, test, beforeEach, afterEach } from "bun:test";
2
+ import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { tmpdir } from "node:os";
2
5
  import { cliPresentationRoot } from "../builtins/presentation.ts";
3
6
  import { completionBashScript } from "../completion.ts";
4
7
  import { cliInvoke } from "../index.ts";
@@ -6,6 +9,21 @@ import type { CliProgram } from "../types.ts";
6
9
  import { cliValidateProgram } from "../validate.ts";
7
10
  import { combineAllDocs, docsEffectiveDefaultTopic } from "./resolve.ts";
8
11
  import { generateMcpGuide } from "./mcp-guide.ts";
12
+ import { docsTopicsToSave, saveDocsTopics } from "./save.ts";
13
+
14
+ let workDir: string;
15
+ let prevCwd: string;
16
+
17
+ beforeEach(() => {
18
+ workDir = mkdtempSync(join(tmpdir(), "argsbarg-docs-save-"));
19
+ prevCwd = process.cwd();
20
+ process.chdir(workDir);
21
+ });
22
+
23
+ afterEach(() => {
24
+ process.chdir(prevCwd);
25
+ rmSync(workDir, { recursive: true, force: true });
26
+ });
9
27
 
10
28
  function docsFixture(mcp = true): CliProgram {
11
29
  return {
@@ -165,3 +183,52 @@ test("generateMcpGuide includes schema URI", () => {
165
183
  const guide = generateMcpGuide(docsFixture(true));
166
184
  expect(guide).toContain("myapp://schema");
167
185
  });
186
+
187
+ test("docs --save writes topic file", async () => {
188
+ const result = await cliInvoke(docsFixture(), ["docs", "readme", "--save"]);
189
+ expect(result.exitCode).toBe(0);
190
+ expect(result.stdout.trim()).toBe("docs/readme.md");
191
+ const text = readFileSync(join(workDir, "docs/readme.md"), "utf8");
192
+ expect(text).toContain("Hello README");
193
+ expect(text).not.toContain("Generated by");
194
+ });
195
+
196
+ test("docs api --save prepends generated hint", async () => {
197
+ const result = await cliInvoke(docsFixture(), ["docs", "api", "--save"]);
198
+ expect(result.exitCode).toBe(0);
199
+ const text = readFileSync(join(workDir, "docs/api.md"), "utf8");
200
+ expect(text.startsWith("<!-- Generated by myapp docs api --save; do not edit. -->\n\n")).toBe(
201
+ true,
202
+ );
203
+ expect(text).toContain("CLI API reference");
204
+ });
205
+
206
+ test("docs schema --save writes JSON file", async () => {
207
+ const result = await cliInvoke(docsFixture(), ["docs", "schema", "--save"]);
208
+ expect(result.exitCode).toBe(0);
209
+ expect(result.stdout.trim()).toBe("docs/schema.json");
210
+ const text = readFileSync(join(workDir, "docs/schema.json"), "utf8");
211
+ expect(text).not.toContain("Generated by");
212
+ const schema = JSON.parse(text);
213
+ expect(schema.key).toBe("myapp");
214
+ });
215
+
216
+ test("docs all --save writes separate topic files", async () => {
217
+ const program = docsFixture(true);
218
+ const result = await cliInvoke(program, ["docs", "all", "--save"]);
219
+ expect(result.exitCode).toBe(0);
220
+ const lines = result.stdout.trim().split("\n");
221
+ expect(lines).toEqual(docsTopicsToSave(program, "all").map((key) => `docs/${key}.md`));
222
+ expect(readFileSync(join(workDir, "docs/readme.md"), "utf8")).not.toContain("Generated by");
223
+ expect(readFileSync(join(workDir, "docs/mcp.md"), "utf8")).toContain(
224
+ "<!-- Generated by myapp docs mcp --save; do not edit. -->",
225
+ );
226
+ });
227
+
228
+ test("saveDocsTopics returns relative paths", () => {
229
+ const paths = saveDocsTopics(docsFixture(), "api");
230
+ expect(paths).toEqual(["docs/api.md"]);
231
+ const text = readFileSync(join(workDir, "docs/api.md"), "utf8");
232
+ expect(text).toContain("<!-- Generated by myapp docs api --save; do not edit. -->");
233
+ expect(text).toContain("CLI API reference");
234
+ });
@@ -77,6 +77,24 @@ export function docsTopicText(program: CliProgram, topic: string): string {
77
77
  return entry.text;
78
78
  }
79
79
 
80
+ /** Full file body for a docs topic (stdout or `--save`). */
81
+ export function docsTopicContent(program: CliProgram, topic: string): string {
82
+ if (topic === "schema") {
83
+ return cliSchemaJson(program);
84
+ }
85
+ if (topic === "api") {
86
+ return generateApiGuide(program);
87
+ }
88
+ if (topic === "skill") {
89
+ return `${generateSkillBundle(program, "cursor").skillMd}\n`;
90
+ }
91
+ if (topic === "all") {
92
+ return `${combineAllDocs(program)}\n`;
93
+ }
94
+ const text = docsTopicText(program, topic);
95
+ return text.endsWith("\n") ? text : `${text}\n`;
96
+ }
97
+
80
98
  /** All bundled docs concatenated with horizontal rules. */
81
99
  export function combineAllDocs(program: CliProgram): string {
82
100
  return docsPrintOrder(program)
@@ -84,36 +102,7 @@ export function combineAllDocs(program: CliProgram): string {
84
102
  .join("\n\n---\n\n");
85
103
  }
86
104
 
87
- /** Writes CLI schema JSON to stdout (`docs schema`). */
88
- export function printDocsSchema(program: CliProgram): void {
89
- process.stdout.write(cliSchemaJson(program));
90
- }
91
-
92
- /** Writes markdown API reference to stdout (`docs api`). */
93
- export function printDocsApi(program: CliProgram): void {
94
- process.stdout.write(generateApiGuide(program));
95
- }
96
-
97
- /** Writes generated Cursor SKILL.md to stdout (`docs skill`). */
98
- export function printDocsSkill(program: CliProgram): void {
99
- const bundle = generateSkillBundle(program, "cursor");
100
- process.stdout.write(`${bundle.skillMd}\n`);
101
- }
102
-
103
105
  /** Writes one docs topic (or `all`) to stdout. */
104
106
  export function printDocsTopic(program: CliProgram, topic: string): void {
105
- if (topic === "schema") {
106
- printDocsSchema(program);
107
- return;
108
- }
109
- if (topic === "api") {
110
- printDocsApi(program);
111
- return;
112
- }
113
- if (topic === "skill") {
114
- printDocsSkill(program);
115
- return;
116
- }
117
- const content = topic === "all" ? combineAllDocs(program) : docsTopicText(program, topic);
118
- process.stdout.write(`${content}\n`);
107
+ process.stdout.write(docsTopicContent(program, topic));
119
108
  }
@@ -0,0 +1,60 @@
1
+ import { mkdirSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import type { CliProgram } from "../types.ts";
4
+ import { docsPrintOrder, docsTopicContent } from "./resolve.ts";
5
+
6
+ /** Relative output directory for `docs --save`. */
7
+ export const DOCS_SAVE_DIR = "docs";
8
+
9
+ /** Builtin docs topics generated by argsbarg (not consumer `docs.topics`). */
10
+ export const DOCS_GENERATED_SAVE_TOPICS = ["mcp", "api", "skill"] as const;
11
+
12
+ /** Whether `--save` should prepend a generated-file hint (argsbarg writers only). */
13
+ export function docsTopicIsGeneratedByArgsbarg(topic: string): boolean {
14
+ return (DOCS_GENERATED_SAVE_TOPICS as readonly string[]).includes(topic);
15
+ }
16
+
17
+ /** HTML comment prepended to generated markdown saved with `--save`. */
18
+ export function docsSaveGeneratedHint(program: CliProgram, topic: string): string {
19
+ return `<!-- Generated by ${program.key} docs ${topic} --save; do not edit. -->\n\n`;
20
+ }
21
+
22
+ /** File body for `--save` (hint on argsbarg-generated markdown only). */
23
+ export function docsTopicContentForSave(program: CliProgram, topic: string): string {
24
+ const content = docsTopicContent(program, topic);
25
+ if (!docsTopicIsGeneratedByArgsbarg(topic)) {
26
+ return content;
27
+ }
28
+ return `${docsSaveGeneratedHint(program, topic)}${content}`;
29
+ }
30
+
31
+ /** Filename for a saved docs topic. */
32
+ export function docsSaveFilename(topic: string): string {
33
+ if (topic === "schema") {
34
+ return "schema.json";
35
+ }
36
+ return `${topic}.md`;
37
+ }
38
+
39
+ /** Topic keys to write for one `docs` invocation (`all` expands to bundled topics). */
40
+ export function docsTopicsToSave(program: CliProgram, topic: string): string[] {
41
+ if (topic === "all") {
42
+ return docsPrintOrder(program);
43
+ }
44
+ return [topic];
45
+ }
46
+
47
+ /** Writes docs topic(s) under `./docs/`; returns relative paths written. */
48
+ export function saveDocsTopics(program: CliProgram, topic: string): string[] {
49
+ const dir = join(process.cwd(), DOCS_SAVE_DIR);
50
+ mkdirSync(dir, { recursive: true });
51
+
52
+ const saved: string[] = [];
53
+ for (const key of docsTopicsToSave(program, topic)) {
54
+ const rel = join(DOCS_SAVE_DIR, docsSaveFilename(key));
55
+ const abs = join(process.cwd(), rel);
56
+ writeFileSync(abs, docsTopicContentForSave(program, key), "utf8");
57
+ saved.push(rel);
58
+ }
59
+ return saved;
60
+ }
@@ -1,5 +1,4 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
- import { CliProgram } from "../types.ts";
3
2
  import { InstallPaths } from "./paths.ts";
4
3
 
5
4
  export interface InstalledArtifacts {
@@ -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 validateOpts(opts: InstallOpts): string | null {
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.all || opts.reinstall || opts.status)) {
64
- return "--uninstall cannot be combined with --all, --reinstall, or --status.";
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 && !opts.uninstall) {
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 = validateOpts(opts);
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
- throw new Error("Nothing to do.");
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
+ });
@@ -1,7 +1,6 @@
1
1
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { dirname } from "node:path";
3
3
  import { CliProgram } from "../types.ts";
4
- import { InstallPaths } from "./paths.ts";
5
4
 
6
5
  export interface McpServerEntry {
7
6
  command: string;
@@ -35,19 +35,19 @@ export interface InstallAction {
35
35
  run: () => string[];
36
36
  }
37
37
 
38
- function wantsBin(opts: InstallOpts): boolean {
38
+ export function wantsInstallBin(opts: InstallOpts): boolean {
39
39
  return !!(opts.all || opts.bin || opts.reinstall);
40
40
  }
41
41
 
42
- function wantsCompletions(opts: InstallOpts): boolean {
42
+ export function wantsInstallCompletions(opts: InstallOpts): boolean {
43
43
  return !!(opts.all || opts.completions);
44
44
  }
45
45
 
46
- function wantsSkill(opts: InstallOpts): boolean {
46
+ export function wantsInstallSkill(opts: InstallOpts): boolean {
47
47
  return !!(opts.all || opts.skill);
48
48
  }
49
49
 
50
- function wantsMcp(opts: InstallOpts, root: CliProgram): boolean {
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 (wantsBin(opts)) {
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 (wantsCompletions(opts)) {
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 (wantsSkill(opts)) {
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 (wantsMcp(opts, root)) {
126
+ if (wantsInstallMcp(opts, root)) {
127
127
  const entry = expectedMcpEntry(root);
128
128
  if (existsSync(join(userHome(), ".cursor"))) {
129
129
  actions.push({
@@ -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, userHome } from "./paths.ts";
10
- import type { InstallOpts } from "./plan.ts";
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
- function scopeAll(opts: InstallOpts): boolean {
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 ((all || opts.bin) && detected.binary) {
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 ((all || opts.completions) && (detected.bashCompletion || detected.zshCompletion || detected.fishCompletion)) {
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 ((all || opts.skill) && detected.cursorSkill) {
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 ((all || opts.skill) && detected.claudeSkill) {
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 ((all || opts.mcp) && resolveCapabilities(root).mcp) {
83
+ if (wantsInstallMcp(opts, root)) {
82
84
  if (detected.cursorMcp) {
83
85
  actions.push({
84
86
  summary: `cursor mcp: ${paths.cursorMcpPath}`,