argsbarg 3.6.4 → 4.0.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 (98) hide show
  1. package/CHANGELOG.md +28 -2
  2. package/README.md +21 -9
  3. package/docs/README.md +12 -8
  4. package/docs/bundled-docs.md +1 -1
  5. package/docs/cli-program.md +62 -2
  6. package/docs/config-schema.md +192 -0
  7. package/docs/developing.md +13 -0
  8. package/docs/install.md +38 -1
  9. package/docs/mcp.md +43 -19
  10. package/docs/output-schema.md +74 -52
  11. package/docs/templates/cursor/rules/cli-program.mdc +10 -5
  12. package/examples/config-app/main.ts +20 -0
  13. package/examples/config-app/program.ts +81 -0
  14. package/examples/config-app/schema.ts +37 -0
  15. package/examples/config-app/types.ts +19 -0
  16. package/examples/consumer-app/README.md +56 -0
  17. package/examples/consumer-app/bun.lock +75 -0
  18. package/examples/consumer-app/capabilities.test.ts +69 -0
  19. package/examples/consumer-app/package.json +17 -0
  20. package/examples/consumer-app/schemas/configSchemas.ts +6 -0
  21. package/examples/consumer-app/schemas/generated/app-config.json +40 -0
  22. package/examples/consumer-app/schemas/generated/status.json +28 -0
  23. package/examples/consumer-app/schemas/outputSchemas.ts +6 -0
  24. package/examples/consumer-app/scripts/schemagen/discover-schema-roots.test.ts +25 -0
  25. package/examples/consumer-app/scripts/schemagen/discover-schema-roots.ts +93 -0
  26. package/examples/consumer-app/scripts/schemagen/naming.ts +82 -0
  27. package/examples/consumer-app/scripts/schemagen.ts +76 -0
  28. package/examples/consumer-app/src/commands/status/types.ts +11 -0
  29. package/examples/consumer-app/src/main.ts +15 -0
  30. package/examples/consumer-app/src/program.ts +116 -0
  31. package/examples/consumer-app/src/types.ts +23 -0
  32. package/examples/consumer-app/tsconfig.json +14 -0
  33. package/examples/formats.ts +10 -3
  34. package/examples/mcp-test.ts +27 -8
  35. package/examples/minimal.ts +4 -3
  36. package/examples/nested.ts +5 -4
  37. package/examples/option-required.ts +8 -4
  38. package/index.d.ts +152 -75
  39. package/package.json +1 -1
  40. package/src/builtins/builtins.test.ts +13 -0
  41. package/src/builtins/config.test.ts +82 -0
  42. package/src/builtins/config.ts +220 -0
  43. package/src/builtins/dispatch.ts +18 -9
  44. package/src/builtins/export.ts +8 -33
  45. package/src/builtins/index.ts +1 -0
  46. package/src/builtins/install.ts +13 -0
  47. package/src/builtins/mcp.ts +1 -1
  48. package/src/builtins/presentation.ts +2 -17
  49. package/src/builtins/registry.ts +40 -0
  50. package/src/capabilities.ts +46 -0
  51. package/src/cli-errors.ts +15 -0
  52. package/src/cli.ts +389 -0
  53. package/src/config/bootstrap.ts +265 -0
  54. package/src/config/context.test.ts +61 -0
  55. package/src/config/context.ts +98 -0
  56. package/src/config/entry.ts +81 -0
  57. package/src/config/file.test.ts +112 -0
  58. package/src/config/file.ts +120 -0
  59. package/src/config/manifest.ts +62 -0
  60. package/src/config/resolve.test.ts +88 -0
  61. package/src/config/resolve.ts +167 -0
  62. package/src/config/schema.ts +101 -0
  63. package/src/config/validate.test.ts +63 -0
  64. package/src/config/validate.ts +292 -0
  65. package/src/config.integration.test.ts +100 -0
  66. package/src/context.ts +5 -0
  67. package/src/docs/docs.test.ts +16 -16
  68. package/src/docs/mcp-guide.ts +35 -11
  69. package/src/hidden-mcpb.test.ts +40 -2
  70. package/src/index.ts +4 -3
  71. package/src/install/index.ts +46 -3
  72. package/src/install/paths.ts +5 -20
  73. package/src/install/plan.ts +6 -0
  74. package/src/install/status.ts +12 -0
  75. package/src/install/uninstall.ts +11 -0
  76. package/src/install/update.test.ts +5 -5
  77. package/src/invoke.test.ts +207 -0
  78. package/src/mcp/bundle.ts +9 -116
  79. package/src/mcp/claude.test.ts +73 -0
  80. package/src/mcp/claude.ts +168 -0
  81. package/src/mcp/env.ts +3 -37
  82. package/src/mcp/server.ts +18 -10
  83. package/src/mcp/tools.ts +3 -7
  84. package/src/mcp/zip.ts +82 -0
  85. package/src/mcp.integration.test.ts +502 -0
  86. package/src/{index.test.ts → parse.test.ts} +24 -935
  87. package/src/paths/host.ts +40 -0
  88. package/src/schema.ts +1 -1
  89. package/src/skill/generate.ts +18 -5
  90. package/src/skill/hint.ts +18 -0
  91. package/src/skill/install.ts +1 -5
  92. package/src/test-fixtures.ts +192 -0
  93. package/src/types.ts +39 -13
  94. package/src/validate.ts +70 -0
  95. package/src/completion.ts +0 -13
  96. package/src/invoke.ts +0 -217
  97. package/src/mcp.ts +0 -28
  98. package/src/runtime.ts +0 -134
