argsbarg 4.0.4 → 4.1.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.
Files changed (147) hide show
  1. package/CHANGELOG.md +77 -1
  2. package/README.md +91 -85
  3. package/docs/README.md +6 -6
  4. package/docs/ai-skills.md +8 -5
  5. package/docs/bundled-docs.md +1 -1
  6. package/docs/cli-program.md +9 -7
  7. package/docs/config-schema.md +37 -13
  8. package/docs/developing.md +8 -8
  9. package/docs/distribution-homebrew.md +103 -0
  10. package/docs/install.md +143 -106
  11. package/docs/mcp.md +23 -12
  12. package/docs/output-schema.md +1 -1
  13. package/examples/full-example/Formula/.gitkeep +0 -0
  14. package/examples/full-example/README.md +98 -0
  15. package/examples/full-example/biome.json +22 -0
  16. package/examples/{consumer-app → full-example}/bun.lock +2 -0
  17. package/examples/full-example/justfile +134 -0
  18. package/examples/{consumer-app → full-example}/package.json +10 -3
  19. package/examples/{consumer-app → full-example}/schemas/generated/app-config.json +1 -1
  20. package/examples/{consumer-app → full-example}/schemas/generated/status.json +1 -1
  21. package/examples/full-example/scripts/create-identity.ts +11 -0
  22. package/examples/full-example/scripts/formula-shared.ts +73 -0
  23. package/examples/full-example/scripts/gen-dev-formula.ts +26 -0
  24. package/examples/full-example/scripts/print-identity.ts +27 -0
  25. package/examples/full-example/src/commands/echo/command.ts +21 -0
  26. package/examples/full-example/src/commands/status/command.test.ts +10 -0
  27. package/examples/full-example/src/commands/status/command.ts +36 -0
  28. package/examples/{consumer-app → full-example}/src/commands/status/types.ts +1 -1
  29. package/examples/full-example/src/index.ts +10 -0
  30. package/examples/full-example/src/program.ts +57 -0
  31. package/examples/{consumer-app → full-example}/src/types.ts +1 -1
  32. package/examples/mcp-test.ts +8 -24
  33. package/examples/nested.ts +1 -3
  34. package/index.d.ts +81 -65
  35. package/package.json +2 -2
  36. package/src/builtins/builtins.test.ts +37 -22
  37. package/src/builtins/completion-group.ts +17 -15
  38. package/src/builtins/config.test.ts +31 -25
  39. package/src/builtins/config.ts +4 -3
  40. package/src/builtins/dispatch.ts +25 -1
  41. package/src/builtins/install.ts +45 -82
  42. package/src/builtins/mcp.ts +1 -1
  43. package/src/builtins/registry.ts +2 -0
  44. package/src/builtins/uninstall.ts +80 -0
  45. package/src/capabilities.ts +5 -7
  46. package/src/cli-tool/cli-smoke.test.ts +19 -0
  47. package/src/cli-tool/create.test.ts +119 -0
  48. package/src/cli-tool/create.ts +380 -0
  49. package/{examples/consumer-app/capabilities.test.ts → src/cli-tool/full-example-capabilities.test.ts} +15 -14
  50. package/src/cli-tool/main.ts +8 -0
  51. package/src/cli-tool/post-create.ts +111 -0
  52. package/src/cli-tool/program.ts +82 -0
  53. package/src/cli-tool/prompt.ts +28 -0
  54. package/src/cli-tool/run-create.ts +149 -0
  55. package/src/config/bootstrap.ts +174 -66
  56. package/src/config/context.test.ts +22 -36
  57. package/src/config/context.ts +5 -4
  58. package/src/config/file.test.ts +66 -56
  59. package/src/config/file.ts +33 -25
  60. package/src/config/resolve.test.ts +192 -1
  61. package/src/config/resolve.ts +92 -13
  62. package/src/config.integration.test.ts +17 -10
  63. package/src/docs/api-guide.test.ts +4 -5
  64. package/src/docs/docs.test.ts +2 -1
  65. package/src/docs/mcp-guide.ts +7 -8
  66. package/src/hidden-mcpb.test.ts +41 -1
  67. package/src/index.ts +7 -10
  68. package/src/install/binary-placement.test.ts +101 -0
  69. package/src/install/binary-placement.ts +47 -0
  70. package/src/install/detect-installed.ts +2 -97
  71. package/src/install/index.ts +239 -168
  72. package/src/install/install-validate.test.ts +61 -0
  73. package/src/install/install.test.ts +170 -90
  74. package/src/install/mcp-openclaw.test.ts +40 -0
  75. package/src/install/mcp-openclaw.ts +106 -0
  76. package/src/install/normalize-uninstall.ts +11 -0
  77. package/src/install/normalize.ts +20 -0
  78. package/src/install/paths.ts +18 -26
  79. package/src/install/plan.ts +40 -261
  80. package/src/install/shell.ts +0 -14
  81. package/src/install/status.test.ts +85 -0
  82. package/src/install/status.ts +22 -15
  83. package/src/install/target-base.ts +93 -0
  84. package/src/install/target-detect.ts +20 -0
  85. package/src/install/target-effective.ts +129 -0
  86. package/src/install/target-mcp-cli.ts +149 -0
  87. package/src/install/target-mcp-json.ts +130 -0
  88. package/src/install/target-plan-build.ts +67 -0
  89. package/src/install/target-registry.ts +57 -0
  90. package/src/install/target-scope.ts +253 -0
  91. package/src/install/target-skill.ts +104 -0
  92. package/src/install/target-types.ts +129 -0
  93. package/src/install/targets/app.ts +60 -0
  94. package/src/install/targets/chatgpt-mcp.ts +12 -0
  95. package/src/install/targets/claude-code-mcp.ts +15 -0
  96. package/src/install/targets/claude-desktop-mcp.ts +12 -0
  97. package/src/install/targets/claude-skill.ts +16 -0
  98. package/src/install/targets/codex-mcp.ts +25 -0
  99. package/src/install/targets/codex-skill.ts +14 -0
  100. package/src/install/targets/configure.ts +63 -0
  101. package/src/install/targets/cursor-mcp.ts +15 -0
  102. package/src/install/targets/cursor-skill.ts +16 -0
  103. package/src/install/targets/index.ts +50 -0
  104. package/src/install/targets/openclaw-mcp.ts +25 -0
  105. package/src/install/targets/openclaw-skill.ts +17 -0
  106. package/src/install/targets/opencode-mcp.ts +101 -0
  107. package/src/install/targets/opencode-skill.ts +15 -0
  108. package/src/install/targets.test.ts +118 -0
  109. package/src/install/uninstall.ts +16 -152
  110. package/src/invoke.test.ts +7 -1
  111. package/src/mcp/bundle.ts +16 -4
  112. package/src/mcp/claude.test.ts +14 -1
  113. package/src/mcp/claude.ts +11 -4
  114. package/src/mcp/env.test.ts +92 -0
  115. package/src/mcp/env.ts +15 -14
  116. package/src/mcp/zip.test.ts +17 -0
  117. package/src/mcp/zip.ts +62 -9
  118. package/src/mcp.integration.test.ts +1 -1
  119. package/src/parse.test.ts +14 -2
  120. package/src/paths/host.ts +11 -11
  121. package/src/paths/remove-empty-dir.ts +13 -0
  122. package/src/prompt.ts +10 -0
  123. package/src/schema.ts +9 -1
  124. package/src/skill/generate.ts +18 -4
  125. package/src/skill/install.ts +33 -6
  126. package/src/skill/naming.ts +28 -0
  127. package/src/types.ts +86 -7
  128. package/src/validate.ts +73 -9
  129. package/docs/templates/cursor/rules/cli-program.mdc +0 -31
  130. package/examples/config-app/main.ts +0 -20
  131. package/examples/config-app/program.ts +0 -81
  132. package/examples/config-app/schema.ts +0 -37
  133. package/examples/config-app/types.ts +0 -19
  134. package/examples/consumer-app/README.md +0 -57
  135. package/examples/consumer-app/src/main.ts +0 -15
  136. package/examples/consumer-app/src/program.ts +0 -108
  137. package/src/install/binary.ts +0 -94
  138. package/src/install/completions.ts +0 -56
  139. package/src/install/update.test.ts +0 -108
  140. package/src/install/update.ts +0 -57
  141. /package/examples/{consumer-app → full-example}/schemas/configSchemas.ts +0 -0
  142. /package/examples/{consumer-app → full-example}/schemas/outputSchemas.ts +0 -0
  143. /package/examples/{consumer-app → full-example}/scripts/schemagen/discover-schema-roots.test.ts +0 -0
  144. /package/examples/{consumer-app → full-example}/scripts/schemagen/discover-schema-roots.ts +0 -0
  145. /package/examples/{consumer-app → full-example}/scripts/schemagen/naming.ts +0 -0
  146. /package/examples/{consumer-app → full-example}/scripts/schemagen.ts +0 -0
  147. /package/examples/{consumer-app → full-example}/tsconfig.json +0 -0
