copillm 0.1.2 → 0.1.4

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.
@@ -3,7 +3,7 @@ import path from "node:path";
3
3
  import { parse as parseToml, TomlError } from "smol-toml";
4
4
  import { ZodError } from "zod";
5
5
  import { getCopillmHome } from "../config/home.js";
6
- import { AgentTomlSchema, UNSET_SENTINEL } from "./schema.js";
6
+ import { AgentTomlSchema } from "./schema.js";
7
7
  export class AgentConfigError extends Error {
8
8
  constructor(message) {
9
9
  super(message);
@@ -97,16 +97,13 @@ function mergeAndResolve(input) {
97
97
  const instructions = instructionsBody !== null && instructionsBody.trim().length > 0
98
98
  ? { body: instructionsBody }
99
99
  : null;
100
- // Merge mcp.servers map; later layers replace earlier same-named entries;
101
- // `inherit = "@unset"` removes an inherited entry.
100
+ // Merge mcp.servers map; later layers replace earlier same-named entries.
101
+ // Defaults are always-on: a profile may override a default by name but
102
+ // cannot remove it.
102
103
  const servers = {};
103
104
  for (const layer of layers) {
104
105
  const layerServers = layer.mcp?.servers ?? {};
105
106
  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
107
  servers[name] = expandEnv(value);
111
108
  }
112
109
  }
@@ -2,6 +2,7 @@ import path from "node:path";
2
2
  import fs from "node:fs";
3
3
  import { stringify as stringifyToml } from "smol-toml";
4
4
  import { AgentConfigError } from "./load.js";
5
+ import { getCopillmHome } from "../config/home.js";
5
6
  import { HASH_COMMENT, HTML_COMMENT, upsertManagedBlock } from "./markerBlock.js";
6
7
  // ─── Codex ────────────────────────────────────────────────────────────────
7
8
  export function renderCodex(input) {
@@ -79,93 +80,54 @@ const TOML_IDENT = /^[A-Za-z0-9_-]+$/;
79
80
  function isValidTomlIdent(name) {
80
81
  return TOML_IDENT.test(name);
81
82
  }
83
+ // ─── Claude Code ──────────────────────────────────────────────────────────
84
+ /**
85
+ * copillm writes Claude's MCP config to ~/.copillm/claude/mcp.json (a
86
+ * copillm-owned location) and injects `--mcp-config <path>` into the launch
87
+ * argv. This is purely additive: Claude continues to read its own project
88
+ * (./.mcp.json) and user (~/.claude.json) scopes, and copillm never touches
89
+ * cwd. Instructions fan-out is not supported for Claude — put project
90
+ * guidance in your own CLAUDE.md and global guidance in ~/.claude/CLAUDE.md.
91
+ */
82
92
  export function renderClaude(input) {
83
93
  const writes = [];
84
94
  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) {
95
+ const cliArgs = [];
96
+ const claudeDir = path.join(getCopillmHome(), "claude");
97
+ const mcpJsonPath = path.join(claudeDir, "mcp.json");
98
+ const serverCount = Object.keys(input.resolved.mcpServers).length;
99
+ if (serverCount > 0) {
100
+ const content = renderClaudeMcp(input.resolved.mcpServers);
101
+ const existing = fs.existsSync(mcpJsonPath) ? fs.readFileSync(mcpJsonPath, "utf8") : null;
102
+ if (existing !== content) {
102
103
  writes.push({
103
- path: claudeMdPath,
104
- content: next,
104
+ path: mcpJsonPath,
105
+ content,
105
106
  mode: 0o600,
106
- description: "CLAUDE.md instructions block"
107
+ description: "Claude Code mcp.json (copillm-managed)"
107
108
  });
108
109
  }
110
+ cliArgs.push("--mcp-config", mcpJsonPath);
109
111
  }
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
- }
112
+ else if (fs.existsSync(mcpJsonPath)) {
113
+ // Profile no longer declares any servers — clear the stale file so we
114
+ // don't keep referencing dead config on the next launch.
115
+ fs.rmSync(mcpJsonPath, { force: true });
116
+ notes.push(`Removed stale ${mcpJsonPath} (no MCP servers in active profile).`);
136
117
  }
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;
118
+ if (input.resolved.instructions) {
119
+ notes.push("Claude: instructions fan-out is unsupported (Claude has no out-of-tree " +
120
+ "instructions hook). Move guidance to ~/.claude/CLAUDE.md or your " +
121
+ "project's CLAUDE.md manually.");
142
122
  }
123
+ return { writes, envOverlay: {}, cliArgs, notes };
124
+ }
125
+ function renderClaudeMcp(servers) {
126
+ const out = {};
143
127
  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;
128
+ out[name] = serverToClaudeShape(server);
167
129
  }
168
- return serialized;
130
+ return `${JSON.stringify({ mcpServers: out }, null, 2)}\n`;
169
131
  }
170
132
  function serverToClaudeShape(server) {
171
133
  if (server.transport === "stdio") {
@@ -3,12 +3,15 @@ import { z } from "zod";
3
3
  * Schema for `~/.copillm/agent.toml` (global) and `<cwd>/.copillm/agent.toml`
4
4
  * (project overlay). See plans/unified-booping-mango.md for design rationale.
5
5
  *
6
- * Sections under `[defaults.*]` apply to every profile; profiles override by
7
- * deep-merge. v1 only wires `instructions` and `mcp` into fan-out the other
6
+ * Sections under `[defaults.*]` always apply, regardless of which profile is
7
+ * active. A profile may override a default by re-declaring an entry with the
8
+ * same key (e.g. `[profiles.work.mcp.servers.<name>]` replaces the same-named
9
+ * `[defaults.mcp.servers.<name>]`). There is no way to *remove* a default from
10
+ * a profile — defaults are intentionally always-on. v1 only wires
11
+ * `instructions` and `mcp` into fan-out — the other
8
12
  * sections (`skills`, `agents`, `hooks`, `permissions`) are reserved-but-
9
13
  * permissive so users can start populating them without future TOML breaking.
10
14
  */
11
- export const UNSET_SENTINEL = "@unset";
12
15
  const StringRecord = z.record(z.string());
13
16
  const McpStdioSchema = z
14
17
  .object({
@@ -28,12 +31,7 @@ const McpHttpSchema = z
28
31
  scope: z.enum(["project", "user"]).optional()
29
32
  })
30
33
  .strict();
31
- const McpInheritUnset = z
32
- .object({
33
- inherit: z.literal(UNSET_SENTINEL)
34
- })
35
- .strict();
36
- export const McpServerSchema = z.union([McpStdioSchema, McpHttpSchema, McpInheritUnset]);
34
+ export const McpServerSchema = z.union([McpStdioSchema, McpHttpSchema]);
37
35
  const InstructionsSchema = z
38
36
  .object({
39
37
  body: z.string()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "copillm",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "Local Copilot proxy CLI (OpenAI/Anthropic-compatible)",
5
5
  "license": "MIT",
6
6
  "type": "module",