@napster-corp/webmcp-toolkit 1.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 (48) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +531 -0
  3. package/bin/webmcp-toolkit.mjs +81 -0
  4. package/dist/debug.d.ts +5 -0
  5. package/dist/debug.d.ts.map +1 -0
  6. package/dist/debug.js +26 -0
  7. package/dist/debug.js.map +1 -0
  8. package/dist/dev-panel.d.ts +22 -0
  9. package/dist/dev-panel.d.ts.map +1 -0
  10. package/dist/dev-panel.js +1046 -0
  11. package/dist/dev-panel.js.map +1 -0
  12. package/dist/index.d.ts +6 -0
  13. package/dist/index.d.ts.map +1 -0
  14. package/dist/index.js +36 -0
  15. package/dist/index.js.map +1 -0
  16. package/dist/model-context.d.ts +13 -0
  17. package/dist/model-context.d.ts.map +1 -0
  18. package/dist/model-context.js +28 -0
  19. package/dist/model-context.js.map +1 -0
  20. package/dist/resources.d.ts +15 -0
  21. package/dist/resources.d.ts.map +1 -0
  22. package/dist/resources.js +179 -0
  23. package/dist/resources.js.map +1 -0
  24. package/dist/tiers.d.ts +31 -0
  25. package/dist/tiers.d.ts.map +1 -0
  26. package/dist/tiers.js +107 -0
  27. package/dist/tiers.js.map +1 -0
  28. package/dist/types.d.ts +145 -0
  29. package/dist/types.d.ts.map +1 -0
  30. package/dist/types.js +9 -0
  31. package/dist/types.js.map +1 -0
  32. package/hooks/post-commit +17 -0
  33. package/package.json +86 -0
  34. package/skills/add-edge-mcp-dev-panel/SKILL.md +206 -0
  35. package/skills/plan-capabilities-and-state/SKILL.md +168 -0
  36. package/skills/setup-edge-mcp/SKILL.md +546 -0
  37. package/skills/sync-webmcp-tools/SKILL.md +26 -0
  38. package/src/debug.ts +26 -0
  39. package/src/dev-panel.ts +1318 -0
  40. package/src/index.ts +66 -0
  41. package/src/model-context.ts +31 -0
  42. package/src/resources.ts +207 -0
  43. package/src/tiers.ts +132 -0
  44. package/src/types.ts +177 -0
  45. package/tools/generate-capabilities.mjs +266 -0
  46. package/tools/install-hook.mjs +81 -0
  47. package/tools/runners/anthropic.mjs +75 -0
  48. package/tools/runners/copilot.mjs +63 -0
