argsbarg 4.0.4 → 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 +30 -1
- package/README.md +5 -5
- package/docs/ai-skills.md +8 -5
- 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 +21 -9
- package/docs/templates/cursor/rules/cli-program.mdc +1 -1
- package/examples/config-app/program.ts +0 -3
- package/examples/consumer-app/README.md +0 -1
- package/examples/consumer-app/src/program.ts +3 -3
- package/examples/mcp-test.ts +8 -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 +1 -1
- 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 +14 -1
- package/src/mcp/claude.ts +11 -4
- 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 +12 -1
- package/src/paths/host.ts +11 -11
- package/src/paths/remove-empty-dir.ts +13 -0
- 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 +65 -4
- package/src/validate.ts +70 -0
package/src/install/uninstall.ts
CHANGED
|
@@ -1,164 +1,19 @@
|
|
|
1
1
|
import { existsSync, rmSync } from "node:fs";
|
|
2
|
-
import { resolveAppConfigPath, uninstallAppConfig } from "../config/file.ts";
|
|
3
2
|
import type { CliProgram } from "../types.ts";
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import { removeMcpConfig } from "./mcp-config.ts";
|
|
9
|
-
import { detectOpenCodeMcpConfigPath, removeOpenCodeMcpConfig } from "./mcp-opencode.ts";
|
|
10
|
-
import { type InstallPaths, userHome } from "./paths.ts";
|
|
11
|
-
import {
|
|
12
|
-
type InstallOpts,
|
|
13
|
-
wantsInstallBin,
|
|
14
|
-
wantsInstallCompletions,
|
|
15
|
-
wantsInstallMcp,
|
|
16
|
-
wantsInstallSkill,
|
|
17
|
-
wantsUninstallConfig,
|
|
18
|
-
} from "./plan.ts";
|
|
3
|
+
import type { InstallPaths } from "./paths.ts";
|
|
4
|
+
import { buildUninstallPlanFromTargets } from "./target-plan-build.ts";
|
|
5
|
+
import { skillTargetForUninstallSummary } from "./target-registry.ts";
|
|
6
|
+
import type { InstallOpts, UninstallAction } from "./target-types.ts";
|
|
19
7
|
|
|
20
|
-
export
|
|
21
|
-
summary: string;
|
|
22
|
-
message: string;
|
|
23
|
-
run: () => string[];
|
|
24
|
-
}
|
|
8
|
+
export type { InstallActionKind, InstallOpts, UninstallAction } from "./target-types.ts";
|
|
25
9
|
|
|
26
|
-
/** Builds uninstall actions for scoped targets
|
|
10
|
+
/** Builds uninstall actions for scoped targets or --all (ignores install.targets on --all). */
|
|
27
11
|
export function buildUninstallPlan(
|
|
28
12
|
root: CliProgram,
|
|
29
13
|
paths: InstallPaths,
|
|
30
14
|
opts: InstallOpts,
|
|
31
15
|
): UninstallAction[] {
|
|
32
|
-
|
|
33
|
-
const dry = !!opts.dry;
|
|
34
|
-
const actions: UninstallAction[] = [];
|
|
35
|
-
|
|
36
|
-
if (wantsInstallBin(opts) && detected.binary) {
|
|
37
|
-
actions.push({
|
|
38
|
-
summary: `binary: ${paths.binaryPath}`,
|
|
39
|
-
message: `Removing binary ${paths.binaryPath}`,
|
|
40
|
-
run: () => uninstallBinary(root, paths, dry),
|
|
41
|
-
});
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
if (
|
|
45
|
-
wantsInstallCompletions(opts) &&
|
|
46
|
-
(detected.bashCompletion || detected.zshCompletion || detected.fishCompletion)
|
|
47
|
-
) {
|
|
48
|
-
if (detected.bashCompletion) {
|
|
49
|
-
actions.push({
|
|
50
|
-
summary: `bash completion: ${paths.bashCompletion}`,
|
|
51
|
-
message: `Removing bash completion ${paths.bashCompletion}`,
|
|
52
|
-
run: () => uninstallCompletions(paths, dry).filter((p) => p === paths.bashCompletion),
|
|
53
|
-
});
|
|
54
|
-
}
|
|
55
|
-
if (detected.zshCompletion) {
|
|
56
|
-
actions.push({
|
|
57
|
-
summary: `zsh completion: ${paths.zshCompletion}`,
|
|
58
|
-
message: `Removing zsh completion ${paths.zshCompletion}`,
|
|
59
|
-
run: () => uninstallCompletions(paths, dry).filter((p) => p === paths.zshCompletion),
|
|
60
|
-
});
|
|
61
|
-
}
|
|
62
|
-
if (detected.fishCompletion) {
|
|
63
|
-
actions.push({
|
|
64
|
-
summary: `fish completion: ${paths.fishCompletion}`,
|
|
65
|
-
message: `Removing fish completion ${paths.fishCompletion}`,
|
|
66
|
-
run: () => uninstallCompletions(paths, dry).filter((p) => p === paths.fishCompletion),
|
|
67
|
-
});
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
if (wantsInstallSkill(opts) && detected.cursorSkill) {
|
|
72
|
-
actions.push({
|
|
73
|
-
summary: `cursor skill: ${paths.cursorSkillDir}/`,
|
|
74
|
-
message: `Removing Cursor skill ${paths.cursorSkillDir}/`,
|
|
75
|
-
run: () => [],
|
|
76
|
-
});
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
if (wantsInstallSkill(opts) && detected.claudeSkill) {
|
|
80
|
-
actions.push({
|
|
81
|
-
summary: `claude skill: ${paths.claudeSkillDir}/`,
|
|
82
|
-
message: `Removing Claude Code skill ${paths.claudeSkillDir}/`,
|
|
83
|
-
run: () => [],
|
|
84
|
-
});
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
if (wantsInstallMcp(opts, root)) {
|
|
88
|
-
if (detected.cursorMcp) {
|
|
89
|
-
actions.push({
|
|
90
|
-
summary: `cursor mcp: ${paths.cursorMcpPath}`,
|
|
91
|
-
message: `Removing MCP server "${paths.mcpName}" from ${paths.cursorMcpPath}`,
|
|
92
|
-
run: () => {
|
|
93
|
-
removeMcpConfig(paths.cursorMcpPath, paths.mcpName, dry);
|
|
94
|
-
return [paths.cursorMcpPath];
|
|
95
|
-
},
|
|
96
|
-
});
|
|
97
|
-
}
|
|
98
|
-
if (detected.claudeMcp) {
|
|
99
|
-
actions.push({
|
|
100
|
-
summary: `claude code mcp: ${paths.claudeMcpPath}`,
|
|
101
|
-
message: `Removing MCP server "${paths.mcpName}" from ${paths.claudeMcpPath}`,
|
|
102
|
-
run: () => {
|
|
103
|
-
removeMcpConfig(paths.claudeMcpPath, paths.mcpName, dry);
|
|
104
|
-
return [paths.claudeMcpPath];
|
|
105
|
-
},
|
|
106
|
-
});
|
|
107
|
-
}
|
|
108
|
-
if (detected.claudeDesktopMcp) {
|
|
109
|
-
actions.push({
|
|
110
|
-
summary: `claude desktop mcp: ${paths.claudeDesktopMcpPath}`,
|
|
111
|
-
message: `Removing MCP server "${paths.mcpName}" from ${paths.claudeDesktopMcpPath}`,
|
|
112
|
-
run: () => {
|
|
113
|
-
removeMcpConfig(paths.claudeDesktopMcpPath, paths.mcpName, dry);
|
|
114
|
-
return [paths.claudeDesktopMcpPath];
|
|
115
|
-
},
|
|
116
|
-
});
|
|
117
|
-
}
|
|
118
|
-
if (detected.opencodeMcp) {
|
|
119
|
-
const openCodePath =
|
|
120
|
-
detectOpenCodeMcpConfigPath(userHome(), paths.mcpName) ?? paths.opencodeMcpPath;
|
|
121
|
-
actions.push({
|
|
122
|
-
summary: `opencode mcp: ${openCodePath}`,
|
|
123
|
-
message: `Removing MCP server "${paths.mcpName}" from ${openCodePath}`,
|
|
124
|
-
run: () => {
|
|
125
|
-
removeOpenCodeMcpConfig(openCodePath, paths.mcpName, dry);
|
|
126
|
-
return [openCodePath];
|
|
127
|
-
},
|
|
128
|
-
});
|
|
129
|
-
}
|
|
130
|
-
if (detected.codexMcp) {
|
|
131
|
-
actions.push({
|
|
132
|
-
summary: `codex mcp: ${paths.codexConfigPath}`,
|
|
133
|
-
message: `Removing MCP server "${paths.mcpName}" from Codex via codex mcp remove`,
|
|
134
|
-
run: () => {
|
|
135
|
-
removeCodexMcpConfig(userHome(), paths.mcpName, dry);
|
|
136
|
-
return [paths.codexConfigPath];
|
|
137
|
-
},
|
|
138
|
-
});
|
|
139
|
-
}
|
|
140
|
-
if (detected.chatGptMcp) {
|
|
141
|
-
actions.push({
|
|
142
|
-
summary: `chatgpt desktop mcp: ${paths.chatGptMcpPath}`,
|
|
143
|
-
message: `Removing MCP server "${paths.mcpName}" from ${paths.chatGptMcpPath}`,
|
|
144
|
-
run: () => {
|
|
145
|
-
removeMcpConfig(paths.chatGptMcpPath, paths.mcpName, dry);
|
|
146
|
-
return [paths.chatGptMcpPath];
|
|
147
|
-
},
|
|
148
|
-
});
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
if (wantsUninstallConfig(opts, root)) {
|
|
153
|
-
const configPath = resolveAppConfigPath(root);
|
|
154
|
-
actions.push({
|
|
155
|
-
summary: `app config: ${configPath}`,
|
|
156
|
-
message: `Removing app config ${configPath}`,
|
|
157
|
-
run: () => (uninstallAppConfig(root, dry) ? [configPath] : []),
|
|
158
|
-
});
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
return actions;
|
|
16
|
+
return buildUninstallPlanFromTargets(root, paths, opts);
|
|
162
17
|
}
|
|
163
18
|
|
|
164
19
|
/** Rimraf skill directories during uninstall. */
|
|
@@ -167,3 +22,12 @@ export function uninstallSkillDir(dir: string, dry: boolean): string[] {
|
|
|
167
22
|
if (!dry) rmSync(dir, { recursive: true, force: true });
|
|
168
23
|
return [`${dir}/`];
|
|
169
24
|
}
|
|
25
|
+
|
|
26
|
+
/** Skill directory for uninstall action summary prefix. */
|
|
27
|
+
export function skillDirFromUninstallSummary(
|
|
28
|
+
summary: string,
|
|
29
|
+
paths: InstallPaths,
|
|
30
|
+
): string | undefined {
|
|
31
|
+
const target = skillTargetForUninstallSummary(summary);
|
|
32
|
+
return target?.skillDir(paths);
|
|
33
|
+
}
|
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
import { afterEach, beforeEach, expect, test } from "bun:test";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
chmodSync,
|
|
4
|
+
existsSync,
|
|
5
|
+
mkdirSync,
|
|
6
|
+
mkdtempSync,
|
|
7
|
+
readFileSync,
|
|
8
|
+
rmSync,
|
|
9
|
+
writeFileSync,
|
|
10
|
+
} from "node:fs";
|
|
3
11
|
import { tmpdir } from "node:os";
|
|
4
12
|
import { join } from "node:path";
|
|
5
13
|
import { cliPresentationRoot } from "../builtins/presentation.ts";
|
|
@@ -68,10 +76,13 @@ test("runInstallMutation honors --from for binary copy", async () => {
|
|
|
68
76
|
writeFileSync(source, "#!/bin/sh\necho hi\n", "utf8");
|
|
69
77
|
chmodSync(source, 0o755);
|
|
70
78
|
|
|
79
|
+
const bindir = join(home, ".local", "bin");
|
|
80
|
+
mkdirSync(bindir, { recursive: true });
|
|
81
|
+
writeFileSync(join(bindir, "testapp"), "old\n", "utf8");
|
|
82
|
+
|
|
71
83
|
const { changed } = await runInstallMutation(root, {
|
|
72
84
|
reinstall: "1",
|
|
73
85
|
yes: "1",
|
|
74
|
-
quiet: "1",
|
|
75
86
|
from: source,
|
|
76
87
|
});
|
|
77
88
|
|
|
@@ -90,6 +101,10 @@ test("Cli.invoke install --update uses hook and reinstalls", async () => {
|
|
|
90
101
|
version: "2.0.0",
|
|
91
102
|
}));
|
|
92
103
|
|
|
104
|
+
const bindir = join(home, ".local", "bin");
|
|
105
|
+
mkdirSync(bindir, { recursive: true });
|
|
106
|
+
writeFileSync(join(bindir, "testapp"), "old\n", "utf8");
|
|
107
|
+
|
|
93
108
|
const result = await new Cli(root).invoke(["install", "--update"]);
|
|
94
109
|
expect(result.exitCode).toBe(0);
|
|
95
110
|
expect(result.stdout).toContain("Updated testapp 1.0.0 → 2.0.0");
|
package/src/install/update.ts
CHANGED
|
@@ -7,9 +7,7 @@ import { installErr } from "./status.ts";
|
|
|
7
7
|
export async function cliUpdate(root: CliProgram): Promise<never> {
|
|
8
8
|
const hook = root.install?.updateGetLatest;
|
|
9
9
|
if (!hook) {
|
|
10
|
-
installErr(
|
|
11
|
-
"install --update is not configured. Set install.updateGetLatest on the program root.",
|
|
12
|
-
);
|
|
10
|
+
installErr("Remote updates are not supported by this app.");
|
|
13
11
|
process.exit(1);
|
|
14
12
|
}
|
|
15
13
|
|
|
@@ -23,7 +21,7 @@ export async function cliUpdate(root: CliProgram): Promise<never> {
|
|
|
23
21
|
}
|
|
24
22
|
|
|
25
23
|
if (!artifact.path || !existsSync(artifact.path)) {
|
|
26
|
-
installErr(
|
|
24
|
+
installErr("Update download did not return a valid app binary.");
|
|
27
25
|
process.exit(1);
|
|
28
26
|
}
|
|
29
27
|
|
|
@@ -38,7 +36,6 @@ export async function cliUpdate(root: CliProgram): Promise<never> {
|
|
|
38
36
|
await runInstallMutation(root, {
|
|
39
37
|
reinstall: "1",
|
|
40
38
|
yes: "1",
|
|
41
|
-
quiet: "1",
|
|
42
39
|
from: artifact.path,
|
|
43
40
|
});
|
|
44
41
|
} catch (err) {
|
package/src/invoke.test.ts
CHANGED
|
@@ -16,7 +16,13 @@ test("ctx.invocation is cli via Cli.run", async () => {
|
|
|
16
16
|
const indexPath = join(import.meta.dir, "index.ts");
|
|
17
17
|
const { stdout } = await $`bun -e ${`
|
|
18
18
|
import { Cli, CliProgram } from ${JSON.stringify(indexPath)};
|
|
19
|
-
const program = {
|
|
19
|
+
const program = {
|
|
20
|
+
key: "t",
|
|
21
|
+
description: "d",
|
|
22
|
+
version: "0.0.0",
|
|
23
|
+
install: { enabled: false },
|
|
24
|
+
handler: (ctx) => console.log(ctx.invocation),
|
|
25
|
+
};
|
|
20
26
|
await new Cli(program).run([]);
|
|
21
27
|
`}`.quiet();
|
|
22
28
|
expect(stdout.toString().trim()).toBe("cli");
|
package/src/mcp/bundle.ts
CHANGED
|
@@ -160,9 +160,21 @@ export function packMcpBundle(program: CliProgram, opts: PackMcpBundleOpts = {})
|
|
|
160
160
|
}
|
|
161
161
|
}
|
|
162
162
|
|
|
163
|
-
/** Runs `mcp bundle`: writes
|
|
163
|
+
/** Runs `mcp bundle`: writes enabled dist artifacts; prints one path per line. */
|
|
164
164
|
export function runMcpBundle(program: CliProgram): void {
|
|
165
|
-
const
|
|
166
|
-
|
|
167
|
-
|
|
165
|
+
const mcp = program.mcpServer;
|
|
166
|
+
if (!mcp?.mcpd && !mcp?.claudePlugin) {
|
|
167
|
+
throw new Error(
|
|
168
|
+
"mcp bundle: enable mcpServer.mcpd and/or mcpServer.claudePlugin on the program root.",
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const lines: string[] = [];
|
|
173
|
+
if (mcp.mcpd) {
|
|
174
|
+
lines.push(packMcpBundle(program));
|
|
175
|
+
}
|
|
176
|
+
if (mcp.claudePlugin) {
|
|
177
|
+
lines.push(packClaudePlugin(program));
|
|
178
|
+
}
|
|
179
|
+
process.stdout.write(`${lines.join("\n")}\n`);
|
|
168
180
|
}
|
package/src/mcp/claude.test.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import {
|
|
2
|
+
import { execSync } from "node:child_process";
|
|
3
|
+
import { mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
3
4
|
import { tmpdir } from "node:os";
|
|
4
5
|
import { join } from "node:path";
|
|
5
6
|
import type { CliProgram } from "../types.ts";
|
|
@@ -36,6 +37,7 @@ describe("claude plugin", () => {
|
|
|
36
37
|
test("generatePluginManifest includes userConfig from program.appConfig", () => {
|
|
37
38
|
const manifest = generatePluginManifest(configFixture, "myapp");
|
|
38
39
|
expect(manifest.name).toBe("myapp");
|
|
40
|
+
expect(manifest.mcpServers).toBe(".mcp.json");
|
|
39
41
|
const userConfig = manifest.userConfig as Record<string, Record<string, unknown>>;
|
|
40
42
|
expect(userConfig.api_token?.description).toBe("Token from settings.");
|
|
41
43
|
expect(userConfig.api_token?.sensitive).toBe(true);
|
|
@@ -72,6 +74,17 @@ describe("claude plugin", () => {
|
|
|
72
74
|
expect(zipText).toContain("Server id: `myapp`");
|
|
73
75
|
expect(zipText).not.toContain("skills/myapp/reference.md");
|
|
74
76
|
expect(zipText).not.toContain("Invoke via shell");
|
|
77
|
+
|
|
78
|
+
writeFileSync(join(work, "out.zip"), zip);
|
|
79
|
+
const extract = join(work, "extract");
|
|
80
|
+
mkdirSync(extract, { recursive: true });
|
|
81
|
+
execSync("unzip -o -q ../out.zip", { cwd: extract });
|
|
82
|
+
const binMode = statSync(join(extract, "bin", "myapp")).mode & 0o777;
|
|
83
|
+
expect(binMode & 0o111).not.toBe(0);
|
|
84
|
+
const pluginJson = JSON.parse(
|
|
85
|
+
readFileSync(join(extract, ".claude-plugin", "plugin.json"), "utf8"),
|
|
86
|
+
) as { mcpServers: string };
|
|
87
|
+
expect(pluginJson.mcpServers).toBe(".mcp.json");
|
|
75
88
|
} finally {
|
|
76
89
|
rmSync(work, { recursive: true, force: true });
|
|
77
90
|
}
|
package/src/mcp/claude.ts
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
readdirSync,
|
|
13
13
|
readFileSync,
|
|
14
14
|
rmSync,
|
|
15
|
+
statSync,
|
|
15
16
|
writeFileSync,
|
|
16
17
|
} from "node:fs";
|
|
17
18
|
import { tmpdir } from "node:os";
|
|
@@ -22,7 +23,7 @@ import { applyPluginSkillHint } from "../skill/hint.ts";
|
|
|
22
23
|
import type { CliMcpBundleConfig, CliProgram } from "../types.ts";
|
|
23
24
|
import { defaultMcpBundlePaths, type PackMcpBundleOpts } from "./bundle.ts";
|
|
24
25
|
import { mcpServerId } from "./tools.ts";
|
|
25
|
-
import { zipStore } from "./zip.ts";
|
|
26
|
+
import { type ZipFileEntry, zipStore } from "./zip.ts";
|
|
26
27
|
|
|
27
28
|
const DIST_DIR = "dist";
|
|
28
29
|
const CLAUDE_PLUGIN_DIR = "claude-plugin";
|
|
@@ -67,6 +68,7 @@ export function generatePluginManifest(
|
|
|
67
68
|
version: program.version,
|
|
68
69
|
description: program.description,
|
|
69
70
|
author: defaultAuthor(bundle),
|
|
71
|
+
mcpServers: ".mcp.json",
|
|
70
72
|
};
|
|
71
73
|
const userConfig = buildProgramUserConfig(program);
|
|
72
74
|
if (userConfig) {
|
|
@@ -93,8 +95,8 @@ export function generatePluginMcpJson(
|
|
|
93
95
|
};
|
|
94
96
|
}
|
|
95
97
|
|
|
96
|
-
function collectZipEntries(rootDir: string, dir = rootDir):
|
|
97
|
-
const entries:
|
|
98
|
+
function collectZipEntries(rootDir: string, dir = rootDir): ZipFileEntry[] {
|
|
99
|
+
const entries: ZipFileEntry[] = [];
|
|
98
100
|
for (const ent of readdirSync(dir, { withFileTypes: true }) as Dirent[]) {
|
|
99
101
|
const full = join(dir, ent.name);
|
|
100
102
|
if (ent.isDirectory()) {
|
|
@@ -105,7 +107,12 @@ function collectZipEntries(rootDir: string, dir = rootDir): { name: string; data
|
|
|
105
107
|
continue;
|
|
106
108
|
}
|
|
107
109
|
const rel = relative(rootDir, full).split("\\").join("/");
|
|
108
|
-
|
|
110
|
+
const stMode = statSync(full).mode;
|
|
111
|
+
const entry: ZipFileEntry = { name: rel, data: readFileSync(full) };
|
|
112
|
+
if (stMode & 0o111) {
|
|
113
|
+
entry.unixMode = stMode;
|
|
114
|
+
}
|
|
115
|
+
entries.push(entry);
|
|
109
116
|
}
|
|
110
117
|
return entries;
|
|
111
118
|
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { execSync } from "node:child_process";
|
|
3
|
+
import { mkdtempSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { zipStore } from "./zip.ts";
|
|
7
|
+
|
|
8
|
+
test("zipStore preserves unix executable mode on extract", () => {
|
|
9
|
+
const work = mkdtempSync(join(tmpdir(), "zip-exec-"));
|
|
10
|
+
const data = Buffer.from("#!/bin/sh\necho hi\n");
|
|
11
|
+
const zip = zipStore([{ name: "bin/tool", data, unixMode: 0o100755 }]);
|
|
12
|
+
writeFileSync(join(work, "plugin.zip"), zip);
|
|
13
|
+
execSync("unzip -o -q plugin.zip", { cwd: work });
|
|
14
|
+
const mode = statSync(join(work, "bin", "tool")).mode & 0o777;
|
|
15
|
+
expect(mode & 0o111).not.toBe(0);
|
|
16
|
+
expect(readFileSync(join(work, "bin", "tool"), "utf8")).toBe("#!/bin/sh\necho hi\n");
|
|
17
|
+
});
|
package/src/mcp/zip.ts
CHANGED
|
@@ -2,6 +2,41 @@
|
|
|
2
2
|
Minimal ZIP writer (store method, no compression) for MCPB and Claude plugin bundles.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
+
/** Info-ZIP Unix UID/GID extra field (`0x7875`) for permission restore on extract. */
|
|
6
|
+
function unixUxExtraField(uid: number, gid: number): Buffer {
|
|
7
|
+
const uidBuf = Buffer.alloc(4);
|
|
8
|
+
uidBuf.writeUInt32LE(uid >>> 0, 0);
|
|
9
|
+
const gidBuf = Buffer.alloc(4);
|
|
10
|
+
gidBuf.writeUInt32LE(gid >>> 0, 0);
|
|
11
|
+
const payload = Buffer.concat([Buffer.from([1, 4]), uidBuf, Buffer.from([4]), gidBuf]);
|
|
12
|
+
const header = Buffer.alloc(4);
|
|
13
|
+
header.writeUInt16LE(0x7875, 0);
|
|
14
|
+
header.writeUInt16LE(payload.length, 2);
|
|
15
|
+
return Buffer.concat([header, payload]);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Info-ZIP Unix permission extra field (`0x7855`). */
|
|
19
|
+
function unixUpExtraField(unixMode: number): Buffer {
|
|
20
|
+
const payload = Buffer.alloc(5);
|
|
21
|
+
payload.writeUInt8(1, 0);
|
|
22
|
+
payload.writeUInt32LE(unixMode >>> 0, 1);
|
|
23
|
+
const header = Buffer.alloc(4);
|
|
24
|
+
header.writeUInt16LE(0x7855, 0);
|
|
25
|
+
header.writeUInt16LE(payload.length, 2);
|
|
26
|
+
return Buffer.concat([header, payload]);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function unixExtraFields(unixMode: number): Buffer {
|
|
30
|
+
const { uid, gid } = defaultUnixIds();
|
|
31
|
+
return Buffer.concat([unixUpExtraField(unixMode), unixUxExtraField(uid, gid)]);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function defaultUnixIds(): { uid: number; gid: number } {
|
|
35
|
+
const uid = typeof process.getuid === "function" ? process.getuid() : 501;
|
|
36
|
+
const gid = typeof process.getgid === "function" ? process.getgid() : 20;
|
|
37
|
+
return { uid, gid };
|
|
38
|
+
}
|
|
39
|
+
|
|
5
40
|
/** CRC-32 for ZIP local headers. */
|
|
6
41
|
function crc32(data: Buffer): number {
|
|
7
42
|
let crc = 0xffffffff;
|
|
@@ -18,8 +53,16 @@ function crc32(data: Buffer): number {
|
|
|
18
53
|
return (crc ^ 0xffffffff) >>> 0;
|
|
19
54
|
}
|
|
20
55
|
|
|
56
|
+
/** One file entry for {@link zipStore}. */
|
|
57
|
+
export interface ZipFileEntry {
|
|
58
|
+
name: string;
|
|
59
|
+
data: Buffer;
|
|
60
|
+
/** Unix permission bits (e.g. `0o755`). Encoded in ZIP external attributes when set. */
|
|
61
|
+
unixMode?: number;
|
|
62
|
+
}
|
|
63
|
+
|
|
21
64
|
/** Writes a minimal ZIP (store, no compression) with one or more files. */
|
|
22
|
-
export function zipStore(files:
|
|
65
|
+
export function zipStore(files: ZipFileEntry[]): Buffer {
|
|
23
66
|
const parts: Buffer[] = [];
|
|
24
67
|
const central: Buffer[] = [];
|
|
25
68
|
let offset = 0;
|
|
@@ -27,9 +70,11 @@ export function zipStore(files: { name: string; data: Buffer }[]): Buffer {
|
|
|
27
70
|
for (const file of files) {
|
|
28
71
|
const nameBuf = Buffer.from(file.name, "utf8");
|
|
29
72
|
const crc = crc32(file.data);
|
|
30
|
-
const
|
|
73
|
+
const unixMode = file.unixMode;
|
|
74
|
+
const extra = unixMode !== undefined ? unixExtraFields(unixMode) : Buffer.alloc(0);
|
|
75
|
+
const local = Buffer.alloc(30 + nameBuf.length + extra.length);
|
|
31
76
|
local.writeUInt32LE(0x04034b50, 0);
|
|
32
|
-
local.writeUInt16LE(20, 4);
|
|
77
|
+
local.writeUInt16LE(unixMode !== undefined ? 10 : 20, 4);
|
|
33
78
|
local.writeUInt16LE(0, 6);
|
|
34
79
|
local.writeUInt16LE(0, 8);
|
|
35
80
|
local.writeUInt16LE(0, 10);
|
|
@@ -38,13 +83,15 @@ export function zipStore(files: { name: string; data: Buffer }[]): Buffer {
|
|
|
38
83
|
local.writeUInt32LE(file.data.length, 18);
|
|
39
84
|
local.writeUInt32LE(file.data.length, 22);
|
|
40
85
|
local.writeUInt32LE(nameBuf.length, 26);
|
|
41
|
-
local.writeUInt16LE(
|
|
86
|
+
local.writeUInt16LE(extra.length, 28);
|
|
42
87
|
nameBuf.copy(local, 30);
|
|
88
|
+
extra.copy(local, 30 + nameBuf.length);
|
|
43
89
|
|
|
44
|
-
const centralHdr = Buffer.alloc(46 + nameBuf.length);
|
|
90
|
+
const centralHdr = Buffer.alloc(46 + nameBuf.length + extra.length);
|
|
45
91
|
centralHdr.writeUInt32LE(0x02014b50, 0);
|
|
46
|
-
|
|
47
|
-
centralHdr.writeUInt16LE(20,
|
|
92
|
+
// macOS Info-ZIP stores version made by before version needed (non-APPNOTE layout).
|
|
93
|
+
centralHdr.writeUInt16LE(unixMode !== undefined ? 0x031e : 20, 4);
|
|
94
|
+
centralHdr.writeUInt16LE(unixMode !== undefined ? 10 : 20, 6);
|
|
48
95
|
centralHdr.writeUInt16LE(0, 8);
|
|
49
96
|
centralHdr.writeUInt16LE(0, 10);
|
|
50
97
|
centralHdr.writeUInt16LE(0, 12);
|
|
@@ -53,13 +100,19 @@ export function zipStore(files: { name: string; data: Buffer }[]): Buffer {
|
|
|
53
100
|
centralHdr.writeUInt32LE(file.data.length, 20);
|
|
54
101
|
centralHdr.writeUInt32LE(file.data.length, 24);
|
|
55
102
|
centralHdr.writeUInt32LE(nameBuf.length, 28);
|
|
56
|
-
centralHdr.writeUInt16LE(
|
|
103
|
+
centralHdr.writeUInt16LE(extra.length, 30);
|
|
57
104
|
centralHdr.writeUInt16LE(0, 32);
|
|
58
105
|
centralHdr.writeUInt16LE(0, 34);
|
|
59
106
|
centralHdr.writeUInt16LE(0, 36);
|
|
60
|
-
|
|
107
|
+
if (unixMode !== undefined) {
|
|
108
|
+
const externalAttr = (unixMode << 16) >>> 0;
|
|
109
|
+
centralHdr.writeUInt32LE(externalAttr, 38);
|
|
110
|
+
} else {
|
|
111
|
+
centralHdr.writeUInt32LE(0, 38);
|
|
112
|
+
}
|
|
61
113
|
centralHdr.writeUInt32LE(offset, 42);
|
|
62
114
|
nameBuf.copy(centralHdr, 46);
|
|
115
|
+
extra.copy(centralHdr, 46 + nameBuf.length);
|
|
63
116
|
|
|
64
117
|
parts.push(local, file.data);
|
|
65
118
|
central.push(centralHdr);
|
|
@@ -469,7 +469,7 @@ test("MCP ping returns empty result", async () => {
|
|
|
469
469
|
test("minimal.ts mcp without opt-in fails", async () => {
|
|
470
470
|
const { stderr, exitCode } = await $`bun run examples/minimal.ts mcp`.nothrow().quiet();
|
|
471
471
|
expect(exitCode).toBe(1);
|
|
472
|
-
expect(stderr.toString()).toContain("MCP is not
|
|
472
|
+
expect(stderr.toString()).toContain("MCP is not available");
|
|
473
473
|
});
|
|
474
474
|
|
|
475
475
|
test("MCP resources/list includes custom resource", async () => {
|
package/src/parse.test.ts
CHANGED
|
@@ -614,7 +614,7 @@ test("cliSchemaExport resolves program key in install notes", () => {
|
|
|
614
614
|
|
|
615
615
|
const json = cliSchemaJson(root);
|
|
616
616
|
expect(json).not.toContain("{argsbarg:program}");
|
|
617
|
-
expect(json).toContain("myapp install --
|
|
617
|
+
expect(json).toContain("myapp install --yes");
|
|
618
618
|
});
|
|
619
619
|
|
|
620
620
|
test("cliSchemaExport resolves {argsbarg:program} in consumer notes", () => {
|
|
@@ -1124,6 +1124,17 @@ test("install config on non-root node is rejected", () => {
|
|
|
1124
1124
|
expect(() => cliValidateProgram(root)).toThrow(/install is only supported on the program root/);
|
|
1125
1125
|
});
|
|
1126
1126
|
|
|
1127
|
+
test("install.prefix is rejected", () => {
|
|
1128
|
+
const root = {
|
|
1129
|
+
key: "app",
|
|
1130
|
+
version: "0.0.0",
|
|
1131
|
+
description: "",
|
|
1132
|
+
install: { prefix: "/opt/bin" },
|
|
1133
|
+
handler: () => {},
|
|
1134
|
+
} as unknown as CliProgram;
|
|
1135
|
+
expect(() => cliValidateProgram(root)).toThrow(/install\.prefix removed/);
|
|
1136
|
+
});
|
|
1137
|
+
|
|
1127
1138
|
test("generateSkillBundle includes frontmatter and compact command index", () => {
|
|
1128
1139
|
const bundle = generateSkillBundle(nestedMcpFixture, "cursor");
|
|
1129
1140
|
expect(bundle.dirName).toBe("nested_ts");
|
package/src/paths/host.ts
CHANGED
|
@@ -21,20 +21,20 @@ export function expandTilde(path: string): string {
|
|
|
21
21
|
return path;
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
/** Display path using `~/` when under the user home directory. */
|
|
25
|
+
export function displayHomePath(absolutePath: string, home = userHome()): string {
|
|
26
|
+
if (home.length > 0 && absolutePath.startsWith(home)) {
|
|
27
|
+
return `~${absolutePath.slice(home.length)}`;
|
|
28
|
+
}
|
|
29
|
+
return absolutePath;
|
|
30
|
+
}
|
|
31
|
+
|
|
24
32
|
/** XDG config base directory (`$XDG_CONFIG_HOME` or `~/.config`). */
|
|
25
33
|
export function xdgConfigHome(home = userHome()): string {
|
|
26
34
|
return process.env.XDG_CONFIG_HOME ?? join(home, ".config");
|
|
27
35
|
}
|
|
28
36
|
|
|
29
|
-
/**
|
|
30
|
-
export function
|
|
31
|
-
return
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
/** OS-appropriate base directory for app config files. */
|
|
35
|
-
export function appConfigHome(home = userHome()): string {
|
|
36
|
-
if (process.platform === "win32") {
|
|
37
|
-
return appDataHome(home);
|
|
38
|
-
}
|
|
39
|
-
return xdgConfigHome(home);
|
|
37
|
+
/** App config library root (`~/.local/lib`). */
|
|
38
|
+
export function appConfigLibHome(home = userHome()): string {
|
|
39
|
+
return join(home, ".local", "lib");
|
|
40
40
|
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { existsSync, readdirSync, rmdirSync } from "node:fs";
|
|
2
|
+
|
|
3
|
+
/** Removes a directory when it exists and is empty. Returns true when removed. */
|
|
4
|
+
export function removeEmptyDir(path: string): boolean {
|
|
5
|
+
if (!existsSync(path)) return false;
|
|
6
|
+
try {
|
|
7
|
+
if (readdirSync(path).length > 0) return false;
|
|
8
|
+
rmdirSync(path);
|
|
9
|
+
return true;
|
|
10
|
+
} catch {
|
|
11
|
+
return false;
|
|
12
|
+
}
|
|
13
|
+
}
|
package/src/skill/generate.ts
CHANGED
|
@@ -13,8 +13,10 @@ import {
|
|
|
13
13
|
} from "../mcp/tools.ts";
|
|
14
14
|
import { collectOptionDefs } from "../parse.ts";
|
|
15
15
|
import { CliOptionKind, type CliProgram } from "../types.ts";
|
|
16
|
+
import type { SkillTarget } from "./naming.ts";
|
|
17
|
+
import { skillDirNameForTarget, skillFrontmatterName } from "./naming.ts";
|
|
16
18
|
|
|
17
|
-
export type SkillTarget
|
|
19
|
+
export type { SkillTarget } from "./naming.ts";
|
|
18
20
|
|
|
19
21
|
export interface SkillBundle {
|
|
20
22
|
dirName: string;
|
|
@@ -103,7 +105,7 @@ function buildConfigurationSection(root: CliProgram): string[] {
|
|
|
103
105
|
|
|
104
106
|
/** Builds SKILL.md body for the given target. */
|
|
105
107
|
function buildSkillMd(root: CliProgram, target: SkillTarget, dirName: string): string {
|
|
106
|
-
const name =
|
|
108
|
+
const name = skillFrontmatterName(root.key, target);
|
|
107
109
|
const description = skillDescription(root);
|
|
108
110
|
const tools = collectMcpTools(root);
|
|
109
111
|
|
|
@@ -161,7 +163,7 @@ function buildSkillMd(root: CliProgram, target: SkillTarget, dirName: string): s
|
|
|
161
163
|
"Do not install under `~/.cursor/skills-cursor/` (reserved for Cursor built-ins).",
|
|
162
164
|
"",
|
|
163
165
|
);
|
|
164
|
-
} else {
|
|
166
|
+
} else if (target === "claude") {
|
|
165
167
|
lines.push(
|
|
166
168
|
"## Claude Code",
|
|
167
169
|
"",
|
|
@@ -171,6 +173,18 @@ function buildSkillMd(root: CliProgram, target: SkillTarget, dirName: string): s
|
|
|
171
173
|
`- Bundled files in this directory are available via \`\${CLAUDE_SKILL_DIR}\` when the skill runs.`,
|
|
172
174
|
"",
|
|
173
175
|
);
|
|
176
|
+
} else if (target === "codex") {
|
|
177
|
+
lines.push(
|
|
178
|
+
"## Codex",
|
|
179
|
+
"",
|
|
180
|
+
`- Global: \`~/.codex/skills/${dirName}/\``,
|
|
181
|
+
"- Enable skills in `~/.codex/config.toml` if required.",
|
|
182
|
+
"",
|
|
183
|
+
);
|
|
184
|
+
} else if (target === "opencode") {
|
|
185
|
+
lines.push("## OpenCode", "", `- Global: \`~/.config/opencode/skills/${dirName}/\``, "");
|
|
186
|
+
} else {
|
|
187
|
+
lines.push("## OpenClaw", "", `- Global: \`~/.openclaw/skills/${dirName}/\``, "");
|
|
174
188
|
}
|
|
175
189
|
|
|
176
190
|
return lines.join("\n");
|
|
@@ -231,7 +245,7 @@ export function generatePluginSkillBundle(root: CliProgram): PluginSkillBundle {
|
|
|
231
245
|
|
|
232
246
|
/** Generates SKILL.md and reference.md for Cursor or Claude Code. */
|
|
233
247
|
export function generateSkillBundle(root: CliProgram, target: SkillTarget): SkillBundle {
|
|
234
|
-
const dirName =
|
|
248
|
+
const dirName = skillDirNameForTarget(root.key, target);
|
|
235
249
|
return {
|
|
236
250
|
dirName,
|
|
237
251
|
skillMd: buildSkillMd(root, target, dirName),
|