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.
- package/CHANGELOG.md +77 -1
- package/README.md +91 -85
- package/docs/README.md +6 -6
- package/docs/ai-skills.md +8 -5
- package/docs/bundled-docs.md +1 -1
- package/docs/cli-program.md +9 -7
- package/docs/config-schema.md +37 -13
- package/docs/developing.md +8 -8
- package/docs/distribution-homebrew.md +103 -0
- package/docs/install.md +143 -106
- package/docs/mcp.md +23 -12
- package/docs/output-schema.md +1 -1
- package/examples/full-example/Formula/.gitkeep +0 -0
- package/examples/full-example/README.md +98 -0
- package/examples/full-example/biome.json +22 -0
- package/examples/{consumer-app → full-example}/bun.lock +2 -0
- package/examples/full-example/justfile +134 -0
- package/examples/{consumer-app → full-example}/package.json +10 -3
- package/examples/{consumer-app → full-example}/schemas/generated/app-config.json +1 -1
- package/examples/{consumer-app → full-example}/schemas/generated/status.json +1 -1
- package/examples/full-example/scripts/create-identity.ts +11 -0
- package/examples/full-example/scripts/formula-shared.ts +73 -0
- package/examples/full-example/scripts/gen-dev-formula.ts +26 -0
- package/examples/full-example/scripts/print-identity.ts +27 -0
- package/examples/full-example/src/commands/echo/command.ts +21 -0
- package/examples/full-example/src/commands/status/command.test.ts +10 -0
- package/examples/full-example/src/commands/status/command.ts +36 -0
- package/examples/{consumer-app → full-example}/src/commands/status/types.ts +1 -1
- package/examples/full-example/src/index.ts +10 -0
- package/examples/full-example/src/program.ts +57 -0
- package/examples/{consumer-app → full-example}/src/types.ts +1 -1
- package/examples/mcp-test.ts +8 -24
- package/examples/nested.ts +1 -3
- package/index.d.ts +81 -65
- package/package.json +2 -2
- package/src/builtins/builtins.test.ts +37 -22
- package/src/builtins/completion-group.ts +17 -15
- package/src/builtins/config.test.ts +31 -25
- package/src/builtins/config.ts +4 -3
- package/src/builtins/dispatch.ts +25 -1
- package/src/builtins/install.ts +45 -82
- package/src/builtins/mcp.ts +1 -1
- package/src/builtins/registry.ts +2 -0
- package/src/builtins/uninstall.ts +80 -0
- package/src/capabilities.ts +5 -7
- package/src/cli-tool/cli-smoke.test.ts +19 -0
- package/src/cli-tool/create.test.ts +119 -0
- package/src/cli-tool/create.ts +380 -0
- package/{examples/consumer-app/capabilities.test.ts → src/cli-tool/full-example-capabilities.test.ts} +15 -14
- package/src/cli-tool/main.ts +8 -0
- package/src/cli-tool/post-create.ts +111 -0
- package/src/cli-tool/program.ts +82 -0
- package/src/cli-tool/prompt.ts +28 -0
- package/src/cli-tool/run-create.ts +149 -0
- package/src/config/bootstrap.ts +174 -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 +192 -1
- package/src/config/resolve.ts +92 -13
- package/src/config.integration.test.ts +17 -10
- package/src/docs/api-guide.test.ts +4 -5
- package/src/docs/docs.test.ts +2 -1
- package/src/docs/mcp-guide.ts +7 -8
- package/src/hidden-mcpb.test.ts +41 -1
- package/src/index.ts +7 -10
- package/src/install/binary-placement.test.ts +101 -0
- package/src/install/binary-placement.ts +47 -0
- package/src/install/detect-installed.ts +2 -97
- package/src/install/index.ts +239 -168
- package/src/install/install-validate.test.ts +61 -0
- package/src/install/install.test.ts +170 -90
- package/src/install/mcp-openclaw.test.ts +40 -0
- package/src/install/mcp-openclaw.ts +106 -0
- package/src/install/normalize-uninstall.ts +11 -0
- package/src/install/normalize.ts +20 -0
- package/src/install/paths.ts +18 -26
- package/src/install/plan.ts +40 -261
- package/src/install/shell.ts +0 -14
- package/src/install/status.test.ts +85 -0
- package/src/install/status.ts +22 -15
- package/src/install/target-base.ts +93 -0
- package/src/install/target-detect.ts +20 -0
- package/src/install/target-effective.ts +129 -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 +253 -0
- package/src/install/target-skill.ts +104 -0
- package/src/install/target-types.ts +129 -0
- package/src/install/targets/app.ts +60 -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/configure.ts +63 -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 +50 -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 +118 -0
- package/src/install/uninstall.ts +16 -152
- package/src/invoke.test.ts +7 -1
- package/src/mcp/bundle.ts +16 -4
- package/src/mcp/claude.test.ts +14 -1
- package/src/mcp/claude.ts +11 -4
- package/src/mcp/env.test.ts +92 -0
- package/src/mcp/env.ts +15 -14
- package/src/mcp/zip.test.ts +17 -0
- package/src/mcp/zip.ts +62 -9
- package/src/mcp.integration.test.ts +1 -1
- package/src/parse.test.ts +14 -2
- package/src/paths/host.ts +11 -11
- package/src/paths/remove-empty-dir.ts +13 -0
- package/src/prompt.ts +10 -0
- package/src/schema.ts +9 -1
- package/src/skill/generate.ts +18 -4
- package/src/skill/install.ts +33 -6
- package/src/skill/naming.ts +28 -0
- package/src/types.ts +86 -7
- package/src/validate.ts +73 -9
- package/docs/templates/cursor/rules/cli-program.mdc +0 -31
- package/examples/config-app/main.ts +0 -20
- package/examples/config-app/program.ts +0 -81
- package/examples/config-app/schema.ts +0 -37
- package/examples/config-app/types.ts +0 -19
- package/examples/consumer-app/README.md +0 -57
- package/examples/consumer-app/src/main.ts +0 -15
- package/examples/consumer-app/src/program.ts +0 -108
- package/src/install/binary.ts +0 -94
- package/src/install/completions.ts +0 -56
- package/src/install/update.test.ts +0 -108
- package/src/install/update.ts +0 -57
- /package/examples/{consumer-app → full-example}/schemas/configSchemas.ts +0 -0
- /package/examples/{consumer-app → full-example}/schemas/outputSchemas.ts +0 -0
- /package/examples/{consumer-app → full-example}/scripts/schemagen/discover-schema-roots.test.ts +0 -0
- /package/examples/{consumer-app → full-example}/scripts/schemagen/discover-schema-roots.ts +0 -0
- /package/examples/{consumer-app → full-example}/scripts/schemagen/naming.ts +0 -0
- /package/examples/{consumer-app → full-example}/scripts/schemagen.ts +0 -0
- /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 {
|
|
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
|
|
45
|
-
const paths = resolveInstallPaths(fixture
|
|
46
|
-
expect(paths.
|
|
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
|
|
71
|
-
const paths = resolveInstallPaths(fixture
|
|
72
|
-
mkdirSync(
|
|
73
|
-
writeFileSync(
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
expect(detected.
|
|
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
|
|
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 === "
|
|
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
|
|
113
|
-
const
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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(
|
|
126
|
-
writeFileSync(
|
|
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(
|
|
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("
|
|
170
|
-
test("
|
|
171
|
-
|
|
172
|
-
expect(
|
|
173
|
-
expect(
|
|
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("
|
|
178
|
-
test("
|
|
179
|
-
const
|
|
180
|
-
|
|
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(
|
|
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(
|
|
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
|
|
269
|
+
const result = await runUninstallMutation(fixture, { all: "1", yes: "1" });
|
|
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("
|
|
206
|
-
const
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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
|
|
218
|
-
const
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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(
|
|
234
|
-
|
|
235
|
-
expect(
|
|
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
|
+
}
|
package/src/install/paths.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
/**
|
|
31
|
-
export function
|
|
32
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
98
|
-
zshRc: join(home, ".zshrc"),
|
|
90
|
+
openclawConfigPath: resolveOpenclawConfigPath(home),
|
|
99
91
|
mcpName: mcpServerId(root),
|
|
100
92
|
skillDirName,
|
|
101
93
|
};
|