klypix-mcp 1.70.0 → 1.71.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 +48 -20
- package/bin/klypix-install.mjs +25 -0
- package/bin/klypix-mcp.mjs +2 -1
- package/package.json +2 -2
- package/src/agent-rules.mjs +67 -17
- package/src/editor-detect.mjs +117 -0
- package/src/mcp-presence.mjs +39 -4
- package/src/mcp-verify.mjs +136 -0
- package/src/setup.mjs +286 -0
package/README.md
CHANGED
|
@@ -85,28 +85,55 @@ It verifies 15 required coordination behaviours — not the 26 tools, and not th
|
|
|
85
85
|
|
|
86
86
|
## Quick start
|
|
87
87
|
|
|
88
|
-
**
|
|
88
|
+
Run this **inside your project**:
|
|
89
89
|
|
|
90
90
|
```bash
|
|
91
91
|
npx klypix-mcp install
|
|
92
92
|
```
|
|
93
93
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
94
|
+
One command, every editor. It finds the project root (walking up, so running it from `src/` is
|
|
95
|
+
fine), gives the project a brain if it doesn't have one, wires the agent tools you actually have
|
|
96
|
+
installed, registers the lossless `.klypix` merge driver if it's a git repo, and then **proves the
|
|
97
|
+
result** before it exits:
|
|
98
|
+
|
|
99
|
+
```text
|
|
100
|
+
project E:\work\api (git repository root)
|
|
101
|
+
brain created brain.klypix — a starter brain, ready for its first decision
|
|
102
|
+
editors Claude Code · Cursor · Codex · Gemini CLI · Antigravity · VS Code
|
|
103
|
+
wired 9 file(s) · 9 updated (skipped 5 for tools you don't have)
|
|
104
|
+
git lossless .klypix merge driver registered
|
|
105
|
+
verified ✓ 26 tools reachable via .mcp.json (892ms)
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
That last line is the point. MCP config fails **silently** — a wrong entry means the server never
|
|
109
|
+
starts, the agent quietly loses every brain verb, and nothing reports an error. So `install` opens
|
|
110
|
+
a real stdio handshake against the config it just wrote and counts the tools that answered. A
|
|
111
|
+
broken entry dies in ~100ms with `Connection closed` and is reported, not shipped.
|
|
112
|
+
|
|
113
|
+
What goes where:
|
|
114
|
+
|
|
115
|
+
- **Machine-global, once** — the engine + runtime in `~/.claude/project-brain`, Claude Code's four
|
|
116
|
+
lifecycle hooks in `~/.claude/settings.json`, and the `~/.codex/AGENTS.md` guidance block. Claude
|
|
117
|
+
Code is therefore covered in every project on that machine that has a `./brain.klypix`.
|
|
118
|
+
- **Per project** — MCP config and rules for Cursor, Codex, Cline, Windsurf, Copilot, Gemini CLI /
|
|
119
|
+
Antigravity and Aider. Run `install` once inside each project.
|
|
97
120
|
|
|
98
|
-
|
|
121
|
+
Three things it deliberately will **not** do:
|
|
99
122
|
|
|
100
|
-
- **
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
123
|
+
- **Write for editors you don't have.** Config is projected only for hosts detected on this
|
|
124
|
+
machine — a two-person team using one editor no longer commits rules for six they never opened.
|
|
125
|
+
A file your project *already* carries stays maintained regardless, so you can't silently stop
|
|
126
|
+
updating your team's committed configs.
|
|
127
|
+
- **Wire a directory that isn't a project.** It refuses your home folder, a drive root, and
|
|
128
|
+
anything with no brain, no git repo and no project manifest. A mistyped command can't seed a
|
|
129
|
+
brain into `C:\Users\you`.
|
|
130
|
+
- **Replace a project-owned server.** A repo-relative launch like
|
|
131
|
+
`node scripts/klypix-mcp-server.mjs` is deliberate — it resolves offline and rides a bundle the
|
|
132
|
+
repo version-gates — so it's left byte-identical and reported. An explicit `link` still rewrites
|
|
133
|
+
everything: an action you didn't ask for stays more conservative than one you did.
|
|
108
134
|
|
|
109
|
-
|
|
135
|
+
Opt out with `--no-project` (CI images, scripted provisioning). `--json` emits the report as
|
|
136
|
+
structured data; `--verify-all` handshakes every written config instead of one.
|
|
110
137
|
|
|
111
138
|
Optional, opt-in, and approved inside Codex itself:
|
|
112
139
|
|
|
@@ -119,15 +146,16 @@ file-overlap warning. Codex owns the trust decision and will ask you to review t
|
|
|
119
146
|
`brain_doctor` reports this layer separately as off, execution-unverified, or active. Even with it
|
|
120
147
|
on, **Codex never captures decisions automatically** — the Codex hook never writes the brain.
|
|
121
148
|
|
|
122
|
-
**
|
|
149
|
+
**Re-project everything explicitly:**
|
|
123
150
|
|
|
124
151
|
```bash
|
|
125
152
|
npx klypix-mcp link
|
|
126
153
|
```
|
|
127
154
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
155
|
+
`install` already does this for the editors you have. Reach for `link` when you want all 14
|
|
156
|
+
managed, hash-stamped files regardless of what's installed — MCP server config for six hosts plus
|
|
157
|
+
rules files for eight — or to repair drift. Managed blocks are merged into your existing
|
|
158
|
+
instruction files and never clobber your content.
|
|
131
159
|
|
|
132
160
|
```bash
|
|
133
161
|
npx klypix-mcp link --check # audits without writing; exits non-zero on drift
|
|
@@ -481,8 +509,8 @@ The MCP verbs below are what agents call. These are what **you** call:
|
|
|
481
509
|
| Command | What it does |
|
|
482
510
|
|---|---|
|
|
483
511
|
| `npx klypix-mcp init` | Seed a starter `brain.klypix` here and print an MCP config |
|
|
484
|
-
| `npx klypix-mcp install` |
|
|
485
|
-
| `npx klypix-mcp link` |
|
|
512
|
+
| `npx klypix-mcp install` | Set up everything: machine engine + hooks, then this project — brain, config for the editors you have, merge driver, verified (see Quick start) |
|
|
513
|
+
| `npx klypix-mcp link` | Re-project all 14 managed files regardless of what is installed (`--check` audits) |
|
|
486
514
|
| `npx klypix-mcp doctor` | One verdict: version, hosts, live sessions, tool count, drift. Exits non-zero — usable as a CI gate |
|
|
487
515
|
| `npx klypix-mcp runtime` | Passive per-connection process/RAM attribution (`--json`, optional `--watch seconds`); never kills or deduplicates |
|
|
488
516
|
| `npx klypix-mcp conformance` | Launch two real MCP clients against this build and verify coordination behaviour |
|
package/bin/klypix-install.mjs
CHANGED
|
@@ -34,6 +34,7 @@ import {
|
|
|
34
34
|
import { brainInstallDecision, deploySourceDecision } from '../src/install-version.mjs';
|
|
35
35
|
import { acquireInstallLockSync, releaseInstallLockSync } from '../src/install-lock.mjs';
|
|
36
36
|
import { collectRepoState } from '../src/repo-state.mjs';
|
|
37
|
+
import { runSetup, renderBrief } from '../src/setup.mjs';
|
|
37
38
|
|
|
38
39
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
39
40
|
const PKG_ROOT = path.resolve(__dirname, '..');
|
|
@@ -48,6 +49,12 @@ const VERSION = (() => { try { return JSON.parse(fs.readFileSync(path.join(PKG_R
|
|
|
48
49
|
const FORCE = process.argv.includes('--force');
|
|
49
50
|
const CODEX_HOOKS = process.argv.includes('--codex-hooks');
|
|
50
51
|
const RUNTIME_ONLY = process.argv.includes('--runtime-only');
|
|
52
|
+
// Project wiring is the default because it is the step users did not know
|
|
53
|
+
// existed. These opt OUT for the cases that genuinely want machine-only:
|
|
54
|
+
// CI images, scripted provisioning, and anyone wiring the project by hand.
|
|
55
|
+
const NO_PROJECT = process.argv.includes('--no-project');
|
|
56
|
+
const VERIFY_ALL = process.argv.includes('--verify-all');
|
|
57
|
+
const SETUP_JSON = process.argv.includes('--json');
|
|
51
58
|
// Released-tag deploy-guard acknowledgement. Deliberately a SEPARATE axis from
|
|
52
59
|
// --force: --force is destination authority (overwrite what is installed),
|
|
53
60
|
// this is source authority (knowingly deploy an untagged working tree).
|
|
@@ -480,6 +487,24 @@ try {
|
|
|
480
487
|
console.log(' Enhanced Codex auto-context + pre-edit overlap guard: re-run with `--codex-hooks`, then approve/review KLYPIX once in a Codex surface that supports hook trust.');
|
|
481
488
|
}
|
|
482
489
|
console.log(' Compatible brain-core updates hot-swap behind the same MCP connection. Only the one-time legacy→supervisor migration, a supervisor change, or an intentionally breaking tool/protocol change needs reconnect.');
|
|
490
|
+
|
|
491
|
+
// 9) PROJECT setup (1.71.0) — the step users never knew they had to take.
|
|
492
|
+
// `install` wired the machine; without this it wired nothing a Cursor,
|
|
493
|
+
// Antigravity, Codex, Cline or Copilot user could see, and the failure
|
|
494
|
+
// was silent. A runtime-only refresh deliberately skips it: that path
|
|
495
|
+
// exists to preserve every host/project config byte.
|
|
496
|
+
if (!RUNTIME_ONLY && !NO_PROJECT) {
|
|
497
|
+
try {
|
|
498
|
+
const report = await runSetup({ verifyAll: VERIFY_ALL });
|
|
499
|
+
if (SETUP_JSON) console.log(JSON.stringify(report, null, 2));
|
|
500
|
+
else console.log(renderBrief(report));
|
|
501
|
+
} catch (e) {
|
|
502
|
+
// The machine install already succeeded and is independently
|
|
503
|
+
// useful; a project-wiring failure must report itself, not undo it.
|
|
504
|
+
console.error(`⚠ project setup could not finish: ${e?.message || e}`);
|
|
505
|
+
console.error(' The machine install is intact — re-run inside your project, or use `npx klypix-mcp link`.');
|
|
506
|
+
}
|
|
507
|
+
}
|
|
483
508
|
console.log(' Verify anytime: `npx klypix-mcp doctor`; prove two-client behavior with `npx klypix-mcp conformance`.');
|
|
484
509
|
} catch (e) {
|
|
485
510
|
releaseInstallLockSync(installLock);
|
package/bin/klypix-mcp.mjs
CHANGED
|
@@ -25,7 +25,8 @@ const USAGE = [
|
|
|
25
25
|
`klypix-mcp ${PKG_VERSION} — shared project brain + MCP coordination server.`,
|
|
26
26
|
'',
|
|
27
27
|
'Verbs:',
|
|
28
|
-
' install [--force] [--codex-hooks]
|
|
28
|
+
' install [--force] [--codex-hooks] set up everything: machine engine + Claude hooks, then THIS project —',
|
|
29
|
+
' [--no-project] [--json] brain, config for the editors you actually have, merge driver, verified',
|
|
29
30
|
' link [dir] [--check] project this project\'s 14 managed agent config files (--check audits, writes nothing, exits 1 on drift)',
|
|
30
31
|
' doctor [--npm] [--all] [--json] read-only self-check; exits 1 on drift',
|
|
31
32
|
' runtime [--json] [--watch seconds] passive MCP process/RAM attribution; never terminates a process',
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "klypix-mcp",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.71.0",
|
|
4
4
|
"description": "Shared project brain and MCP coordination server for multi-agent coding.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -84,7 +84,7 @@
|
|
|
84
84
|
"bench": "node bin/klypix-mcp.mjs bench",
|
|
85
85
|
"test:bench": "node test/bench.mjs",
|
|
86
86
|
"pretest": "node test/publish-workflow.mjs",
|
|
87
|
-
"test": "node test/publish-verdict.mjs && node test/remote-client.mjs && node test/project-graph.mjs && node test/project-map-cli.mjs && node test/mcp-auto-update.mjs && node test/mcp-supervisor.mjs && node test/runtime-inspector.mjs && node test/codex-hooks.mjs && node test/request-identity.mjs && node test/session-identity-core.mjs && node test/agent-presence.mjs && node test/message-delivery-v3.mjs && node test/claude-message-delivery-v3.mjs && node test/result-reconcile.mjs && node test/evidence-publication-gate.mjs && node test/release-evidence-cli.mjs && node test/intent-guard.mjs && node test/git-capture-install.mjs && node test/brain-history.mjs && node test/brain-graveyard.mjs && node test/archived-visibility.mjs && node test/finding-routing.mjs && node test/finding-routing-hook.mjs && node test/presence-relay.mjs && node test/install-version.mjs && node test/context-gateway.mjs && node test/repo-state.mjs && node test/released-tag-guard.mjs && node test/conformance.mjs && node test/brain-doctor.mjs && node test/version-currency.mjs && node test/ship-capture.mjs && node test/lane-message.mjs && node test/brain-quality.mjs && node test/brain-connect-orphans.mjs && node test/orphan-gardener.mjs && node test/brief-and-recall.mjs && node test/layout-cluster.mjs && node test/brain-ask.mjs && node test/retrieval-fusion.mjs && node test/field-report-2026-07-04.mjs && node test/autoprop.mjs && node test/overlay-recency-2026-07-12.mjs && node test/brain-challenge.mjs && node test/brain-lens.mjs && node test/brain-kind.mjs && node test/rule-drafts.mjs && node test/claim-engine.mjs && node test/skill-staleness.mjs && node test/canvas-view.mjs && node test/status-completeness.mjs && node test/semantic-security.mjs && node test/semantic-gate.mjs && node test/memory-runtime.mjs && node test/semantic-cache.mjs && node test/decay-status.mjs && node test/decay-hook.mjs && node test/evidence-anchors.mjs && node test/presence-visibility.mjs && node test/presence-liveness.mjs && node test/observed-scope.mjs && node test/release-lease.mjs && node test/completion-guard.mjs && node test/merge-brains.mjs && node test/concurrent-writes.mjs && node test/lock-interop.mjs && node test/capture-write-failure.mjs && node test/a2a-smoke.mjs && node test/cli-args.mjs && node test/format-guard.mjs && node test/git-tools.mjs && node test/uninstall.mjs",
|
|
87
|
+
"test": "node test/publish-verdict.mjs && node test/remote-client.mjs && node test/project-graph.mjs && node test/project-map-cli.mjs && node test/mcp-auto-update.mjs && node test/mcp-supervisor.mjs && node test/runtime-inspector.mjs && node test/codex-hooks.mjs && node test/request-identity.mjs && node test/session-identity-core.mjs && node test/agent-presence.mjs && node test/message-delivery-v3.mjs && node test/claude-message-delivery-v3.mjs && node test/result-reconcile.mjs && node test/evidence-publication-gate.mjs && node test/release-evidence-cli.mjs && node test/intent-guard.mjs && node test/git-capture-install.mjs && node test/brain-history.mjs && node test/brain-graveyard.mjs && node test/archived-visibility.mjs && node test/finding-routing.mjs && node test/finding-routing-hook.mjs && node test/presence-relay.mjs && node test/install-version.mjs && node test/context-gateway.mjs && node test/repo-state.mjs && node test/released-tag-guard.mjs && node test/conformance.mjs && node test/brain-doctor.mjs && node test/version-currency.mjs && node test/ship-capture.mjs && node test/lane-message.mjs && node test/brain-quality.mjs && node test/brain-connect-orphans.mjs && node test/orphan-gardener.mjs && node test/brief-and-recall.mjs && node test/layout-cluster.mjs && node test/brain-ask.mjs && node test/retrieval-fusion.mjs && node test/field-report-2026-07-04.mjs && node test/autoprop.mjs && node test/overlay-recency-2026-07-12.mjs && node test/brain-challenge.mjs && node test/brain-lens.mjs && node test/brain-kind.mjs && node test/rule-drafts.mjs && node test/claim-engine.mjs && node test/skill-staleness.mjs && node test/canvas-view.mjs && node test/status-completeness.mjs && node test/semantic-security.mjs && node test/semantic-gate.mjs && node test/memory-runtime.mjs && node test/semantic-cache.mjs && node test/decay-status.mjs && node test/decay-hook.mjs && node test/evidence-anchors.mjs && node test/presence-visibility.mjs && node test/undeclared-active.mjs && node test/presence-liveness.mjs && node test/observed-scope.mjs && node test/release-lease.mjs && node test/completion-guard.mjs && node test/merge-brains.mjs && node test/concurrent-writes.mjs && node test/lock-interop.mjs && node test/capture-write-failure.mjs && node test/a2a-smoke.mjs && node test/one-command-setup.mjs && node test/cli-args.mjs && node test/format-guard.mjs && node test/git-tools.mjs && node test/uninstall.mjs",
|
|
88
88
|
"test:memory": "node test/memory-runtime.mjs",
|
|
89
89
|
"test:memory:soak": "node --expose-gc test/memory-soak.mjs",
|
|
90
90
|
"runtime": "node bin/klypix-runtime.mjs"
|
package/src/agent-rules.mjs
CHANGED
|
@@ -660,46 +660,73 @@ function classifyMcp(file, wrapKey, projectDir) {
|
|
|
660
660
|
|
|
661
661
|
// The projection map — the single source of truth shared by WRITE (linkProject) and
|
|
662
662
|
// CHECK (auditProject), so the two can never disagree about what's projected where.
|
|
663
|
+
//
|
|
664
|
+
// `editors` (1.71.0) names which detected hosts justify writing each file, so a
|
|
665
|
+
// project stops collecting managed config for tools nobody on the machine has.
|
|
666
|
+
// `standard: true` marks the cross-tool AGENTS.md convention — justified by ANY
|
|
667
|
+
// agent host rather than one specific id. Filtering only happens when a caller
|
|
668
|
+
// passes an explicit editor set; the default projects everything, exactly as
|
|
669
|
+
// before, so every existing caller is unaffected.
|
|
663
670
|
function targets(projectDir) {
|
|
664
671
|
const j = (...p) => path.join(projectDir, ...p);
|
|
665
672
|
return {
|
|
666
673
|
rules: [
|
|
667
|
-
{ tool: 'Codex / AGENTS.md standard', file: j('AGENTS.md'), kind: 'merge' },
|
|
668
|
-
{ 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---' },
|
|
669
|
-
{ tool: 'Windsurf', file: j('.windsurf', 'rules', 'klypix-brain.md'), kind: 'dedicated', frontmatter: '---\ntrigger: always_on\n---' },
|
|
670
|
-
{ tool: 'Cline', file: j('.clinerules', 'klypix-brain.md'), kind: 'dedicated', frontmatter: '' },
|
|
671
|
-
{ tool: 'GitHub Copilot', file: j('.github', 'copilot-instructions.md'), kind: 'merge' },
|
|
674
|
+
{ tool: 'Codex / AGENTS.md standard', file: j('AGENTS.md'), kind: 'merge', editors: ['codex'], standard: true },
|
|
675
|
+
{ tool: 'Cursor', file: j('.cursor', 'rules', 'klypix-brain.mdc'), kind: 'dedicated', editors: ['cursor'], frontmatter: '---\ndescription: KLYPIX project brain — read at task start, capture decisions\nalwaysApply: true\n---' },
|
|
676
|
+
{ tool: 'Windsurf', file: j('.windsurf', 'rules', 'klypix-brain.md'), kind: 'dedicated', editors: ['windsurf'], frontmatter: '---\ntrigger: always_on\n---' },
|
|
677
|
+
{ tool: 'Cline', file: j('.clinerules', 'klypix-brain.md'), kind: 'dedicated', editors: ['cline'], frontmatter: '' },
|
|
678
|
+
{ tool: 'GitHub Copilot', file: j('.github', 'copilot-instructions.md'), kind: 'merge', editors: ['copilot'] },
|
|
672
679
|
// Added 1.13.0 — close the "not generated at all" coverage gap the audit flagged.
|
|
673
|
-
{ tool: 'Gemini CLI', file: j('GEMINI.md'), kind: 'merge' },
|
|
674
|
-
{ tool: 'Aider', file: j('CONVENTIONS.md'), kind: 'dedicated', frontmatter: '' },
|
|
680
|
+
{ tool: 'Gemini CLI', file: j('GEMINI.md'), kind: 'merge', editors: ['gemini-cli', 'antigravity'] },
|
|
681
|
+
{ tool: 'Aider', file: j('CONVENTIONS.md'), kind: 'dedicated', editors: ['aider'], frontmatter: '' },
|
|
675
682
|
// Added 1.29.1 — Antigravity (Gemini IDE) reads .agents/AGENTS.md; the desktop
|
|
676
683
|
// app already writes it (projectBrainConnect.ts), this adds CLI audit coverage.
|
|
677
684
|
// kind:'merge' so the fenced block coexists with any existing content.
|
|
678
|
-
{ tool: 'Antigravity', file: j('.agents', 'AGENTS.md'), kind: 'merge' },
|
|
685
|
+
{ tool: 'Antigravity', file: j('.agents', 'AGENTS.md'), kind: 'merge', editors: ['antigravity'] },
|
|
679
686
|
// Intentionally NO project CLAUDE.md target. Claude receives the same task
|
|
680
687
|
// contract mechanically through lifecycle hooks + MCP tool instructions;
|
|
681
688
|
// the desktop legacy connector still owns CLAUDE.md, so a second Core
|
|
682
689
|
// writer here would create duelling managed blocks and update churn.
|
|
683
690
|
],
|
|
684
691
|
mcp: [
|
|
685
|
-
{ tool: 'Codex', file: j('.codex', 'config.toml'), format: 'toml' },
|
|
686
|
-
{ tool: 'Claude Code', file: j('.mcp.json'), wrapKey: 'mcpServers', withType: false },
|
|
687
|
-
{ tool: 'Cursor', file: j('.cursor', 'mcp.json'), wrapKey: 'mcpServers', withType: false },
|
|
688
|
-
{ tool: 'Cline', file: j('.cline', 'mcp.json'), wrapKey: 'mcpServers', withType: false },
|
|
689
|
-
{ tool: 'Gemini CLI / Antigravity', file: j('.gemini', 'settings.json'), wrapKey: 'mcpServers', withType: false },
|
|
690
|
-
{ tool: 'VS Code (Copilot/Continue)', file: j('.vscode', 'mcp.json'), wrapKey: 'servers', withType: true },
|
|
692
|
+
{ tool: 'Codex', file: j('.codex', 'config.toml'), format: 'toml', editors: ['codex'] },
|
|
693
|
+
{ tool: 'Claude Code', file: j('.mcp.json'), wrapKey: 'mcpServers', withType: false, editors: ['claude-code'] },
|
|
694
|
+
{ tool: 'Cursor', file: j('.cursor', 'mcp.json'), wrapKey: 'mcpServers', withType: false, editors: ['cursor'] },
|
|
695
|
+
{ tool: 'Cline', file: j('.cline', 'mcp.json'), wrapKey: 'mcpServers', withType: false, editors: ['cline'] },
|
|
696
|
+
{ tool: 'Gemini CLI / Antigravity', file: j('.gemini', 'settings.json'), wrapKey: 'mcpServers', withType: false, editors: ['gemini-cli', 'antigravity'] },
|
|
697
|
+
{ tool: 'VS Code (Copilot/Continue)', file: j('.vscode', 'mcp.json'), wrapKey: 'servers', withType: true, editors: ['vscode', 'copilot'] },
|
|
691
698
|
],
|
|
692
699
|
};
|
|
693
700
|
}
|
|
694
701
|
|
|
702
|
+
/**
|
|
703
|
+
* Should this target be projected, given the hosts detected on the machine?
|
|
704
|
+
*
|
|
705
|
+
* Three independent yeses, in order of strength:
|
|
706
|
+
* 1. a detected host reads it;
|
|
707
|
+
* 2. it is the cross-tool AGENTS.md standard and SOME agent host is present;
|
|
708
|
+
* 3. the file already exists in the project — a teammate committed it, so it
|
|
709
|
+
* stays current whether or not THIS machine has that editor. Without this
|
|
710
|
+
* rule a one-editor developer would silently stop maintaining the configs
|
|
711
|
+
* the rest of their team depends on.
|
|
712
|
+
*/
|
|
713
|
+
function targetJustified(target, editors, anyEditor) {
|
|
714
|
+
if (!editors) return true; // unfiltered: project everything
|
|
715
|
+
if ((target.editors || []).some((id) => editors.has(id))) return true;
|
|
716
|
+
if (target.standard && anyEditor) return true;
|
|
717
|
+
return exists(target.file);
|
|
718
|
+
}
|
|
719
|
+
|
|
695
720
|
const relFile = (projectDir, abs) => path.relative(projectDir, abs).replace(/\\/g, '/');
|
|
696
721
|
|
|
697
722
|
/**
|
|
698
723
|
* Wire a project so EVERY agent tool reads + captures its brain automatically — or,
|
|
699
724
|
* with { check:true }, AUDIT the projection without touching disk.
|
|
700
725
|
* @param {string} projectDir absolute project root (holds ./brain.klypix)
|
|
701
|
-
* @param {{ version?: string, check?: boolean }} [opts]
|
|
702
|
-
*
|
|
726
|
+
* @param {{ version?: string, check?: boolean, editors?: Set<string>|string[] }} [opts]
|
|
727
|
+
* editors — when supplied, project only what a detected host actually reads
|
|
728
|
+
* (plus files the project already carries). Omit for the legacy write-all.
|
|
729
|
+
* @returns {{ rules, mcp, hasBrain, version, check, skipped: Array }}
|
|
703
730
|
*/
|
|
704
731
|
export function linkProject(projectDir, opts = {}) {
|
|
705
732
|
const version = opts.version || resolveVersion();
|
|
@@ -707,6 +734,29 @@ export function linkProject(projectDir, opts = {}) {
|
|
|
707
734
|
const t = targets(projectDir);
|
|
708
735
|
const hasBrain = exists(path.join(projectDir, 'brain.klypix')) || exists(path.join(projectDir, 'brain.any'));
|
|
709
736
|
|
|
737
|
+
const editors = opts.editors == null ? null
|
|
738
|
+
: (opts.editors instanceof Set ? opts.editors : new Set(opts.editors));
|
|
739
|
+
const anyEditor = !!editors && editors.size > 0;
|
|
740
|
+
// Files the caller has decided not to touch at all — used by the automatic
|
|
741
|
+
// setup path to leave a project-owned (vendored, repo-relative) server alone.
|
|
742
|
+
// An explicitly typed `link` still rewrites everything; an action the user
|
|
743
|
+
// did not ask for should be more conservative than one they did.
|
|
744
|
+
const exclude = opts.exclude instanceof Set ? opts.exclude
|
|
745
|
+
: new Set(Array.isArray(opts.exclude) ? opts.exclude : []);
|
|
746
|
+
const skipped = [];
|
|
747
|
+
const keep = (target) => {
|
|
748
|
+
const file = relFile(projectDir, target.file);
|
|
749
|
+
if (exclude.has(file)) {
|
|
750
|
+
skipped.push({ tool: target.tool, file, why: 'project-owned server — left untouched' });
|
|
751
|
+
return false;
|
|
752
|
+
}
|
|
753
|
+
if (targetJustified(target, editors, anyEditor)) return true;
|
|
754
|
+
skipped.push({ tool: target.tool, file, why: 'not installed on this machine' });
|
|
755
|
+
return false;
|
|
756
|
+
};
|
|
757
|
+
t.rules = t.rules.filter(keep);
|
|
758
|
+
t.mcp = t.mcp.filter(keep);
|
|
759
|
+
|
|
710
760
|
const rules = t.rules.map((r) => {
|
|
711
761
|
const file = relFile(projectDir, r.file);
|
|
712
762
|
try {
|
|
@@ -761,7 +811,7 @@ export function linkProject(projectDir, opts = {}) {
|
|
|
761
811
|
}
|
|
762
812
|
});
|
|
763
813
|
|
|
764
|
-
return { rules, mcp, hasBrain, version, check };
|
|
814
|
+
return { rules, mcp, hasBrain, version, check, skipped };
|
|
765
815
|
}
|
|
766
816
|
|
|
767
817
|
/**
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// Which agent tools does this machine ACTUALLY have?
|
|
2
|
+
//
|
|
3
|
+
// Before 1.71 `link` projected all 14 managed files into every project, for
|
|
4
|
+
// every supported host, whether or not the user had ever installed it. Those
|
|
5
|
+
// files get COMMITTED, so a two-person team using one editor shipped rules and
|
|
6
|
+
// MCP config for six they had never opened — repo clutter that reads as noise
|
|
7
|
+
// in review and makes the tool look presumptuous on first contact.
|
|
8
|
+
//
|
|
9
|
+
// This module answers the narrower question the projection should have been
|
|
10
|
+
// asking: is this host present on this machine? Detection is deliberately
|
|
11
|
+
// EVIDENCE-BASED and conservative — every positive names the path or variable
|
|
12
|
+
// that produced it (`why`), so a wrong answer is auditable rather than
|
|
13
|
+
// mysterious. Absence is never proof; the caller pairs this with the
|
|
14
|
+
// project-level signal (a target file already committed) and keeps projecting
|
|
15
|
+
// anything a teammate already relies on.
|
|
16
|
+
//
|
|
17
|
+
// Pure except for fs existence checks, and every environment input is
|
|
18
|
+
// injectable so the whole matrix is testable without touching a real home dir.
|
|
19
|
+
import fs from 'fs';
|
|
20
|
+
import path from 'path';
|
|
21
|
+
import os from 'os';
|
|
22
|
+
|
|
23
|
+
/** Roaming-config root per platform — where Electron editors keep their profile. */
|
|
24
|
+
export function appDataDir({ platform = process.platform, home = os.homedir(), env = process.env } = {}) {
|
|
25
|
+
if (platform === 'win32') return env.APPDATA || path.join(home, 'AppData', 'Roaming');
|
|
26
|
+
if (platform === 'darwin') return path.join(home, 'Library', 'Application Support');
|
|
27
|
+
return env.XDG_CONFIG_HOME || path.join(home, '.config');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Extension hosts whose extension folders we scan for editor PLUGINS (Cline,
|
|
31
|
+
// Copilot). VS Code forks keep the same `<publisher>.<name>-<version>` layout.
|
|
32
|
+
const EXTENSION_ROOTS = ['.vscode', '.vscode-insiders', '.cursor', '.windsurf', '.vscode-oss'];
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The detection matrix. Each entry lists independent signals; ANY hit is a
|
|
36
|
+
* positive, because a user may have a CLI without the GUI or vice versa.
|
|
37
|
+
* - home: dot-dirs/files directly under the home directory
|
|
38
|
+
* - app: profile directory names under the platform's roaming-config root
|
|
39
|
+
* - ext: installed-extension id patterns (for plugin-shaped hosts)
|
|
40
|
+
* - env: environment variables an editor exports into its own terminal —
|
|
41
|
+
* the strongest signal available, since it means we are running
|
|
42
|
+
* INSIDE that editor right now
|
|
43
|
+
*/
|
|
44
|
+
const EDITORS = [
|
|
45
|
+
{ id: 'claude-code', name: 'Claude Code', home: ['.claude'], env: ['CLAUDE_CODE_SESSION_ID', 'CLAUDE_PID'] },
|
|
46
|
+
{ id: 'cursor', name: 'Cursor', home: ['.cursor'], app: ['Cursor'], env: ['CURSOR_SESSION_ID', 'CURSOR_TRACE_ID'] },
|
|
47
|
+
{ id: 'codex', name: 'Codex', home: ['.codex'], env: ['CODEX_THREAD_ID'] },
|
|
48
|
+
{ id: 'gemini-cli', name: 'Gemini CLI', home: ['.gemini'] },
|
|
49
|
+
{ id: 'antigravity', name: 'Antigravity', home: ['.antigravity'], app: ['Antigravity'] },
|
|
50
|
+
{ id: 'vscode', name: 'VS Code', home: ['.vscode', '.vscode-insiders'], app: ['Code', 'Code - Insiders', 'VSCodium'] },
|
|
51
|
+
{ id: 'windsurf', name: 'Windsurf', home: ['.windsurf'], app: ['Windsurf'], env: ['WINDSURF_SESSION_ID'] },
|
|
52
|
+
{ id: 'cline', name: 'Cline', ext: [/^saoudrizwan\.claude-dev/i], env: ['CLINE_SESSION_ID'] },
|
|
53
|
+
{ id: 'copilot', name: 'GitHub Copilot', ext: [/^github\.copilot/i] },
|
|
54
|
+
{ id: 'aider', name: 'Aider', home: ['.aider', '.aider.conf.yml', '.aider.model.settings.yml'] },
|
|
55
|
+
];
|
|
56
|
+
|
|
57
|
+
const exists = (p) => { try { return fs.existsSync(p); } catch { return false; } };
|
|
58
|
+
|
|
59
|
+
/** Installed extension ids across every VS Code-shaped host on this machine. */
|
|
60
|
+
function installedExtensions(home) {
|
|
61
|
+
const ids = [];
|
|
62
|
+
for (const root of EXTENSION_ROOTS) {
|
|
63
|
+
const dir = path.join(home, root, 'extensions');
|
|
64
|
+
let entries;
|
|
65
|
+
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { continue; }
|
|
66
|
+
for (const e of entries) {
|
|
67
|
+
if (!e.isDirectory()) continue;
|
|
68
|
+
ids.push({ id: e.name, from: `${root}/extensions/${e.name}` });
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return ids;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Detect the agent tools present on this machine.
|
|
76
|
+
* @param {{ home?: string, platform?: string, env?: object }} [opts]
|
|
77
|
+
* @returns {{ present: Map<string,{id,name,why}>, absent: Array<{id,name}>, all: Array }}
|
|
78
|
+
*/
|
|
79
|
+
export function detectEditors(opts = {}) {
|
|
80
|
+
const home = opts.home || os.homedir();
|
|
81
|
+
const platform = opts.platform || process.platform;
|
|
82
|
+
const env = opts.env || process.env;
|
|
83
|
+
const appRoot = appDataDir({ platform, home, env });
|
|
84
|
+
|
|
85
|
+
// Scanned once — readdir on up to five extension roots is the only
|
|
86
|
+
// non-trivial cost in this module, and most machines have one or two.
|
|
87
|
+
let extensions = null;
|
|
88
|
+
const getExtensions = () => (extensions ??= installedExtensions(home));
|
|
89
|
+
|
|
90
|
+
const present = new Map();
|
|
91
|
+
const absent = [];
|
|
92
|
+
|
|
93
|
+
for (const ed of EDITORS) {
|
|
94
|
+
let why = null;
|
|
95
|
+
|
|
96
|
+
for (const key of ed.env || []) {
|
|
97
|
+
if (env[key]) { why = `running inside it (${key})`; break; }
|
|
98
|
+
}
|
|
99
|
+
if (!why) for (const dir of ed.home || []) {
|
|
100
|
+
if (exists(path.join(home, dir))) { why = `~/${dir}`; break; }
|
|
101
|
+
}
|
|
102
|
+
if (!why) for (const dir of ed.app || []) {
|
|
103
|
+
if (exists(path.join(appRoot, dir))) { why = `${path.basename(appRoot)}/${dir}`; break; }
|
|
104
|
+
}
|
|
105
|
+
if (!why && ed.ext) {
|
|
106
|
+
const hit = getExtensions().find((x) => ed.ext.some((re) => re.test(x.id)));
|
|
107
|
+
if (hit) why = hit.from;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (why) present.set(ed.id, { id: ed.id, name: ed.name, why });
|
|
111
|
+
else absent.push({ id: ed.id, name: ed.name });
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return { present, absent, all: EDITORS.map((e) => ({ id: e.id, name: e.name })) };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export { EDITORS };
|
package/src/mcp-presence.mjs
CHANGED
|
@@ -541,6 +541,15 @@ const publicSession = (session, now) => {
|
|
|
541
541
|
};
|
|
542
542
|
};
|
|
543
543
|
|
|
544
|
+
// Freshness floor for "is this undeclared connection actually WORKING?".
|
|
545
|
+
// Only real prompt/tool work stamps activityAt; heartbeats deliberately
|
|
546
|
+
// preserve it, so a fresh stamp means work, not merely a live socket.
|
|
547
|
+
const UNDECLARED_ACTIVE_MS = 10 * 60 * 1000;
|
|
548
|
+
const isUndeclaredActive = (session, now) =>
|
|
549
|
+
!isTaskSession(session)
|
|
550
|
+
&& Number(session?.activityAt || 0) > 0
|
|
551
|
+
&& now - Number(session.activityAt) < UNDECLARED_ACTIVE_MS;
|
|
552
|
+
|
|
544
553
|
export function buildPresenceSnapshot(sessions, selfId, { now = Date.now() } = {}) {
|
|
545
554
|
const connected = Array.isArray(sessions) ? sessions : [];
|
|
546
555
|
const self = connected.find((session) => session.id === selfId) || null;
|
|
@@ -548,25 +557,46 @@ export function buildPresenceSnapshot(sessions, selfId, { now = Date.now() } = {
|
|
|
548
557
|
const others = connected.filter((session) => session.id !== selfId && !isSuspectedTwin(session, self));
|
|
549
558
|
const twinCount = connected.length - 1 - others.length;
|
|
550
559
|
const tasks = others.filter(isTaskSession);
|
|
560
|
+
// 1.71.0 — "background" conflated two very different things: a connection
|
|
561
|
+
// sitting idle, and a session actively editing this repo that never declared
|
|
562
|
+
// a scope. Only the second one falsifies the coordination promise, because a
|
|
563
|
+
// peer reading this snapshot cannot see the work it is doing. Doctor has
|
|
564
|
+
// drawn that line since 1.70; the sync response buried it in one number.
|
|
565
|
+
const undeclaredActive = others.filter((session) => isUndeclaredActive(session, now));
|
|
551
566
|
return {
|
|
552
567
|
connectionCount: connected.length - Math.max(0, twinCount),
|
|
553
568
|
activeTaskCount: tasks.length + (self && isTaskSession(self) ? 1 : 0),
|
|
554
|
-
//
|
|
555
|
-
// declared a task may still be actively working — say so, don't bury it.
|
|
569
|
+
// Retained verbatim for every existing reader: idle + undeclared-active.
|
|
556
570
|
backgroundConnectionCount: others.length - tasks.length,
|
|
571
|
+
// ADDITIVE: the subset that is provably working without declaring scope.
|
|
572
|
+
undeclaredActiveCount: undeclaredActive.length,
|
|
573
|
+
idleConnectionCount: Math.max(0, others.length - tasks.length - undeclaredActive.length),
|
|
557
574
|
suspectedTwinCount: Math.max(0, twinCount),
|
|
558
575
|
self: self ? publicSession(self, now) : null,
|
|
559
576
|
peers: tasks.map((session) => publicSession(session, now)),
|
|
577
|
+
// Named, not just counted — a peer can address them with brain_message.
|
|
578
|
+
undeclaredActive: undeclaredActive.map((session) => publicSession(session, now)),
|
|
560
579
|
};
|
|
561
580
|
}
|
|
562
581
|
|
|
563
|
-
function formatTaskPresence(snapshot, now = Date.now()) {
|
|
582
|
+
export function formatTaskPresence(snapshot, now = Date.now()) {
|
|
564
583
|
const taskWord = snapshot.activeTaskCount === 1 ? 'task' : 'tasks';
|
|
565
584
|
const connectionWord = snapshot.connectionCount === 1 ? 'connection' : 'connections';
|
|
585
|
+
// The undeclared-ACTIVE count is stated separately and first among the
|
|
586
|
+
// caveats: it is the only figure here that means "work you cannot see".
|
|
587
|
+
const unseen = Number(snapshot.undeclaredActiveCount || 0);
|
|
588
|
+
const idle = Number(snapshot.idleConnectionCount ?? snapshot.backgroundConnectionCount ?? 0);
|
|
589
|
+
const caveats = [];
|
|
590
|
+
if (unseen) caveats.push(`${unseen} working WITHOUT declared scope — real edits you cannot see`);
|
|
591
|
+
if (idle) caveats.push(`${idle} connected but idle`);
|
|
566
592
|
const lines = [
|
|
567
593
|
`KLYPIX task presence: ${snapshot.activeTaskCount} active ${taskWord} across ${snapshot.connectionCount} live ${connectionWord}`
|
|
568
|
-
+ (
|
|
594
|
+
+ (caveats.length ? ` (${caveats.join('; ')})` : '') + '.',
|
|
569
595
|
];
|
|
596
|
+
if (unseen) {
|
|
597
|
+
lines.push(`⚠ Overlap detection covers DECLARED scope only, so ${unseen === 1 ? 'that session is' : 'those sessions are'} invisible to it.`
|
|
598
|
+
+ ' Treat a clean conflict report as incomplete, and coordinate directly (brain_message) before touching shared files.');
|
|
599
|
+
}
|
|
570
600
|
if (!snapshot.peers.length) {
|
|
571
601
|
lines.push('No other DECLARED task is active; connections that never called brain_sync are not listed here — they may still be working (see the connection count above).');
|
|
572
602
|
return lines.join('\n');
|
|
@@ -2462,6 +2492,11 @@ export function createMcpPresence({
|
|
|
2462
2492
|
connections: snapshot.connectionCount,
|
|
2463
2493
|
activeTasks: snapshot.activeTaskCount,
|
|
2464
2494
|
backgroundConnections: snapshot.backgroundConnectionCount,
|
|
2495
|
+
// 1.71.0 — the half of `backgroundConnections` that is actually
|
|
2496
|
+
// WORKING. A caller that reports "no conflicts" while this is non-zero
|
|
2497
|
+
// is reporting an incomplete search, not a clear one.
|
|
2498
|
+
undeclaredActive: snapshot.undeclaredActiveCount,
|
|
2499
|
+
idleConnections: snapshot.idleConnectionCount,
|
|
2465
2500
|
},
|
|
2466
2501
|
peers: snapshot.peers,
|
|
2467
2502
|
conflicts,
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// Prove the config we just wrote actually starts a server.
|
|
2
|
+
//
|
|
3
|
+
// The recorded lesson this exists to enforce: CONFIG WHOSE BREAKAGE MODE IS
|
|
4
|
+
// SILENCE NEEDS A GATE, NOT VIGILANCE. When a klypix entry is wrong the MCP
|
|
5
|
+
// server simply does not start — no error, no warning; the agent quietly loses
|
|
6
|
+
// every brain verb and degrades to whatever static context it already had. A
|
|
7
|
+
// project once lost all 17 verbs for five days that way, and the only thing
|
|
8
|
+
// that ever proved it was driving a REAL stdio handshake from the config
|
|
9
|
+
// file's own values (broken config: dead in 101ms; fixed: ~1s, 17 tools).
|
|
10
|
+
//
|
|
11
|
+
// So this module never trusts what the writer believes it wrote. It re-reads
|
|
12
|
+
// the file from disk, extracts the command and args the EDITOR will use, and
|
|
13
|
+
// speaks JSON-RPC to whatever that launches. A pass means a real client would
|
|
14
|
+
// connect; anything else is reported with the reason rather than swallowed.
|
|
15
|
+
import fs from 'fs';
|
|
16
|
+
import path from 'path';
|
|
17
|
+
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
|
18
|
+
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
|
19
|
+
import { safeReadCodexConfig } from './agent-rules.mjs';
|
|
20
|
+
|
|
21
|
+
const isKlypix = (name) => /klypix/i.test(String(name || ''));
|
|
22
|
+
|
|
23
|
+
/** Pull a TOML array-of-strings value (args = ["a", "b"]) out of a table block. */
|
|
24
|
+
function parseTomlArgs(block) {
|
|
25
|
+
const m = String(block || '').match(/^[ \t]*args[ \t]*=[ \t]*\[([\s\S]*?)\]/m);
|
|
26
|
+
if (!m) return [];
|
|
27
|
+
const out = [];
|
|
28
|
+
const re = /(["'])((?:\\.|(?!\1)[^\\])*)\1/g;
|
|
29
|
+
let hit;
|
|
30
|
+
while ((hit = re.exec(m[1]))) out.push(hit[2].replace(/\\(["'\\])/g, '$1'));
|
|
31
|
+
return out;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Read a written MCP config and return the launch spec an editor would use.
|
|
36
|
+
* @returns {{ ok: boolean, command?: string, args?: string[], cwd?: string, why?: string }}
|
|
37
|
+
*/
|
|
38
|
+
export function readLaunchSpec(file) {
|
|
39
|
+
if (!fs.existsSync(file)) return { ok: false, why: 'config file not written' };
|
|
40
|
+
let raw;
|
|
41
|
+
try { raw = fs.readFileSync(file, 'utf8'); }
|
|
42
|
+
catch (e) { return { ok: false, why: `unreadable: ${e?.message || e}` }; }
|
|
43
|
+
|
|
44
|
+
if (/\.toml$/i.test(file)) {
|
|
45
|
+
const parsed = safeReadCodexConfig(file);
|
|
46
|
+
if (!parsed.ok) return { ok: false, why: parsed.error };
|
|
47
|
+
const name = Object.keys(parsed.servers).find(isKlypix);
|
|
48
|
+
if (!name) return { ok: false, why: 'no klypix server entry' };
|
|
49
|
+
const entry = parsed.servers[name];
|
|
50
|
+
if (!entry.command) return { ok: false, why: 'entry has no command' };
|
|
51
|
+
return { ok: true, command: entry.command, args: parseTomlArgs(entry.raw), cwd: entry.cwd || undefined };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
let cfg;
|
|
55
|
+
try { cfg = JSON.parse(raw); }
|
|
56
|
+
catch (e) { return { ok: false, why: `invalid JSON: ${e?.message || e}` }; }
|
|
57
|
+
// `.vscode/mcp.json` uses `servers`; everyone else uses `mcpServers`.
|
|
58
|
+
const bag = (cfg && typeof cfg === 'object')
|
|
59
|
+
? (cfg.mcpServers && typeof cfg.mcpServers === 'object' ? cfg.mcpServers
|
|
60
|
+
: cfg.servers && typeof cfg.servers === 'object' ? cfg.servers : null)
|
|
61
|
+
: null;
|
|
62
|
+
if (!bag) return { ok: false, why: 'no mcpServers/servers block' };
|
|
63
|
+
const name = Object.keys(bag).find(isKlypix);
|
|
64
|
+
if (!name) return { ok: false, why: 'no klypix server entry' };
|
|
65
|
+
const entry = bag[name] || {};
|
|
66
|
+
if (!entry.command) return { ok: false, why: 'entry has no command' };
|
|
67
|
+
return {
|
|
68
|
+
ok: true,
|
|
69
|
+
command: String(entry.command),
|
|
70
|
+
args: Array.isArray(entry.args) ? entry.args.map(String) : [],
|
|
71
|
+
cwd: entry.cwd ? String(entry.cwd) : undefined,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Launch the server exactly as the editor would and count the tools it offers.
|
|
77
|
+
*
|
|
78
|
+
* `cwd` defaults to the project root because several hosts resolve a relative
|
|
79
|
+
* `--vault .` against the session's working directory, not the config file's
|
|
80
|
+
* location — verifying from anywhere else would test a path no editor uses.
|
|
81
|
+
*
|
|
82
|
+
* @param {{ file: string, projectDir: string, timeoutMs?: number, env?: object }} opts
|
|
83
|
+
* @returns {Promise<{ ok: boolean, toolCount?: number, ms?: number, why?: string, hasBrainSync?: boolean }>}
|
|
84
|
+
*/
|
|
85
|
+
export async function verifyMcpConfig({ file, projectDir, timeoutMs = 25_000, env = process.env }) {
|
|
86
|
+
const spec = readLaunchSpec(file);
|
|
87
|
+
if (!spec.ok) return { ok: false, why: spec.why };
|
|
88
|
+
|
|
89
|
+
const started = Date.now();
|
|
90
|
+
let client = null;
|
|
91
|
+
try {
|
|
92
|
+
const childEnv = Object.fromEntries(
|
|
93
|
+
Object.entries({ ...env, KLYPIX_AUTO_UPDATE: '0', KLYPIX_RERANK: '0' })
|
|
94
|
+
.filter(([, v]) => typeof v === 'string'),
|
|
95
|
+
);
|
|
96
|
+
const transport = new StdioClientTransport({
|
|
97
|
+
command: spec.command,
|
|
98
|
+
args: spec.args,
|
|
99
|
+
cwd: spec.cwd || projectDir,
|
|
100
|
+
env: childEnv,
|
|
101
|
+
stderr: 'pipe',
|
|
102
|
+
});
|
|
103
|
+
client = new Client({ name: 'klypix-setup-verify', version: '1.0.0' });
|
|
104
|
+
|
|
105
|
+
// A hung child must not hang setup. The race leaves the transport to the
|
|
106
|
+
// finally block, which closes it either way.
|
|
107
|
+
const connected = await Promise.race([
|
|
108
|
+
client.connect(transport).then(() => 'ok'),
|
|
109
|
+
new Promise((r) => setTimeout(() => r('timeout'), timeoutMs)),
|
|
110
|
+
]);
|
|
111
|
+
if (connected === 'timeout') return { ok: false, why: `no response within ${Math.round(timeoutMs / 1000)}s` };
|
|
112
|
+
|
|
113
|
+
const listed = await Promise.race([
|
|
114
|
+
client.listTools().then((t) => t),
|
|
115
|
+
new Promise((r) => setTimeout(() => r(null), timeoutMs)),
|
|
116
|
+
]);
|
|
117
|
+
if (!listed) return { ok: false, why: 'connected but never listed its tools' };
|
|
118
|
+
|
|
119
|
+
const tools = Array.isArray(listed.tools) ? listed.tools : [];
|
|
120
|
+
return {
|
|
121
|
+
ok: tools.length > 0,
|
|
122
|
+
toolCount: tools.length,
|
|
123
|
+
hasBrainSync: tools.some((t) => t?.name === 'brain_sync'),
|
|
124
|
+
ms: Date.now() - started,
|
|
125
|
+
...(tools.length ? {} : { why: 'server started but offered no tools' }),
|
|
126
|
+
};
|
|
127
|
+
} catch (e) {
|
|
128
|
+
// The classic broken-config signature is an immediate "Connection closed".
|
|
129
|
+
const why = String(e?.message || e).replace(/\s+/g, ' ').slice(0, 200);
|
|
130
|
+
return { ok: false, why, ms: Date.now() - started };
|
|
131
|
+
} finally {
|
|
132
|
+
try { await client?.close(); } catch { /* the child is exiting anyway */ }
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export { parseTomlArgs };
|
package/src/setup.mjs
ADDED
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
// One command that knows where it is.
|
|
2
|
+
//
|
|
3
|
+
// Until 1.71 a user had to know three things nobody told them: that `install`
|
|
4
|
+
// wires the machine but not the project, that `link` writes the per-editor
|
|
5
|
+
// config, and that `init` seeds the brain. Miss the middle one — as the website
|
|
6
|
+
// invited people to — and seven of eight editors showed an empty MCP panel with
|
|
7
|
+
// no error. A promise is only as true as its quietest step, so this module
|
|
8
|
+
// deletes the steps instead of documenting them.
|
|
9
|
+
//
|
|
10
|
+
// It deliberately DELEGATES rather than reimplements: the brain seed comes from
|
|
11
|
+
// the existing `init` verb and the merge driver from `git-driver install`, both
|
|
12
|
+
// already tested and both idempotent (init refuses to overwrite an existing
|
|
13
|
+
// brain; the driver re-registers harmlessly). Running them as child processes
|
|
14
|
+
// keeps their exact behaviour and keeps this file thin — a duplicated seed is a
|
|
15
|
+
// second source of truth waiting to drift.
|
|
16
|
+
import fs from 'fs';
|
|
17
|
+
import path from 'path';
|
|
18
|
+
import { fileURLToPath } from 'url';
|
|
19
|
+
import { execFile } from 'child_process';
|
|
20
|
+
import { detectEditors } from './editor-detect.mjs';
|
|
21
|
+
import { linkProject } from './agent-rules.mjs';
|
|
22
|
+
import { verifyMcpConfig, readLaunchSpec } from './mcp-verify.mjs';
|
|
23
|
+
|
|
24
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
25
|
+
const CLI = path.join(__dirname, '..', 'bin', 'klypix-mcp.mjs');
|
|
26
|
+
const BRAIN_NAMES = ['brain.klypix', 'brain.any'];
|
|
27
|
+
const exists = (p) => { try { return fs.existsSync(p); } catch { return false; } };
|
|
28
|
+
|
|
29
|
+
function runCli(args, cwd, timeout = 60_000) {
|
|
30
|
+
return new Promise((resolve) => {
|
|
31
|
+
execFile(process.execPath, [CLI, ...args], { cwd, timeout, windowsHide: true }, (error, stdout, stderr) => {
|
|
32
|
+
resolve({ ok: !error, out: String(stdout || ''), err: String(stderr || ''), code: error?.code ?? 0 });
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Where does this project actually start?
|
|
39
|
+
*
|
|
40
|
+
* Users run commands from wherever they happen to be — `src/`, `admin/`, a
|
|
41
|
+
* package folder — so cwd is the wrong answer more often than it is the right
|
|
42
|
+
* one. An existing brain wins over the git root, because a monorepo may keep
|
|
43
|
+
* per-package brains; the git root is the fallback that makes a fresh repo seed
|
|
44
|
+
* its brain beside the code rather than three levels down.
|
|
45
|
+
*/
|
|
46
|
+
export function resolveProjectRoot(startDir = process.cwd()) {
|
|
47
|
+
let dir = path.resolve(startDir);
|
|
48
|
+
const chain = [];
|
|
49
|
+
for (let i = 0; i < 40; i++) {
|
|
50
|
+
chain.push(dir);
|
|
51
|
+
const parent = path.dirname(dir);
|
|
52
|
+
if (parent === dir) break;
|
|
53
|
+
dir = parent;
|
|
54
|
+
}
|
|
55
|
+
for (const d of chain) {
|
|
56
|
+
for (const name of BRAIN_NAMES) if (exists(path.join(d, name))) return { root: d, why: `found ${name}` };
|
|
57
|
+
}
|
|
58
|
+
for (const d of chain) {
|
|
59
|
+
if (exists(path.join(d, '.git'))) return { root: d, why: 'git repository root' };
|
|
60
|
+
}
|
|
61
|
+
return { root: path.resolve(startDir), why: 'current directory' };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Manifests that mark a directory as somebody's project even without git.
|
|
65
|
+
// Non-developers are a first-class audience here, so "no repo" must not mean
|
|
66
|
+
// "no brain" — but it does mean we need SOME positive signal before writing.
|
|
67
|
+
const PROJECT_MANIFESTS = [
|
|
68
|
+
'package.json', 'pyproject.toml', 'requirements.txt', 'Cargo.toml', 'go.mod',
|
|
69
|
+
'pom.xml', 'build.gradle', 'Gemfile', 'composer.json', 'CMakeLists.txt',
|
|
70
|
+
'README.md', 'AGENTS.md', 'CLAUDE.md',
|
|
71
|
+
];
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Is it safe and sensible to wire this directory?
|
|
75
|
+
*
|
|
76
|
+
* `install` is run from wherever the user happens to be standing, and the
|
|
77
|
+
* failure we must never ship is seeding a brain into a home directory or a
|
|
78
|
+
* drive root because someone typed the command in the wrong window. A refusal
|
|
79
|
+
* is cheap; an unexpected brain.klypix in C:\Users\me is not.
|
|
80
|
+
*/
|
|
81
|
+
export function projectSignal(root, { home = null } = {}) {
|
|
82
|
+
const resolved = path.resolve(root);
|
|
83
|
+
const homeDir = path.resolve(home || (process.env.USERPROFILE || process.env.HOME || ''));
|
|
84
|
+
if (homeDir && resolved === homeDir) return { ok: false, why: 'this is your home folder, not a project' };
|
|
85
|
+
if (path.dirname(resolved) === resolved) return { ok: false, why: 'this is a drive root, not a project' };
|
|
86
|
+
|
|
87
|
+
for (const name of BRAIN_NAMES) if (exists(path.join(resolved, name))) return { ok: true, why: `has ${name}` };
|
|
88
|
+
if (exists(path.join(resolved, '.git'))) return { ok: true, why: 'git repository' };
|
|
89
|
+
for (const name of PROJECT_MANIFESTS) if (exists(path.join(resolved, name))) return { ok: true, why: `has ${name}` };
|
|
90
|
+
return { ok: false, why: 'no project files found here' };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Every MCP config this project could carry, relative to its root. Kept beside
|
|
94
|
+
// the projection map's own list so the ownership check covers all six hosts,
|
|
95
|
+
// not just the Claude Code one the original migration knew about.
|
|
96
|
+
const MCP_FILES = [
|
|
97
|
+
'.mcp.json', '.cursor/mcp.json', '.cline/mcp.json',
|
|
98
|
+
'.gemini/settings.json', '.vscode/mcp.json', '.codex/config.toml',
|
|
99
|
+
];
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Is this config pinned to a server the PROJECT owns?
|
|
103
|
+
*
|
|
104
|
+
* A repo-relative node launch (`scripts/klypix-mcp-server.mjs`) is a deliberate
|
|
105
|
+
* choice: it resolves offline, is identical on every machine, and rides a
|
|
106
|
+
* vendored bundle the repo version-gates, so the running server can never skew
|
|
107
|
+
* from what the repo ships. Replacing it with a machine-specific ~/.claude path
|
|
108
|
+
* would break exactly the property it was chosen for — and silently. The
|
|
109
|
+
* existing `.mcp.json` migration has always honoured this; automatic setup must
|
|
110
|
+
* honour it across every host.
|
|
111
|
+
*/
|
|
112
|
+
export function isProjectOwnedMcp(file) {
|
|
113
|
+
const spec = readLaunchSpec(file);
|
|
114
|
+
if (!spec.ok) return false;
|
|
115
|
+
const first = spec.args?.[0];
|
|
116
|
+
return /(^|[\\/])node(\.exe)?$/i.test(spec.command)
|
|
117
|
+
&& typeof first === 'string'
|
|
118
|
+
&& first !== '-e'
|
|
119
|
+
&& !first.startsWith('-')
|
|
120
|
+
&& !path.isAbsolute(first);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** The written config most worth proving — the host we are running inside, if any. */
|
|
124
|
+
function pickVerifyTarget(written, present) {
|
|
125
|
+
const inside = [...present.values()].find((e) => /running inside it/.test(e.why));
|
|
126
|
+
const byEditor = (id) => written.find((w) => (w.editors || []).includes(id));
|
|
127
|
+
return (inside && byEditor(inside.id))
|
|
128
|
+
|| byEditor('claude-code')
|
|
129
|
+
|| byEditor('cursor')
|
|
130
|
+
|| written[0]
|
|
131
|
+
|| null;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Wire this project end to end, then prove it.
|
|
136
|
+
* @param {{ cwd?, verifyAll?, skipVerify?, timeoutMs? }} [opts]
|
|
137
|
+
* @returns {Promise<object>} a structured report (also the `--json` payload)
|
|
138
|
+
*/
|
|
139
|
+
export async function runSetup(opts = {}) {
|
|
140
|
+
const started = Date.now();
|
|
141
|
+
const { root, why: rootWhy } = resolveProjectRoot(opts.cwd || process.cwd());
|
|
142
|
+
const report = {
|
|
143
|
+
schema: 1,
|
|
144
|
+
project: root,
|
|
145
|
+
rootReason: rootWhy,
|
|
146
|
+
brain: null,
|
|
147
|
+
editors: { present: [], skippedTargets: [] },
|
|
148
|
+
wrote: [],
|
|
149
|
+
gitDriver: null,
|
|
150
|
+
verified: null,
|
|
151
|
+
warnings: [],
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
// 0) Refuse politely rather than write into somewhere that isn't a project.
|
|
155
|
+
const signal = projectSignal(root);
|
|
156
|
+
if (!signal.ok) {
|
|
157
|
+
report.skipped = signal.why;
|
|
158
|
+
report.ms = Date.now() - started;
|
|
159
|
+
return report;
|
|
160
|
+
}
|
|
161
|
+
report.rootReason = `${rootWhy}`;
|
|
162
|
+
|
|
163
|
+
// 1) A brain, or there is nothing to wire.
|
|
164
|
+
const brainPath = BRAIN_NAMES.map((n) => path.join(root, n)).find(exists);
|
|
165
|
+
if (brainPath) {
|
|
166
|
+
report.brain = { path: brainPath, created: false };
|
|
167
|
+
} else {
|
|
168
|
+
const seeded = await runCli(['init'], root);
|
|
169
|
+
const madePath = BRAIN_NAMES.map((n) => path.join(root, n)).find(exists);
|
|
170
|
+
if (!madePath) {
|
|
171
|
+
report.brain = { path: null, created: false, error: (seeded.err || seeded.out).trim().slice(0, 300) || 'init produced no brain' };
|
|
172
|
+
report.warnings.push('No brain could be created — nothing else could be wired.');
|
|
173
|
+
report.ms = Date.now() - started;
|
|
174
|
+
return report;
|
|
175
|
+
}
|
|
176
|
+
report.brain = { path: madePath, created: true };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// 2) Which hosts does this machine actually have?
|
|
180
|
+
const detected = detectEditors();
|
|
181
|
+
report.editors.present = [...detected.present.values()];
|
|
182
|
+
|
|
183
|
+
// 3) Project config for those hosts only — plus anything the project already
|
|
184
|
+
// carries, so a one-editor developer keeps their team's files current.
|
|
185
|
+
const projectOwned = MCP_FILES.filter((rel) => isProjectOwnedMcp(path.join(root, rel)));
|
|
186
|
+
const linked = linkProject(root, { editors: detected.present.keys(), exclude: projectOwned });
|
|
187
|
+
report.editors.skippedTargets = linked.skipped;
|
|
188
|
+
report.wrote = [...linked.rules, ...linked.mcp]
|
|
189
|
+
.filter((t) => t.status !== 'error' && t.action !== 'skipped')
|
|
190
|
+
.map((t) => ({ tool: t.tool, file: t.file, action: t.action || t.status || 'ok' }));
|
|
191
|
+
for (const t of [...linked.rules, ...linked.mcp]) {
|
|
192
|
+
if (t.status === 'error' || (t.action === 'skipped' && t.why)) {
|
|
193
|
+
report.warnings.push(`${t.file}: ${t.why || 'could not be written'}`);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// 4) The merge driver, silently — never a settings panel with an Install
|
|
198
|
+
// button. Only meaningful inside a git repo.
|
|
199
|
+
if (exists(path.join(root, '.git'))) {
|
|
200
|
+
const drv = await runCli(['git-driver', 'install', root], root, 30_000);
|
|
201
|
+
report.gitDriver = { ok: drv.ok, detail: (drv.out || drv.err).split('\n').find((l) => l.trim())?.trim() || null };
|
|
202
|
+
if (!drv.ok) report.warnings.push('Could not register the .klypix merge driver — brain merges will need manual conflict resolution.');
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// 5) Prove it. Writing a config is not evidence that a server starts, and the
|
|
206
|
+
// failure mode of a wrong entry is silence, so setup does not exit until a
|
|
207
|
+
// real client has connected and counted tools.
|
|
208
|
+
if (!opts.skipVerify) {
|
|
209
|
+
// linkProject reports per-file results, not the editor ids behind them, so
|
|
210
|
+
// re-attach them here purely to choose WHICH config to prove.
|
|
211
|
+
const idFor = {
|
|
212
|
+
'.codex/config.toml': ['codex'], '.mcp.json': ['claude-code'], '.cursor/mcp.json': ['cursor'],
|
|
213
|
+
'.cline/mcp.json': ['cline'], '.gemini/settings.json': ['gemini-cli', 'antigravity'], '.vscode/mcp.json': ['vscode', 'copilot'],
|
|
214
|
+
};
|
|
215
|
+
const mcpWritten = linked.mcp
|
|
216
|
+
.filter((m) => m.status !== 'error' && m.action !== 'skipped')
|
|
217
|
+
.map((m) => ({ ...m, editors: idFor[m.file] || [] }));
|
|
218
|
+
// A project-owned config is left untouched but is still what the editor
|
|
219
|
+
// will launch — and an unverified vendored path is precisely how a repo
|
|
220
|
+
// once lost every brain verb for five days. Prove it too.
|
|
221
|
+
for (const rel of projectOwned) {
|
|
222
|
+
mcpWritten.push({ tool: `${rel} (project-owned)`, file: rel, editors: idFor[rel] || [] });
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const chosen = opts.verifyAll ? mcpWritten : [pickVerifyTarget(mcpWritten, detected.present)].filter(Boolean);
|
|
226
|
+
const results = [];
|
|
227
|
+
for (const target of chosen) {
|
|
228
|
+
const res = await verifyMcpConfig({
|
|
229
|
+
file: path.join(root, target.file),
|
|
230
|
+
projectDir: root,
|
|
231
|
+
timeoutMs: opts.timeoutMs || 25_000,
|
|
232
|
+
});
|
|
233
|
+
results.push({ tool: target.tool, file: target.file, ...res });
|
|
234
|
+
if (!res.ok) report.warnings.push(`${target.file} did not start a server: ${res.why}`);
|
|
235
|
+
}
|
|
236
|
+
report.verified = results;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
report.ms = Date.now() - started;
|
|
240
|
+
return report;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** The five-line brief a human reads after the command finishes. */
|
|
244
|
+
export function renderBrief(report) {
|
|
245
|
+
const L = [];
|
|
246
|
+
const rel = (p) => path.relative(report.project, p).replace(/\\/g, '/') || path.basename(p);
|
|
247
|
+
L.push('');
|
|
248
|
+
if (report.skipped) {
|
|
249
|
+
L.push(` No project wired — ${report.skipped}.`);
|
|
250
|
+
L.push(` Run this again inside your project folder: cd <your project> && npx klypix-mcp install`);
|
|
251
|
+
L.push('');
|
|
252
|
+
return L.join('\n');
|
|
253
|
+
}
|
|
254
|
+
L.push(` project ${report.project} (${report.rootReason})`);
|
|
255
|
+
L.push(report.brain?.created
|
|
256
|
+
? ` brain created ${rel(report.brain.path)} — a starter brain, ready for its first decision`
|
|
257
|
+
: report.brain?.path ? ` brain ${rel(report.brain.path)} (already here — left untouched)`
|
|
258
|
+
: ' brain ✗ none');
|
|
259
|
+
|
|
260
|
+
const names = report.editors.present.map((e) => e.name);
|
|
261
|
+
L.push(` editors ${names.length ? names.join(' · ') : 'none detected'}`);
|
|
262
|
+
|
|
263
|
+
const changed = report.wrote.filter((w) => w.action && !/^(ok|unchanged|current)$/i.test(w.action));
|
|
264
|
+
L.push(` wired ${report.wrote.length} file(s)${changed.length ? ` · ${changed.length} updated` : ' · all current'}`
|
|
265
|
+
+ (report.editors.skippedTargets.length ? ` (skipped ${report.editors.skippedTargets.length} for tools you don't have)` : ''));
|
|
266
|
+
|
|
267
|
+
if (report.gitDriver) {
|
|
268
|
+
L.push(` git ${report.gitDriver.ok ? 'lossless .klypix merge driver registered' : '✗ merge driver not registered'}`);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
for (const v of report.verified || []) {
|
|
272
|
+
L.push(v.ok
|
|
273
|
+
? ` verified ✓ ${v.toolCount} tools reachable via ${v.file} (${v.ms}ms)`
|
|
274
|
+
: ` verified ✗ ${v.file} — ${v.why}`);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
L.push('');
|
|
278
|
+
if (report.warnings.length) {
|
|
279
|
+
for (const w of report.warnings) L.push(` ⚠ ${w}`);
|
|
280
|
+
L.push('');
|
|
281
|
+
} else if ((report.verified || []).some((v) => v.ok)) {
|
|
282
|
+
L.push(' Open the project in your editor and ask it: "sync with the KLYPIX brain."');
|
|
283
|
+
L.push('');
|
|
284
|
+
}
|
|
285
|
+
return L.join('\n');
|
|
286
|
+
}
|