argsbarg 4.0.4 → 4.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (147) hide show
  1. package/CHANGELOG.md +77 -1
  2. package/README.md +91 -85
  3. package/docs/README.md +6 -6
  4. package/docs/ai-skills.md +8 -5
  5. package/docs/bundled-docs.md +1 -1
  6. package/docs/cli-program.md +9 -7
  7. package/docs/config-schema.md +37 -13
  8. package/docs/developing.md +8 -8
  9. package/docs/distribution-homebrew.md +103 -0
  10. package/docs/install.md +143 -106
  11. package/docs/mcp.md +23 -12
  12. package/docs/output-schema.md +1 -1
  13. package/examples/full-example/Formula/.gitkeep +0 -0
  14. package/examples/full-example/README.md +98 -0
  15. package/examples/full-example/biome.json +22 -0
  16. package/examples/{consumer-app → full-example}/bun.lock +2 -0
  17. package/examples/full-example/justfile +134 -0
  18. package/examples/{consumer-app → full-example}/package.json +10 -3
  19. package/examples/{consumer-app → full-example}/schemas/generated/app-config.json +1 -1
  20. package/examples/{consumer-app → full-example}/schemas/generated/status.json +1 -1
  21. package/examples/full-example/scripts/create-identity.ts +11 -0
  22. package/examples/full-example/scripts/formula-shared.ts +73 -0
  23. package/examples/full-example/scripts/gen-dev-formula.ts +26 -0
  24. package/examples/full-example/scripts/print-identity.ts +27 -0
  25. package/examples/full-example/src/commands/echo/command.ts +21 -0
  26. package/examples/full-example/src/commands/status/command.test.ts +10 -0
  27. package/examples/full-example/src/commands/status/command.ts +36 -0
  28. package/examples/{consumer-app → full-example}/src/commands/status/types.ts +1 -1
  29. package/examples/full-example/src/index.ts +10 -0
  30. package/examples/full-example/src/program.ts +57 -0
  31. package/examples/{consumer-app → full-example}/src/types.ts +1 -1
  32. package/examples/mcp-test.ts +8 -24
  33. package/examples/nested.ts +1 -3
  34. package/index.d.ts +81 -65
  35. package/package.json +2 -2
  36. package/src/builtins/builtins.test.ts +37 -22
  37. package/src/builtins/completion-group.ts +17 -15
  38. package/src/builtins/config.test.ts +31 -25
  39. package/src/builtins/config.ts +4 -3
  40. package/src/builtins/dispatch.ts +25 -1
  41. package/src/builtins/install.ts +45 -82
  42. package/src/builtins/mcp.ts +1 -1
  43. package/src/builtins/registry.ts +2 -0
  44. package/src/builtins/uninstall.ts +80 -0
  45. package/src/capabilities.ts +5 -7
  46. package/src/cli-tool/cli-smoke.test.ts +19 -0
  47. package/src/cli-tool/create.test.ts +119 -0
  48. package/src/cli-tool/create.ts +380 -0
  49. package/{examples/consumer-app/capabilities.test.ts → src/cli-tool/full-example-capabilities.test.ts} +15 -14
  50. package/src/cli-tool/main.ts +8 -0
  51. package/src/cli-tool/post-create.ts +111 -0
  52. package/src/cli-tool/program.ts +82 -0
  53. package/src/cli-tool/prompt.ts +28 -0
  54. package/src/cli-tool/run-create.ts +149 -0
  55. package/src/config/bootstrap.ts +174 -66
  56. package/src/config/context.test.ts +22 -36
  57. package/src/config/context.ts +5 -4
  58. package/src/config/file.test.ts +66 -56
  59. package/src/config/file.ts +33 -25
  60. package/src/config/resolve.test.ts +192 -1
  61. package/src/config/resolve.ts +92 -13
  62. package/src/config.integration.test.ts +17 -10
  63. package/src/docs/api-guide.test.ts +4 -5
  64. package/src/docs/docs.test.ts +2 -1
  65. package/src/docs/mcp-guide.ts +7 -8
  66. package/src/hidden-mcpb.test.ts +41 -1
  67. package/src/index.ts +7 -10
  68. package/src/install/binary-placement.test.ts +101 -0
  69. package/src/install/binary-placement.ts +47 -0
  70. package/src/install/detect-installed.ts +2 -97
  71. package/src/install/index.ts +239 -168
  72. package/src/install/install-validate.test.ts +61 -0
  73. package/src/install/install.test.ts +170 -90
  74. package/src/install/mcp-openclaw.test.ts +40 -0
  75. package/src/install/mcp-openclaw.ts +106 -0
  76. package/src/install/normalize-uninstall.ts +11 -0
  77. package/src/install/normalize.ts +20 -0
  78. package/src/install/paths.ts +18 -26
  79. package/src/install/plan.ts +40 -261
  80. package/src/install/shell.ts +0 -14
  81. package/src/install/status.test.ts +85 -0
  82. package/src/install/status.ts +22 -15
  83. package/src/install/target-base.ts +93 -0
  84. package/src/install/target-detect.ts +20 -0
  85. package/src/install/target-effective.ts +129 -0
  86. package/src/install/target-mcp-cli.ts +149 -0
  87. package/src/install/target-mcp-json.ts +130 -0
  88. package/src/install/target-plan-build.ts +67 -0
  89. package/src/install/target-registry.ts +57 -0
  90. package/src/install/target-scope.ts +253 -0
  91. package/src/install/target-skill.ts +104 -0
  92. package/src/install/target-types.ts +129 -0
  93. package/src/install/targets/app.ts +60 -0
  94. package/src/install/targets/chatgpt-mcp.ts +12 -0
  95. package/src/install/targets/claude-code-mcp.ts +15 -0
  96. package/src/install/targets/claude-desktop-mcp.ts +12 -0
  97. package/src/install/targets/claude-skill.ts +16 -0
  98. package/src/install/targets/codex-mcp.ts +25 -0
  99. package/src/install/targets/codex-skill.ts +14 -0
  100. package/src/install/targets/configure.ts +63 -0
  101. package/src/install/targets/cursor-mcp.ts +15 -0
  102. package/src/install/targets/cursor-skill.ts +16 -0
  103. package/src/install/targets/index.ts +50 -0
  104. package/src/install/targets/openclaw-mcp.ts +25 -0
  105. package/src/install/targets/openclaw-skill.ts +17 -0
  106. package/src/install/targets/opencode-mcp.ts +101 -0
  107. package/src/install/targets/opencode-skill.ts +15 -0
  108. package/src/install/targets.test.ts +118 -0
  109. package/src/install/uninstall.ts +16 -152
  110. package/src/invoke.test.ts +7 -1
  111. package/src/mcp/bundle.ts +16 -4
  112. package/src/mcp/claude.test.ts +14 -1
  113. package/src/mcp/claude.ts +11 -4
  114. package/src/mcp/env.test.ts +92 -0
  115. package/src/mcp/env.ts +15 -14
  116. package/src/mcp/zip.test.ts +17 -0
  117. package/src/mcp/zip.ts +62 -9
  118. package/src/mcp.integration.test.ts +1 -1
  119. package/src/parse.test.ts +14 -2
  120. package/src/paths/host.ts +11 -11
  121. package/src/paths/remove-empty-dir.ts +13 -0
  122. package/src/prompt.ts +10 -0
  123. package/src/schema.ts +9 -1
  124. package/src/skill/generate.ts +18 -4
  125. package/src/skill/install.ts +33 -6
  126. package/src/skill/naming.ts +28 -0
  127. package/src/types.ts +86 -7
  128. package/src/validate.ts +73 -9
  129. package/docs/templates/cursor/rules/cli-program.mdc +0 -31
  130. package/examples/config-app/main.ts +0 -20
  131. package/examples/config-app/program.ts +0 -81
  132. package/examples/config-app/schema.ts +0 -37
  133. package/examples/config-app/types.ts +0 -19
  134. package/examples/consumer-app/README.md +0 -57
  135. package/examples/consumer-app/src/main.ts +0 -15
  136. package/examples/consumer-app/src/program.ts +0 -108
  137. package/src/install/binary.ts +0 -94
  138. package/src/install/completions.ts +0 -56
  139. package/src/install/update.test.ts +0 -108
  140. package/src/install/update.ts +0 -57
  141. /package/examples/{consumer-app → full-example}/schemas/configSchemas.ts +0 -0
  142. /package/examples/{consumer-app → full-example}/schemas/outputSchemas.ts +0 -0
  143. /package/examples/{consumer-app → full-example}/scripts/schemagen/discover-schema-roots.test.ts +0 -0
  144. /package/examples/{consumer-app → full-example}/scripts/schemagen/discover-schema-roots.ts +0 -0
  145. /package/examples/{consumer-app → full-example}/scripts/schemagen/naming.ts +0 -0
  146. /package/examples/{consumer-app → full-example}/scripts/schemagen.ts +0 -0
  147. /package/examples/{consumer-app → full-example}/tsconfig.json +0 -0