@@ -0,0 +1,207 @@
1
+ /*
2
+ Domain-specific regression tests (split from index.test.ts).
3
+ */
4
+
5
+ import { expect, test } from "bun:test";
6
+ import { join } from "node:path";
7
+ import { $ } from "bun";
8
+ import { Cli, type CliContext, CliOptionKind } from "./index.ts";
9
+ import { ParseKind, parse, postParseValidate } from "./parse.ts";
10
+ import { testProgram, varargsReadFixture } from "./test-fixtures.ts";
11
+ import type { CliLeaf } from "./types.ts";
12
+ import { isCliRouter } from "./types.ts";
13
+ import { cliValidateProgram } from "./validate.ts";
14
+
15
+ test("ctx.invocation is cli via Cli.run", async () => {
16
+ const indexPath = join(import.meta.dir, "index.ts");
17
+ const { stdout } = await $`bun -e ${`
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) };
20
+ await new Cli(program).run([]);
21
+ `}`.quiet();
22
+ expect(stdout.toString().trim()).toBe("cli");
23
+ });
24
+
25
+ test("ctx.invocation is mcp via Cli.invoke", async () => {
26
+ let seen = "";
27
+ const root = testProgram({
28
+ key: "app",
29
+ description: "",
30
+ handler: (ctx: CliContext) => {
31
+ seen = ctx.invocation;
32
+ },
33
+ });
34
+ cliValidateProgram(root);
35
+ const result = await new Cli(root).invoke([]);
36
+ expect(result.kind).toBe("ok");
37
+ expect(seen).toBe("mcp");
38
+ });
39
+
40
+ test("Cli.invoke rejects invalid Enum value", async () => {
41
+ const root = testProgram({
42
+ key: "app",
43
+ description: "",
44
+ handler: () => {},
45
+ options: [
46
+ {
47
+ name: "mode",
48
+ description: "Mode.",
49
+ kind: CliOptionKind.Enum,
50
+ choices: ["dev", "prod"],
51
+ required: true,
52
+ },
53
+ ],
54
+ });
55
+ cliValidateProgram(root);
56
+ const result = await new Cli(root).invoke(["--mode", "staging"]);
57
+ expect(result.kind).toBe("error");
58
+ expect(result.errorMsg).toContain("not one of");
59
+ });
60
+
61
+ test("Cli.invoke accepts valid Enum value", async () => {
62
+ const root = testProgram({
63
+ key: "app",
64
+ description: "",
65
+ handler: (ctx: CliContext) => {
66
+ console.log(ctx.stringOpt("mode"));
67
+ },
68
+ options: [
69
+ {
70
+ name: "mode",
71
+ description: "Mode.",
72
+ kind: CliOptionKind.Enum,
73
+ choices: ["dev", "prod"],
74
+ required: true,
75
+ },
76
+ ],
77
+ });
78
+ cliValidateProgram(root);
79
+ const result = await new Cli(root).invoke(["--mode", "dev"]);
80
+ expect(result.kind).toBe("ok");
81
+ expect(result.stdout.trim()).toBe("dev");
82
+ });
83
+
84
+ test("varargs trailing option after positionals via Cli.invoke", async () => {
85
+ const root = varargsReadFixture();
86
+ cliValidateProgram(root);
87
+ const pr = postParseValidate(root, parse(root, ["read", "file.txt", "--json"]));
88
+ expect(pr.kind).toBe(ParseKind.Ok);
89
+ expect(pr.args).toEqual(["file.txt"]);
90
+ expect(pr.opts.json).toBe("1");
91
+ });
92
+
93
+ test("varargs option before positionals", () => {
94
+ const root = varargsReadFixture();
95
+ cliValidateProgram(root);
96
+ const pr = postParseValidate(root, parse(root, ["read", "--json", "file.txt"]));
97
+ expect(pr.kind).toBe(ParseKind.Ok);
98
+ expect(pr.args).toEqual(["file.txt"]);
99
+ expect(pr.opts.json).toBe("1");
100
+ });
101
+
102
+ test("varargs multiple files then trailing option", () => {
103
+ const root = varargsReadFixture();
104
+ cliValidateProgram(root);
105
+ const pr = postParseValidate(root, parse(root, ["read", "a.txt", "b.txt", "--json"]));
106
+ expect(pr.kind).toBe(ParseKind.Ok);
107
+ expect(pr.args).toEqual(["a.txt", "b.txt"]);
108
+ expect(pr.opts.json).toBe("1");
109
+ });
110
+
111
+ test("varargs double dash forces positional", () => {
112
+ const root = varargsReadFixture();
113
+ cliValidateProgram(root);
114
+ const pr = postParseValidate(root, parse(root, ["read", "file.txt", "--", "--json"]));
115
+ expect(pr.kind).toBe(ParseKind.Ok);
116
+ expect(pr.args).toEqual(["file.txt", "--json"]);
117
+ expect(pr.opts.json).toBeUndefined();
118
+ });
119
+
120
+ test("varargs unknown flag errors", async () => {
121
+ const root = varargsReadFixture();
122
+ cliValidateProgram(root);
123
+ const result = await new Cli(root).invoke(["read", "--unknown"]);
124
+ expect(result.kind).toBe("error");
125
+ expect(result.stderr).toContain("--unknown");
126
+ });
127
+
128
+ test("varargs scoped help in tail", () => {
129
+ const root = varargsReadFixture();
130
+ cliValidateProgram(root);
131
+ const pr = parse(root, ["read", "file.txt", "--help"]);
132
+ expect(pr.kind).toBe(ParseKind.Help);
133
+ expect(pr.helpPath).toContain("read");
134
+ expect(pr.helpExplicit).toBe(true);
135
+ });
136
+
137
+ test("ctx.positional returns single slot value", async () => {
138
+ const root = testProgram({
139
+ key: "app",
140
+ description: "",
141
+ commands: [
142
+ {
143
+ key: "x",
144
+ description: "",
145
+ positionals: [{ name: "path", description: "", kind: CliOptionKind.String }],
146
+ handler: (ctx: CliContext) => {
147
+ captured = ctx.positional("path");
148
+ },
149
+ },
150
+ ],
151
+ });
152
+ let captured: string | string[] | undefined;
153
+ cliValidateProgram(root);
154
+ await new Cli(root).invoke(["x", "./file"]);
155
+ expect(captured).toBe("./file");
156
+ });
157
+
158
+ test("ctx.positional returns varargs array", async () => {
159
+ const root = varargsReadFixture();
160
+ let captured: string | string[] | undefined;
161
+ if (isCliRouter(root)) {
162
+ (root.commands[0] as CliLeaf).handler = (ctx) => {
163
+ captured = ctx.positional("files");
164
+ };
165
+ }
166
+ cliValidateProgram(root);
167
+ await new Cli(root).invoke(["read", "a.txt", "b.txt"]);
168
+ expect(captured).toEqual(["a.txt", "b.txt"]);
169
+ });
170
+
171
+ test("ctx.positional returns undefined for absent optional slot", async () => {
172
+ const root = testProgram({
173
+ key: "app",
174
+ description: "",
175
+ commands: [
176
+ {
177
+ key: "x",
178
+ description: "",
179
+ positionals: [
180
+ { name: "opt", description: "", kind: CliOptionKind.String, argMin: 0, argMax: 1 },
181
+ ],
182
+ handler: (ctx: CliContext) => {
183
+ captured = ctx.positional("opt");
184
+ },
185
+ },
186
+ ],
187
+ });
188
+ let captured: string | string[] | undefined;
189
+ cliValidateProgram(root);
190
+ await new Cli(root).invoke(["x"]);
191
+ expect(captured).toBeUndefined();
192
+ });
193
+
194
+ test("ctx.positional varargs matches ctx.args", async () => {
195
+ const root = varargsReadFixture();
196
+ let positional: string | string[] | undefined;
197
+ let args: string[] = [];
198
+ if (isCliRouter(root)) {
199
+ (root.commands[0] as CliLeaf).handler = (ctx) => {
200
+ positional = ctx.positional("files");
201
+ args = ctx.args;
202
+ };
203
+ }
204
+ cliValidateProgram(root);
205
+ await new Cli(root).invoke(["read", "a.txt", "b.txt"]);
206
+ expect(positional).toEqual(args);
207
+ });
package/src/mcp/bundle.ts CHANGED
@@ -14,8 +14,11 @@ import {
14
14
  } from "node:fs";
