copillm 0.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 (38) hide show
  1. package/README.md +52 -0
  2. package/dist/agentconfig/apply.js +53 -0
  3. package/dist/agentconfig/load.js +163 -0
  4. package/dist/agentconfig/markerBlock.js +76 -0
  5. package/dist/agentconfig/render.js +317 -0
  6. package/dist/agentconfig/schema.js +65 -0
  7. package/dist/auth/copilotToken.js +122 -0
  8. package/dist/auth/credentials.js +221 -0
  9. package/dist/auth/deviceFlow.js +89 -0
  10. package/dist/auth/ensureAuthenticated.js +55 -0
  11. package/dist/auth/githubIdentity.js +42 -0
  12. package/dist/auth/interactivePrompt.js +135 -0
  13. package/dist/claude/cache.js +20 -0
  14. package/dist/claude/settingsConflict.js +85 -0
  15. package/dist/cli/agentEnv.js +56 -0
  16. package/dist/cli/configCommands.js +149 -0
  17. package/dist/cli/envBlock.js +43 -0
  18. package/dist/cli/launchAgent.js +59 -0
  19. package/dist/cli/resolveAgent.js +361 -0
  20. package/dist/cli.js +1178 -0
  21. package/dist/codex/init.js +93 -0
  22. package/dist/config/config.js +51 -0
  23. package/dist/config/fsSecurity.js +39 -0
  24. package/dist/config/home.js +62 -0
  25. package/dist/config/logging.js +33 -0
  26. package/dist/config/upstream.js +38 -0
  27. package/dist/models/anthropicDefaults.js +138 -0
  28. package/dist/models/discovery.js +208 -0
  29. package/dist/pi/init.js +174 -0
  30. package/dist/server/anthropicModelsResponse.js +151 -0
  31. package/dist/server/codexSchema.js +100 -0
  32. package/dist/server/debugInfo.js +48 -0
  33. package/dist/server/lock.js +150 -0
  34. package/dist/server/proxy.js +715 -0
  35. package/dist/translation/openaiAnthropic.js +391 -0
  36. package/dist/translation/streamingOpenAIToAnthropic.js +290 -0
  37. package/dist/types/index.js +1 -0
  38. package/package.json +50 -0