@@ -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,92 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { chmodSync, mkdtempSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { applyShellEnv, bootstrapMcpEnv } from "./env.ts";
6
+
7
+ const TEST_VAR = "ARGS_BARG_SHELL_ENV_TEST";
8
+
9
+ describe("mcp/env", () => {
10
+ test("applyShellEnv merges PATH and fills missing vars", () => {
11
+ const prevPath = process.env.PATH;
12
+ const prevTest = process.env[TEST_VAR];
13
+ delete process.env[TEST_VAR];
14
+ try {
15
+ process.env.PATH = "/host/bin";
16
+ applyShellEnv({
17
+ PATH: "/shell/bin:/host/bin",
18
+ [TEST_VAR]: "from-shell",
19
+ });
20
+ expect(process.env.PATH).toBe("/shell/bin:/host/bin");
21
+ expect(process.env[TEST_VAR]).toBe("from-shell");
22
+ } finally {
23
+ if (prevPath === undefined) delete process.env.PATH;
24
+ else process.env.PATH = prevPath;
25
+ if (prevTest === undefined) delete process.env[TEST_VAR];
26
+ else process.env[TEST_VAR] = prevTest;
27
+ }
28
+ });
29
+
30
+ test("applyShellEnv does not overwrite host vars except PATH merge", () => {
31
+ const prev = process.env.HOME;
32
+ process.env.HOME = "/host/home";
33
+ try {
34
+ applyShellEnv({ HOME: "/shell/home" });
35
+ expect(process.env.HOME).toBe("/host/home");
36
+ } finally {
37
+ if (prev === undefined) delete process.env.HOME;
38
+ else process.env.HOME = prev;
39
+ }
40
+ });
41
+ });
42
+
43
+ describe("bootstrapMcpEnv", () => {
44
+ let fakeShell: string;
45
+ let prevShell: string | undefined;
46
+ let prevTest: string | undefined;
47
+
48
+ beforeEach(() => {
49
+ const dir = mkdtempSync(join(tmpdir(), "argsbarg-shell-env-"));
50
+ fakeShell = join(dir, "fake-shell.sh");
51
+ writeFileSync(
52
+ fakeShell,
53
+ `#!/bin/sh
54
+ if [ "$1" = "-l" ] && [ "$2" = "-c" ] && [ "$3" = "env" ]; then
55
+ echo "${TEST_VAR}=from_fake_shell"
56
+ fi
57
+ `,
58
+ );
59
+ chmodSync(fakeShell, 0o755);
60
+ prevShell = process.env.SHELL;
61
+ prevTest = process.env[TEST_VAR];
62
+ delete process.env[TEST_VAR];
63
+ process.env.SHELL = fakeShell;
64
+ });
65
+
66
+ afterEach(() => {
67
+ if (prevShell === undefined) delete process.env.SHELL;
68
+ else process.env.SHELL = prevShell;
69
+ if (prevTest === undefined) delete process.env[TEST_VAR];
70
+ else process.env[TEST_VAR] = prevTest;
71
+ });
72
+
73
+ test("defaults on when shellEnv is undefined", () => {
74
+ bootstrapMcpEnv({});
75
+ expect(process.env[TEST_VAR]).toBe("from_fake_shell");
76
+ });
77
+
78
+ test("runs when shellEnv is true", () => {
79
+ bootstrapMcpEnv({ shellEnv: true });
80
+ expect(process.env[TEST_VAR]).toBe("from_fake_shell");
81
+ });
82
+
83
+ test("uses explicit shell path when shellEnv is a string", () => {
84
+ bootstrapMcpEnv({ shellEnv: fakeShell });
85
+ expect(process.env[TEST_VAR]).toBe("from_fake_shell");
86
+ });
87
+
88
+ test("skips capture when shellEnv is false", () => {
89
+ bootstrapMcpEnv({ shellEnv: false });
90
+ expect(process.env[TEST_VAR]).toBeUndefined();
91
+ });
92
+ });
package/src/mcp/env.ts CHANGED
@@ -39,21 +39,22 @@ export function applyShellEnv(env: Record<string, string>): void {
39
39
  }
40
40
  }
