argsbarg 4.0.3 → 4.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/CHANGELOG.md +42 -1
  2. package/README.md +6 -6
  3. package/docs/ai-skills.md +9 -4
  4. package/docs/bundled-docs.md +1 -0
  5. package/docs/cli-program.md +4 -4
  6. package/docs/config-schema.md +1 -4
  7. package/docs/developing.md +1 -1
  8. package/docs/install.md +160 -39
  9. package/docs/mcp.md +38 -11
  10. package/docs/templates/cursor/rules/cli-program.mdc +1 -1
  11. package/examples/config-app/program.ts +0 -3
  12. package/examples/consumer-app/README.md +1 -1
  13. package/examples/consumer-app/src/program.ts +5 -13
  14. package/examples/mcp-test.ts +14 -24
  15. package/index.d.ts +60 -5
  16. package/package.json +1 -1
  17. package/src/builtins/builtins.test.ts +20 -4
  18. package/src/builtins/config.test.ts +31 -25
  19. package/src/builtins/config.ts +4 -3
  20. package/src/builtins/install.ts +50 -57
  21. package/src/builtins/mcp.ts +1 -1
  22. package/src/capabilities.ts +4 -4
  23. package/src/cli.ts +2 -0
  24. package/src/config/bootstrap.ts +167 -66
  25. package/src/config/context.test.ts +22 -36
  26. package/src/config/context.ts +5 -4
  27. package/src/config/file.test.ts +66 -56
  28. package/src/config/file.ts +33 -25
  29. package/src/config/resolve.test.ts +25 -1
  30. package/src/config/resolve.ts +43 -8
  31. package/src/config.integration.test.ts +17 -10
  32. package/src/docs/api-guide.test.ts +1 -1
  33. package/src/docs/docs.test.ts +1 -1
  34. package/src/docs/mcp-guide.ts +15 -4
  35. package/src/docs/mcp-resources.test.ts +63 -0
  36. package/src/docs/mcp-resources.ts +68 -0
  37. package/src/hidden-mcpb.test.ts +41 -1
  38. package/src/index.ts +4 -0
  39. package/src/install/{binary.ts → app.ts} +13 -13
  40. package/src/install/bootstrap.ts +22 -0
  41. package/src/install/detect-installed.ts +2 -97
  42. package/src/install/index.ts +187 -110
  43. package/src/install/install-validate.test.ts +61 -0
  44. package/src/install/install.test.ts +138 -42
  45. package/src/install/mcp-openclaw.test.ts +40 -0
  46. package/src/install/mcp-openclaw.ts +106 -0
  47. package/src/install/normalize.ts +35 -0
  48. package/src/install/paths.ts +27 -13
  49. package/src/install/plan.ts +30 -259
  50. package/src/install/shell.ts +2 -2
  51. package/src/install/status.test.ts +85 -0
  52. package/src/install/status.ts +22 -9
  53. package/src/install/target-base.ts +93 -0
  54. package/src/install/target-detect.ts +20 -0
  55. package/src/install/target-effective.ts +131 -0
  56. package/src/install/target-mcp-cli.ts +149 -0
  57. package/src/install/target-mcp-json.ts +130 -0
  58. package/src/install/target-plan-build.ts +67 -0
  59. package/src/install/target-registry.ts +57 -0
  60. package/src/install/target-scope.ts +266 -0
  61. package/src/install/target-skill.ts +104 -0
  62. package/src/install/target-types.ts +145 -0
  63. package/src/install/targets/app.ts +69 -0
  64. package/src/install/targets/chatgpt-mcp.ts +12 -0
  65. package/src/install/targets/claude-code-mcp.ts +15 -0
  66. package/src/install/targets/claude-desktop-mcp.ts +12 -0
  67. package/src/install/targets/claude-skill.ts +16 -0
  68. package/src/install/targets/codex-mcp.ts +25 -0
  69. package/src/install/targets/codex-skill.ts +14 -0
  70. package/src/install/targets/completions.ts +133 -0
  71. package/src/install/targets/configure.ts +59 -0
  72. package/src/install/targets/cursor-mcp.ts +15 -0
  73. package/src/install/targets/cursor-skill.ts +16 -0
  74. package/src/install/targets/index.ts +53 -0
  75. package/src/install/targets/openclaw-mcp.ts +25 -0
  76. package/src/install/targets/openclaw-skill.ts +17 -0
  77. package/src/install/targets/opencode-mcp.ts +101 -0
  78. package/src/install/targets/opencode-skill.ts +15 -0
  79. package/src/install/targets.test.ts +136 -0
  80. package/src/install/uninstall.ts +16 -152
  81. package/src/install/update.test.ts +17 -2
  82. package/src/install/update.ts +2 -5
  83. package/src/invoke.test.ts +7 -1
  84. package/src/mcp/bundle.ts +16 -4
  85. package/src/mcp/claude.test.ts +23 -4
  86. package/src/mcp/claude.ts +18 -13
  87. package/src/mcp/tools.ts +4 -1
  88. package/src/mcp/zip.test.ts +17 -0
  89. package/src/mcp/zip.ts +62 -9
  90. package/src/mcp.integration.test.ts +18 -1
  91. package/src/parse.test.ts +57 -4
  92. package/src/paths/host.ts +11 -11
  93. package/src/paths/remove-empty-dir.ts +13 -0
  94. package/src/skill/generate.ts +89 -5
  95. package/src/skill/hint.ts +5 -0
  96. package/src/skill/install.ts +33 -6
  97. package/src/skill/naming.ts +28 -0
  98. package/src/types.ts +65 -4
  99. package/src/validate.ts +79 -4
