argsbarg 4.0.3 → 4.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/CHANGELOG.md +42 -1
  2. package/README.md +6 -6
  3. package/docs/ai-skills.md +9 -4
  4. package/docs/bundled-docs.md +1 -0
  5. package/docs/cli-program.md +4 -4
  6. package/docs/config-schema.md +1 -4
  7. package/docs/developing.md +1 -1
  8. package/docs/install.md +160 -39
  9. package/docs/mcp.md +38 -11
  10. package/docs/templates/cursor/rules/cli-program.mdc +1 -1
  11. package/examples/config-app/program.ts +0 -3
  12. package/examples/consumer-app/README.md +1 -1
  13. package/examples/consumer-app/src/program.ts +5 -13
  14. package/examples/mcp-test.ts +14 -24
  15. package/index.d.ts +60 -5
  16. package/package.json +1 -1
  17. package/src/builtins/builtins.test.ts +20 -4
  18. package/src/builtins/config.test.ts +31 -25
  19. package/src/builtins/config.ts +4 -3
  20. package/src/builtins/install.ts +50 -57
  21. package/src/builtins/mcp.ts +1 -1
  22. package/src/capabilities.ts +4 -4
  23. package/src/cli.ts +2 -0
  24. package/src/config/bootstrap.ts +167 -66
  25. package/src/config/context.test.ts +22 -36
  26. package/src/config/context.ts +5 -4
  27. package/src/config/file.test.ts +66 -56
  28. package/src/config/file.ts +33 -25
  29. package/src/config/resolve.test.ts +25 -1
  30. package/src/config/resolve.ts +43 -8
  31. package/src/config.integration.test.ts +17 -10
  32. package/src/docs/api-guide.test.ts +1 -1
  33. package/src/docs/docs.test.ts +1 -1
  34. package/src/docs/mcp-guide.ts +15 -4
  35. package/src/docs/mcp-resources.test.ts +63 -0
  36. package/src/docs/mcp-resources.ts +68 -0
  37. package/src/hidden-mcpb.test.ts +41 -1
  38. package/src/index.ts +4 -0
  39. package/src/install/{binary.ts → app.ts} +13 -13
  40. package/src/install/bootstrap.ts +22 -0
  41. package/src/install/detect-installed.ts +2 -97
  42. package/src/install/index.ts +187 -110
  43. package/src/install/install-validate.test.ts +61 -0
  44. package/src/install/install.test.ts +138 -42
  45. package/src/install/mcp-openclaw.test.ts +40 -0
  46. package/src/install/mcp-openclaw.ts +106 -0
  47. package/src/install/normalize.ts +35 -0
  48. package/src/install/paths.ts +27 -13
  49. package/src/install/plan.ts +30 -259
  50. package/src/install/shell.ts +2 -2
  51. package/src/install/status.test.ts +85 -0
  52. package/src/install/status.ts +22 -9
  53. package/src/install/target-base.ts +93 -0
  54. package/src/install/target-detect.ts +20 -0
  55. package/src/install/target-effective.ts +131 -0
  56. package/src/install/target-mcp-cli.ts +149 -0
  57. package/src/install/target-mcp-json.ts +130 -0
  58. package/src/install/target-plan-build.ts +67 -0
  59. package/src/install/target-registry.ts +57 -0
  60. package/src/install/target-scope.ts +266 -0
  61. package/src/install/target-skill.ts +104 -0
  62. package/src/install/target-types.ts +145 -0
  63. package/src/install/targets/app.ts +69 -0
  64. package/src/install/targets/chatgpt-mcp.ts +12 -0
  65. package/src/install/targets/claude-code-mcp.ts +15 -0
  66. package/src/install/targets/claude-desktop-mcp.ts +12 -0
  67. package/src/install/targets/claude-skill.ts +16 -0
  68. package/src/install/targets/codex-mcp.ts +25 -0
  69. package/src/install/targets/codex-skill.ts +14 -0
  70. package/src/install/targets/completions.ts +133 -0
  71. package/src/install/targets/configure.ts +59 -0
  72. package/src/install/targets/cursor-mcp.ts +15 -0
  73. package/src/install/targets/cursor-skill.ts +16 -0
  74. package/src/install/targets/index.ts +53 -0
  75. package/src/install/targets/openclaw-mcp.ts +25 -0
  76. package/src/install/targets/openclaw-skill.ts +17 -0
  77. package/src/install/targets/opencode-mcp.ts +101 -0
  78. package/src/install/targets/opencode-skill.ts +15 -0
  79. package/src/install/targets.test.ts +136 -0
  80. package/src/install/uninstall.ts +16 -152
  81. package/src/install/update.test.ts +17 -2
  82. package/src/install/update.ts +2 -5
  83. package/src/invoke.test.ts +7 -1
  84. package/src/mcp/bundle.ts +16 -4
  85. package/src/mcp/claude.test.ts +23 -4
  86. package/src/mcp/claude.ts +18 -13
  87. package/src/mcp/tools.ts +4 -1
  88. package/src/mcp/zip.test.ts +17 -0
  89. package/src/mcp/zip.ts +62 -9
  90. package/src/mcp.integration.test.ts +18 -1
  91. package/src/parse.test.ts +57 -4
  92. package/src/paths/host.ts +11 -11
  93. package/src/paths/remove-empty-dir.ts +13 -0
  94. package/src/skill/generate.ts +89 -5
  95. package/src/skill/hint.ts +5 -0
  96. package/src/skill/install.ts +33 -6
  97. package/src/skill/naming.ts +28 -0
  98. package/src/types.ts +65 -4
  99. package/src/validate.ts +79 -4