41
41
 
42
- /** Applies mcpServer shellEnv bootstrap. */
42
+ /** Applies mcpServer shellEnv bootstrap (default on; opt out with `shellEnv: false`). */
43
43
  export function bootstrapMcpEnv(config: { shellEnv?: boolean | string }): void {
44
+ if (config.shellEnv === false) {
45
+ return;
46
+ }
44
47
  const shellEnvCfg = config.shellEnv;
45
- if (shellEnvCfg) {
46
- const shell =
47
- typeof shellEnvCfg === "string"
48
- ? shellEnvCfg
49
- : (process.env.SHELL ?? (process.platform === "darwin" ? "/bin/zsh" : "/bin/bash"));
50
- const captured = captureShellEnv(shell);
51
- if (Object.keys(captured).length === 0) {
52
- process.stderr.write(
53
- `[argsbarg] shellEnv: failed to capture shell environment from ${shell}\n`,
54
- );
55
- } else {
56
- applyShellEnv(captured);
57
- }
48
+ const shell =
49
+ typeof shellEnvCfg === "string"
50
+ ? shellEnvCfg
51
+ : (process.env.SHELL ?? (process.platform === "darwin" ? "/bin/zsh" : "/bin/bash"));
52
+ const captured = captureShellEnv(shell);
53
+ if (Object.keys(captured).length === 0) {
54
+ process.stderr.write(
55
+ `[argsbarg] shellEnv: failed to capture shell environment from ${shell}\n`,
56
+ );
57
+ } else {
58
+ applyShellEnv(captured);
58
59
  }
59
60
  }
@@ -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
@@ -495,7 +495,7 @@ test("leaf completion help prints correctly", async () => {
495
495
  const out = stdout.toString();
496
496
  expect(exitCode).toBe(0);
497
497
  expect(out).toContain("Show help for this command.");
498
- expect(out).toContain("Manual install:");
498
+ expect(out).toContain("Homebrew");
499
499
  expect(stderr.toString()).toBe("");
500
500
  });
501
501
 
@@ -529,6 +529,7 @@ test("docs schema exports JSON for leaf roots", async () => {
529
529
  "completion",
530
530
  "version",
531
531
  "install",
532
+ "uninstall",
532
533
  "docs",
533
534
  ]);
534
535
  });