15
15
  import { tmpdir } from "node:os";
16
16
  import { basename, join, resolve } from "node:path";
17
+ import { buildProgramUserConfig } from "../config/manifest.ts";
17
18
  import type { CliMcpBundleConfig, CliProgram } from "../types.ts";
19
+ import { packClaudePlugin } from "./claude.ts";
18
20
  import { collectMcpTools, mcpServerId } from "./tools.ts";
21
+ import { zipStore } from "./zip.ts";
19
22
 
20
23
  const MANIFEST_VERSION = "0.3";
21
24
  const DIST_DIR = "dist";
@@ -38,41 +41,9 @@ export function defaultMcpBundlePaths(program: CliProgram, cwd = process.cwd()):
38
41
  };
39
42
  }
40
43
 
41
- /** Collects unique env var names from MCP tool `requiresEnv` for manifest `user_config`. */
42
- function collectUserConfigEnvVars(program: CliProgram): string[] {
43
- const names = new Set<string>();
44
- for (const tool of collectMcpTools(program)) {
45
- for (const env of tool.leaf.mcpTool?.requiresEnv ?? []) {
46
- if (env.length > 0) {
47
- names.add(env);
48
- }
49
- }
50
- }
51
- return [...names].sort();
52
- }
53
-
54
- /** Builds manifest `user_config` entries for required environment variables. */
44
+ /** Builds MCPB/plugin user_config from schema entries with `env` set. */
55
45
  function buildUserConfig(program: CliProgram): Record<string, unknown> | undefined {
56
- const envVars = collectUserConfigEnvVars(program);
57
- if (envVars.length === 0) {
58
- return undefined;
59
- }
60
- const out: Record<string, unknown> = {};
61
- for (const name of envVars) {
62
- const key =
63
- name
64
- .toLowerCase()
65
- .replace(/[^a-z0-9]+/g, "_")
66
- .replace(/^_|_$/g, "") || "env_var";
67
- out[key] = {
68
- type: "string",
69
- title: name,
70
- description: `Value for environment variable ${name}`,
71
- sensitive: /key|token|secret|password/i.test(name),
72
- required: true,
73
- };
74
- }
75
- return out;
46
+ return buildProgramUserConfig(program);
76
47
  }