@@ -1,14 +1,25 @@
1
1
  import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
- import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
2
+ import { chmodSync, existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
+ import { writeAppConfigFile } from "../config/file.ts";
5
6
  import type { CliProgram } from "../types.ts";
6
7
  import { detectInstalledArtifacts } from "./detect-installed.ts";
7
- import { parseInstallOpts, runInstallMutation, validateInstallOpts } from "./index.ts";
8
+ import {
9
+ cliInstall,
10
+ interactiveSelectionAssumesApp,
11
+ mergeInteractiveSelection,
12
+ parseInstallOpts,
13
+ runInstallMutation,
14
+ runUninstallMutation,
15
+ validateInstallOpts,
16
+ validateUninstallOpts,
17
+ } from "./index.ts";
8
18
  import { opencodeConfigDir } from "./mcp-opencode.ts";
19
+ import { normalizeUninstallRawOpts } from "./normalize-uninstall.ts";
9
20
  import { resolveClaudeDesktopMcpPath, resolveInstallPaths } from "./paths.ts";
10
- import { buildInstallPlan } from "./plan.ts";
11
- import { printInstallStatus } from "./status.ts";
21
+ import { buildInstallPlan, buildUpdatePlan } from "./plan.ts";
22
+ import { printInstallStatus, writeInteractiveInstallIntro } from "./status.ts";
12
23
  import { buildUninstallPlan } from "./uninstall.ts";
13
24
 
14
25
  const fixture: CliProgram = {
@@ -21,7 +32,6 @@ const fixture: CliProgram = {
21
32
 
22
33
  let home: string;
23
34
  let prevHome: string | undefined;
24
-
25
35
  let prevXdg: string | undefined;
26
36
 
27
37
  beforeEach(() => {
@@ -41,16 +51,10 @@ afterEach(() => {
41
51
  });
42
52
 
43
53
  describe("install paths", () => {
44
- test("resolveInstallPaths honors prefix", () => {
45
- const paths = resolveInstallPaths(fixture, { prefix: join(home, "custom", "bin") });
46
- expect(paths.binaryPath).toBe(join(home, "custom", "bin", "testapp"));
47
- });
48
-
49
- test("fish completion uses XDG_CONFIG_HOME", () => {
50
- const xdg = join(home, "xdg");
51
- process.env.XDG_CONFIG_HOME = xdg;
52
- const paths = resolveInstallPaths(fixture, {});
53
- expect(paths.fishCompletion).toBe(join(xdg, "fish", "completions", "testapp.fish"));
54
+ test("resolveInstallPaths includes skill and mcp paths", () => {
55
+ const paths = resolveInstallPaths(fixture);
56
+ expect(paths.cursorSkillDir).toContain(".cursor/skills");
57
+ expect(paths.cursorMcpPath).toContain("mcp.json");
54
58
  });
55
59
 
56
60
  test("claude desktop mcp path on darwin", () => {
@@ -67,63 +71,101 @@ describe("install paths", () => {
67
71
  });
68
72
 
69
73
  describe("detect installed", () => {
70
- test("detects binary and completions", () => {
71
- const paths = resolveInstallPaths(fixture, {});
72
- mkdirSync(paths.bindir, { recursive: true });
73
- writeFileSync(paths.binaryPath, "fake", "utf8");
74
- mkdirSync(join(home, ".bash_completion.d"), { recursive: true });
75
- writeFileSync(paths.bashCompletion, "# bash", "utf8");
76
-
77
- const detected = detectInstalledArtifacts(paths);
78
- expect(detected.binary).toBe(true);
79
- expect(detected.bashCompletion).toBe(true);
80
- expect(detected.zshCompletion).toBe(false);
74
+ test("detects cursor mcp when configured", () => {
75
+ const paths = resolveInstallPaths(fixture);
76
+ mkdirSync(join(home, ".cursor"), { recursive: true });
77
+ writeFileSync(
78
+ paths.cursorMcpPath,
79
+ JSON.stringify({ mcpServers: { testapp: { command: "testapp", args: ["mcp"] } } }),
80
+ "utf8",
81
+ );
82
+
83
+ const detected = detectInstalledArtifacts(paths, fixture);
84
+ expect(detected.cursorMcp).toBe(true);
85
+ expect(detected.app).toBe(false);
81
86
  });
82
87
  });
83
88
 
84
89
  describe("install plan", () => {
85
- test("buildInstallPlan --all includes binary", () => {
86
- const paths = resolveInstallPaths(fixture, {});
90
+ test("buildInstallPlan --all omits app self-install", () => {
91
+ const paths = resolveInstallPaths(fixture);
87
92
  const plan = buildInstallPlan(fixture, paths, parseInstallOpts({ all: "1" }));
88
- expect(plan.some((a) => a.kind === "binary")).toBe(true);
93
+ expect(plan.some((a) => a.kind === "app")).toBe(false);
94
+ expect(plan.some((a) => a.kind.endsWith("-mcp"))).toBe(true);
95
+ });
96
+
97
+ test("buildInstallPlan --mcp without yes omits implicit app", () => {
98
+ const paths = resolveInstallPaths(fixture);
99
+ const plan = buildInstallPlan(fixture, paths, parseInstallOpts({ mcp: "1" }));
100
+ expect(plan.some((a) => a.kind === "app")).toBe(false);
101
+ });
102
+
103
+ test("buildUpdatePlan greenfield falls back to install-all", () => {
104
+ const paths = resolveInstallPaths(fixture);
105
+ const plan = buildUpdatePlan(fixture, paths, parseInstallOpts({ reinstall: "1", yes: "1" }));
106
+ expect(plan.length).toBeGreaterThan(0);
107
+ expect(plan.some((a) => a.kind === "app")).toBe(false);
108
+ });
109
+
110
+ test("buildInstallPlan --mcp skips MCP when agentIntegration is skill", () => {
111
+ const skillApp: CliProgram = {
112
+ ...fixture,
113
+ install: { agentIntegration: "skill" },
114
+ };
115
+ const paths = resolveInstallPaths(skillApp);
116
+ mkdirSync(join(home, ".claude"), { recursive: true });
117
+ mkdirSync(join(home, ".cursor"), { recursive: true });
118
+ const plan = buildInstallPlan(skillApp, paths, parseInstallOpts({ mcp: "1" }));
119
+ expect(plan.some((a) => a.kind.endsWith("-mcp"))).toBe(false);
89
120
  });
90
121
 
91
122
  test("buildInstallPlan --mcp includes claude desktop when app data exists", () => {
92
- const paths = resolveInstallPaths(fixture, {});
123
+ const paths = resolveInstallPaths(fixture);
124
+ mkdirSync(join(home, ".claude"), { recursive: true });
93
125
  mkdirSync(join(home, "Library", "Application Support", "Claude"), { recursive: true });
94
126
  const plan = buildInstallPlan(fixture, paths, parseInstallOpts({ mcp: "1" }));
95
127
  expect(plan.some((a) => a.kind === "claude-desktop-mcp")).toBe(process.platform === "darwin");
96
128
  expect(plan.some((a) => a.kind === "claude-mcp")).toBe(true);
97
129
  });
98
130
 
99
- test("buildInstallPlan --mcp skips claude desktop without app data", () => {
100
- const paths = resolveInstallPaths(fixture, {});
101
- const plan = buildInstallPlan(fixture, paths, parseInstallOpts({ mcp: "1" }));
102
- expect(plan.some((a) => a.kind === "claude-desktop-mcp")).toBe(false);
103
- });
104
-
105
131
  test("buildInstallPlan --mcp includes opencode when config dir exists", () => {
106
- const paths = resolveInstallPaths(fixture, {});
132
+ const paths = resolveInstallPaths(fixture);
107
133
  mkdirSync(opencodeConfigDir(home), { recursive: true });
108
134
  const plan = buildInstallPlan(fixture, paths, parseInstallOpts({ mcp: "1" }));
109
135
  expect(plan.some((a) => a.kind === "opencode-mcp")).toBe(true);
110
136
  });
111
137
 
112
- test("buildInstallPlan --mcp includes chatgpt when app data exists", () => {
113
- const paths = resolveInstallPaths(fixture, {});
114
- if (process.platform === "darwin") {
115
- mkdirSync(join(home, "Library", "Application Support", "ChatGPT"), { recursive: true });
116
- const plan = buildInstallPlan(fixture, paths, parseInstallOpts({ mcp: "1" }));
117
- expect(plan.some((a) => a.kind === "chatgpt-desktop-mcp")).toBe(true);
118
- }
138
+ test("buildInstallPlan --mcp --skill --configure is not empty when agents exist", () => {
139
+ const program: CliProgram = {
140
+ ...fixture,
141
+ install: { agentIntegration: "both" },
142
+ appConfig: {
143
+ entries: { token: { description: "API token." } },
144
+ },
145
+ };
146
+ const paths = resolveInstallPaths(program);
147
+ mkdirSync(join(home, ".cursor"), { recursive: true });
148
+ mkdirSync(join(home, ".claude"), { recursive: true });
149
+ const plan = buildInstallPlan(
150
+ program,
151
+ paths,
152
+ parseInstallOpts({ mcp: "1", skill: "1", configure: "1" }),
153
+ );
154
+ expect(plan.length).toBeGreaterThan(0);
155
+ expect(plan.some((a) => a.kind.endsWith("-mcp"))).toBe(true);
156
+ expect(plan.some((a) => a.kind.endsWith("-skill"))).toBe(true);
119
157
  });
120
158
  });
121
159
 
122
160
  describe("install status", () => {
123
- test("printInstallStatus human output", () => {
124
- const paths = resolveInstallPaths(fixture, {});
125
- mkdirSync(paths.bindir, { recursive: true });
126
- writeFileSync(paths.binaryPath, "fake", "utf8");
161
+ test("printInstallStatus human output with MCP", () => {
162
+ const paths = resolveInstallPaths(fixture);
163
+ mkdirSync(join(home, ".cursor"), { recursive: true });
164
+ writeFileSync(
165
+ paths.cursorMcpPath,
166
+ JSON.stringify({ mcpServers: { testapp: { command: "testapp", args: ["mcp"] } } }),
167
+ "utf8",
168
+ );
127
169
 
128
170
  const chunks: string[] = [];
129
171
  const orig = process.stdout.write;
@@ -135,14 +177,14 @@ describe("install status", () => {
135
177
  printInstallStatus(fixture, {});
136
178
  const out = chunks.join("");
137
179
  expect(out).toContain("Installed artifacts for testapp");
138
- expect(out).toContain(paths.binaryPath);
180
+ expect(out).toContain("cursor mcp");
139
181
  } finally {
140
182
  process.stdout.write = orig;
141
183
  }
142
184
  });
143
185
 
144
186
  test("printInstallStatus json output", () => {
145
- const paths = resolveInstallPaths(fixture, {});
187
+ const paths = resolveInstallPaths(fixture);
146
188
  mkdirSync(join(home, ".cursor"), { recursive: true });
147
189
  writeFileSync(
148
190
  paths.cursorMcpPath,
@@ -166,72 +208,110 @@ describe("install status", () => {
166
208
  });
167
209
  });
168
210
 
169
- describe("parseInstallOpts", () => {
170
- test("json implies yes in validate path via cliInstall", () => {
171
- const opts = parseInstallOpts({ json: "1", all: "1" });
172
- expect(opts.json).toBe(true);
173
- expect(opts.all).toBe(true);
211
+ describe("interactive selection", () => {
212
+ test("mergeInteractiveSelection prepends 1 when app is assumed", () => {
213
+ expect(mergeInteractiveSelection([2, 3], 5, true)).toEqual([1, 2, 3]);
214
+ expect(mergeInteractiveSelection([1, 3], 5, true)).toEqual([1, 3]);
215
+ expect(mergeInteractiveSelection([2, 3], 5, false)).toEqual([2, 3]);
216
+ });
217
+
218
+ test("interactiveSelectionAssumesApp is always false", () => {
219
+ const paths = resolveInstallPaths(fixture);
220
+ const plan = buildInstallPlan(fixture, paths, parseInstallOpts({ all: "1" }));
221
+ expect(interactiveSelectionAssumesApp(plan, false)).toBe(false);
174
222
  });
175
223
  });
176
224
 
177
- describe("validateInstallOpts", () => {
178
- test("uninstall requires a target flag", () => {
179
- const opts = parseInstallOpts({ uninstall: "1", yes: "1" });
180
- expect(validateInstallOpts(opts)).toContain("Specify at least one target");
225
+ describe("install output", () => {
226
+ test("writeInteractiveInstallIntro prints app Setup banner on stderr", () => {
227
+ const chunks: string[] = [];
228
+ const orig = process.stderr.write;
229
+ process.stderr.write = ((s: string | Uint8Array) => {
230
+ chunks.push(String(s));
231
+ return true;
232
+ }) as typeof process.stderr.write;
233
+ try {
234
+ writeInteractiveInstallIntro(fixture);
235
+ expect(chunks.join("")).toBe("\ntestapp Setup\n\n");
236
+ } finally {
237
+ process.stderr.write = orig;
238
+ }
239
+ });
240
+ });
241
+
242
+ describe("validateUninstallOpts", () => {
243
+ test("bare uninstall normalizes to --all", () => {
244
+ const opts = parseInstallOpts(normalizeUninstallRawOpts({ yes: "1" }));
245
+ expect(validateUninstallOpts(opts)).toBeNull();
246
+ expect(opts.all).toBe(true);
181
247
  });
182
248
 
183
249
  test("uninstall allows --all", () => {
184
250
  const opts = parseInstallOpts({ uninstall: "1", all: "1", yes: "1" });
185
- expect(validateInstallOpts(opts)).toBeNull();
251
+ expect(validateUninstallOpts(opts)).toBeNull();
186
252
  });
187
253
 
188
254
  test("uninstall rejects --reinstall", () => {
189
255
  const opts = parseInstallOpts({ uninstall: "1", reinstall: "1", all: "1" });
190
- expect(validateInstallOpts(opts)).toContain("--reinstall");
256
+ expect(validateUninstallOpts(opts)).toContain("--reinstall");
257
+ });
258
+ });
259
+
260
+ describe("validateInstallOpts", () => {
261
+ test("install --uninstall is rejected", () => {
262
+ const opts = parseInstallOpts({ uninstall: "1", all: "1", yes: "1" });
263
+ expect(validateInstallOpts(opts)).toContain("uninstall");
191
264
  });
192
265
  });
193
266
 
194
267
  describe("install mutation", () => {
195
268
  test("uninstall --all with nothing installed succeeds", async () => {
196
- const result = await runInstallMutation(fixture, { uninstall: "1", all: "1", yes: "1" });
269
+ const result = await runUninstallMutation(fixture, { all: "1", yes: "1" });
197
270
  expect(result.changed).toEqual([]);
198
271
  });
199
272
 
200
- test("install --skill with no agent dirs succeeds", async () => {
273
+ test("install --skill skips cursor and claude when agent dirs missing", async () => {
201
274
  const result = await runInstallMutation(fixture, { skill: "1", yes: "1", dry: "1" });
202
- expect(result.changed).toEqual([]);
275
+ expect(
276
+ result.changed.every((p) => !p.includes("/.cursor/") && !p.includes("/.claude/skills/")),
277
+ ).toBe(true);
203
278
  });
204
279
 
205
- test("uninstall --all removes detected binary", async () => {
206
- const paths = resolveInstallPaths(fixture, {});
207
- mkdirSync(paths.bindir, { recursive: true });
208
- writeFileSync(paths.binaryPath, "fake", "utf8");
209
-
210
- const result = await runInstallMutation(fixture, { uninstall: "1", all: "1", yes: "1" });
211
- expect(result.changed).toContain(paths.binaryPath);
212
- expect(existsSync(paths.binaryPath)).toBe(false);
280
+ test("install --all --yes dry-run succeeds when stdin is not a TTY", async () => {
281
+ const program: CliProgram = {
282
+ ...fixture,
283
+ appConfig: {
284
+ entries: { token: { description: "API token." } },
285
+ },
286
+ };
287
+ const origIsTTY = process.stdin.isTTY;
288
+ Object.defineProperty(process.stdin, "isTTY", { value: false, configurable: true });
289
+ const exitCode = await new Promise<number>((resolve) => {
290
+ const origExit = process.exit;
291
+ process.exit = ((code?: number) => {
292
+ process.exit = origExit;
293
+ resolve(code ?? 0);
294
+ }) as typeof process.exit;
295
+ void cliInstall(program, { all: "1", yes: "1", dry: "1" });
296
+ });
297
+ Object.defineProperty(process.stdin, "isTTY", { value: origIsTTY, configurable: true });
298
+ expect(exitCode).toBe(0);
213
299
  });
214
300
  });
215
301
 
216
302
  describe("uninstall plan", () => {
217
- test("buildUninstallPlan --all scopes like install --all", () => {
218
- const paths = resolveInstallPaths(fixture, {});
219
- mkdirSync(paths.bindir, { recursive: true });
220
- writeFileSync(paths.binaryPath, "fake", "utf8");
221
-
222
- const plan = buildUninstallPlan(fixture, paths, parseInstallOpts({ uninstall: "1", all: "1" }));
223
- expect(plan.some((a) => a.summary.startsWith("binary:"))).toBe(true);
224
- });
225
-
226
- test("buildUninstallPlan --bin ignores completions", () => {
227
- const paths = resolveInstallPaths(fixture, {});
228
- mkdirSync(paths.bindir, { recursive: true });
229
- writeFileSync(paths.binaryPath, "fake", "utf8");
230
- mkdirSync(join(home, ".bash_completion.d"), { recursive: true });
231
- writeFileSync(paths.bashCompletion, "# bash", "utf8");
303
+ test("buildUninstallPlan --all runs configure last when config exists", () => {
304
+ const program: CliProgram = {
305
+ ...fixture,
306
+ appConfig: {
307
+ entries: { token: { description: "API token." } },
308
+ },
309
+ };
310
+ const paths = resolveInstallPaths(program);
311
+ writeAppConfigFile(program, { token: "secret" });
232
312
 
233
- const plan = buildUninstallPlan(fixture, paths, parseInstallOpts({ uninstall: "1", bin: "1" }));
234
- expect(plan).toHaveLength(1);
235
- expect(plan[0]?.summary.startsWith("binary:")).toBe(true);
313
+ const plan = buildUninstallPlan(program, paths, parseInstallOpts({ uninstall: "1", all: "1" }));
314
+ const configureIdx = plan.findIndex((a) => a.summary.startsWith("app config:"));
315
+ expect(configureIdx).toBe(plan.length - 1);
236
316
  });
237
317
  });
@@ -0,0 +1,40 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { openclawMcpHasServer, resolveOpenclawConfigPath } from "./mcp-openclaw.ts";
6
+
7
+ let home: string;
8
+ let prevHome: string | undefined;
9
+
10
+ beforeEach(() => {
11
+ home = mkdtempSync(join(tmpdir(), "argsbarg-openclaw-"));
12
+ prevHome = process.env.HOME;
13
+ process.env.HOME = home;
14
+ });
15
+
16
+ afterEach(() => {
17
+ if (prevHome === undefined) delete process.env.HOME;
18
+ else process.env.HOME = prevHome;
19
+ rmSync(home, { recursive: true, force: true });
20
+ });
21
+
22
+ describe("openclaw mcp config", () => {
23
+ test("openclawMcpHasServer detects configured server", () => {
24
+ const path = resolveOpenclawConfigPath(home);
25
+ mkdirSync(join(home, ".openclaw"), { recursive: true });
26
+ writeFileSync(
27
+ path,
28
+ JSON.stringify({
29
+ mcp: {
30
+ servers: {
31
+ testapp: { command: "testapp", args: ["mcp"] },
32
+ },
33
+ },
34
+ }),
35
+ "utf8",
36
+ );
37
+ expect(openclawMcpHasServer(home, "testapp")).toBe(true);
38
+ expect(openclawMcpHasServer(home, "other")).toBe(false);
39
+ });
40
+ });
@@ -0,0 +1,106 @@
1
+ import { existsSync, mkdirSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import type { McpServerEntry } from "./mcp-config.ts";
4
+
5
+ /** True when the `openclaw` executable is on PATH. */
6
+ export function openclawOnPath(): boolean {
7
+ return Bun.which("openclaw") !== null;
8
+ }
9
+
10
+ /** Global OpenClaw config path (`~/.openclaw/openclaw.json`). */
11
+ export function resolveOpenclawConfigPath(home: string): string {
12
+ return join(home, ".openclaw", "openclaw.json");
13
+ }
14
+
15
+ function readOpenclawMcpEntry(path: string, name: string): McpServerEntry | undefined {
16
+ if (!existsSync(path)) return undefined;
17
+ try {
18
+ const data = JSON.parse(readFileSync(path, "utf8")) as {
19
+ mcp?: { servers?: Record<string, { command?: string; args?: string[] }> };
20
+ };
21
+ const entry = data.mcp?.servers?.[name];
22
+ if (!entry?.command) return undefined;
23
+ return { command: entry.command, args: entry.args ?? [] };
24
+ } catch {
25
+ return undefined;
26
+ }
27
+ }
28
+
29
+ export function openclawMcpHasServer(home: string, name: string): boolean {
30
+ const path = resolveOpenclawConfigPath(home);
31
+ if (!existsSync(path)) return false;
32
+ try {
33
+ const data = JSON.parse(readFileSync(path, "utf8")) as {
34
+ mcp?: { servers?: Record<string, unknown> };
35
+ };
36
+ return data.mcp?.servers?.[name] !== undefined;
37
+ } catch {
38
+ return false;
39
+ }
40
+ }
41
+
42
+ function entriesEqual(a: McpServerEntry, b: McpServerEntry): boolean {
43
+ return a.command === b.command && JSON.stringify(a.args) === JSON.stringify(b.args);
44
+ }
45
+
46
+ /** Returns an error when an existing OpenClaw entry conflicts, or null if safe to merge. */
47
+ export function checkOpenclawMcpConflict(
48
+ home: string,
49
+ name: string,
50
+ expected: McpServerEntry,
51
+ yes: boolean,
52
+ ): string | null {
53
+ const path = resolveOpenclawConfigPath(home);
54
+ const existing = readOpenclawMcpEntry(path, name);
55
+ if (existing && !entriesEqual(existing, expected) && !yes) {
56
+ return (
57
+ `MCP server "${name}" in ${path} differs from expected OpenClaw entry.\n` +
58
+ ` existing: ${JSON.stringify(existing)}\n` +
59
+ ` expected: ${JSON.stringify(expected)}\n` +
60
+ `Use --yes to overwrite.`
61
+ );
62
+ }
63
+ return null;
64
+ }
65
+
66
+ function runOpenclaw(args: string[]): void {
67
+ const proc = Bun.spawnSync({
68
+ cmd: ["openclaw", ...args],
69
+ stdout: "pipe",
70
+ stderr: "pipe",
71
+ });
72
+ if (proc.exitCode !== 0) {
73
+ const err = new TextDecoder().decode(proc.stderr).trim();
74
+ throw new Error(err || `openclaw ${args.join(" ")} failed (exit ${proc.exitCode})`);
75
+ }
76
+ }
77
+
78
+ /** Registers a stdio MCP server via `openclaw mcp add`. */
79
+ export function mergeOpenclawMcpConfig(
80
+ home: string,
81
+ name: string,
82
+ entry: McpServerEntry,
83
+ dry: boolean,
84
+ overwrite: boolean,
85
+ ): string {
86
+ const configPath = resolveOpenclawConfigPath(home);
87
+ if (dry) return configPath;
88
+
89
+ mkdirSync(join(home, ".openclaw"), { recursive: true });
90
+ if (openclawMcpHasServer(home, name) && overwrite) {
91
+ runOpenclaw(["mcp", "unset", name]);
92
+ }
93
+
94
+ const args = ["mcp", "add", name, "--command", entry.command];
95
+ for (const arg of entry.args) {
96
+ args.push("--arg", arg);
97
+ }
98
+ runOpenclaw(args);
99
+ return configPath;
100
+ }
101
+
102
+ /** Removes an OpenClaw MCP server via `openclaw mcp unset`. */
103
+ export function removeOpenclawMcpConfig(home: string, name: string, dry: boolean): void {
104
+ if (dry || !openclawMcpHasServer(home, name)) return;
105
+ runOpenclaw(["mcp", "unset", name]);
106
+ }
@@ -0,0 +1,11 @@
1
+ /** Normalizes bare `uninstall` to `--all`. */
2
+ export function normalizeUninstallRawOpts(raw: Record<string, string>): Record<string, string> {
3
+ const flag = (name: string) => raw[name] === "1";
4
+ const out: Record<string, string> = { ...raw, uninstall: "1" };
5
+
6
+ const hasUninstallTarget = flag("all") || flag("skill") || flag("mcp") || flag("configure");
7
+ if (!hasUninstallTarget) {
8
+ out.all = "1";
9
+ }
10
+ return out;
11
+ }
@@ -0,0 +1,20 @@
1
+ /** Normalizes bare `install` to `--all`. */
2
+ export function normalizeInstallRawOpts(raw: Record<string, string>): Record<string, string> {
3
+ const flag = (name: string) => raw[name] === "1";
4
+ const out = { ...raw };
5
+
6
+ if (flag("status") || flag("reinstall")) {
7
+ return out;
8
+ }
9
+
10
+ const hasInstallTarget = flag("all") || flag("skill") || flag("mcp");
11
+
12
+ if (flag("configure") && !hasInstallTarget) {
13
+ return out;
14
+ }
15
+
16
+ if (!hasInstallTarget) {
17
+ out.all = "1";
18
+ }
19
+ return out;
20
+ }
@@ -1,39 +1,34 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import { dirname, join } from "node:path";
3
3
  import { mcpServerId, sanitizeToolSegment } from "../mcp/tools.ts";
4
- import { expandTilde, userHome, xdgConfigHome } from "../paths/host.ts";
4
+ import { displayHomePath, userHome, xdgConfigHome } from "../paths/host.ts";
5
+ import { skillDirNameForTarget } from "../skill/install.ts";
5
6
  import type { CliProgram } from "../types.ts";
7
+ import { resolveOpenclawConfigPath } from "./mcp-openclaw.ts";
6
8
  import { resolveOpenCodeConfigPathForInstall } from "./mcp-opencode.ts";
7
9
 
8
10
  export interface InstallPaths {
9
- bindir: string;
10
- binaryPath: string;
11
- bashCompletion: string;
12
- zshCompletion: string;
13
- fishCompletion: string;
14
11
  cursorSkillDir: string;
15
12
  claudeSkillDir: string;
13
+ codexSkillDir: string;
14
+ opencodeSkillDir: string;
15
+ openclawSkillDir: string;
16
16
  cursorMcpPath: string;
17
17
  claudeMcpPath: string;
18
18
  claudeDesktopMcpPath: string;
19
19
  opencodeMcpPath: string;
20
20
  chatGptMcpPath: string;
21
21
  codexConfigPath: string;
22
- bashRc: string;
23
- zshRc: string;
22
+ openclawConfigPath: string;
24
23
  mcpName: string;
25
24
  skillDirName: string;
26
25
  }
27
26
 
28
27
  export { userHome } from "../paths/host.ts";
29
28
 
30
- /** Resolves the binary install directory from CLI flag, env, or config. */
31
- export function resolveBindir(root: CliProgram, prefix?: string): string {
32
- const raw = prefix ?? process.env.INSTALL_PREFIX ?? root.install?.prefix;
33
- if (raw) {
34
- return expandTilde(raw);
35
- }
36
- return join(userHome(), ".local", "bin");
29
+ /** Format an absolute path for user-facing install output. */
30
+ export function displayInstallPath(path: string): string {
31
+ return displayHomePath(path);
37
32
  }
38
33
 
39
34
  /** Resolves Claude Desktop `claude_desktop_config.json` for the current OS. */
@@ -71,31 +66,28 @@ export function chatGptDesktopPresent(_home: string, configPath: string): boolea
71
66
  }
72
67
 
73
68
  /** Resolves all install artifact paths for a program root. */
74
- export function resolveInstallPaths(root: CliProgram, opts: { prefix?: string }): InstallPaths {
69
+ export function resolveInstallPaths(root: CliProgram): InstallPaths {
75
70
  const home = userHome();
76
- const bindir = resolveBindir(root, opts.prefix);
77
- const key = root.key;
78
71
  const skillDirName = sanitizeToolSegment(root.key);
79
- const xdgConfig = xdgConfigHome(home);
72
+ const codexSlug = skillDirNameForTarget(root.key, "codex");
73
+ const opencodeSlug = skillDirNameForTarget(root.key, "opencode");
74
+ const openclawSlug = skillDirNameForTarget(root.key, "openclaw");
80
75
  const claudeDesktopMcpPath = resolveClaudeDesktopMcpPath(home);
81
76
  const chatGptMcpPath = resolveChatGptMcpPath(home);
82
77
 
83
78
  return {
84
- bindir,
85
- binaryPath: join(bindir, key),
86
- bashCompletion: join(home, ".bash_completion.d", key),
87
- zshCompletion: join(home, ".zsh", "completions", `_${key}`),
88
- fishCompletion: join(xdgConfig, "fish", "completions", `${key}.fish`),
89
79
  cursorSkillDir: join(home, ".cursor", "skills", skillDirName),
90
80
  claudeSkillDir: join(home, ".claude", "skills", skillDirName),
81
+ codexSkillDir: join(home, ".codex", "skills", codexSlug),
82
+ opencodeSkillDir: join(home, ".config", "opencode", "skills", opencodeSlug),
83
+ openclawSkillDir: join(home, ".openclaw", "skills", openclawSlug),
91
84
  cursorMcpPath: join(home, ".cursor", "mcp.json"),
92
85
  claudeMcpPath: join(home, ".claude.json"),
93
86
  claudeDesktopMcpPath,
94
87
  opencodeMcpPath: resolveOpenCodeConfigPathForInstall(home),
95
88
  chatGptMcpPath,
96
89
  codexConfigPath: join(home, ".codex", "config.toml"),
97
- bashRc: join(home, ".bashrc"),
98
- zshRc: join(home, ".zshrc"),
90
+ openclawConfigPath: resolveOpenclawConfigPath(home),
99
91
  mcpName: mcpServerId(root),
100
92
  skillDirName,
101
93
  };