package/README.md ADDED
@@ -0,0 +1,52 @@
1
+ # copillm
2
+
3
+ > **Unofficial proxy to make your Copilot CLI seat power everything.**
4
+
5
+ [![PR gate](https://github.com/jcjc-dev/copillm/actions/workflows/pr-gate.yml/badge.svg?branch=main)](https://github.com/jcjc-dev/copillm/actions/workflows/pr-gate.yml)
6
+ [![Release gate (nightly + on release)](https://github.com/jcjc-dev/copillm/actions/workflows/release-gate.yml/badge.svg?branch=main&event=schedule)](https://github.com/jcjc-dev/copillm/actions/workflows/release-gate.yml)
7
+
8
+ A local proxy that exposes OpenAI- and Anthropic-compatible HTTP endpoints backed by your GitHub Copilot subscription. One login — point Codex CLI, Claude Code, or any compatible tool at it and go.
9
+
10
+ > ⚠️ **Experimental / research tool.** Independent, unofficial client of GitHub Copilot's private API. Not affiliated with GitHub, Microsoft, OpenAI, or Anthropic. The upstream API can change without notice. Use at your own risk.
11
+
12
+ ---
13
+
14
+ ## Quick start
15
+
16
+ ```bash
17
+ # 1. one-time login (GitHub device flow)
18
+ npx copillm login
19
+
20
+ # 2. launch your agent — copillm auto-starts the daemon, installs the agent if needed,
21
+ # and wires up all env vars for you
22
+ npx copillm claude # Claude Code, preconfigured
23
+ npx copillm codex # Codex CLI, preconfigured
24
+ ```
25
+
26
+ Requires Node.js ≥ 20. That's it — no global install, no config files, no API keys to juggle.
27
+
28
+ Pass extra args through with `--`:
29
+
30
+ ```bash
31
+ copillm claude -- --model opus
32
+ copillm codex -- --help
33
+ ```
34
+
35
+ ---
36
+
37
+ ## Documentation
38
+
39
+ Full docs live at **[jcjc-dev.github.io/copillm](https://jcjc-dev.github.io/copillm/)**:
40
+
41
+ - **[Getting started](https://jcjc-dev.github.io/copillm/getting-started/)** — install, login, first run
42
+ - **[CLI reference](https://jcjc-dev.github.io/copillm/cli-reference/)** — every command and flag
43
+ - **[Using with Claude Code](https://jcjc-dev.github.io/copillm/claude-code/)** — env wiring, gateway discovery, the `[1m]` 1M-context alias
44
+ - **[Using with Codex CLI](https://jcjc-dev.github.io/copillm/codex/)** — env wiring, `config.toml` generation
45
+ - **[HTTP API reference](https://jcjc-dev.github.io/copillm/http-api/)** — endpoints, translation caveats
46
+ - **[Building from source & CI](https://jcjc-dev.github.io/copillm/development/)** — for contributors
47
+
48
+ ---
49
+
50
+ ## Contributing
51
+
52
+ Issues and PRs welcome — see the [development guide](https://jcjc-dev.github.io/copillm/development/).
@@ -0,0 +1,53 @@
1
+ import { writeFileSecureAtomic } from "../config/fsSecurity.js";
2
+ import { loadAgentConfig } from "./load.js";
3
+ import { planRender } from "./render.js";
4
+ import { backupIfMismatch } from "./markerBlock.js";
5
+ /**
6
+ * Load the resolved profile and compute every FileWrite for the target agent
7
+ * BEFORE touching disk. Any validation error throws before a single byte is
8
+ * written, so a botched config never leaves the filesystem half-updated.
9
+ *
10
+ * Returns `{ active: null }` and zero writes when no agent.toml exists
11
+ * anywhere — callers should treat this as a clean no-op.
12
+ */
13
+ export function applyAgentConfig(opts) {
14
+ if (opts.skip) {
15
+ return { active: null, writes: [], envOverlay: {}, cliArgs: [], notes: [], sources: [] };
16
+ }
17
+ const load = loadAgentConfig({
18
+ cwd: opts.cwd,
19
+ profileOverride: opts.profileOverride ?? null
20
+ });
21
+ if (!load) {
22
+ return { active: null, writes: [], envOverlay: {}, cliArgs: [], notes: [], sources: [] };
23
+ }
24
+ const rendered = planRender(opts, load);
25
+ // Phase 2: write. By this point all validation passed and the renderer
26
+ // produced complete file bodies — failures here are IO errors, not user
27
+ // input problems.
28
+ for (const write of rendered.writes) {
29
+ backupIfMismatch(write.path, write.content);
30
+ writeFileSecureAtomic(write.path, write.content, write.mode);
31
+ }
32
+ return {
33
+ active: load.active,
34
+ writes: rendered.writes,
35
+ envOverlay: rendered.envOverlay,
36
+ cliArgs: rendered.cliArgs,
37
+ notes: rendered.notes,
38
+ sources: load.sources
39
+ };
40
+ }
41
+ export function formatApplyNotes(result, agent) {
42
+ if (result.active === null)
43
+ return [];
44
+ const lines = [];
45
+ lines.push(`copillm config: applied profile "${result.active}" to ${agent}`);
46
+ for (const write of result.writes) {
47
+ lines.push(` → wrote ${write.path} (${write.description})`);
48
+ }
49
+ for (const note of result.notes) {
50
+ lines.push(` • ${note}`);
51
+ }
52
+ return lines;
53
+ }
@@ -0,0 +1,163 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { parse as parseToml, TomlError } from "smol-toml";
4
+ import { ZodError } from "zod";
5
+ import { getCopillmHome } from "../config/home.js";
6
+ import { AgentTomlSchema, UNSET_SENTINEL } from "./schema.js";
7
+ export class AgentConfigError extends Error {
8
+ constructor(message) {
9
+ super(message);
10
+ this.name = "AgentConfigError";
11
+ }
12
+ }
13
+ /**
14
+ * Returns null when no agent.toml exists anywhere. Callers should treat this
15
+ * as a clean no-op and skip fan-out entirely.
16
+ */
17
+ export function loadAgentConfig(options) {
18
+ const globalPath = path.join(getCopillmHome(), "agent.toml");
19
+ const projectPath = path.join(options.cwd, ".copillm", "agent.toml");
20
+ const globalDoc = readDocument(globalPath, "global");
21
+ const projectDoc = readDocument(projectPath, "project");
22
+ if (!globalDoc && !projectDoc) {
23
+ return null;
24
+ }
25
+ const active = options.profileOverride ??
26
+ projectDoc?.parsed.active_profile ??
27
+ globalDoc?.parsed.active_profile ??
28
+ "default";
29
+ const resolved = mergeAndResolve({ globalDoc, projectDoc, profileName: active });
30
+ const sources = [];
31
+ if (globalDoc)
32
+ sources.push({ path: globalDoc.filePath, scope: "global" });
33
+ if (projectDoc)
34
+ sources.push({ path: projectDoc.filePath, scope: "project" });
35
+ return { active, resolved, sources };
36
+ }
37
+ function readDocument(filePath, scope) {
38
+ if (!fs.existsSync(filePath)) {
39
+ return null;
40
+ }
41
+ const raw = fs.readFileSync(filePath, "utf8");
42
+ let parsed;
43
+ try {
44
+ parsed = parseToml(raw);
45
+ }
46
+ catch (error) {
47
+ if (error instanceof TomlError) {
48
+ const where = `line ${error.line} col ${error.column}`;
49
+ throw new AgentConfigError(`Failed to parse ${filePath}: ${error.message} (at ${where}). ` +
50
+ `TOML duplicate keys and syntax errors are not auto-corrected — fix the file and re-run.`);
51
+ }
52
+ throw error;
53
+ }
54
+ let validated;
55
+ try {
56
+ validated = AgentTomlSchema.parse(parsed);
57
+ }
58
+ catch (error) {
59
+ if (error instanceof ZodError) {
60
+ const issues = error.issues
61
+ .map((issue) => ` • ${issue.path.join(".") || "<root>"}: ${issue.message}`)
62
+ .join("\n");
63
+ throw new AgentConfigError(`${filePath} does not match the expected schema:\n${issues}`);
64
+ }
65
+ throw error;
66
+ }
67
+ return { filePath, scope, parsed: validated };
68
+ }
69
+ function mergeAndResolve(input) {
70
+ const layers = [];
71
+ if (input.globalDoc?.parsed.defaults)
72
+ layers.push(input.globalDoc.parsed.defaults);
73
+ if (input.globalDoc?.parsed.profiles?.[input.profileName]) {
74
+ layers.push(input.globalDoc.parsed.profiles[input.profileName]);
75
+ }
76
+ if (input.projectDoc?.parsed.defaults)
77
+ layers.push(input.projectDoc.parsed.defaults);
78
+ if (input.projectDoc?.parsed.profiles?.[input.profileName]) {
79
+ layers.push(input.projectDoc.parsed.profiles[input.profileName]);
80
+ }
81
+ if (layers.length === 0) {
82
+ const where = [];
83
+ if (input.globalDoc)
84
+ where.push(input.globalDoc.filePath);
85
+ if (input.projectDoc)
86
+ where.push(input.projectDoc.filePath);
87
+ throw new AgentConfigError(`No profile "${input.profileName}" found in ${where.join(" or ")}. ` +
88
+ `Add [profiles.${input.profileName}] or set active_profile.`);
89
+ }
90
+ // Merge instructions: later layers overwrite (typically the project tail).
91
+ let instructionsBody = null;
92
+ for (const layer of layers) {
93
+ if (layer.instructions?.body !== undefined) {
94
+ instructionsBody = layer.instructions.body;
95
+ }
96
+ }
97
+ const instructions = instructionsBody !== null && instructionsBody.trim().length > 0
98
+ ? { body: instructionsBody }
99
+ : null;
100
+ // Merge mcp.servers map; later layers replace earlier same-named entries;
101
+ // `inherit = "@unset"` removes an inherited entry.
102
+ const servers = {};
103
+ for (const layer of layers) {
104
+ const layerServers = layer.mcp?.servers ?? {};
105
+ for (const [name, value] of Object.entries(layerServers)) {
106
+ if ("inherit" in value && value.inherit === UNSET_SENTINEL) {
107
+ delete servers[name];
108
+ continue;
109
+ }
110
+ servers[name] = expandEnv(value);
111
+ }
112
+ }
113
+ const reserved = {
114
+ skills: mergeRecord(layers, "skills"),
115
+ agents: mergeRecord(layers, "agents"),
116
+ hooks: mergeRecord(layers, "hooks"),
117
+ permissions: mergeRecord(layers, "permissions")
118
+ };
119
+ return { instructions, mcpServers: servers, reserved };
120
+ }
121
+ function mergeRecord(layers, key) {
122
+ const out = {};
123
+ for (const layer of layers) {
124
+ const sub = layer[key];
125
+ if (sub)
126
+ Object.assign(out, sub);
127
+ }
128
+ return out;
129
+ }
130
+ /** Expand `${VAR}` and `${VAR:-default}` in url/command/args/env/headers. */
131
+ function expandEnv(entry) {
132
+ const expand = (value) => expandString(value);
133
+ if (entry.transport === "stdio") {
134
+ return {
135
+ ...entry,
136
+ command: expand(entry.command),
137
+ args: entry.args?.map(expand),
138
+ env: entry.env ? expandRecord(entry.env) : undefined
139
+ };
140
+ }
141
+ return {
142
+ ...entry,
143
+ url: expand(entry.url),
144
+ headers: entry.headers ? expandRecord(entry.headers) : undefined
145
+ };
146
+ }
147
+ function expandRecord(rec) {
148
+ const out = {};
149
+ for (const [k, v] of Object.entries(rec))
150
+ out[k] = expandString(v);
151
+ return out;
152
+ }
153
+ const ENV_PATTERN = /\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-([^}]*))?\}/g;
154
+ export function expandString(value) {
155
+ return value.replace(ENV_PATTERN, (_match, name, fallback) => {
156
+ const fromEnv = process.env[name];
157
+ if (fromEnv !== undefined && fromEnv !== "")
158
+ return fromEnv;
159
+ if (fallback !== undefined)
160
+ return fallback;
161
+ throw new AgentConfigError(`Required env var "${name}" is not set and no default was provided in the agent.toml expansion.`);
162
+ });
163
+ }
@@ -0,0 +1,76 @@
1
+ import fs from "node:fs";
2
+ import { AgentConfigError } from "./load.js";
3
+ export const HTML_COMMENT = { commentStart: "<!--", commentEnd: " -->" };
4
+ export const HASH_COMMENT = { commentStart: "#", commentEnd: "" };
5
+ const MARKER_ID = "copillm:managed";
6
+ function begin(style, id) {
7
+ return `${style.commentStart} ${id} begin${style.commentEnd}`;
8
+ }
9
+ function end(style, id) {
10
+ return `${style.commentStart} ${id} end${style.commentEnd}`;
11
+ }
12
+ export function upsertManagedBlock(existing, body, style = HTML_COMMENT, id = MARKER_ID) {
13
+ const beginLine = begin(style, id);
14
+ const endLine = end(style, id);
15
+ const trimmedBody = body.replace(/^\s+|\s+$/g, "");
16
+ const block = trimmedBody.length > 0 ? `${beginLine}\n${trimmedBody}\n${endLine}` : "";
17
+ const beginIdx = existing.indexOf(beginLine);
18
+ const endIdx = existing.indexOf(endLine);
19
+ if (beginIdx !== -1 && endIdx !== -1 && endIdx > beginIdx) {
20
+ // Replace in place. Strip the existing block + its surrounding newlines so
21
+ // we don't accumulate blank lines on repeated runs.
22
+ const before = existing.slice(0, beginIdx).replace(/\s+$/, "");
23
+ const after = existing.slice(endIdx + endLine.length).replace(/^\s+/, "");
24
+ if (block.length === 0) {
25
+ return ensureTrailingNewline(joinWithBlank(before, after));
26
+ }
27
+ return ensureTrailingNewline(joinWithBlank(before, block, after));
28
+ }
29
+ if (beginIdx !== -1 || endIdx !== -1) {
30
+ throw new AgentConfigError(`Found a partial copillm-managed marker block; refusing to write. ` +
31
+ `Restore both "${beginLine}" and "${endLine}" or remove them entirely.`);
32
+ }
33
+ if (block.length === 0) {
34
+ return existing;
35
+ }
36
+ if (existing.length === 0) {
37
+ return `${block}\n`;
38
+ }
39
+ return ensureTrailingNewline(joinWithBlank(existing.replace(/\s+$/, ""), block));
40
+ }
41
+ function ensureTrailingNewline(s) {
42
+ return s.endsWith("\n") ? s : s + "\n";
43
+ }
44
+ function joinWithBlank(...parts) {
45
+ return parts.filter((p) => p.length > 0).join("\n\n");
46
+ }
47
+ /**
48
+ * If `target` exists and its current content (outside any managed block)
49
+ * differs from `lastWritten`, copy aside to a timestamped `.bak` so the
50
+ * user's edits aren't silently overwritten. Lifted from `src/pi/init.ts`.
51
+ *
52
+ * Best-effort: backup failures don't block the write — preferable to abort
53
+ * the user's launch over a backup error.
54
+ */
55
+ export function backupIfMismatch(target, newContent) {
56
+ let existing;
57
+ try {
58
+ existing = fs.readFileSync(target, "utf8");
59
+ }
60
+ catch (error) {
61
+ if (error.code === "ENOENT")
62
+ return null;
63
+ return null;
64
+ }
65
+ if (existing === newContent)
66
+ return null;
67
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-");
68
+ const backupPath = `${target}.copillm-backup-${stamp}.bak`;
69
+ try {
70
+ fs.copyFileSync(target, backupPath);
71
+ return backupPath;
72
+ }
73
+ catch {
74
+ return null;
75
+ }
76
+ }
@@ -0,0 +1,317 @@
1
+ import path from "node:path";
2
+ import fs from "node:fs";
3
+ import { stringify as stringifyToml } from "smol-toml";
4
+ import { AgentConfigError } from "./load.js";
5
+ import { HASH_COMMENT, HTML_COMMENT, upsertManagedBlock } from "./markerBlock.js";
6
+ // ─── Codex ────────────────────────────────────────────────────────────────
7
+ export function renderCodex(input) {
8
+ const writes = [];
9
+ const notes = [];
10
+ // 1. MCP block inside <CODEX_HOME>/config.toml. The file is generated by
11
+ // src/codex/init.ts and ends with a blank line; we append the marker
12
+ // section after it.
13
+ const codexConfigPath = path.join(input.codexHomeDir, "config.toml");
14
+ const mcpToml = renderCodexMcpToml(input.resolved.mcpServers);
15
+ if (fs.existsSync(codexConfigPath)) {
16
+ const existing = fs.readFileSync(codexConfigPath, "utf8");
17
+ const next = upsertManagedBlock(existing, mcpToml, HASH_COMMENT);
18
+ if (next !== existing) {
19
+ writes.push({
20
+ path: codexConfigPath,
21
+ content: next,
22
+ mode: 0o600,
23
+ description: "Codex config.toml MCP block"
24
+ });
25
+ }
26
+ }
27
+ else {
28
+ notes.push(`Codex config not found at ${codexConfigPath}; skipping MCP injection. ` +
29
+ `Run \`copillm start\` first.`);
30
+ }
31
+ // 2. AGENTS.md instruction block.
32
+ if (input.resolved.instructions) {
33
+ const agentsPath = path.join(input.codexHomeDir, "AGENTS.md");
34
+ const existing = fs.existsSync(agentsPath) ? fs.readFileSync(agentsPath, "utf8") : "";
35
+ const next = upsertManagedBlock(existing, input.resolved.instructions.body, HTML_COMMENT);
36
+ if (next !== existing) {
37
+ writes.push({
38
+ path: agentsPath,
39
+ content: next,
40
+ mode: 0o600,
41
+ description: "Codex AGENTS.md instructions block"
42
+ });
43
+ }
44
+ }
45
+ return { writes, envOverlay: {}, cliArgs: [], notes };
46
+ }
47
+ function renderCodexMcpToml(servers) {
48
+ if (Object.keys(servers).length === 0)
49
+ return "";
50
+ // Build a single TOML document `{ mcp_servers: { name: {...} } }` and feed
51
+ // smol-toml's stringify so nested maps (env, http_headers) emit valid TOML
52
+ // inline-table syntax instead of being half-stripped by ad-hoc post-processing.
53
+ const out = { mcp_servers: {} };
54
+ for (const [name, server] of Object.entries(servers)) {
55
+ if (!isValidTomlIdent(name)) {
56
+ throw new AgentConfigError(`MCP server name "${name}" is not a valid TOML identifier; ` +
57
+ `use only letters, digits, dashes, and underscores.`);
58
+ }
59
+ if (server.transport === "stdio") {
60
+ const entry = { command: server.command };
61
+ if (server.args)
62
+ entry.args = server.args;
63
+ if (server.env)
64
+ entry.env = server.env;
65
+ if (server.cwd)
66
+ entry.cwd = server.cwd;
67
+ out.mcp_servers[name] = entry;
68
+ }
69
+ else {
70
+ const entry = { url: server.url };
71
+ if (server.headers)
72
+ entry.http_headers = server.headers;
73
+ out.mcp_servers[name] = entry;
74
+ }
75
+ }
76
+ return stringifyToml(out).trimEnd();
77
+ }
78
+ const TOML_IDENT = /^[A-Za-z0-9_-]+$/;
79
+ function isValidTomlIdent(name) {
80
+ return TOML_IDENT.test(name);
81
+ }
82
+ export function renderClaude(input) {
83
+ const writes = [];
84
+ const notes = [];
85
+ // 1. .mcp.json (project scope) — preserve user entries.
86
+ const mcpJsonPath = path.join(input.cwd, ".mcp.json");
87
+ const claudeMcp = renderClaudeMcp(mcpJsonPath, input.resolved.mcpServers);
88
+ if (claudeMcp) {
89
+ writes.push({
90
+ path: mcpJsonPath,
91
+ content: claudeMcp,
92
+ mode: 0o600,
93
+ description: "Claude Code .mcp.json"
94
+ });
95
+ }
96
+ // 2. CLAUDE.md instruction block.
97
+ if (input.resolved.instructions) {
98
+ const claudeMdPath = path.join(input.cwd, "CLAUDE.md");
99
+ const existing = fs.existsSync(claudeMdPath) ? fs.readFileSync(claudeMdPath, "utf8") : "";
100
+ const next = upsertManagedBlock(existing, input.resolved.instructions.body, HTML_COMMENT);
101
+ if (next !== existing) {
102
+ writes.push({
103
+ path: claudeMdPath,
104
+ content: next,
105
+ mode: 0o600,
106
+ description: "CLAUDE.md instructions block"
107
+ });
108
+ }
109
+ }
110
+ return { writes, envOverlay: {}, cliArgs: [], notes };
111
+ }
112
+ function renderClaudeMcp(mcpJsonPath, servers) {
113
+ let existing = {};
114
+ if (fs.existsSync(mcpJsonPath)) {
115
+ try {
116
+ existing = JSON.parse(fs.readFileSync(mcpJsonPath, "utf8"));
117
+ if (typeof existing !== "object" || existing === null) {
118
+ throw new Error("not an object");
119
+ }
120
+ }
121
+ catch (error) {
122
+ throw new AgentConfigError(`Failed to parse ${mcpJsonPath}: ${error.message}. ` +
123
+ `Fix or remove the file and re-run.`);
124
+ }
125
+ }
126
+ const managed = new Set(existing._copillmManaged ?? []);
127
+ const userOwned = new Set(Object.keys(existing.mcpServers ?? {}).filter((n) => !managed.has(n)));
128
+ // Detect conflicts: a copillm-managed name collides with a user-owned name.
129
+ const newNames = Object.keys(servers);
130
+ for (const name of newNames) {
131
+ if (userOwned.has(name)) {
132
+ throw new AgentConfigError(`MCP server name "${name}" already exists in ${mcpJsonPath} and is owned by the user ` +
133
+ `(not previously managed by copillm). Rename one side, or move the user's entry ` +
134
+ `into agent.toml so copillm can manage it.`);
135
+ }
136
+ }
137
+ // Strip previously-managed entries from the user's file before adding ours.
138
+ const nextServers = {};
139
+ for (const [name, value] of Object.entries(existing.mcpServers ?? {})) {
140
+ if (!managed.has(name))
141
+ nextServers[name] = value;
142
+ }
143
+ for (const [name, server] of Object.entries(servers)) {
144
+ nextServers[name] = serverToClaudeShape(server);
145
+ }
146
+ const nextFile = { ...existing };
147
+ if (Object.keys(nextServers).length > 0) {
148
+ nextFile.mcpServers = nextServers;
149
+ }
150
+ else {
151
+ delete nextFile.mcpServers;
152
+ }
153
+ if (newNames.length > 0) {
154
+ nextFile._copillmManaged = newNames;
155
+ }
156
+ else {
157
+ delete nextFile._copillmManaged;
158
+ }
159
+ const serialized = `${JSON.stringify(nextFile, null, 2)}\n`;
160
+ // Avoid pointless writes when state is unchanged.
161
+ if (fs.existsSync(mcpJsonPath) && fs.readFileSync(mcpJsonPath, "utf8") === serialized) {
162
+ return null;
163
+ }
164
+ // If we'd produce an empty file (no servers, no other keys), skip writing.
165
+ if (Object.keys(nextFile).length === 0 && !fs.existsSync(mcpJsonPath)) {
166
+ return null;
167
+ }
168
+ return serialized;
169
+ }
170
+ function serverToClaudeShape(server) {
171
+ if (server.transport === "stdio") {
172
+ const out = {
173
+ type: "stdio",
174
+ command: server.command
175
+ };
176
+ if (server.args)
177
+ out.args = server.args;
178
+ if (server.env)
179
+ out.env = server.env;
180
+ if (server.cwd)
181
+ out.cwd = server.cwd;
182
+ return out;
183
+ }
184
+ const out = {
185
+ type: server.transport,
186
+ url: server.url
187
+ };
188
+ if (server.headers)
189
+ out.headers = server.headers;
190
+ return out;
191
+ }
192
+ // ─── pi ───────────────────────────────────────────────────────────────────
193
+ const PI_EXTENSION_DIRNAME = "copillm-mcp";
194
+ export function renderPi(input) {
195
+ const writes = [];
196
+ const notes = [];
197
+ const piHome = path.join(process.env.HOME ?? "", ".pi");
198
+ const extensionDir = path.join(piHome, "agent", "extensions", PI_EXTENSION_DIRNAME);
199
+ // 1. servers.json — the resolved server list the extension reads at startup.
200
+ const serversJson = renderPiServersJson(input.resolved.mcpServers);
201
+ writes.push({
202
+ path: path.join(extensionDir, "servers.json"),
203
+ content: serversJson,
204
+ mode: 0o600,
205
+ description: "pi MCP extension servers.json"
206
+ });
207
+ // 2. index.ts — the extension template (constant — see piExtensionTemplate.ts).
208
+ writes.push({
209
+ path: path.join(extensionDir, "index.ts"),
210
+ content: PI_EXTENSION_INDEX_TS,
211
+ mode: 0o600,
212
+ description: "pi MCP extension index.ts"
213
+ });
214
+ // 3. instructions prompt registered by the extension on session_start.
215
+ if (input.resolved.instructions) {
216
+ const promptPath = path.join(piHome, "agent", "prompts", "copillm-profile.md");
217
+ writes.push({
218
+ path: promptPath,
219
+ content: `${input.resolved.instructions.body.trim()}\n`,
220
+ mode: 0o600,
221
+ description: "pi profile prompt"
222
+ });
223
+ }
224
+ if (Object.keys(input.resolved.mcpServers).length === 0 && !input.resolved.instructions) {
225
+ notes.push("pi: no MCP servers or instructions in active profile; extension still written as a no-op.");
226
+ }
227
+ return { writes, envOverlay: {}, cliArgs: [], notes };
228
+ }
229
+ function renderPiServersJson(servers) {
230
+ const out = {};
231
+ for (const [name, server] of Object.entries(servers)) {
232
+ out[name] = serverToClaudeShape(server); // same wire shape works
233
+ }
234
+ return `${JSON.stringify({ servers: out }, null, 2)}\n`;
235
+ }
236
+ // Template for the pi extension. Kept inline (small) so a single commit ships
237
+ // both the renderer and the runtime side-by-side. The extension is
238
+ // deliberately conservative: it logs what it sees and registers a placeholder
239
+ // tool per server. Wiring real MCP stdio/http transport is left for a follow-up
240
+ // PR — this lands the plumbing without claiming working tool-calls.
241
+ const PI_EXTENSION_INDEX_TS = `// Generated by copillm. Do not edit by hand.
242
+ // Source of truth: ~/.copillm/agent.toml
243
+ //
244
+ // This extension is registered automatically by copillm whenever you run
245
+ // \`copillm pi\`. It loads the resolved MCP server list from the sibling
246
+ // servers.json and exposes each entry to pi. v1 only registers the servers
247
+ // and surfaces them via a slash command; real MCP transport wiring lands in
248
+ // a follow-up.
249
+
250
+ import fs from "node:fs";
251
+ import path from "node:path";
252
+
253
+ interface PiApi {
254
+ registerCommand: (name: string, handler: () => Promise<string> | string) => void;
255
+ on?: (event: string, handler: (...args: unknown[]) => void) => void;
256
+ }
257
+
258
+ export default function activate(pi: PiApi): void {
259
+ const serversPath = path.join(__dirname, "servers.json");
260
+ let servers: Record<string, unknown> = {};
261
+ try {
262
+ const raw = JSON.parse(fs.readFileSync(serversPath, "utf8")) as { servers?: Record<string, unknown> };
263
+ servers = raw.servers ?? {};
264
+ } catch {
265
+ servers = {};
266
+ }
267
+
268
+ pi.registerCommand("copillm-mcp", () => {
269
+ const names = Object.keys(servers);
270
+ if (names.length === 0) return "No MCP servers configured via copillm.";
271
+ return "copillm-managed MCP servers:\\n" + names.map((n) => " - " + n).join("\\n");
272
+ });
273
+
274
+ const promptPath = path.join(process.env.HOME ?? "", ".pi", "agent", "prompts", "copillm-profile.md");
275
+ if (fs.existsSync(promptPath) && typeof pi.on === "function") {
276
+ pi.on("session_start", () => {
277
+ try {
278
+ const body = fs.readFileSync(promptPath, "utf8");
279
+ // pi swallows return values from event handlers; logging the body
280
+ // suffices for the v1 plumbing — instruction injection lands in v2.
281
+ console.log("[copillm] loaded profile prompt (" + body.length + " bytes)");
282
+ } catch {
283
+ /* swallow */
284
+ }
285
+ });
286
+ }
287
+ }
288
+ `;
289
+ // ─── Copilot CLI (stub) ───────────────────────────────────────────────────
290
+ export function renderCopilot(_input) {
291
+ return {
292
+ writes: [],
293
+ envOverlay: {},
294
+ cliArgs: [],
295
+ notes: [
296
+ "Copilot CLI: native MCP config format is not yet documented publicly. " +
297
+ "Skipping fan-out. Track upstream and remove this stub when the path is known."
298
+ ]
299
+ };
300
+ }
301
+ export function planRender(opts, load) {
302
+ const baseInput = { resolved: load.resolved, cwd: opts.cwd };
303
+ switch (opts.agent) {
304
+ case "codex": {
305
+ if (!opts.codexHomeDir) {
306
+ throw new AgentConfigError("renderCodex requires codexHomeDir");
307
+ }
308
+ return renderCodex({ ...baseInput, codexHomeDir: opts.codexHomeDir });
309
+ }
310
+ case "claude":
311
+ return renderClaude(baseInput);
312
+ case "pi":
313
+ return renderPi(baseInput);
314
+ case "copilot":
315
+ return renderCopilot(baseInput);
316
+ }
317
+ }