77
48
 
78
49
  /** Default author when `mcpServer.bundle.author` is unset. */
@@ -134,85 +105,6 @@ export function generateMcpManifest(
134
105
  return manifest;
135
106
  }
136
107
 
137
- /** CRC-32 table for ZIP local headers (store method). */
138
- function crc32(data: Buffer): number {
139
- let crc = 0xffffffff;
140
- for (let i = 0; i < data.length; i++) {
141
- const byte = data[i];
142
- if (byte === undefined) {
143
- continue;
144
- }
145
- crc ^= byte;
146
- for (let j = 0; j < 8; j++) {
147
- crc = (crc >>> 1) ^ (crc & 1 ? 0xedb88320 : 0);
148
- }
149
- }
150
- return (crc ^ 0xffffffff) >>> 0;
151
- }
152
-
153
- /** Writes a minimal ZIP (store, no compression) with one or more files. */
154
- function zipStore(files: { name: string; data: Buffer }[]): Buffer {
155
- const parts: Buffer[] = [];
156
- const central: Buffer[] = [];
157
- let offset = 0;
158
-
159
- for (const file of files) {
160
- const nameBuf = Buffer.from(file.name, "utf8");
161
- const crc = crc32(file.data);
162
- const local = Buffer.alloc(30 + nameBuf.length);
163
- local.writeUInt32LE(0x04034b50, 0);
164
- local.writeUInt16LE(20, 4);
165
- local.writeUInt16LE(0, 6);
166
- local.writeUInt16LE(0, 8);
167
- local.writeUInt16LE(0, 10);
168
- local.writeUInt16LE(0, 12);
169
- local.writeUInt32LE(crc, 14);
170
- local.writeUInt32LE(file.data.length, 18);
171
- local.writeUInt32LE(file.data.length, 22);
172
- local.writeUInt32LE(nameBuf.length, 26);
173
- local.writeUInt16LE(0, 28);
174
- nameBuf.copy(local, 30);
175
-
176
- const centralHdr = Buffer.alloc(46 + nameBuf.length);
177
- centralHdr.writeUInt32LE(0x02014b50, 0);
178
- centralHdr.writeUInt16LE(20, 4);
179
- centralHdr.writeUInt16LE(20, 6);
180
- centralHdr.writeUInt16LE(0, 8);
181
- centralHdr.writeUInt16LE(0, 10);
182
- centralHdr.writeUInt16LE(0, 12);
183
- centralHdr.writeUInt16LE(0, 14);
184
- centralHdr.writeUInt32LE(crc, 16);
185
- centralHdr.writeUInt32LE(file.data.length, 20);
186
- centralHdr.writeUInt32LE(file.data.length, 24);
187
- centralHdr.writeUInt32LE(nameBuf.length, 28);
188
- centralHdr.writeUInt16LE(0, 30);
189
- centralHdr.writeUInt16LE(0, 32);
190
- centralHdr.writeUInt16LE(0, 34);
191
- centralHdr.writeUInt16LE(0, 36);
192
- centralHdr.writeUInt32LE(0, 38);
193
- centralHdr.writeUInt32LE(offset, 42);
194
- nameBuf.copy(centralHdr, 46);
195
-
196
- parts.push(local, file.data);
197
- central.push(centralHdr);
198
- offset += local.length + file.data.length;
199
- }
200
-
201
- const centralStart = offset;
202
- const centralBuf = Buffer.concat(central);
203
- const end = Buffer.alloc(22);
204
- end.writeUInt32LE(0x06054b50, 0);
205
- end.writeUInt16LE(0, 4);
206
- end.writeUInt16LE(0, 6);
207
- end.writeUInt16LE(files.length, 8);
208
- end.writeUInt16LE(files.length, 10);
209
- end.writeUInt32LE(centralBuf.length, 12);
210
- end.writeUInt32LE(centralStart, 16);
211
- end.writeUInt16LE(0, 20);
212
-
213
- return Buffer.concat([...parts, centralBuf, end]);
214
- }
215
-
216
108
  export interface PackMcpBundleOpts {
217
109
  cwd?: string;
218
110
  binaryPath?: string;
@@ -268,8 +160,9 @@ export function packMcpBundle(program: CliProgram, opts: PackMcpBundleOpts = {})
268
160
  }