@@ -0,0 +1,266 @@
1
+ // generate-capabilities.mjs — orchestrator for keeping a host app's WebMCP
2
+ // tools file (`src/webmcp/tools.ts`) in sync with its code via an autonomous
3
+ // coding agent.
4
+ //
5
+ // Engine-agnostic: this file builds the prompt (from this package's skills),
6
+ // detects the tools file, loads env, then hands off to a pluggable RUNNER that
7
+ // actually drives an agent. Pick the engine with:
8
+ //
9
+ // WEBMCP_TOOLKIT_ENGINE=anthropic (default — Claude Agent SDK, needs ANTHROPIC_API_KEY)
10
+ // WEBMCP_TOOLKIT_ENGINE=copilot (GitHub Copilot CLI, needs a Copilot subscription)
11
+ //
12
+ // A runner is `tools/runners/<engine>.mjs` exporting:
13
+ // export async function runAgent({ prompt, cwd, capabilitiesPath }): Promise<string>
14
+ //
15
+ // Whatever the engine, the contract is the same: analyze the app, edit ONLY the
16
+ // tools file, leave the change uncommitted for review.
17
+
18
+ import { readFile } from "node:fs/promises";
19
+ import { existsSync, readFileSync, readdirSync } from "node:fs";
20
+ import { fileURLToPath } from "node:url";
21
+ import { dirname, join, relative, resolve, sep } from "node:path";
22
+
23
+ const __dirname = dirname(fileURLToPath(import.meta.url));
24
+ const PACKAGE_ROOT = resolve(__dirname, "..");
25
+
26
+ const DEFAULT_ENGINE = "anthropic";
27
+
28
+ // Pluggable engines. Static map (not arbitrary path import) so an unknown
29
+ // engine fails with a clear message instead of a module-not-found.
30
+ const ENGINES = {
31
+ anthropic: () => import("./runners/anthropic.mjs"),
32
+ copilot: () => import("./runners/copilot.mjs"),
33
+ };
34
+
35
+ // The prompt is itself a skill (`sync-webmcp-tools`) that lives in the skills
36
+ // folder like every other one — a single artifact whether this automation runs
37
+ // it or it is followed directly in chat. The task skill defers methodology to
38
+ // the two skills below, which we inline (not via any engine's skill
39
+ // auto-discovery) so the run is self-contained on any engine.
40
+ const TASK_SKILL = "skills/sync-webmcp-tools/SKILL.md";
41
+ const METHODOLOGY_SKILLS = [
42
+ "skills/plan-capabilities-and-state/SKILL.md",
43
+ "skills/setup-edge-mcp/SKILL.md",
44
+ ];
45
+
46
+ // Dirs we never descend into while discovering where WebMCP lives.
47
+ const SKIP_DIRS = new Set([
48
+ "node_modules", ".git", "dist", "build", "out", ".next", ".turbo",
49
+ ".cache", "coverage", ".vercel", ".svelte-kit", ".output",
50
+ ]);
51
+ // Default location used ONLY for a first-time setup where nothing exists yet.
52
+ const DEFAULT_TOOLS_FILE = "src/webmcp/tools.ts";
53
+
54
+ // Credentials a runner might need. Plain Node scripts don't read `.env.local`
55
+ // the way Next.js does, so the hook/manual run wouldn't otherwise see a key kept
56
+ // there. Load these from the app's `.env.local` / `.env` if not already in the
57
+ // environment (an exported value always wins — e.g. a CI secret).
58
+ const ENV_KEYS = ["ANTHROPIC_API_KEY", "GITHUB_TOKEN", "GH_TOKEN", "COPILOT_TOKEN"];
59
+
60
+ function loadEnvVars(targetDir) {
61
+ for (const name of [".env.local", ".env"]) {
62
+ const p = join(targetDir, name);
63
+ if (!existsSync(p)) continue;
64
+ const text = readFileSync(p, "utf8");
65
+ for (const key of ENV_KEYS) {
66
+ if (process.env[key]) continue;
67
+ const m = text.match(new RegExp(`^\\s*${key}\\s*=\\s*(.+?)\\s*$`, "m"));
68
+ if (!m) continue;
69
+ let v = m[1].trim();
70
+ if (/^(".*"|'.*')$/.test(v)) v = v.slice(1, -1);
71
+ process.env[key] = v;
72
+ }
73
+ }
74
+ }
75
+
76
+ // Walk the project (skipping build/dependency dirs) and discover where WebMCP
77
+ // actually lives — no hardcoded path convention. Collects every directory named
78
+ // `webmcp` and every source file, so we can locate the module by its folder or,
79
+ // failing that, by which file imports the toolkit.
80
+ function scanProject(targetDir, depth = 0, acc = { webmcpDirs: [], sources: [] }) {
81
+ if (depth > 12) return acc;
82
+ let entries;
83
+ try {
84
+ entries = readdirSync(targetDir, { withFileTypes: true });
85
+ } catch {
86
+ return acc;
87
+ }
88
+ for (const e of entries) {
89
+ if (e.name.startsWith(".") && e.isDirectory()) continue;
90
+ const full = join(targetDir, e.name);
91
+ if (e.isDirectory()) {
92
+ if (SKIP_DIRS.has(e.name)) continue;
93
+ if (e.name === "webmcp") acc.webmcpDirs.push(full);
94
+ scanProject(full, depth + 1, acc);
95
+ } else if (e.isFile() && /\.(ts|tsx|js|jsx|mjs|cjs)$/.test(e.name)) {
96
+ acc.sources.push(full);
97
+ }
98
+ }
99
+ return acc;
100
+ }
101
+
102
+ // Resolve the tools file WITHOUT specifying a path: discover it from the
103
+ // project. Order: explicit override → the `webmcp` module folder (wherever it
104
+ // sits, even if tools.ts was deleted) → the file that actually wires up the
105
+ // toolkit → a first-time default.
106
+ function detectToolsFile(targetDir, override) {
107
+ if (override) return override;
108
+ const root = resolve(targetDir);
109
+
110
+ const { webmcpDirs, sources } = scanProject(root);
111
+
112
+ // 1. A directory named `webmcp` anywhere in the project. Prefer the
113
+ // shallowest. Targets <dir>/tools.ts — works even if the file was
114
+ // deleted (the folder still has index.ts / resources.ts).
115
+ if (webmcpDirs.length) {
116
+ webmcpDirs.sort((a, b) => a.split(sep).length - b.split(sep).length);
117
+ return relative(root, join(webmcpDirs[0], "tools.ts"));
118
+ }
119
+
120
+ // 2. No conventional folder — find where the toolkit is actually registered
121
+ // (imports `@napster-corp/webmcp-toolkit` AND calls registerTool /
122
+ // registerStatefulTool) and use that file's directory.
123
+ for (const file of sources) {
124
+ let text;
125
+ try {
126
+ text = readFileSync(file, "utf8");
127
+ } catch {
128
+ continue;
129
+ }
130
+ if (
131
+ text.includes("@napster-corp/webmcp-toolkit") &&
132
+ /register(Stateful)?Tool\s*\(/.test(text)
133
+ ) {
134
+ return relative(root, join(dirname(file), "tools.ts"));
135
+ }
136
+ }
137
+
138
+ // 3. Nothing set up yet — first-time location (the agent creates it here).
139
+ return DEFAULT_TOOLS_FILE;
140
+ }
141
+
142
+ // Strip a leading YAML frontmatter block so the task skill's instruction body
143
+ // leads the prompt (the `---name---` header is noise to the runner agent).
144
+ function stripFrontmatter(md) {
145
+ return md.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n/, "").trimStart();
146
+ }
147
+
148
+ // Assemble the runner prompt: the `sync-webmcp-tools` task skill, followed by the
149
+ // methodology skills inlined so the run is self-contained on any engine (the
150
+ // runner agent has no skill auto-discovery). The prompt is UNIVERSAL — it lives
151
+ // entirely in the skills folder and names no app-specific path; where the tools
152
+ // file lives is the methodology skills' job. (The CLI still detects a path
153
+ // separately, but only to diff the result for its summary — see detectToolsFile
154
+ // / summarizeChange.)
155
+ async function buildPrompt() {
156
+ const taskMd = await readFile(join(PACKAGE_ROOT, TASK_SKILL), "utf8");
157
+ const task = stripFrontmatter(taskMd);
158
+ const methodology = (
159
+ await Promise.all(
160
+ METHODOLOGY_SKILLS.map(async (rel) => {
161
+ const text = await readFile(join(PACKAGE_ROOT, rel), "utf8").catch(() => null);
162
+ return text ? `<skill path="${rel}">\n${text}\n</skill>` : null;
163
+ }),
164
+ )
165
+ )
166
+ .filter(Boolean)
167
+ .join("\n\n");
168
+ return `${task}\n\n## Methodology skills (inlined — your source of truth)\n\n${methodology}`;
169
+ }
170
+
171
+ async function loadRunner(engine) {
172
+ const load = ENGINES[engine];
173
+ if (!load) {
174
+ throw new Error(
175
+ `Unknown engine "${engine}". Use one of: ${Object.keys(ENGINES).join(", ")} ` +
176
+ "(set WEBMCP_TOOLKIT_ENGINE or pass --engine).",
177
+ );
178
+ }
179
+ return load();
180
+ }
181
+
182
+ // The tool names registered in a file (`name: '…'` inside registerTool /
183
+ // registerStatefulTool).
184
+ function toolNames(src) {
185
+ if (!src) return [];
186
+ return [...src.matchAll(/name:\s*['"]([^'"]+)['"]/g)].map((m) => m[1]);
187
+ }
188
+
189
+ // Deterministic, engine-agnostic summary of what the run actually did to the
190
+ // file — printed regardless of what the agent narrated (so Anthropic and Copilot
191
+ // give the same feedback, and a no-op is reported as such).
192
+ function summarizeChange(toolsPath, before, after) {
193
+ const rule = "──────────────────────────────────────────────";
194
+ const out = [`\n[webmcp-toolkit] result ${rule}`];
195
+
196
+ if (before === after) {
197
+ out.push(
198
+ after === null
199
+ ? `[webmcp-toolkit] ${toolsPath}: not written (the engine produced no file).`
200
+ : `[webmcp-toolkit] ${toolsPath}: no changes — already in sync.`,
201
+ );
202
+ return out.join("\n");
203
+ }
204
+
205
+ const beforeNames = new Set(toolNames(before));
206
+ const afterNames = toolNames(after);
207
+ const afterSet = new Set(afterNames);
208
+ const added = afterNames.filter((n) => !beforeNames.has(n));
209
+ const removed = [...beforeNames].filter((n) => !afterSet.has(n));
210
+
211
+ if (before === null) {
212
+ out.push(
213
+ `[webmcp-toolkit] ${toolsPath}: created — ${afterNames.length} tools` +
214
+ (afterNames.length ? ` (${afterNames.join(", ")})` : ""),
215
+ );
216
+ return out.join("\n");
217
+ }
218
+ if (after === null) {
219
+ out.push(`[webmcp-toolkit] ${toolsPath}: removed (no tools file written).`);
220
+ return out.join("\n");
221
+ }
222
+
223
+ const beforeLines = before.split("\n").length;
224
+ const afterLines = after.split("\n").length;
225
+ out.push(`[webmcp-toolkit] ${toolsPath}: ${beforeLines} → ${afterLines} lines`);
226
+ if (added.length) out.push(`[webmcp-toolkit] + added (${added.length}): ${added.join(", ")}`);
227
+ if (removed.length) out.push(`[webmcp-toolkit] - removed (${removed.length}): ${removed.join(", ")}`);
228
+ if (!added.length && !removed.length) {
229
+ out.push(`[webmcp-toolkit] ~ updated in place (same names; descriptions/args/execute changed)`);
230
+ }
231
+ out.push(`[webmcp-toolkit] total: ${afterNames.length} tools`);
232
+ return out.join("\n");
233
+ }
234
+
235
+ /**
236
+ * Run the generation. Returns the runner's final summary text.
237
+ * @param {{ targetDir?: string, file?: string, engine?: string }} [opts]
238
+ */
239
+ export async function generateCapabilities(opts = {}) {
240
+ const targetDir = resolve(opts.targetDir ?? process.cwd());
241
+ loadEnvVars(targetDir);
242
+ const toolsPath = detectToolsFile(targetDir, opts.file);
243
+ const engine = (opts.engine ?? process.env.WEBMCP_TOOLKIT_ENGINE ?? DEFAULT_ENGINE).toLowerCase();
244
+
245
+ process.stderr.write(
246
+ `[webmcp-toolkit] engine: ${engine}\n` +
247
+ `[webmcp-toolkit] target: ${targetDir}\n` +
248
+ `[webmcp-toolkit] file: ${toolsPath}\n`,
249
+ );
250
+
251
+ // Universal prompt assembled from the task skill + methodology skills (see
252
+ // buildPrompt). `toolsPath` is used only below to diff the result for the
253
+ // deterministic summary, never injected into the prompt.
254
+ const prompt = await buildPrompt();
255
+
256
+ const absPath = join(targetDir, toolsPath);
257
+ const before = existsSync(absPath) ? readFileSync(absPath, "utf8") : null;
258
+
259
+ const runner = await loadRunner(engine);
260
+ const result = await runner.runAgent({ prompt, cwd: targetDir, capabilitiesPath: toolsPath });
261
+
262
+ // Report what actually changed on disk — same feedback for every engine.
263
+ const after = existsSync(absPath) ? readFileSync(absPath, "utf8") : null;
264
+ process.stdout.write(summarizeChange(toolsPath, before, after) + "\n");
265
+ return result;
266
+ }
@@ -0,0 +1,81 @@
1
+ // install-hook.mjs — installs the WebMCP Toolkit post-commit hook into the host repo.
2
+ //
3
+ // The hook is opt-in per commit: it only regenerates capabilities when the
4
+ // commit message contains the `[webmcp-toolkit]` marker (see hooks/post-commit). This
5
+ // keeps every other commit fast and free.
6
+ //
7
+ // Idempotent: re-running replaces the managed block, and it composes with an
8
+ // existing post-commit hook by appending a clearly-delimited section rather
9
+ // than clobbering it.
10
+
11
+ import { execFileSync } from "node:child_process";
12
+ import { readFile, writeFile, chmod, mkdir } from "node:fs/promises";
13
+ import { existsSync } from "node:fs";
14
+ import { fileURLToPath } from "node:url";
15
+ import { dirname, join, resolve } from "node:path";
16
+
17
+ const __dirname = dirname(fileURLToPath(import.meta.url));
18
+ const PACKAGE_ROOT = resolve(__dirname, "..");
19
+
20
+ const BEGIN = "# >>> webmcp-toolkit post-commit >>>";
21
+ const END = "# <<< webmcp-toolkit post-commit <<<";
22
+
23
+ function gitHooksDir(cwd) {
24
+ // Respect core.hooksPath if the repo overrides it; else .git/hooks.
25
+ try {
26
+ const custom = execFileSync("git", ["config", "--get", "core.hooksPath"], {
27
+ cwd,
28
+ encoding: "utf8",
29
+ }).trim();
30
+ if (custom) return resolve(cwd, custom);
31
+ } catch {
32
+ /* not set */
33
+ }
34
+ const gitDir = execFileSync("git", ["rev-parse", "--git-path", "hooks"], {
35
+ cwd,
36
+ encoding: "utf8",
37
+ }).trim();
38
+ return resolve(cwd, gitDir);
39
+ }
40
+
41
+ export async function installHook(opts = {}) {
42
+ const cwd = resolve(opts.targetDir ?? process.cwd());
43
+
44
+ // Confirm we're in a git repo.
45
+ try {
46
+ execFileSync("git", ["rev-parse", "--is-inside-work-tree"], { cwd, stdio: "ignore" });
47
+ } catch {
48
+ throw new Error(`Not a git repository: ${cwd}`);
49
+ }
50
+
51
+ const hooksDir = gitHooksDir(cwd);
52
+ await mkdir(hooksDir, { recursive: true });
53
+ const hookPath = join(hooksDir, "post-commit");
54
+
55
+ const managed = await readFile(join(PACKAGE_ROOT, "hooks/post-commit"), "utf8");
56
+ const block = `${BEGIN}\n${managed.trim()}\n${END}\n`;
57
+
58
+ let existing = existsSync(hookPath) ? await readFile(hookPath, "utf8") : "";
59
+
60
+ if (existing.includes(BEGIN)) {
61
+ // Replace the managed block in place.
62
+ existing = existing.replace(
63
+ new RegExp(`${escapeRe(BEGIN)}[\\s\\S]*?${escapeRe(END)}\\n?`),
64
+ block,
65
+ );
66
+ } else if (existing.trim()) {
67
+ // Append to an existing hook, keeping its shebang at the top.
68
+ existing = `${existing.replace(/\n*$/, "")}\n\n${block}`;
69
+ } else {
70
+ existing = `#!/bin/sh\n\n${block}`;
71
+ }
72
+
73
+ await writeFile(hookPath, existing, "utf8");
74
+ await chmod(hookPath, 0o755);
75
+
76
+ return hookPath;
77
+ }
78
+
79
+ function escapeRe(s) {
80
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
81
+ }
@@ -0,0 +1,75 @@
1
+ // runners/anthropic.mjs — drives the Claude Agent SDK (@anthropic-ai/claude-agent-sdk).
2
+ //
3
+ // Auth: ANTHROPIC_API_KEY (local dev, or a CI secret).
4
+ // Dependency: @anthropic-ai/claude-agent-sdk (optional peer of @napster-corp/webmcp-toolkit).
5
+ //
6
+ // ⚠️ The query() options below are from the @anthropic-ai/claude-agent-sdk
7
+ // README — verify field names against your installed version if you upgrade.
8
+
9
+ // Always use the latest, most capable model for code analysis.
10
+ const MODEL = "claude-opus-4-8";
11
+
12
+ export async function runAgent({ prompt, cwd }) {
13
+ if (!process.env.ANTHROPIC_API_KEY) {
14
+ throw new Error(
15
+ "ANTHROPIC_API_KEY is not set. Put it in the app's .env.local, export it, " +
16
+ "or provide it as a CI secret.",
17
+ );
18
+ }
19
+
20
+ let query;
21
+ try {
22
+ ({ query } = await import("@anthropic-ai/claude-agent-sdk"));
23
+ } catch {
24
+ throw new Error(
25
+ "Missing dependency `@anthropic-ai/claude-agent-sdk`.\n" +
26
+ "Install it where you run generation:\n" +
27
+ " npm install --save-dev @anthropic-ai/claude-agent-sdk",
28
+ );
29
+ }
30
+
31
+ let finalText = "";
32
+ for await (const message of query({
33
+ prompt,
34
+ options: {
35
+ model: MODEL,
36
+ // Whitelist the read/search/edit tools the task needs…
37
+ allowedTools: ["Read", "Grep", "Glob", "Edit", "Write"],
38
+ // …and HARD-block shell/network/subagents. (Required: bypassPermissions
39
+ // auto-allows anything not explicitly disallowed, so allowedTools alone
40
+ // is not a hard boundary — disallowedTools is.)
41
+ disallowedTools: ["Bash", "WebSearch", "WebFetch", "Task", "KillShell"],
42
+ // Autonomous: don't prompt for permission (no TTY in a hook/CI).
43
+ permissionMode: "bypassPermissions",
44
+ // Analyze the host app; honor its CLAUDE.md / .claude conventions.
45
+ cwd,
46
+ settingSources: ["project"],
47
+ // Bound the run so a hook can't hang indefinitely.
48
+ maxTurns: 40,
49
+ },
50
+ })) {
51
+ if (message.type === "assistant") {
52
+ for (const block of message.message?.content ?? []) {
53
+ if (block.type === "text" && block.text) {
54
+ process.stdout.write(block.text);
55
+ finalText = block.text;
56
+ } else if (block.type === "tool_use") {
57
+ process.stderr.write(
58
+ `\n[webmcp-toolkit] ${block.name} ${describeTool(block.input)}\n`,
59
+ );
60
+ }
61
+ }
62
+ } else if (message.type === "result") {
63
+ if (message.subtype && message.subtype !== "success") {
64
+ throw new Error(`Agent run ended: ${message.subtype}`);
65
+ }
66
+ }
67
+ }
68
+ process.stdout.write("\n");
69
+ return finalText;
70
+ }
71
+
72
+ function describeTool(input) {
73
+ if (!input || typeof input !== "object") return "";
74
+ return input.file_path ?? input.path ?? input.pattern ?? "";
75
+ }
@@ -0,0 +1,63 @@
1
+ // runners/copilot.mjs — drives the GitHub Copilot CLI (agentic mode) instead of
2
+ // the Claude Agent SDK. Same contract: run the prompt in `cwd`, edit only the
3
+ // capabilities file, leave it uncommitted.
4
+ //
5
+ // Auth: your GitHub Copilot subscription (`copilot` to sign in). NO Anthropic key.
6
+ // Install: npm i -g @github/copilot (the agentic CLI, not the `gh copilot` ext).
7
+ //
8
+ // Invocation (verified against `copilot --help`):
9
+ // copilot <flags> -p "<prompt>"
10
+ // `--allow-all-tools` is REQUIRED for non-interactive runs, so it's the default.
11
+ // The prompt is passed as the value of the prompt flag and kept ADJACENT to it,
12
+ // so other flags are never swallowed as the prompt.
13
+ //
14
+ // Env overrides:
15
+ // WEBMCP_TOOLKIT_COPILOT_BIN path to the CLI (default: "copilot")
16
+ // WEBMCP_TOOLKIT_COPILOT_ARGS flags placed BEFORE -p (default: "--allow-all-tools")
17
+ // e.g. to scope tools:
18
+ // "--allow-all-tools --available-tools str_replace,view"
19
+ // WEBMCP_TOOLKIT_COPILOT_PROMPT_FLAG the prompt flag (default: "-p")
20
+
21
+ import { spawn } from "node:child_process";
22
+
23
+ export async function runAgent({ prompt, cwd }) {
24
+ const bin = process.env.WEBMCP_TOOLKIT_COPILOT_BIN || "copilot";
25
+ const promptFlag = process.env.WEBMCP_TOOLKIT_COPILOT_PROMPT_FLAG || "-p";
26
+ const extra = (process.env.WEBMCP_TOOLKIT_COPILOT_ARGS ?? "--allow-all-tools")
27
+ .split(" ")
28
+ .filter(Boolean);
29
+
30
+ // Prompt is ONE argv entry (no shell), as the value immediately after the
31
+ // prompt flag: `copilot <extra…> -p "<prompt>"`.
32
+ const args = [...extra, promptFlag, prompt];
33
+
34
+ return new Promise((resolvePromise, reject) => {
35
+ const child = spawn(bin, args, {
36
+ cwd,
37
+ stdio: ["ignore", "inherit", "inherit"], // stream Copilot's output through
38
+ env: process.env,
39
+ });
40
+
41
+ child.on("error", (err) => {
42
+ if (err.code === "ENOENT") {
43
+ reject(
44
+ new Error(
45
+ `GitHub Copilot CLI ("${bin}") not found on PATH.\n` +
46
+ "Install it and sign in (a Copilot subscription is required):\n" +
47
+ " npm i -g @github/copilot # then run `copilot` once to sign in\n" +
48
+ "Or point WEBMCP_TOOLKIT_COPILOT_BIN at the binary.",
49
+ ),
50
+ );
51
+ } else {
52
+ reject(err);
53
+ }
54
+ });
55
+
56
+ // Note: the Copilot CLI can exit 0 even on policy/auth errors — its output
57
+ // (streamed above) is the source of truth. We surface non-zero codes only.
58
+ child.on("close", (code) => {
59
+ if (code === 0) resolvePromise("");
60
+ else reject(new Error(`Copilot CLI exited with code ${code}.`));
61
+ });
62
+ });
63
+ }