@@ -2,13 +2,20 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
2
  import { 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
+ interactiveSelectionAssumesApp,
10
+ mergeInteractiveSelection,
11
+ parseInstallOpts,
12
+ runInstallMutation,
13
+ validateInstallOpts,
14
+ } from "./index.ts";
8
15
  import { opencodeConfigDir } from "./mcp-opencode.ts";
9
16
  import { resolveClaudeDesktopMcpPath, resolveInstallPaths } from "./paths.ts";
10
17
  import { buildInstallPlan } from "./plan.ts";
11
- import { printInstallStatus } from "./status.ts";
18
+ import { printInstallStatus, writeInteractiveInstallIntro } from "./status.ts";
12
19
  import { buildUninstallPlan } from "./uninstall.ts";
13
20
 
14
21
  const fixture: CliProgram = {
@@ -41,15 +48,15 @@ afterEach(() => {
41
48
  });
42
49
 
43
50
  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"));
51
+ test("resolveInstallPaths uses ~/.local/bin for app", () => {
52
+ const paths = resolveInstallPaths(fixture);
53
+ expect(paths.appPath).toBe(join(home, ".local", "bin", "testapp"));
47
54
  });
48
55
 
49
56
  test("fish completion uses XDG_CONFIG_HOME", () => {
50
57
  const xdg = join(home, "xdg");
51
58
  process.env.XDG_CONFIG_HOME = xdg;
52
- const paths = resolveInstallPaths(fixture, {});
59
+ const paths = resolveInstallPaths(fixture);
53
60
  expect(paths.fishCompletion).toBe(join(xdg, "fish", "completions", "testapp.fish"));
54
61
  });
55
62
 
@@ -67,29 +74,56 @@ describe("install paths", () => {
67
74
  });
68
75
 
69
76
  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");
77
+ test("detects app and completions", () => {
78
+ const paths = resolveInstallPaths(fixture);
79
+ mkdirSync(paths.appDir, { recursive: true });
80
+ writeFileSync(paths.appPath, "fake", "utf8");
74
81
  mkdirSync(join(home, ".bash_completion.d"), { recursive: true });
75
82
  writeFileSync(paths.bashCompletion, "# bash", "utf8");
76
83
 
77
- const detected = detectInstalledArtifacts(paths);
78
- expect(detected.binary).toBe(true);
84
+ const detected = detectInstalledArtifacts(paths, fixture);
85
+ expect(detected.app).toBe(true);
79
86
  expect(detected.bashCompletion).toBe(true);
80
87
  expect(detected.zshCompletion).toBe(false);
81
88
  });
82
89
  });