269
161
  }
270
162
 
271
- /** Runs `mcp bundle`: writes `dist/<key>.mcpb` and prints the path on stdout. */
163
+ /** Runs `mcp bundle`: writes `.mcpb` and Claude Code plugin zip; prints both paths. */
272
164
  export function runMcpBundle(program: CliProgram): void {
273
- const outPath = packMcpBundle(program);
274
- process.stdout.write(`${outPath}\n`);
165
+ const mcpbPath = packMcpBundle(program);
166
+ const pluginPath = packClaudePlugin(program);
167
+ process.stdout.write(`${mcpbPath}\n${pluginPath}\n`);
275
168
  }
@@ -0,0 +1,73 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import type { CliProgram } from "../types.ts";
6
+ import {
7
+ defaultClaudePluginPaths,
8
+ generatePluginManifest,
9
+ generatePluginMcpJson,
10
+ packClaudePlugin,
11
+ pluginName,
12
+ } from "./claude.ts";
13
+
14
+ const configFixture: CliProgram = {
15
+ key: "myapp",
16
+ version: "1.0.0",
17
+ description: "Demo.",
18
+ mcpServer: { enabled: true },
19
+ appConfig: {
20
+ entries: {
21
+ apiToken: {
22
+ description: "Token from settings.",
23
+ env: "API_TOKEN",
24
+ sensitive: true,
25
+ },
26
+ },
27
+ },
28
+ commands: [{ key: "run", description: "Run.", handler: () => {} }],
29
+ };
30
+
31
+ describe("claude plugin", () => {
32
+ test("pluginName is kebab-case", () => {
33
+ expect(pluginName({ ...configFixture, key: "MyApp" })).toBe("my-app");
34
+ });
35
+
36
+ test("generatePluginManifest includes userConfig from program.appConfig", () => {
37
+ const manifest = generatePluginManifest(configFixture, "myapp");
38
+ expect(manifest.name).toBe("myapp");
39
+ const userConfig = manifest.userConfig as Record<string, Record<string, unknown>>;
40
+ expect(userConfig.api_token?.description).toBe("Token from settings.");
41
+ expect(userConfig.api_token?.sensitive).toBe(true);
42
+ });
43
+
44
+ test("generatePluginMcpJson uses CLAUDE_PLUGIN_ROOT and env mapping", () => {
45
+ const json = generatePluginMcpJson(configFixture, "myapp");
46
+ const entry = json.myapp as { command: string; args: string[]; env: Record<string, string> };
47
+ expect(entry.command).toBe("${CLAUDE_PLUGIN_ROOT}/bin/myapp");
48
+ expect(entry.args).toEqual(["mcp"]);
49
+ expect(entry.env.API_TOKEN).toBe("${user_config.api_token}");
50
+ });
51
+
52
+ test("defaultClaudePluginPaths", () => {
53
+ const cwd = "/tmp/work";
54
+ const paths = defaultClaudePluginPaths(configFixture, cwd);
55
+ expect(paths.pluginZipPath).toBe(join(cwd, "dist", "claude-plugin", "myapp.zip"));
56
+ });
57
+
58
+ test("packClaudePlugin writes zip with expected entries", () => {
59
+ const work = mkdtempSync(join(tmpdir(), "claude-plugin-test-"));
60
+ try {
61
+ const dist = join(work, "dist");
62
+ mkdirSync(dist, { recursive: true });
63
+ const binaryPath = join(dist, "myapp");
64
+ writeFileSync(binaryPath, "#!/bin/sh\n", { mode: 0o755 });
65
+ const paths = defaultClaudePluginPaths(configFixture, work);
66
+ packClaudePlugin(configFixture, { cwd: work, binaryPath });
67
+ const zipPath = paths.pluginZipPath;
68
+ expect(readFileSync(zipPath).length).toBeGreaterThan(0);
69
+ } finally {
70
+ rmSync(work, { recursive: true, force: true });
71
+ }
72
+ });
73
+ });
@@ -0,0 +1,168 @@
1
+ /*
2
+ Packs a Claude Code plugin zip from a compiled CLI binary.
3
+ Internal module — not exported from index.ts.
4
+ */
5
+
6
+ import {
7
+ cpSync,
8
+ type Dirent,
9
+ existsSync,
10
+ mkdirSync,
11
+ mkdtempSync,
12
+ readdirSync,
13
+ readFileSync,
14
+ rmSync,
15
+ writeFileSync,
16
+ } from "node:fs";
17
+ import { tmpdir } from "node:os";
18
+ import { basename, join, relative, resolve } from "node:path";
19
+ import { buildPluginMcpEnvMapping, buildProgramUserConfig } from "../config/manifest.ts";
20
+ import { generateSkillBundle } from "../skill/generate.ts";
21
+ import { applySkillBundleHints } from "../skill/hint.ts";
22
+ import type { CliMcpBundleConfig, CliProgram } from "../types.ts";
23
+ import { defaultMcpBundlePaths, type PackMcpBundleOpts } from "./bundle.ts";
24
+ import { mcpServerId, sanitizeToolSegment } from "./tools.ts";
25
+ import { zipStore } from "./zip.ts";
26
+
27
+ const DIST_DIR = "dist";
28
+ const CLAUDE_PLUGIN_DIR = "claude-plugin";
29
+
30
+ /** Kebab-case plugin name for plugin.json (Claude Code requires kebab-case). */
31
+ export function pluginName(program: CliProgram): string {
32
+ return program.key
33
+ .replace(/([a-z0-9])([A-Z])/g, "$1-$2")
34
+ .replace(/[^a-zA-Z0-9]+/g, "-")
35
+ .replace(/^-+|-+$/g, "")
36
+ .toLowerCase();
37
+ }
38
+
39
+ function defaultAuthor(bundle?: CliMcpBundleConfig): {
40
+ name: string;
41
+ email?: string;
42
+ url?: string;
43
+ } {
44
+ return bundle?.author ?? { name: "Unknown" };
45
+ }
46
+
47
+ /** Default plugin zip output under cwd. */
48
+ export function defaultClaudePluginPaths(program: CliProgram, cwd = process.cwd()) {
49
+ const binaryName = program.key;
50
+ const dist = join(cwd, DIST_DIR);
51
+ const name = pluginName(program);
52
+ return {
53
+ pluginZipPath: join(dist, CLAUDE_PLUGIN_DIR, `${name}.zip`),
54
+ binaryPath: join(dist, binaryName),
55
+ binaryName,
56
+ };
57
+ }
58
+
59
+ /** Generates `.claude-plugin/plugin.json` object. */
60
+ export function generatePluginManifest(
61
+ program: CliProgram,
62
+ _binaryName: string,
63
+ ): Record<string, unknown> {
64
+ const bundle = program.mcpServer?.bundle;
65
+ const manifest: Record<string, unknown> = {
66
+ name: pluginName(program),
67
+ version: program.version,
68
+ description: program.description,
69
+ author: defaultAuthor(bundle),
70
+ };
71
+ const userConfig = buildProgramUserConfig(program);
72
+ if (userConfig) {
73
+ manifest.userConfig = userConfig;
74
+ }
75
+ return manifest;
76
+ }
77
+
78
+ /** Generates plugin `.mcp.json` stdio server config. */
79
+ export function generatePluginMcpJson(
80
+ program: CliProgram,
81
+ binaryName: string,
82
+ ): Record<string, unknown> {
83
+ const mcp: Record<string, unknown> = {
84
+ command: `\${CLAUDE_PLUGIN_ROOT}/bin/${binaryName}`,
85
+ args: ["mcp"],
86
+ };
87
+ const env = buildPluginMcpEnvMapping(program);
88
+ if (env) {
89
+ mcp.env = env;
90
+ }
91
+ return {
92
+ [mcpServerId(program)]: mcp,
93
+ };
94
+ }
95
+
96
+ function collectZipEntries(rootDir: string, dir = rootDir): { name: string; data: Buffer }[] {
97
+ const entries: { name: string; data: Buffer }[] = [];
98
+ for (const ent of readdirSync(dir, { withFileTypes: true }) as Dirent[]) {
99
+ const full = join(dir, ent.name);
100
+ if (ent.isDirectory()) {
101
+ entries.push(...collectZipEntries(rootDir, full));
102
+ continue;
103
+ }
104
+ if (!ent.isFile()) {
105
+ continue;
106
+ }
107
+ const rel = relative(rootDir, full).split("\\").join("/");
108
+ entries.push({ name: rel, data: readFileSync(full) });
109
+ }
110
+ return entries;
111
+ }
112
+
113
+ function writePluginTree(
114
+ pluginRoot: string,
115
+ program: CliProgram,
116
+ binaryPath: string,
117
+ binaryName: string,
118
+ ): void {
119
+ const skillDirName = sanitizeToolSegment(program.key);
120
+ const bundle = generateSkillBundle(program, "claude");
121
+ const hinted = applySkillBundleHints(program, bundle.skillMd, bundle.referenceMd);
122
+
123
+ mkdirSync(join(pluginRoot, ".claude-plugin"), { recursive: true });
124
+ mkdirSync(join(pluginRoot, "bin"), { recursive: true });
125
+ mkdirSync(join(pluginRoot, "skills", skillDirName), { recursive: true });
126
+
127
+ writeFileSync(
128
+ join(pluginRoot, ".claude-plugin", "plugin.json"),
129
+ `${JSON.stringify(generatePluginManifest(program, binaryName), null, 2)}\n`,
130
+ );
131
+ writeFileSync(
132
+ join(pluginRoot, ".mcp.json"),
133
+ `${JSON.stringify(generatePluginMcpJson(program, binaryName), null, 2)}\n`,
134
+ );
135
+ 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);
138
+ }
139
+
140
+ /**
141
+ * Writes `dist/claude-plugin/<name>.zip` with manifests, binary, and skill bundle.
142
+ * Requires the compiled binary to exist.
143
+ */
144
+ export function packClaudePlugin(program: CliProgram, opts: PackMcpBundleOpts = {}): string {
145
+ const cwd = opts.cwd ?? process.cwd();
146
+ const defaults = defaultClaudePluginPaths(program, cwd);
147
+ const mcpDefaults = defaultMcpBundlePaths(program, cwd);
148
+ const binaryPath = resolve(cwd, opts.binaryPath ?? mcpDefaults.binaryPath);
149
+ const pluginZipPath = resolve(cwd, defaults.pluginZipPath);
150
+ const binaryName = basename(binaryPath);
151
+
152
+ if (!existsSync(binaryPath)) {
153
+ throw new Error(
154
+ `Binary not found: ${binaryPath}. Build with compile first (expected dist/${program.key}).`,
155
+ );
156
+ }
157
+
158
+ const staging = mkdtempSync(join(tmpdir(), "claude-plugin-"));
159
+ try {
160
+ writePluginTree(staging, program, binaryPath, binaryName);
161
+ const zip = zipStore(collectZipEntries(staging));
162
+ mkdirSync(join(pluginZipPath, ".."), { recursive: true });
163
+ writeFileSync(pluginZipPath, zip);
164
+ return pluginZipPath;
165
+ } finally {
166
+ rmSync(staging, { recursive: true, force: true });
167
+ }
168
+ }
package/src/mcp/env.ts CHANGED
@@ -1,9 +1,8 @@
1
1
  /*
2
- This module bootstraps process.env for MCP servers from login shell and .env files.
2
+ This module bootstraps process.env for MCP servers from login shell.
3
3
  */
