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 +15 -5
- package/bin/klypix-install.mjs +43 -4
- package/bin/klypix-link.mjs +5 -4
- package/package.json +2 -2
- package/src/agent-rules.mjs +314 -1
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
|
|
13
|
+
**Claude Code + Codex (native, machine-wide):**
|
|
14
14
|
|
|
15
15
|
```bash
|
|
16
16
|
npx klypix-mcp install
|
|
17
17
|
```
|
|
18
18
|
|
|
19
|
-
|
|
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
|
-
|
|
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.
|
|
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`,
|
|
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
|
|
package/bin/klypix-install.mjs
CHANGED
|
@@ -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 {
|
|
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) {
|
|
52
|
-
|
|
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('
|
|
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) {
|
package/bin/klypix-link.mjs
CHANGED
|
@@ -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
|
|
4
|
-
// same automatic read+capture
|
|
5
|
-
// Aider, and any AGENTS.md-reading agent,
|
|
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
|
|
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.
|
|
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": [
|
package/src/agent-rules.mjs
CHANGED
|
@@ -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
|
|
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
|
});
|