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 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
- **Claude Code + Codex:**
88
+ Run this **inside your project**:
89
89
 
90
90
  ```bash
91
91
  npx klypix-mcp install
92
92
  ```
93
93
 
94
- This copies the engine and a local MCP runtime into `~/.claude/project-brain`, wires Claude Code's
95
- four lifecycle hooks, writes Codex's global `~/.codex/AGENTS.md` guidance block, and wires Codex's
96
- MCP connection **for the project you run it in**.
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
- Be precise about what "machine-global" covers:
121
+ Three things it deliberately will **not** do:
99
122
 
100
- - **Machine-global** — the engine + runtime in `~/.claude/project-brain`, the four Claude Code
101
- hooks in `~/.claude/settings.json`, and the `~/.codex/AGENTS.md` guidance. Claude Code is
102
- therefore covered in every project on that machine that has a `./brain.klypix`.
103
- - **Per project** — Codex's MCP connection. `install` writes it into `<cwd>/.codex/config.toml`,
104
- only when that directory has a `brain.klypix`, and it deliberately **removes** any *global*
105
- `~/.codex/config.toml` KLYPIX entry (a global entry resolves its `--vault` from the wrong
106
- directory and binds the wrong brain). Run `install` — or `link` — once inside each brain project
107
- you want Codex wired to.
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
- It does **not** set up Cursor, Cline, Windsurf, Copilot, Gemini CLI or Aider — those need `link`.
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
- **Every other agent tool — one command per project:**
149
+ **Re-project everything explicitly:**
123
150
 
124
151
  ```bash
125
152
  npx klypix-mcp link
126
153
  ```
127
154
 
128
- Writes 14 managed, hash-stamped files: MCP server config for six hosts, plus rules files for
129
- eight. Managed blocks are merged into your existing instruction files and never clobber your
130
- content.
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` | Install the engine + Claude Code hooks on this machine (see Quick start) |
485
- | `npx klypix-mcp link` | Wire this project for Cursor, Cline, Windsurf, Copilot, Gemini CLI, Aider (`--check` audits) |
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 |
@@ -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);
@@ -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] install/update this machine\'s brain engine + Claude Code 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.70.0",
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"
@@ -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
- * @returns {{ rules: Array, mcp: Array, hasBrain: boolean, version: string, check: boolean }}
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 };
@@ -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
- // "background" here means SYNC-SILENT, not idle: a connection that never
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
- + (snapshot.backgroundConnectionCount ? ` (${snapshot.backgroundConnectionCount} connected without a declared task — presence known, scope unknown)` : '') + '.',
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
+ }