83
90
 
84
91
  describe("install plan", () => {
85
- test("buildInstallPlan --all includes binary", () => {
86
- const paths = resolveInstallPaths(fixture, {});
92
+ test("buildInstallPlan --all includes app", () => {
93
+ const paths = resolveInstallPaths(fixture);
87
94
  const plan = buildInstallPlan(fixture, paths, parseInstallOpts({ all: "1" }));
88
- expect(plan.some((a) => a.kind === "binary")).toBe(true);
95
+ expect(plan.some((a) => a.kind === "app")).toBe(true);
96
+ });
97
+
98
+ test("buildInstallPlan --mcp --yes also includes app", () => {
99
+ const paths = resolveInstallPaths(fixture);
100
+ const opts = parseInstallOpts({ mcp: "1", yes: "1" });
101
+ validateInstallOpts(opts);
102
+ const plan = buildInstallPlan(fixture, paths, opts);
103
+ expect(plan.some((a) => a.kind === "app")).toBe(true);
104
+ });
105
+
106
+ test("buildInstallPlan --mcp without yes omits app", () => {
107
+ const paths = resolveInstallPaths(fixture);
108
+ const plan = buildInstallPlan(fixture, paths, parseInstallOpts({ mcp: "1" }));
109
+ expect(plan.some((a) => a.kind === "app")).toBe(false);
110
+ });
111
+
112
+ test("buildInstallPlan --mcp skips MCP when agentIntegration is skill", () => {
113
+ const skillApp: CliProgram = {
114
+ ...fixture,
115
+ install: { agentIntegration: "skill" },
116
+ };
117
+ const paths = resolveInstallPaths(skillApp);
118
+ mkdirSync(join(home, ".claude"), { recursive: true });
119
+ mkdirSync(join(home, ".cursor"), { recursive: true });
120
+ const plan = buildInstallPlan(skillApp, paths, parseInstallOpts({ mcp: "1" }));
121
+ expect(plan.some((a) => a.kind.endsWith("-mcp"))).toBe(false);
89
122
  });
90
123
 
91
124
  test("buildInstallPlan --mcp includes claude desktop when app data exists", () => {
92
- const paths = resolveInstallPaths(fixture, {});
125
+ const paths = resolveInstallPaths(fixture);
126
+ mkdirSync(join(home, ".claude"), { recursive: true });
93
127
  mkdirSync(join(home, "Library", "Application Support", "Claude"), { recursive: true });
94
128
  const plan = buildInstallPlan(fixture, paths, parseInstallOpts({ mcp: "1" }));
95
129
  expect(plan.some((a) => a.kind === "claude-desktop-mcp")).toBe(process.platform === "darwin");
@@ -97,20 +131,20 @@ describe("install plan", () => {
97
131
  });
98
132
 
99
133
  test("buildInstallPlan --mcp skips claude desktop without app data", () => {
100
- const paths = resolveInstallPaths(fixture, {});
134
+ const paths = resolveInstallPaths(fixture);
101
135
  const plan = buildInstallPlan(fixture, paths, parseInstallOpts({ mcp: "1" }));
102
136
  expect(plan.some((a) => a.kind === "claude-desktop-mcp")).toBe(false);
103
137
  });
104
138
 
105
139
  test("buildInstallPlan --mcp includes opencode when config dir exists", () => {
106
- const paths = resolveInstallPaths(fixture, {});
140
+ const paths = resolveInstallPaths(fixture);
107
141
  mkdirSync(opencodeConfigDir(home), { recursive: true });
108
142
  const plan = buildInstallPlan(fixture, paths, parseInstallOpts({ mcp: "1" }));
109
143
  expect(plan.some((a) => a.kind === "opencode-mcp")).toBe(true);
110
144
  });
111
145
 
112
146
  test("buildInstallPlan --mcp includes chatgpt when app data exists", () => {
113
- const paths = resolveInstallPaths(fixture, {});
147
+ const paths = resolveInstallPaths(fixture);
114
148
  if (process.platform === "darwin") {
115
149
  mkdirSync(join(home, "Library", "Application Support", "ChatGPT"), { recursive: true });
116
150
  const plan = buildInstallPlan(fixture, paths, parseInstallOpts({ mcp: "1" }));
@@ -121,9 +155,9 @@ describe("install plan", () => {
121
155
 
122
156
  describe("install status", () => {
123
157
  test("printInstallStatus human output", () => {
124
- const paths = resolveInstallPaths(fixture, {});
125
- mkdirSync(paths.bindir, { recursive: true });
126
- writeFileSync(paths.binaryPath, "fake", "utf8");
158
+ const paths = resolveInstallPaths(fixture);
159
+ mkdirSync(paths.appDir, { recursive: true });
160
+ writeFileSync(paths.appPath, "fake", "utf8");
127
161
 
128
162
  const chunks: string[] = [];
129
163
  const orig = process.stdout.write;
@@ -135,14 +169,14 @@ describe("install status", () => {
135
169
  printInstallStatus(fixture, {});
136
170
  const out = chunks.join("");
137
171
  expect(out).toContain("Installed artifacts for testapp");
138
- expect(out).toContain(paths.binaryPath);
172
+ expect(out).toContain("~/.local/bin/testapp");
139
173
  } finally {
140
174
  process.stdout.write = orig;
141
175
  }
142
176
  });
143
177
 
144
178
  test("printInstallStatus json output", () => {
145
- const paths = resolveInstallPaths(fixture, {});
179
+ const paths = resolveInstallPaths(fixture);
146
180
  mkdirSync(join(home, ".cursor"), { recursive: true });
147
181
  writeFileSync(
148
182
  paths.cursorMcpPath,
@@ -166,6 +200,45 @@ describe("install status", () => {
166
200
  });
167
201
  });
168
202
 
203
+ describe("interactive selection", () => {
204
+ test("mergeInteractiveSelection prepends 1 when app is assumed", () => {
205
+ expect(mergeInteractiveSelection([2, 3], 5, true)).toEqual([1, 2, 3]);
206
+ expect(mergeInteractiveSelection([1, 3], 5, true)).toEqual([1, 3]);
207
+ expect(mergeInteractiveSelection([2, 3], 5, false)).toEqual([2, 3]);
208
+ });
209
+
210
+ test("interactiveSelectionAssumesApp when install plan starts with app", () => {
211
+ const paths = resolveInstallPaths(fixture);
212
+ const plan = buildInstallPlan(fixture, paths, parseInstallOpts({ all: "1" }));
213
+ expect(interactiveSelectionAssumesApp(plan, false)).toBe(true);
214
+ expect(interactiveSelectionAssumesApp(plan, true)).toBe(false);
215
+ });
216
+
217
+ test("interactiveSelectionAssumesApp false when app not in plan", () => {
218
+ const paths = resolveInstallPaths(fixture);
219
+ const plan = buildInstallPlan(fixture, paths, parseInstallOpts({ completions: "1" }));
220
+ expect(plan.some((a) => a.kind === "app")).toBe(false);
221
+ expect(interactiveSelectionAssumesApp(plan, false)).toBe(false);
222
+ });
223
+ });
224
+
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
+
169
242
  describe("parseInstallOpts", () => {
170
243
  test("json implies yes in validate path via cliInstall", () => {
171
244
  const opts = parseInstallOpts({ json: "1", all: "1" });
@@ -197,41 +270,64 @@ describe("install mutation", () => {
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");
280
+ test("uninstall --all removes detected app", async () => {
281
+ const paths = resolveInstallPaths(fixture);
282
+ mkdirSync(paths.appDir, { recursive: true });
283
+ writeFileSync(paths.appPath, "fake", "utf8");
209
284
 
210
285
  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);
286
+ expect(result.changed).toContain(paths.appPath);
287
+ expect(existsSync(paths.appPath)).toBe(false);
288
+ expect(existsSync(paths.appDir)).toBe(true);
213
289
  });
214
290
  });
215
291
 
216
292
  describe("uninstall plan", () => {
217
293
  test("buildUninstallPlan --all scopes like install --all", () => {
218
- const paths = resolveInstallPaths(fixture, {});
219
- mkdirSync(paths.bindir, { recursive: true });
220
- writeFileSync(paths.binaryPath, "fake", "utf8");
294
+ const paths = resolveInstallPaths(fixture);
295
+ mkdirSync(paths.appDir, { recursive: true });
296
+ writeFileSync(paths.appPath, "fake", "utf8");
221
297
 
222
298
  const plan = buildUninstallPlan(fixture, paths, parseInstallOpts({ uninstall: "1", all: "1" }));
223
- expect(plan.some((a) => a.summary.startsWith("binary:"))).toBe(true);
299
+ expect(plan.some((a) => a.summary.startsWith("app:"))).toBe(true);
300
+ });
301
+
302
+ test("buildUninstallPlan --all runs configure last", () => {
303
+ const program: CliProgram = {
304
+ ...fixture,
305
+ appConfig: {
306
+ entries: { token: { description: "API token." } },
307
+ },
308
+ };
309
+ const paths = resolveInstallPaths(program);
310
+ mkdirSync(paths.appDir, { recursive: true });
311
+ writeFileSync(paths.appPath, "fake", "utf8");
312
+ writeAppConfigFile(program, { token: "secret" });
313
+
314
+ const plan = buildUninstallPlan(program, paths, parseInstallOpts({ uninstall: "1", all: "1" }));
315
+ const appIdx = plan.findIndex((a) => a.summary.startsWith("app:"));
316
+ const configureIdx = plan.findIndex((a) => a.summary.startsWith("app config:"));
317
+ expect(appIdx).toBeGreaterThanOrEqual(0);
318
+ expect(configureIdx).toBeGreaterThan(appIdx);
319
+ expect(configureIdx).toBe(plan.length - 1);
224
320
  });
225
321
 
226
- test("buildUninstallPlan --bin ignores completions", () => {
227
- const paths = resolveInstallPaths(fixture, {});
228
- mkdirSync(paths.bindir, { recursive: true });
229
- writeFileSync(paths.binaryPath, "fake", "utf8");
322
+ test("buildUninstallPlan --app ignores completions", () => {
323
+ const paths = resolveInstallPaths(fixture);
324
+ mkdirSync(paths.appDir, { recursive: true });
325
+ writeFileSync(paths.appPath, "fake", "utf8");
230
326
  mkdirSync(join(home, ".bash_completion.d"), { recursive: true });
231
327
  writeFileSync(paths.bashCompletion, "# bash", "utf8");
232
328
 
233
- const plan = buildUninstallPlan(fixture, paths, parseInstallOpts({ uninstall: "1", bin: "1" }));
329
+ const plan = buildUninstallPlan(fixture, paths, parseInstallOpts({ uninstall: "1", app: "1" }));
234
330
  expect(plan).toHaveLength(1);
235
- expect(plan[0]?.summary.startsWith("binary:")).toBe(true);
331
+ expect(plan[0]?.summary.startsWith("app:")).toBe(true);
236
332
  });
237
333
  });
@@ -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,35 @@
1
+ /** Normalizes bare `install` / `install --uninstall` 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") || flag("update")) {
7
+ return out;
8
+ }
9
+
10
+ const hasInstallTarget =
11
+ flag("all") || flag("app") || flag("completions") || flag("skill") || flag("mcp");
12
+ const hasUninstallTarget =
13
+ flag("all") ||
14
+ flag("app") ||
15
+ flag("completions") ||
16
+ flag("skill") ||
17
+ flag("mcp") ||
18
+ flag("configure");
19
+
20
+ if (flag("configure") && !flag("uninstall") && !hasInstallTarget) {
21
+ return out;
22
+ }
23
+
24
+ if (flag("uninstall")) {
25
+ if (!hasUninstallTarget) {
26
+ out.all = "1";
27
+ }
28
+ return out;
29
+ }
30
+
31
+ if (!hasInstallTarget) {
32
+ out.all = "1";
33
+ }
34
+ return out;
35
+ }
@@ -1,24 +1,30 @@
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
+ appDir: string;
12
+ appPath: string;
11
13
  bashCompletion: string;
12
14
  zshCompletion: string;
13
15
  fishCompletion: string;
14
16
  cursorSkillDir: string;
15
17
  claudeSkillDir: string;
18
+ codexSkillDir: string;
19
+ opencodeSkillDir: string;
20
+ openclawSkillDir: string;
16
21
  cursorMcpPath: string;
17
22
  claudeMcpPath: string;
18
23
  claudeDesktopMcpPath: string;
19
24
  opencodeMcpPath: string;
20
25
  chatGptMcpPath: string;
21
26
  codexConfigPath: string;
27
+ openclawConfigPath: string;
22
28
  bashRc: string;
23
29
  zshRc: string;
24
30
  mcpName: string;
@@ -27,12 +33,13 @@ export interface InstallPaths {
27
33
 
28
34
  export { userHome } from "../paths/host.ts";
29
35
 
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
+ /** Format an absolute path for user-facing install output. */
37
+ export function displayInstallPath(path: string): string {
38
+ return displayHomePath(path);
39
+ }
40
+
41
+ /** App install directory (`~/.local/bin`). */
42
+ export function resolveAppDir(): string {
36
43
  return join(userHome(), ".local", "bin");
37
44
  }
38
45
 
@@ -71,29 +78,36 @@ export function chatGptDesktopPresent(_home: string, configPath: string): boolea
71
78
  }
72
79
 
73
80
  /** Resolves all install artifact paths for a program root. */
74
- export function resolveInstallPaths(root: CliProgram, opts: { prefix?: string }): InstallPaths {
81
+ export function resolveInstallPaths(root: CliProgram): InstallPaths {
75
82
  const home = userHome();
76
- const bindir = resolveBindir(root, opts.prefix);
83
+ const appDir = resolveAppDir();
77
84
  const key = root.key;
78
85
  const skillDirName = sanitizeToolSegment(root.key);
86
+ const codexSlug = skillDirNameForTarget(root.key, "codex");
87
+ const opencodeSlug = skillDirNameForTarget(root.key, "opencode");
88
+ const openclawSlug = skillDirNameForTarget(root.key, "openclaw");
79
89
  const xdgConfig = xdgConfigHome(home);
80
90
  const claudeDesktopMcpPath = resolveClaudeDesktopMcpPath(home);
81
91
  const chatGptMcpPath = resolveChatGptMcpPath(home);
82
92
 
83
93
  return {
84
- bindir,
85
- binaryPath: join(bindir, key),
94
+ appDir,
95
+ appPath: join(appDir, key),
86
96
  bashCompletion: join(home, ".bash_completion.d", key),
87
97
  zshCompletion: join(home, ".zsh", "completions", `_${key}`),
88
98
  fishCompletion: join(xdgConfig, "fish", "completions", `${key}.fish`),
89
99
  cursorSkillDir: join(home, ".cursor", "skills", skillDirName),
90
100
  claudeSkillDir: join(home, ".claude", "skills", skillDirName),
101
+ codexSkillDir: join(home, ".codex", "skills", codexSlug),
102
+ opencodeSkillDir: join(home, ".config", "opencode", "skills", opencodeSlug),
103
+ openclawSkillDir: join(home, ".openclaw", "skills", openclawSlug),
91
104
  cursorMcpPath: join(home, ".cursor", "mcp.json"),
92
105
  claudeMcpPath: join(home, ".claude.json"),
93
106
  claudeDesktopMcpPath,
94
107
  opencodeMcpPath: resolveOpenCodeConfigPathForInstall(home),
95
108
  chatGptMcpPath,
96
109
  codexConfigPath: join(home, ".codex", "config.toml"),
110
+ openclawConfigPath: resolveOpenclawConfigPath(home),
97
111
  bashRc: join(home, ".bashrc"),
98
112
  zshRc: join(home, ".zshrc"),
99
113
  mcpName: mcpServerId(root),