@@ -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 { uninstallBinary } from "./binary.ts";
5
- import { uninstallCompletions } from "./completions.ts";
6
- import { detectInstalledArtifacts } from "./detect-installed.ts";
7
- import { removeCodexMcpConfig } from "./mcp-codex.ts";
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 interface UninstallAction {
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 (--all mirrors install --all). */
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
- const detected = detectInstalledArtifacts(paths);
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 { chmodSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
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");
@@ -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(`updateGetLatest returned missing binary: ${JSON.stringify(artifact.path)}`);
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) {
@@ -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 = { key: "t", description: "d", version: "0.0.0", handler: (ctx) => console.log(ctx.invocation) };
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 `.mcpb` and Claude Code plugin zip; prints both paths. */
163
+ /** Runs `mcp bundle`: writes enabled dist artifacts; prints one path per line. */
164
164
  export function runMcpBundle(program: CliProgram): void {
165
- const mcpbPath = packMcpBundle(program);
166
- const pluginPath = packClaudePlugin(program);
167
- process.stdout.write(`${mcpbPath}\n${pluginPath}\n`);
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
  }
@@ -1,5 +1,6 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
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);
@@ -55,7 +57,7 @@ describe("claude plugin", () => {
55
57
  expect(paths.pluginZipPath).toBe(join(cwd, "dist", "claude-plugin", "myapp.zip"));
56
58
  });
57
59
 
58
- test("packClaudePlugin writes zip with expected entries", () => {
60
+ test("packClaudePlugin writes zip with MCP pointer skill only", () => {
59
61
  const work = mkdtempSync(join(tmpdir(), "claude-plugin-test-"));
60
62
  try {
61
63
  const dist = join(work, "dist");
@@ -64,8 +66,25 @@ describe("claude plugin", () => {
64
66
  writeFileSync(binaryPath, "#!/bin/sh\n", { mode: 0o755 });
65
67
  const paths = defaultClaudePluginPaths(configFixture, work);
66
68
  packClaudePlugin(configFixture, { cwd: work, binaryPath });
67
- const zipPath = paths.pluginZipPath;
68
- expect(readFileSync(zipPath).length).toBeGreaterThan(0);
69
+ const zip = readFileSync(paths.pluginZipPath);
70
+ expect(zip.length).toBeGreaterThan(0);
71
+ const zipText = zip.toString("utf8");
72
+ expect(zipText).toContain("skills/myapp/SKILL.md");
73
+ expect(zipText).toContain("MCP toolset");
74
+ expect(zipText).toContain("Server id: `myapp`");
75
+ expect(zipText).not.toContain("skills/myapp/reference.md");
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");
69
88
  } finally {
70
89
  rmSync(work, { recursive: true, force: true });
71
90
  }
package/src/mcp/claude.ts CHANGED
@@ -12,17 +12,18 @@ 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";
18
19
  import { basename, join, relative, resolve } from "node:path";
19
20
  import { buildPluginMcpEnvMapping, buildProgramUserConfig } from "../config/manifest.ts";
20
- import { generateSkillBundle } from "../skill/generate.ts";
21
- import { applySkillBundleHints } from "../skill/hint.ts";
21
+ import { generatePluginSkillBundle } from "../skill/generate.ts";
22
+ 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
- import { mcpServerId, sanitizeToolSegment } from "./tools.ts";
25
- import { zipStore } from "./zip.ts";
25
+ import { mcpServerId } from "./tools.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): { name: string; data: Buffer }[] {
97
- const entries: { name: string; data: Buffer }[] = [];
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
- entries.push({ name: rel, data: readFileSync(full) });
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
  }
@@ -116,13 +123,12 @@ function writePluginTree(
116
123
  binaryPath: string,
117
124
  binaryName: string,
118
125
  ): void {
119
- const skillDirName = sanitizeToolSegment(program.key);
120
- const bundle = generateSkillBundle(program, "claude");
121
- const hinted = applySkillBundleHints(program, bundle.skillMd, bundle.referenceMd);
126
+ const bundle = generatePluginSkillBundle(program);
127
+ const skillMd = applyPluginSkillHint(program, bundle.skillMd);
122
128
 
123
129
  mkdirSync(join(pluginRoot, ".claude-plugin"), { recursive: true });
124
130
  mkdirSync(join(pluginRoot, "bin"), { recursive: true });
125
- mkdirSync(join(pluginRoot, "skills", skillDirName), { recursive: true });
131
+ mkdirSync(join(pluginRoot, "skills", bundle.dirName), { recursive: true });
126
132
 
127
133
  writeFileSync(
128
134
  join(pluginRoot, ".claude-plugin", "plugin.json"),
@@ -133,8 +139,7 @@ function writePluginTree(
133
139
  `${JSON.stringify(generatePluginMcpJson(program, binaryName), null, 2)}\n`,
134
140
  );
135
141
  cpSync(binaryPath, join(pluginRoot, "bin", binaryName), { mode: 0o755 });
136
- writeFileSync(join(pluginRoot, "skills", skillDirName, "SKILL.md"), hinted.skillMd);
137
- writeFileSync(join(pluginRoot, "skills", skillDirName, "reference.md"), hinted.referenceMd);
142
+ writeFileSync(join(pluginRoot, "skills", bundle.dirName, "SKILL.md"), skillMd);
138
143
  }
139
144
 
140
145
  /**
package/src/mcp/tools.ts CHANGED
@@ -3,6 +3,7 @@ This module maps CliProgram leaf nodes to MCP tool definitions and converts
3
3
  flat JSON tool arguments into argv for Cli.invoke.
4
4
  */
5
5
 
6
+ import { docsMcpResources } from "../docs/mcp-resources.ts";
6
7
  import { cliResolveNotes } from "../help.ts";
7
8
  import { visibleOptions } from "../hidden.ts";
8
9
  import { collectOptionDefs } from "../parse.ts";
@@ -26,6 +27,8 @@ export function defaultMcpSchemaUri(mcpId: string): string {
26
27
  return `${mcpId}://schema`;
27
28
  }
28
29
 
30
+ export { defaultDocsTopicResourceUri, resolveDocsTopicResourceUri } from "../docs/mcp-resources.ts";
31
+
29
32
  /** Sanitizes a command key segment for MCP tool names and server identity. */
30
33
  export function sanitizeToolSegment(key: string): string {
31
34
  return key.replace(/[^a-zA-Z0-9]/g, "_");
@@ -209,7 +212,7 @@ export function allMcpResources(root: CliProgram): McpResourceEntry[] {
209
212
  mimeType: r.mimeType ?? "text/plain",
210
213
  load: r.load,
211
214
  }));
212
- return [builtIn, ...user];
215
+ return [builtIn, ...docsMcpResources(root), ...user];
213
216
  }
214
217
 
215
218
  /** Recursively collects MCP tool definitions from user leaf commands. */
@@ -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: { name: string; data: Buffer }[]): Buffer {
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 local = Buffer.alloc(30 + nameBuf.length);
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(0, 28);
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
- centralHdr.writeUInt16LE(20, 4);
47
- centralHdr.writeUInt16LE(20, 6);
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(0, 30);
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
- centralHdr.writeUInt32LE(0, 38);
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 enabled");
472
+ expect(stderr.toString()).toContain("MCP is not available");
473
473
  });
474
474
 
475
475
  test("MCP resources/list includes custom resource", async () => {
@@ -480,9 +480,26 @@ test("MCP resources/list includes custom resource", async () => {
480
480
  const res = responses.get(10) as { result: { resources: { uri: string }[] } };
481
481
  const uris = res.result.resources.map((r) => r.uri);
482
482
  expect(uris).toContain("mcp_test://schema");
483
+ expect(uris).toContain("mcp_test://docs/readme");
483
484
  expect(uris).toContain("test://hello");
484
485
  });
485
486
 
487
+ test("MCP resources/read returns docs topic resource body", async () => {
488
+ const responses = await mcpRequest(
489
+ [
490
+ {
491
+ jsonrpc: "2.0",
492
+ id: 13,
493
+ method: "resources/read",
494
+ params: { uri: "mcp_test://docs/readme" },
495
+ },
496
+ ],
497
+ { script: "examples/mcp-test.ts" },
498
+ );
499
+ const res = responses.get(13) as { result: { contents: { text: string }[] } };
500
+ expect(res.result.contents[0]?.text).toBe("# MCP test readme\n");
501
+ });
502
+
486
503
  test("MCP resources/read returns custom resource body", async () => {
487
504
  const responses = await mcpRequest(
488
505
  [{ jsonrpc: "2.0", id: 11, method: "resources/read", params: { uri: "test://hello" } }],