@@ -614,7 +615,7 @@ test("cliSchemaExport resolves program key in install notes", () => {
614
615
 
615
616
  const json = cliSchemaJson(root);
616
617
  expect(json).not.toContain("{argsbarg:program}");
617
- expect(json).toContain("myapp install --all --yes");
618
+ expect(json).toContain("brew install");
618
619
  });
619
620
 
620
621
  test("cliSchemaExport resolves {argsbarg:program} in consumer notes", () => {
@@ -1124,6 +1125,17 @@ test("install config on non-root node is rejected", () => {
1124
1125
  expect(() => cliValidateProgram(root)).toThrow(/install is only supported on the program root/);
1125
1126
  });
1126
1127
 
1128
+ test("install.prefix is rejected", () => {
1129
+ const root = {
1130
+ key: "app",
1131
+ version: "0.0.0",
1132
+ description: "",
1133
+ install: { prefix: "/opt/bin" },
1134
+ handler: () => {},
1135
+ } as unknown as CliProgram;
1136
+ expect(() => cliValidateProgram(root)).toThrow(/install\.prefix removed/);
1137
+ });
1138
+
1127
1139
  test("generateSkillBundle includes frontmatter and compact command index", () => {
1128
1140
  const bundle = generateSkillBundle(nestedMcpFixture, "cursor");
1129
1141
  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
+ }
package/src/prompt.ts ADDED
@@ -0,0 +1,10 @@
1
+ /** Shared terminal prompt helpers. */
2
+
3
+ import { readSync } from "node:fs";
4
+
5
+ /** Read one line from stdin (no masking). */
6
+ export function readPromptLine(): string {
7
+ const buf = Buffer.alloc(4096);
8
+ const n = readSync(0, buf, { length: 4096 });
9
+ return buf.toString("utf8", 0, n).replace(/\r?\n$/, "");
10
+ }
package/src/schema.ts CHANGED
@@ -13,7 +13,15 @@ import {
13
13
  leafOutputSchema,
14
14
  } from "./types.ts";
15
15
 
16
- const RESERVED = new Set(["completion", "install", "docs", "mcp", "version", "config"]);
16
+ const RESERVED = new Set([
17
+ "completion",
18
+ "install",
19
+ "uninstall",
20
+ "docs",
21
+ "mcp",
22
+ "version",
23
+ "config",
24
+ ]);
17
25
 
18
26
  function exportCommand(cmd: CliNode, root: CliProgram): CliSchemaExport | null {
19
27
  if (cmd.hidden) {
@@ -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),
@@ -5,19 +5,28 @@ import type { CliProgram } from "../types.ts";
5
5
  import { generateSkillBundle, type SkillTarget } from "./generate.ts";
6
6
  import { applySkillInstallHints } from "./hint.ts";
7
7
 
8
+ export { skillDirNameForTarget, skillFrontmatterName, skillSlug } from "./naming.ts";
9
+
8
10
  export interface SkillInstallOpts {
9
11
  global?: boolean;
10
- /** When true, remove an existing skill directory before writing. */
11
12
  rimraf?: boolean;
12
- /** When true, skip writes but return paths that would change. */
13
13
  dry?: boolean;
14
14
  }
15
15
 
16
16
  function resolveSkillDir(target: SkillTarget, dirName: string, global: boolean): string {
17
- const base = global
18
- ? join(userHome(), target === "cursor" ? ".cursor" : ".claude", "skills")
19
- : join(process.cwd(), target === "cursor" ? ".cursor" : ".claude", "skills");
20
- return join(base, dirName);
17
+ const home = userHome();
18
+ switch (target) {
19
+ case "cursor":
20
+ return join(global ? home : process.cwd(), ".cursor", "skills", dirName);
21
+ case "claude":
22
+ return join(global ? home : process.cwd(), ".claude", "skills", dirName);
23
+ case "codex":
24
+ return join(global ? home : process.cwd(), ".codex", "skills", dirName);
25
+ case "opencode":
26
+ return join(global ? home : process.cwd(), ".config", "opencode", "skills", dirName);
27
+ case "openclaw":
28
+ return join(global ? home : process.cwd(), ".openclaw", "skills", dirName);
29
+ }
21
30
  }
22
31
 
23
32
  /** Writes SKILL.md and reference.md; returns changed file paths. */
@@ -47,3 +56,21 @@ export function cliSkillInstall(
47
56
  changed.push(skillPath, refPath);
48
57
  return changed;
49
58
  }
59
+
60
+ /** Maps install action kind to skill target. */
61
+ export function skillTargetFromActionKind(kind: string): SkillTarget | undefined {
62
+ switch (kind) {
63
+ case "cursor-skill":
64
+ return "cursor";
65
+ case "claude-skill":
66
+ return "claude";
67
+ case "codex-skill":
68
+ return "codex";
69
+ case "opencode-skill":
70
+ return "opencode";
71
+ case "openclaw-skill":
72
+ return "openclaw";
73
+ default:
74
+ return undefined;
75
+ }
76
+ }
@@ -0,0 +1,28 @@
1
+ /** Agent skill install targets. */
2
+ export type SkillTarget = "cursor" | "claude" | "codex" | "opencode" | "openclaw";
3
+
4
+ import { sanitizeToolSegment } from "../mcp/tools.ts";
5
+
6
+ /** Kebab-case skill folder + frontmatter name for agents that require hyphen slugs. */
7
+ export function skillSlug(programKey: string): string {
8
+ return programKey
9
+ .replace(/[^a-zA-Z0-9]+/g, "-")
10
+ .replace(/^-+|-+$/g, "")
11
+ .toLowerCase();
12
+ }
13
+
14
+ /** Directory name for a skill target (underscore vs kebab conventions). */
15
+ export function skillDirNameForTarget(programKey: string, target: SkillTarget): string {
16
+ if (target === "cursor" || target === "claude") {
17
+ return sanitizeToolSegment(programKey);
18
+ }
19
+ return skillSlug(programKey);
20
+ }
21
+
22
+ /** Frontmatter `name` for SKILL.md by target. */
23
+ export function skillFrontmatterName(programKey: string, target: SkillTarget): string {
24
+ if (target === "cursor" || target === "claude") {
25
+ return sanitizeToolSegment(programKey);
26
+ }
27
+ return skillSlug(programKey);
28
+ }