klypix-mcp 1.32.0 → 1.33.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 CHANGED
@@ -10,13 +10,21 @@ You've seen the setup: point an AI at a folder of notes, watch the graph fill up
10
10
 
11
11
  ## Quick start
12
12
 
13
- **Claude Code (native — live hooks, zero effort):**
13
+ **Claude Code + Codex (native, machine-wide):**
14
14
 
15
15
  ```bash
16
16
  npx klypix-mcp install
17
17
  ```
18
18
 
19
- Every repo on your machine with a `brain.klypix` now auto-briefs each session with the project's state and auto-captures decisions as you work. Give a project a brain by dropping a `brain.klypix` in it — the [KLYPIX app](https://klypix.com) does it in one click (*Save canvas as project brain*), or `create_canvas` makes one from any agent.
19
+ Claude Code gets live hooks for auto-brief + auto-capture. Codex gets a native
20
+ `~/.codex/config.toml` MCP registration and conditional global guidance that activates
21
+ only when a project contains `./brain.klypix`. Existing MCP servers, Codex settings, and
22
+ personal instructions are preserved and backed up before KLYPIX-owned blocks change.
23
+ Restart Codex after installation so it loads the new server.
24
+
25
+ Give a project a brain by dropping a `brain.klypix` in it — the
26
+ [KLYPIX app](https://klypix.com) does it in one click (*Save canvas as project brain*),
27
+ or `create_canvas` makes one from any agent.
20
28
 
21
29
  **Every other agent tool (one command per project):**
22
30
 
@@ -24,7 +32,9 @@ Every repo on your machine with a `brain.klypix` now auto-briefs each session wi
24
32
  npx klypix-mcp link
25
33
  ```
26
34
 
27
- Extends the brain to Cursor & VS Code/Copilot (MCP config + rules) and to Cline, Windsurf, Gemini CLI, Aider, and any AGENTS.md-reading agent — always-on rules that teach the tool to read and capture the brain.
35
+ Adds project-native config for Codex, Cursor, and VS Code/Copilot, plus rules for
36
+ Cline, Windsurf, Gemini CLI, Aider, and any AGENTS.md-reading agent. Verify all
37
+ managed files without changing them with `npx klypix-mcp link --check`.
28
38
 
29
39
  ## What a project brain does
30
40
 
@@ -86,11 +96,11 @@ Then ask your agent things like *"what did we decide about auth?"*, *"challenge
86
96
 
87
97
  ### Tools vs. the *automatic* brain
88
98
 
89
- This package is the **agent-neutral read/write surface** — any MCP client gets the tools above on demand (*pull*), in any project. The **automatic** brain — auto-capturing decisions from your work, injecting the brief into each session, coordinating concurrent sessions (*push*) — runs in a host **hook**, which `npx klypix-mcp install` wires for Claude Code (and the [KLYPIX desktop app](https://klypix.com) ships built-in). Other tools get the always-on rules via `link`.
99
+ This package is the **agent-neutral read/write surface** — any MCP client gets the tools above on demand (*pull*), in any project. `install` wires Claude Code's live hooks and Codex's native MCP + conditional global guidance; `link` projects the repository-level MCP and instruction files used by Codex and the other coding agents. Full transcript-driven auto-capture remains a Claude Code hook capability; Codex and other hookless clients capture durable decisions through the MCP instructions and `brain_note`.
90
100
 
91
101
  ### Updates — the propagation contract
92
102
 
93
- `install` lays the whole brain (hooks + engine + local MCP server) into `~/.claude/project-brain`, and the emitted config runs the server **from that installed bundle** (no npx cache to go stale). Updates are then automatic: at session start the hook checks npm (≤ once/24h, fail-open, disable with `KLYPIX_AUTO_UPDATE=0`) and self-installs newer releases. One honest caveat: a running stdio server can't hot-swap — a new binary loads on the next full app launch (or `/mcp` reconnect); `brain_doctor`'s RUNNING line tells you when that's needed.
103
+ `install` lays the whole brain (hooks + engine + local MCP server) into `~/.claude/project-brain`, then points both Claude and Codex at that installed bundle so there is no npx cache to go stale. Updates are automatic through the Claude session-start hook: it checks npm (≤ once/24h, fail-open, disable with `KLYPIX_AUTO_UPDATE=0`) and self-installs newer releases, including healing the Codex registration. One honest caveat: a running stdio server can't hot-swap — a new binary loads on the next full app launch (or `/mcp` reconnect); `brain_doctor`'s RUNNING line tells you when that's needed.
94
104
 
95
105
  ## Also speaks A2A (Agent-to-Agent)
96
106
 
@@ -16,7 +16,11 @@ import fs from 'fs';
16
16
  import os from 'os';
17
17
  import path from 'path';
18
18
  import { fileURLToPath } from 'url';
19
- import { mcpServerEntry } from '../src/agent-rules.mjs';
19
+ import {
20
+ connectCodexMcpServer,
21
+ mcpServerEntry,
22
+ mergeCodexGlobalInstructions,
23
+ } from '../src/agent-rules.mjs';
20
24
 
21
25
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
22
26
  const PKG_ROOT = path.resolve(__dirname, '..');
@@ -29,6 +33,7 @@ const HOME = os.homedir();
29
33
  const CLAUDE_DIR = path.join(HOME, '.claude');
30
34
  const BRAIN_DIR = path.join(CLAUDE_DIR, 'project-brain');
31
35
  const SETTINGS = path.join(CLAUDE_DIR, 'settings.json');
36
+ const CODEX_CONFIG = path.join(HOME, '.codex', 'config.toml');
32
37
  const HOOK_MARK = 'global-brain-hook';
33
38
  const exists = (p) => { try { fs.statSync(p); return true; } catch { return false; } };
34
39
  const fwd = (p) => p.replace(/\\/g, '/');
@@ -40,6 +45,28 @@ function copyDir(src, dest) {
40
45
  }
41
46
  }
42
47
 
48
+ // Codex has two independent integration layers: MCP gives it the brain tools,
49
+ // while a conditional managed block in ~/.codex/AGENTS.md tells every session
50
+ // to use those tools when ./brain.klypix exists.
51
+ function wireCodex() {
52
+ const mcp = connectCodexMcpServer({
53
+ configPath: CODEX_CONFIG,
54
+ entry: mcpServerEntry({ vault: '.', home: HOME }),
55
+ });
56
+ const instructions = mergeCodexGlobalInstructions(HOME);
57
+ return { mcp, instructions, ok: mcp.ok && instructions.ok };
58
+ }
59
+
60
+ function reportCodex(result) {
61
+ if (result.ok) {
62
+ console.log(`✓ wired Codex: MCP tools (${result.mcp.action}) + conditional project-brain guidance (${result.instructions.action})`);
63
+ return;
64
+ }
65
+ if (!result.mcp.ok) console.error(`⚠ Codex MCP was not changed: ${result.mcp.error}`);
66
+ if (!result.instructions.ok) console.error(`⚠ Codex guidance was not changed: ${result.instructions.error}`);
67
+ console.error(' Claude Code installation is intact. Fix the Codex warning, then re-run this command.');
68
+ }
69
+
43
70
  // ── Never-downgrade gate ─────────────────────────────────────────────────────
44
71
  // The brain version is a UNIFIED namespace = the klypix-mcp version, stamped by BOTH
45
72
  // the npm install (here) and the desktop bundle, so the two channels compare cleanly.
@@ -48,8 +75,16 @@ function copyDir(src, dest) {
48
75
  const cmpSemver = (a, b) => { const pa = String(a || '').split('.').map(n => parseInt(n, 10) || 0), pb = String(b || '').split('.').map(n => parseInt(n, 10) || 0); for (let i = 0; i < 3; i++) { if ((pa[i] || 0) !== (pb[i] || 0)) return (pa[i] || 0) - (pb[i] || 0); } return 0; };
49
76
  const cur = (() => { try { return JSON.parse(fs.readFileSync(path.join(BRAIN_DIR, '.brain-version.json'), 'utf8')); } catch { return null; } })();
50
77
  if (!FORCE && cur) {
51
- if (cur.dev === true) { console.log(`• A dev deploy owns ${BRAIN_DIR} (dev:true) — leaving it untouched. Re-run with --force to override.`); process.exit(0); }
52
- if (cur.brainVersion && cmpSemver(cur.brainVersion, VERSION) > 0) { console.log(`• Installed brain v${cur.brainVersion} is newer than this package v${VERSION} — not downgrading. Re-run with --force to override.`); process.exit(0); }
78
+ if (cur.dev === true) {
79
+ console.log(`• A dev deploy owns ${BRAIN_DIR} (dev:true) — leaving it untouched. Re-run with --force to override.`);
80
+ reportCodex(wireCodex());
81
+ process.exit(0);
82
+ }
83
+ if (cur.brainVersion && cmpSemver(cur.brainVersion, VERSION) > 0) {
84
+ console.log(`• Installed brain v${cur.brainVersion} is newer than this package v${VERSION} — not downgrading. Re-run with --force to override.`);
85
+ reportCodex(wireCodex());
86
+ process.exit(0);
87
+ }
53
88
  }
54
89
 
55
90
  // ── Install lock (auto-propagation, part D — concurrency) ─────────────────────
@@ -218,6 +253,9 @@ try {
218
253
  // (heals an existing stale config so the next MCP server spawn runs current).
219
254
  const migrated = migrateProjectMcpConfig();
220
255
 
256
+ // Codex needs both native MCP tools and conditional global guidance.
257
+ const codex = wireCodex();
258
+
221
259
  // 8) READINESS check — re-read what we just wrote and confirm all 4 hooks actually
222
260
  // took (a malformed pre-existing group, a partial merge, or a later hand-edit can
223
261
  // leave the brain LIVE but not LEARNING — liveness ≠ readiness). Warn, don't fail.
@@ -226,12 +264,13 @@ try {
226
264
  const notWired = ['SessionStart', 'UserPromptSubmit', 'Stop', 'PostToolUse'].filter(e => !wiredFor(e));
227
265
 
228
266
  if (gotLock) releaseLock();
267
+ reportCodex(codex);
229
268
  console.log(`✓ installed klypix brain v${VERSION} → ${BRAIN_DIR} (${n} scripts, ${deps} dep packages)`);
230
269
  if (!notWired.length) console.log('✓ wired 4 hooks: SessionStart · UserPromptSubmit (--prompt) · Stop (--capture) · PostToolUse (--live) → settings.json');
231
270
  else console.error(`⚠ readiness: ${notWired.length} hook(s) did NOT take (${notWired.join(', ')}) — the brain will read but not capture/sync. Re-run \`npx klypix-mcp install --force\` or check ${SETTINGS}.`);
