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.
Files changed (94) hide show
  1. package/CHANGELOG.md +30 -1
  2. package/README.md +5 -5
  3. package/docs/ai-skills.md +8 -5
  4. package/docs/cli-program.md +4 -4
  5. package/docs/config-schema.md +1 -4
  6. package/docs/developing.md +1 -1
  7. package/docs/install.md +160 -39
  8. package/docs/mcp.md +21 -9
  9. package/docs/templates/cursor/rules/cli-program.mdc +1 -1
  10. package/examples/config-app/program.ts +0 -3
  11. package/examples/consumer-app/README.md +0 -1
  12. package/examples/consumer-app/src/program.ts +3 -3
  13. package/examples/mcp-test.ts +8 -24
  14. package/index.d.ts +60 -5
  15. package/package.json +1 -1
  16. package/src/builtins/builtins.test.ts +20 -4
  17. package/src/builtins/config.test.ts +31 -25
  18. package/src/builtins/config.ts +4 -3
  19. package/src/builtins/install.ts +50 -57
  20. package/src/builtins/mcp.ts +1 -1
  21. package/src/capabilities.ts +4 -4
  22. package/src/cli.ts +2 -0
  23. package/src/config/bootstrap.ts +167 -66
  24. package/src/config/context.test.ts +22 -36
  25. package/src/config/context.ts +5 -4
  26. package/src/config/file.test.ts +66 -56
  27. package/src/config/file.ts +33 -25
  28. package/src/config/resolve.test.ts +25 -1
  29. package/src/config/resolve.ts +43 -8
  30. package/src/config.integration.test.ts +17 -10
  31. package/src/docs/api-guide.test.ts +1 -1
  32. package/src/docs/docs.test.ts +1 -1
  33. package/src/docs/mcp-guide.ts +1 -1
  34. package/src/hidden-mcpb.test.ts +41 -1
  35. package/src/index.ts +4 -0
  36. package/src/install/{binary.ts → app.ts} +13 -13
  37. package/src/install/bootstrap.ts +22 -0
  38. package/src/install/detect-installed.ts +2 -97
  39. package/src/install/index.ts +187 -110
  40. package/src/install/install-validate.test.ts +61 -0
  41. package/src/install/install.test.ts +138 -42
  42. package/src/install/mcp-openclaw.test.ts +40 -0
  43. package/src/install/mcp-openclaw.ts +106 -0
  44. package/src/install/normalize.ts +35 -0
  45. package/src/install/paths.ts +27 -13
  46. package/src/install/plan.ts +30 -259
  47. package/src/install/shell.ts +2 -2
  48. package/src/install/status.test.ts +85 -0
  49. package/src/install/status.ts +22 -9
  50. package/src/install/target-base.ts +93 -0
  51. package/src/install/target-detect.ts +20 -0
  52. package/src/install/target-effective.ts +131 -0
  53. package/src/install/target-mcp-cli.ts +149 -0
  54. package/src/install/target-mcp-json.ts +130 -0
  55. package/src/install/target-plan-build.ts +67 -0
  56. package/src/install/target-registry.ts +57 -0
  57. package/src/install/target-scope.ts +266 -0
  58. package/src/install/target-skill.ts +104 -0
  59. package/src/install/target-types.ts +145 -0
  60. package/src/install/targets/app.ts +69 -0
  61. package/src/install/targets/chatgpt-mcp.ts +12 -0
  62. package/src/install/targets/claude-code-mcp.ts +15 -0
  63. package/src/install/targets/claude-desktop-mcp.ts +12 -0
  64. package/src/install/targets/claude-skill.ts +16 -0
  65. package/src/install/targets/codex-mcp.ts +25 -0
  66. package/src/install/targets/codex-skill.ts +14 -0
  67. package/src/install/targets/completions.ts +133 -0
  68. package/src/install/targets/configure.ts +59 -0
  69. package/src/install/targets/cursor-mcp.ts +15 -0
  70. package/src/install/targets/cursor-skill.ts +16 -0
  71. package/src/install/targets/index.ts +53 -0
  72. package/src/install/targets/openclaw-mcp.ts +25 -0
  73. package/src/install/targets/openclaw-skill.ts +17 -0
  74. package/src/install/targets/opencode-mcp.ts +101 -0
  75. package/src/install/targets/opencode-skill.ts +15 -0
  76. package/src/install/targets.test.ts +136 -0
  77. package/src/install/uninstall.ts +16 -152
  78. package/src/install/update.test.ts +17 -2
  79. package/src/install/update.ts +2 -5
  80. package/src/invoke.test.ts +7 -1
  81. package/src/mcp/bundle.ts +16 -4
  82. package/src/mcp/claude.test.ts +14 -1
  83. package/src/mcp/claude.ts +11 -4
  84. package/src/mcp/zip.test.ts +17 -0
  85. package/src/mcp/zip.ts +62 -9
  86. package/src/mcp.integration.test.ts +1 -1
  87. package/src/parse.test.ts +12 -1
  88. package/src/paths/host.ts +11 -11
  89. package/src/paths/remove-empty-dir.ts +13 -0
  90. package/src/skill/generate.ts +18 -4
  91. package/src/skill/install.ts +33 -6
  92. package/src/skill/naming.ts +28 -0
  93. package/src/types.ts +65 -4
  94. package/src/validate.ts +70 -0
@@ -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);
@@ -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): { 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
  }
@@ -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 () => {
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 --all --yes");
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
- /** Windows `%APPDATA%` (or `~/AppData/Roaming`). */
30
- export function appDataHome(home = userHome()): string {
31
- return process.env.APPDATA ?? join(home, "AppData", "Roaming");
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
+ }
@@ -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 = "cursor" | "claude";
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 = sanitizeToolSegment(root.key);
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 = sanitizeToolSegment(root.key);
248
+ const dirName = skillDirNameForTarget(root.key, target);
235
249
  return {
236
250
  dirName,
237
251
  skillMd: buildSkillMd(root, target, dirName),