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.
- package/CHANGELOG.md +42 -1
- package/README.md +6 -6
- package/docs/ai-skills.md +9 -4
- package/docs/bundled-docs.md +1 -0
- package/docs/cli-program.md +4 -4
- package/docs/config-schema.md +1 -4
- package/docs/developing.md +1 -1
- package/docs/install.md +160 -39
- package/docs/mcp.md +38 -11
- package/docs/templates/cursor/rules/cli-program.mdc +1 -1
- package/examples/config-app/program.ts +0 -3
- package/examples/consumer-app/README.md +1 -1
- package/examples/consumer-app/src/program.ts +5 -13
- package/examples/mcp-test.ts +14 -24
- package/index.d.ts +60 -5
- package/package.json +1 -1
- package/src/builtins/builtins.test.ts +20 -4
- package/src/builtins/config.test.ts +31 -25
- package/src/builtins/config.ts +4 -3
- package/src/builtins/install.ts +50 -57
- package/src/builtins/mcp.ts +1 -1
- package/src/capabilities.ts +4 -4
- package/src/cli.ts +2 -0
- package/src/config/bootstrap.ts +167 -66
- package/src/config/context.test.ts +22 -36
- package/src/config/context.ts +5 -4
- package/src/config/file.test.ts +66 -56
- package/src/config/file.ts +33 -25
- package/src/config/resolve.test.ts +25 -1
- package/src/config/resolve.ts +43 -8
- package/src/config.integration.test.ts +17 -10
- package/src/docs/api-guide.test.ts +1 -1
- package/src/docs/docs.test.ts +1 -1
- package/src/docs/mcp-guide.ts +15 -4
- package/src/docs/mcp-resources.test.ts +63 -0
- package/src/docs/mcp-resources.ts +68 -0
- package/src/hidden-mcpb.test.ts +41 -1
- package/src/index.ts +4 -0
- package/src/install/{binary.ts → app.ts} +13 -13
- package/src/install/bootstrap.ts +22 -0
- package/src/install/detect-installed.ts +2 -97
- package/src/install/index.ts +187 -110
- package/src/install/install-validate.test.ts +61 -0
- package/src/install/install.test.ts +138 -42
- package/src/install/mcp-openclaw.test.ts +40 -0
- package/src/install/mcp-openclaw.ts +106 -0
- package/src/install/normalize.ts +35 -0
- package/src/install/paths.ts +27 -13
- package/src/install/plan.ts +30 -259
- package/src/install/shell.ts +2 -2
- package/src/install/status.test.ts +85 -0
- package/src/install/status.ts +22 -9
- package/src/install/target-base.ts +93 -0
- package/src/install/target-detect.ts +20 -0
- package/src/install/target-effective.ts +131 -0
- package/src/install/target-mcp-cli.ts +149 -0
- package/src/install/target-mcp-json.ts +130 -0
- package/src/install/target-plan-build.ts +67 -0
- package/src/install/target-registry.ts +57 -0
- package/src/install/target-scope.ts +266 -0
- package/src/install/target-skill.ts +104 -0
- package/src/install/target-types.ts +145 -0
- package/src/install/targets/app.ts +69 -0
- package/src/install/targets/chatgpt-mcp.ts +12 -0
- package/src/install/targets/claude-code-mcp.ts +15 -0
- package/src/install/targets/claude-desktop-mcp.ts +12 -0
- package/src/install/targets/claude-skill.ts +16 -0
- package/src/install/targets/codex-mcp.ts +25 -0
- package/src/install/targets/codex-skill.ts +14 -0
- package/src/install/targets/completions.ts +133 -0
- package/src/install/targets/configure.ts +59 -0
- package/src/install/targets/cursor-mcp.ts +15 -0
- package/src/install/targets/cursor-skill.ts +16 -0
- package/src/install/targets/index.ts +53 -0
- package/src/install/targets/openclaw-mcp.ts +25 -0
- package/src/install/targets/openclaw-skill.ts +17 -0
- package/src/install/targets/opencode-mcp.ts +101 -0
- package/src/install/targets/opencode-skill.ts +15 -0
- package/src/install/targets.test.ts +136 -0
- package/src/install/uninstall.ts +16 -152
- package/src/install/update.test.ts +17 -2
- package/src/install/update.ts +2 -5
- package/src/invoke.test.ts +7 -1
- package/src/mcp/bundle.ts +16 -4
- package/src/mcp/claude.test.ts +23 -4
- package/src/mcp/claude.ts +18 -13
- package/src/mcp/tools.ts +4 -1
- package/src/mcp/zip.test.ts +17 -0
- package/src/mcp/zip.ts +62 -9
- package/src/mcp.integration.test.ts +18 -1
- package/src/parse.test.ts +57 -4
- package/src/paths/host.ts +11 -11
- package/src/paths/remove-empty-dir.ts +13 -0
- package/src/skill/generate.ts +89 -5
- package/src/skill/hint.ts +5 -0
- package/src/skill/install.ts +33 -6
- package/src/skill/naming.ts +28 -0
- package/src/types.ts +65 -4
- 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 {
|
|
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
|
|
45
|
-
const paths = resolveInstallPaths(fixture
|
|
46
|
-
expect(paths.
|
|
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
|
|
71
|
-
const paths = resolveInstallPaths(fixture
|
|
72
|
-
mkdirSync(paths.
|
|
73
|
-
writeFileSync(paths.
|
|
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.
|
|
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
|
|
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 === "
|
|
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.
|
|
126
|
-
writeFileSync(paths.
|
|
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(
|
|
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
|
|
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(
|
|
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
|
|
206
|
-
const paths = resolveInstallPaths(fixture
|
|
207
|
-
mkdirSync(paths.
|
|
208
|
-
writeFileSync(paths.
|
|
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.
|
|
212
|
-
expect(existsSync(paths.
|
|
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.
|
|
220
|
-
writeFileSync(paths.
|
|
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("
|
|
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 --
|
|
227
|
-
const paths = resolveInstallPaths(fixture
|
|
228
|
-
mkdirSync(paths.
|
|
229
|
-
writeFileSync(paths.
|
|
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",
|
|
329
|
+
const plan = buildUninstallPlan(fixture, paths, parseInstallOpts({ uninstall: "1", app: "1" }));
|
|
234
330
|
expect(plan).toHaveLength(1);
|
|
235
|
-
expect(plan[0]?.summary.startsWith("
|
|
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
|
+
}
|
package/src/install/paths.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
10
|
-
|
|
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
|
-
/**
|
|
31
|
-
export function
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
|
81
|
+
export function resolveInstallPaths(root: CliProgram): InstallPaths {
|
|
75
82
|
const home = userHome();
|
|
76
|
-
const
|
|
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
|
-
|
|
85
|
-
|
|
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),
|