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.
- package/README.md +52 -0
- package/dist/agentconfig/apply.js +53 -0
- package/dist/agentconfig/load.js +163 -0
- package/dist/agentconfig/markerBlock.js +76 -0
- package/dist/agentconfig/render.js +317 -0
- package/dist/agentconfig/schema.js +65 -0
- package/dist/auth/copilotToken.js +122 -0
- package/dist/auth/credentials.js +221 -0
- package/dist/auth/deviceFlow.js +89 -0
- package/dist/auth/ensureAuthenticated.js +55 -0
- package/dist/auth/githubIdentity.js +42 -0
- package/dist/auth/interactivePrompt.js +135 -0
- package/dist/claude/cache.js +20 -0
- package/dist/claude/settingsConflict.js +85 -0
- package/dist/cli/agentEnv.js +56 -0
- package/dist/cli/configCommands.js +149 -0
- package/dist/cli/envBlock.js +43 -0
- package/dist/cli/launchAgent.js +59 -0
- package/dist/cli/resolveAgent.js +361 -0
- package/dist/cli.js +1178 -0
- package/dist/codex/init.js +93 -0
- package/dist/config/config.js +51 -0
- package/dist/config/fsSecurity.js +39 -0
- package/dist/config/home.js +62 -0
- package/dist/config/logging.js +33 -0
- package/dist/config/upstream.js +38 -0
- package/dist/models/anthropicDefaults.js +138 -0
- package/dist/models/discovery.js +208 -0
- package/dist/pi/init.js +174 -0
- package/dist/server/anthropicModelsResponse.js +151 -0
- package/dist/server/codexSchema.js +100 -0
- package/dist/server/debugInfo.js +48 -0
- package/dist/server/lock.js +150 -0
- package/dist/server/proxy.js +715 -0
- package/dist/translation/openaiAnthropic.js +391 -0
- package/dist/translation/streamingOpenAIToAnthropic.js +290 -0
- package/dist/types/index.js +1 -0
- 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
|
+
[](https://github.com/jcjc-dev/copillm/actions/workflows/pr-gate.yml)
|
|
6
|
+
[](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
|
+
}
|