4
4
 
5
5
  import { spawnSync } from "node:child_process";
6
- import { readFileSync } from "node:fs";
7
6
 
8
7
  /** Parses `env` stdout from a login shell into a key/value map. */
9
8
  export function captureShellEnv(shell: string): Record<string, string> {
@@ -40,38 +39,8 @@ export function applyShellEnv(env: Record<string, string>): void {
40
39
  }
41
40
  }
42
41
 
43
- /** Loads a .env file into process.env (always overwrites). Warns on stderr if missing. */
44
- export function loadEnvFile(envFile: string): void {
45
- const resolved = envFile.startsWith("~") ? envFile.replace("~", process.env.HOME ?? "") : envFile;
46
- let text: string;
47
- try {
48
- text = readFileSync(resolved, "utf8");
49
- } catch {
50
- process.stderr.write(`[argsbarg] envFile not found: ${envFile}\n`);
51
- return;
52
- }
53
- for (const line of text.split("\n")) {
54
- const trimmed = line.trim();
55
- if (!trimmed || trimmed.startsWith("#")) {
56
- continue;
57
- }
58
- const eq = trimmed.indexOf("=");
59
- if (eq < 1) {
60
- continue;
61
- }
62
- const key = trimmed.slice(0, eq).trim();
63
- let val = trimmed.slice(eq + 1).trim();
64
- if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
65
- val = val.slice(1, -1);
66
- }
67
- if (key) {
68
- process.env[key] = val;
69
- }
70
- }
71
- }
72
-
73
- /** Applies mcpServer shellEnv and envFile bootstrap in order. */
74
- export function bootstrapMcpEnv(config: { shellEnv?: boolean | string; envFile?: string }): void {
42
+ /** Applies mcpServer shellEnv bootstrap. */
43
+ export function bootstrapMcpEnv(config: { shellEnv?: boolean | string }): void {
75
44
  const shellEnvCfg = config.shellEnv;
76
45
  if (shellEnvCfg) {
77
46
  const shell =
@@ -87,7 +56,4 @@ export function bootstrapMcpEnv(config: { shellEnv?: boolean | string; envFile?:
87
56
  applyShellEnv(captured);
88
57
  }
89
58
  }
90
- if (config.envFile) {
91
- loadEnvFile(config.envFile);
92
- }
93
59
  }