232
271
  console.log(`✓ MCP server runs from the local bundle (node ${fwd(path.join(BRAIN_DIR, 'klypix-mcp-server.mjs'))}) — no npx cache, works offline, always the installed version.`);
233
272
  if (migrated) console.log(`✓ migrated ${migrated.file} klypix-canvas server: ${migrated.from} → ${migrated.to} (backup: .mcp.json.klypix-bak). Reconnect (/mcp) or restart to pick it up.`);
234
- console.log(' Every project with a ./brain.klypix now auto-reads its brief + captures decisions.');
273
+ console.log(' Claude Code auto-briefs + captures through hooks; Codex reads + writes through native MCP and managed guidance.');
235
274
  console.log(' ⚠ Open sessions keep their OLD server until relaunched: fully quit & reopen the app (a session resume / new chat does NOT respawn the MCP server). `brain_doctor`\'s RUNNING line confirms when you\'re current.');
236
275
  console.log(' Verify anytime: `npx klypix-mcp doctor` (is the brain current + wired + in sync, who else is live).');
237
276
  } catch (e) {
@@ -1,8 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
  // klypix-link — make THIS project's brain automatic for EVERY agent tool.
3
- // `npx klypix-mcp install` gives Claude Code the brain via hooks; `link` extends the
4
- // same automatic read+capture to Cursor, Cline, Windsurf, Copilot/VS Code, Gemini CLI,
5
- // Aider, and any AGENTS.md-reading agent, by dropping each tool's native MCP config +
3
+ // `npx klypix-mcp install` gives Claude Code hooks and wires Codex globally; `link`
4
+ // extends the same automatic read+capture per project to Codex, Cursor, Cline,
5
+ // Windsurf, Copilot/VS Code, Gemini CLI, Aider, and any AGENTS.md-reading agent,
6
+ // by dropping each tool's native MCP config +
6
7
  // rules file in the current project. Idempotent — re-run anytime to refresh. The managed
7
8
  // block carries the brain version + a content hash, so:
8
9
  //
@@ -48,7 +49,7 @@ try {
48
49
 
49
50
  const changed = [...rules, ...mcp].filter(r => r.action && !['unchanged', 'skipped'].includes(r.action)).length;
50
51
  console.log(`\n✓ ${changed} file(s) written/updated — every agent opened in this project now reads + captures ./brain.klypix.`);
51
- console.log(' Cline & Windsurf MCP servers live in their global config; the rules file points them at the brain regardless.');
52
+ console.log(' Codex gets .codex/config.toml + AGENTS.md; Cline & Windsurf use their global MCP config plus project rules.');
52
53
  console.log(' Verify anytime with `npx klypix-mcp link --check` (or `npx klypix-mcp doctor`).');
53
54
  if (!hasBrain) {
54
55
  console.log('\n⚠ No ./brain.klypix here yet — the rules reference it for when you create one.');
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "klypix-mcp",
3
- "version": "1.32.0",
4
- "description": "Every project gets a brain — one open .klypix file your AI agents read, write, and argue from, over MCP. Works with Claude, Cursor, Cline, any model.",
3
+ "version": "1.33.0",
4
+ "description": "Every project gets a brain — one open .klypix file your AI agents read, write, and argue from, over MCP. Works with Claude, Codex, Cursor, Cline, any model.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
7
7
  "keywords": [
@@ -49,6 +49,304 @@ export function mcpServerEntry({ vault = '.', withType = false, home } = {}) {
49
49
  if (!base) base = { command: 'npx', args: ['-y', 'klypix-mcp', '--vault', vault] };
50
50
  return withType ? { type: 'stdio', ...base } : base;
51
51
  }
52
+
53
+ // Codex uses TOML rather than the JSON MCP shape used by the other clients.
54
+ // These helpers replace only KLYPIX-owned table sections, preserving comments,
55
+ // formatting, and every unrelated setting byte-for-byte. They intentionally do
56
+ // not serialize the whole document.
57
+ const CODEX_GLOBAL_START = '<!-- klypix-codex:start (managed by klypix-mcp) -->';
58
+ const CODEX_GLOBAL_END = '<!-- klypix-codex:end -->';
59
+ const CODEX_GLOBAL_RE = /<!-- klypix-codex:start \(managed by klypix-mcp\) -->[\s\S]*?<!-- klypix-codex:end -->\r?\n?/;
60
+ const CODEX_GLOBAL_BODY = [
61
+ CODEX_GLOBAL_START,
62
+ '## KLYPIX project brains',
63
+ '',
64
+ 'When the current project contains `./brain.klypix`, treat it as the authoritative shared project memory.',
65
+ 'At task start, read `.claude/brain-brief.md` when present; otherwise use the `klypix-canvas`',
66
+ 'MCP tools (`brain_ask`, `read_canvas` with canvas `"brain"`, or `brain_insights`).',
67
+ 'Capture durable decisions and milestones with `brain_note`. Never hand-edit `brain.klypix`.',
68
+ 'If the project has no `brain.klypix`, ignore this section.',
69
+ CODEX_GLOBAL_END,
70
+ ].join('\n');
71
+
72
+ const tomlKey = (value) => /^[A-Za-z0-9_-]+$/.test(String(value)) ? String(value) : JSON.stringify(String(value));
73
+ const tomlString = (value) => JSON.stringify(String(value));
74
+ const eolOf = (raw) => raw.includes('\r\n') ? '\r\n' : '\n';
75
+
76
+ function parseTomlDottedKey(source) {
77
+ const out = [];
78
+ let token = '';
79
+ let quote = null;
80
+ let escaped = false;
81
+ const push = () => {
82
+ const s = token.trim();
83
+ token = '';
84
+ if (!s) return false;
85
+ if (s.startsWith('"')) {
86
+ try { out.push(JSON.parse(s)); } catch { return false; }
87
+ } else if (s.startsWith("'")) {
88
+ if (s.length < 2 || !s.endsWith("'")) return false;
89
+ out.push(s.slice(1, -1));
90
+ } else {
91
+ if (!/^[A-Za-z0-9_-]+$/.test(s)) return false;
92
+ out.push(s);
93
+ }
94
+ return true;
95
+ };
96
+ for (let i = 0; i < source.length; i++) {
97
+ const ch = source[i];
98
+ if (quote === '"') {
99
+ token += ch;
100
+ if (escaped) escaped = false;
101
+ else if (ch === '\\') escaped = true;
102
+ else if (ch === '"') quote = null;
103
+ continue;
104
+ }
105
+ if (quote === "'") {
106
+ token += ch;
107
+ if (ch === "'") quote = null;
108
+ continue;
109
+ }
110
+ if (ch === '"' || ch === "'") { quote = ch; token += ch; continue; }
111
+ if (ch === '.') { if (!push()) return null; continue; }
112
+ token += ch;
113
+ }
114
+ if (quote || !push()) return null;
115
+ return out;
116
+ }
117
+
118
+ function scanTomlMultiline(line, initial) {
119
+ let state = initial;
120
+ let i = 0;
121
+ while (i < line.length) {
122
+ if (state) {
123
+ let at = line.indexOf(state, i);
124
+ while (state === '"""' && at >= 0) {
125
+ let slashes = 0;
126
+ for (let j = at - 1; j >= 0 && line[j] === '\\'; j--) slashes++;
127
+ if (slashes % 2 === 0) break;
128
+ at = line.indexOf(state, at + 3);
129
+ }
130
+ if (at < 0) return state;
131
+ state = null;
132
+ i = at + 3;
133
+ continue;
134
+ }
135
+ const ch = line[i];
136
+ if (ch === '#') break;
137
+ const tri = line.slice(i, i + 3);
138
+ if (tri === '"""' || tri === "'''") { state = tri; i += 3; continue; }
139
+ if (ch === '"') {
140
+ i++;
141
+ let escaped = false;
142
+ while (i < line.length) {
143
+ const c = line[i++];
144
+ if (escaped) escaped = false;
145
+ else if (c === '\\') escaped = true;
146
+ else if (c === '"') break;
147
+ }
148
+ continue;
149
+ }
150
+ if (ch === "'") {
151
+ const close = line.indexOf("'", i + 1);
152
+ i = close < 0 ? line.length : close + 1;
153
+ continue;
154
+ }
155
+ i++;
156
+ }
157
+ return state;
158
+ }
159
+
160
+ function scanTomlTables(raw) {
161
+ const tables = [];
162
+ const lines = raw.match(/[^\r\n]*(?:\r\n|\n|\r|$)/g) || [];
163
+ let offset = 0;
164
+ let multiline = null;
165
+ for (const fullLine of lines) {
166
+ if (!fullLine) continue;
167
+ const line = fullLine.replace(/(?:\r\n|\n|\r)$/, '').replace(/^\uFEFF/, '');
168
+ if (!multiline) {
169
+ const array = line.match(/^[ \t]*\[\[(.+)\]\][ \t]*(?:#.*)?$/);
170
+ const table = array ? null : line.match(/^[ \t]*\[([^\[\]].*)\][ \t]*(?:#.*)?$/);
171
+ const match = array || table;
172
+ if (match) {
173
+ const parts = parseTomlDottedKey(match[1].trim());
174
+ if (!parts) return { ok: false, error: 'contains a table header KLYPIX cannot safely parse', tables: [] };
175
+ tables.push({ start: offset, end: raw.length, parts, array: !!array });
176
+ }
177
+ }
178
+ multiline = scanTomlMultiline(line, multiline);
179
+ offset += fullLine.length;
180
+ }
181
+ if (multiline) return { ok: false, error: 'contains an unterminated multiline TOML string', tables: [] };
182
+ for (let i = 0; i < tables.length - 1; i++) tables[i].end = tables[i + 1].start;
183
+ return { ok: true, tables };
184
+ }
185
+
186
+ function parseTomlStringValue(rawValue) {
187
+ const value = rawValue.trim();
188
+ if (value.startsWith('"')) {
189
+ const m = value.match(/^"((?:\\.|[^"])*)"/);
190
+ if (!m) return null;
191
+ try { return JSON.parse(`"${m[1]}"`); } catch { return null; }
192
+ }
193
+ const m = value.match(/^'([^']*)'/);
194
+ return m ? m[1] : null;
195
+ }
196
+
197
+ export function safeReadCodexConfig(configPath) {
198
+ if (!exists(configPath)) return { ok: true, raw: '', servers: {} };
199
+ let raw;
200
+ try { raw = fs.readFileSync(configPath, 'utf8'); }
201
+ catch (e) { return { ok: false, raw: '', servers: {}, error: `Can't read ${configPath}: ${e?.message || e}` }; }
202
+ const scanned = scanTomlTables(raw);
203
+ if (!scanned.ok) {
204
+ return { ok: false, raw, servers: {}, error: `${path.basename(configPath)} ${scanned.error}. Fix it first - KLYPIX left it untouched.` };
205
+ }
206
+ const servers = {};
207
+ for (const table of scanned.tables) {
208
+ if (table.array || table.parts[0] !== 'mcp_servers' || table.parts.length < 2) continue;
209
+ const name = String(table.parts[1]);
210
+ if (servers[name]) continue; // nested .env/.tools table for an existing server
211
+ const block = raw.slice(table.start, table.end);
212
+ const commandLine = block.match(/^[ \t]*command[ \t]*=[ \t]*(.+)$/m);
213
+ const command = commandLine ? parseTomlStringValue(commandLine[1]) : null;
214
+ const launchesKlypix = /(?:klypix-mcp-server\.mjs|(?:^|["'\s,])klypix-mcp(?:["'\s,]|$))/i.test(block)
215
+ && !!command && /(?:^|[\\/])(node|node\.exe|npx|npx\.cmd|cmd|cmd\.exe)$/i.test(command);
216
+ servers[name] = { command, launchesKlypix, raw: block };
217
+ }
218
+ return { ok: true, raw, servers, tables: scanned.tables };
219
+ }
220
+
221
+ function renderCodexMcpTable(name, entry, cwd) {
222
+ const args = Array.isArray(entry?.args) ? entry.args.map(tomlString).join(', ') : '';
223
+ const lines = [
224
+ `[mcp_servers.${tomlKey(name)}]`,
225
+ `command = ${tomlString(entry?.command || 'npx')}`,
226
+ `args = [${args}]`,
227
+ ];
228
+ if (cwd) lines.push(`cwd = ${tomlString(cwd)}`);
229
+ // A cold npx bootstrap can exceed Codex's 10 second default; local installs
230
+ // start immediately, but the larger timeout is harmless and makes first use reliable.
231
+ lines.push('startup_timeout_sec = 120');
232
+ return lines.join('\n');
233
+ }
234
+
235
+ function writeCodexConfig(configPath, raw, next) {
236
+ try {
237
+ ensureDir(configPath);
238
+ let backup;
239
+ if (raw) {
240
+ backup = configPath + '.klypix-bak';
241
+ try { fs.writeFileSync(backup, raw, 'utf8'); } catch { backup = undefined; }
242
+ }
243
+ const tmp = configPath + '.klypix-tmp';
244
+ fs.writeFileSync(tmp, next, 'utf8');
245
+ const verify = safeReadCodexConfig(tmp);
246
+ if (!verify.ok) { try { fs.unlinkSync(tmp); } catch { /* best effort */ } return { ok: false, error: verify.error }; }
247
+ fs.renameSync(tmp, configPath);
248
+ return { ok: true, backup };
249
+ } catch (e) {
250
+ return { ok: false, error: `Couldn't write ${configPath}: ${e?.message || e}` };
251
+ }
252
+ }
253
+
254
+ export function connectCodexMcpServer({ configPath, name = 'klypix-canvas', entry, cwd } = {}) {
255
+ const parsed = safeReadCodexConfig(configPath);
256
+ if (!parsed.ok) return { ok: false, error: parsed.error };
257
+ const existingNames = Object.keys(parsed.servers).filter(k => /klypix/i.test(k));
258
+ const chosen = existingNames[0] || name;
259
+ const owned = (parsed.tables || []).filter(t => t.parts[0] === 'mcp_servers' && t.parts.length >= 2 && /klypix/i.test(String(t.parts[1])));
260
+ const eol = eolOf(parsed.raw);
261
+ const block = renderCodexMcpTable(chosen, entry, cwd).replace(/\n/g, eol);
262
+ let next = parsed.raw;
263
+ if (owned.length) {
264
+ const firstStart = owned[0].start;
265
+ for (const table of [...owned].sort((a, b) => b.start - a.start)) {
266
+ next = next.slice(0, table.start) + (table.start === firstStart ? block + eol + eol : '') + next.slice(table.end);
267
+ }
268
+ } else {
269
+ const needsEol = next.length > 0 && !/[\r\n]$/.test(next);
270
+ const spacer = next.trim() ? eol : '';
271
+ next += (needsEol ? eol : '') + spacer + block + eol + eol;
272
+ }
273
+ if (next === parsed.raw) {
274
+ return { ok: true, action: 'unchanged', name: chosen, path: configPath, otherServers: Object.keys(parsed.servers).filter(k => !/klypix/i.test(k)) };
275
+ }
276
+ const written = writeCodexConfig(configPath, parsed.raw, next);
277
+ if (!written.ok) return written;
278
+ return {
279
+ ok: true,
280
+ action: existingNames.length ? 'updated' : 'connected',
281
+ name: chosen,
282
+ path: configPath,
283
+ backup: written.backup,
284
+ otherServers: Object.keys(parsed.servers).filter(k => !/klypix/i.test(k)),
285
+ };
286
+ }
287
+
288
+ export function disconnectCodexMcpServer({ configPath } = {}) {
289
+ const parsed = safeReadCodexConfig(configPath);
290
+ if (!parsed.ok) return { ok: false, error: parsed.error };
291
+ const owned = (parsed.tables || []).filter(t => t.parts[0] === 'mcp_servers' && t.parts.length >= 2 && /klypix/i.test(String(t.parts[1])));
292
+ if (!owned.length) return { ok: true, action: 'unchanged', path: configPath };
293
+ let next = parsed.raw;
294
+ for (const table of [...owned].sort((a, b) => b.start - a.start)) {
295
+ next = next.slice(0, table.start) + next.slice(table.end);
296
+ }
297
+ const written = writeCodexConfig(configPath, parsed.raw, next);
298
+ if (!written.ok) return written;
299
+ return {
300
+ ok: true,
301
+ action: 'disconnected',
302
+ path: configPath,
303
+ backup: written.backup,
304
+ otherServers: Object.keys(parsed.servers).filter(k => !/klypix/i.test(k)),
305
+ };
306
+ }
307
+
308
+ export function mergeCodexGlobalInstructions(home = os.homedir()) {
309
+ const file = path.join(home, '.codex', 'AGENTS.md');
310
+ let raw = '';
311
+ try { raw = fs.readFileSync(file, 'utf8'); } catch { /* fresh file */ }
312
+ const eol = eolOf(raw);
313
+ const block = CODEX_GLOBAL_BODY.replace(/\n/g, eol);
314
+ const next = CODEX_GLOBAL_RE.test(raw)
315
+ ? raw.replace(CODEX_GLOBAL_RE, block + eol)
316
+ : (raw.trim() ? raw.replace(/\s*$/, '') + eol + eol + block + eol : block + eol);
317
+ if (next === raw) return { ok: true, action: 'unchanged', path: file };
318
+ try {
319
+ ensureDir(file);
320
+ let backup;
321
+ if (raw) { backup = file + '.klypix-bak'; try { fs.writeFileSync(backup, raw, 'utf8'); } catch { backup = undefined; } }
322
+ const tmp = file + '.klypix-tmp';
323
+ fs.writeFileSync(tmp, next, 'utf8');
324
+ fs.renameSync(tmp, file);
325
+ return { ok: true, action: CODEX_GLOBAL_RE.test(raw) ? 'updated' : 'connected', path: file, backup };
326
+ } catch (e) { return { ok: false, error: `Couldn't write ${file}: ${e?.message || e}` }; }
327
+ }
328
+
329
+ export function removeCodexGlobalInstructions(home = os.homedir()) {
330
+ const file = path.join(home, '.codex', 'AGENTS.md');
331
+ let raw = '';
332
+ try { raw = fs.readFileSync(file, 'utf8'); } catch { return { ok: true, action: 'unchanged', path: file }; }
333
+ if (!CODEX_GLOBAL_RE.test(raw)) return { ok: true, action: 'unchanged', path: file };
334
+ const next = raw.replace(CODEX_GLOBAL_RE, '').replace(/^\s+|\s+$/g, (m, offset) => offset === 0 ? '' : '\n');
335
+ try {
336
+ const backup = file + '.klypix-bak';
337
+ try { fs.writeFileSync(backup, raw, 'utf8'); } catch { /* best effort */ }
338
+ const tmp = file + '.klypix-tmp';
339
+ fs.writeFileSync(tmp, next, 'utf8');
340
+ fs.renameSync(tmp, file);
341
+ return { ok: true, action: 'disconnected', path: file, backup };
342
+ } catch (e) { return { ok: false, error: `Couldn't write ${file}: ${e?.message || e}` }; }
343
+ }
344
+
345
+ export function codexGlobalInstructionsInstalled(home = os.homedir()) {
346
+ try { return CODEX_GLOBAL_RE.test(fs.readFileSync(path.join(home, '.codex', 'AGENTS.md'), 'utf8')); }
347
+ catch { return false; }
348
+ }
349
+
52
350
  const sha8 = (s) => crypto.createHash('sha1').update(String(s)).digest('hex').slice(0, 8);
53
351
  const cmpSemver = (a, b) => { const pa = String(a || '').split('.').map(n => parseInt(n, 10) || 0), pb = String(b || '').split('.').map(n => parseInt(n, 10) || 0); for (let i = 0; i < 3; i++) { if ((pa[i] || 0) !== (pb[i] || 0)) return (pa[i] || 0) - (pb[i] || 0); } return 0; };
54
352
 
@@ -253,7 +551,7 @@ function targets(projectDir) {
253
551
  const j = (...p) => path.join(projectDir, ...p);
254
552
  return {
255
553
  rules: [
256
- { tool: 'AGENTS.md (cross-tool standard)', file: j('AGENTS.md'), kind: 'merge' },
554
+ { tool: 'Codex / AGENTS.md standard', file: j('AGENTS.md'), kind: 'merge' },
257
555
  { tool: 'Cursor', file: j('.cursor', 'rules', 'klypix-brain.mdc'), kind: 'dedicated', frontmatter: '---\ndescription: KLYPIX project brain — read at task start, capture decisions\nalwaysApply: true\n---' },
258
556
  { tool: 'Windsurf', file: j('.windsurf', 'rules', 'klypix-brain.md'), kind: 'dedicated', frontmatter: '---\ntrigger: always_on\n---' },
259
557
  { tool: 'Cline', file: j('.clinerules', 'klypix-brain.md'), kind: 'dedicated', frontmatter: '' },
@@ -267,6 +565,7 @@ function targets(projectDir) {
267
565
  { tool: 'Antigravity', file: j('.agents', 'AGENTS.md'), kind: 'merge' },
268
566
  ],
269
567
  mcp: [
568
+ { tool: 'Codex', file: j('.codex', 'config.toml'), format: 'toml' },
270
569
  { tool: 'Cursor', file: j('.cursor', 'mcp.json'), wrapKey: 'mcpServers', withType: false },
271
570
  { tool: 'VS Code (Copilot/Continue)', file: j('.vscode', 'mcp.json'), wrapKey: 'servers', withType: true },
272
571
  ],
@@ -299,6 +598,20 @@ export function linkProject(projectDir, opts = {}) {
299
598
  // MCP), so we skip .mcp.json to avoid double-registering its server.
300
599
  const mcp = t.mcp.map((m) => {
301
600
  const file = relFile(projectDir, m.file);
601
+ if (m.format === 'toml') {
602
+ if (check) {
603
+ const parsed = safeReadCodexConfig(m.file);
604
+ if (!parsed.ok) return { tool: m.tool, file, status: 'hand-edited', why: parsed.error };
605
+ const name = Object.keys(parsed.servers).find(k => /klypix/i.test(k));
606
+ const entry = name ? parsed.servers[name] : null;
607
+ return { tool: m.tool, file, status: entry ? (entry.launchesKlypix ? 'ok' : 'hand-edited') : 'missing', why: entry && !entry.launchesKlypix ? 'entry no longer launches klypix-mcp' : undefined };
608
+ }
609
+ // Project config is commonly committed. Keep it machine-portable; the
610
+ // 120s Codex startup timeout covers the one-time npx bootstrap.
611
+ const portableEntry = { command: 'npx', args: ['-y', 'klypix-mcp', '--vault', '.'] };
612
+ const res = connectCodexMcpServer({ configPath: m.file, entry: portableEntry, cwd: '..' });
613
+ return res.ok ? { tool: m.tool, file, ...res } : { tool: m.tool, file, action: 'skipped', why: res.error };
614
+ }
302
615
  if (check) return { tool: m.tool, file, ...classifyMcp(m.file, m.wrapKey) };
303
616
  return { tool: m.tool, file, ...mergeMcpJson(m.file, m.wrapKey, m.withType) };
304
617
  });