@sym-bot/mesh-channel 0.3.4 → 0.3.6

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/bin/install.js CHANGED
@@ -1,603 +1,603 @@
1
- #!/usr/bin/env node
2
- 'use strict';
3
-
4
- /**
5
- * sym-mesh-channel install — interactive setup for the MCP server.
6
- *
7
- * Run: npx @sym-bot/mesh-channel init
8
- *
9
- * What it does:
10
- * 1. Detects the platform and the host name suggestion (claude-mac /
11
- * claude-win / claude-linux), or accepts an override.
12
- * 2. Resolves the absolute path to the installed server.js so Claude
13
- * Code can spawn it.
14
- * 3. Reads ~/.claude.json (the Claude Code settings file), backs it
15
- * up, adds an `mcpServers` entry under the current project for
16
- * `claude-sym-mesh`, atomically writes the result.
17
- * 4. Prints the launch command including the Channels dev flag.
18
- *
19
- * Safety:
20
- * - Backs up ~/.claude.json to ~/.claude.json.bak-<timestamp> before
21
- * any write.
22
- * - Validates JSON parses round-trip before writing.
23
- * - Atomic via write-to-tmp + rename.
24
- * - Refuses to overwrite a LIVE claude-sym-mesh entry without --force.
25
- * An entry whose args[0] server.js path no longer exists on disk is
26
- * treated as STALE and rewritten in place — a stale entry guarantees
27
- * a broken MCP transport, so "preserving" it is never what the user
28
- * wants. SYM_NODE_NAME from the stale entry is preserved so the
29
- * mesh identity doesn't drift to the hostname-based default.
30
- * - Also scans every project-scoped mcpServers entry and rewrites any
31
- * project entry whose claude-sym-mesh.args[0] path has gone stale,
32
- * again preserving each project's SYM_NODE_NAME. This prevents the
33
- * "ghost project" failure mode where user-global was fixed but
34
- * project-scoped entries silently continue to point at the old path.
35
- *
36
- * Copyright (c) 2026 SYM.BOT. Apache 2.0 License.
37
- */
38
-
39
- const fs = require('fs');
40
- const path = require('path');
41
- const os = require('os');
42
-
43
- const args = process.argv.slice(2);
44
- const force = args.includes('--force');
45
- const isPostinstall = args.includes('--postinstall');
46
- const isProject = args.includes('--project');
47
- const cmd = args.find((a) => !a.startsWith('--')) || 'init';
48
-
49
- // --group <name>: persist a SYM_GROUP env entry into the written .mcp.json /
50
- // ~/.claude.json so the node joins that group on every Claude Code launch.
51
- // Without this flag, the env block omits SYM_GROUP and the node falls back
52
- // to the default _sym._tcp mesh on startup. Runtime sym_join_group hot-swaps
53
- // only last for the current session — without persistence, peers in named
54
- // groups silently revert to default and become invisible to teammates.
55
- const groupArgIdx = args.indexOf('--group');
56
- const groupArg = groupArgIdx !== -1 ? args[groupArgIdx + 1] : null;
57
-
58
- if (cmd !== 'init' && cmd !== 'doctor') {
59
- process.stderr.write(`Unknown command: ${cmd}\nUsage: sym-mesh-channel init [--project] [--force] [--group <name>]\n sym-mesh-channel doctor\n`);
60
- process.exit(1);
61
- }
62
-
63
- const KEBAB_CASE_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
64
- function validateGroupValue(value, source) {
65
- if (!value) return;
66
- if (value === 'default') return;
67
- if (!KEBAB_CASE_RE.test(value)) {
68
- process.stderr.write(`ERROR: ${source} "${value}" must be kebab-case (e.g. backend-team) or "default".\n`);
69
- process.exit(1);
70
- }
71
- }
72
- validateGroupValue(groupArg, '--group');
73
- // Apply the same gate to the env-var path. Pre-0.3.4-followup, a malformed
74
- // SYM_GROUP=' ' or SYM_GROUP=Backend_Team value flowed through unvalidated
75
- // and got written into the .mcp.json env block as-is, producing an mDNS
76
- // service type the SymNode would silently fail to register on. Now both
77
- // inputs share the validator with the same error message shape.
78
- validateGroupValue(process.env.SYM_GROUP, 'SYM_GROUP');
79
-
80
- // ── isStaleEntry: a claude-sym-mesh entry whose server.js path is gone ──
81
- // Returns true when the entry exists but its args[0] path does not resolve
82
- // to a file on disk. Such an entry can never spawn the MCP server — every
83
- // launch yields "Failed to reconnect" in /mcp. Treating it as rewritable
84
- // on postinstall means users who move or uninstall an old copy of the repo
85
- // get healed automatically on the next `npm install -g @sym-bot/mesh-channel`
86
- // without needing to know about --force.
87
- function isStaleEntry(entry) {
88
- if (!entry || !Array.isArray(entry.args) || entry.args.length === 0) return false;
89
- const p = entry.args[0];
90
- if (typeof p !== 'string' || !p) return false;
91
- try { return !fs.existsSync(p); } catch { return true; }
92
- }
93
-
94
- // preserveNodeName: return the SYM_NODE_NAME from an existing entry's env
95
- // so rewrites keep the mesh identity. Falls back to nothing if absent; the
96
- // caller then uses the computed default.
97
- function preserveNodeName(entry) {
98
- if (!entry || !entry.env || typeof entry.env.SYM_NODE_NAME !== 'string') return null;
99
- const n = entry.env.SYM_NODE_NAME.trim();
100
- return n || null;
101
- }
102
-
103
- // preserveGroup: return the SYM_GROUP from an existing entry's env so
104
- // rewrites keep the mesh group. Same shape as preserveNodeName — without
105
- // this, healing a stale entry would drop a previously-persisted group
106
- // and silently downgrade the node to the default _sym._tcp mesh,
107
- // stranding teammates who stay in the named group.
108
- function preserveGroup(entry) {
109
- if (!entry || !entry.env || typeof entry.env.SYM_GROUP !== 'string') return null;
110
- const g = entry.env.SYM_GROUP.trim();
111
- return g || null;
112
- }
113
-
114
- // --postinstall always runs global install (npm postinstall runs from
115
- // npm's staging directory, not the user's project dir). If both flags
116
- // are passed, the --project flag is ignored during postinstall.
117
- const useProjectMode = isProject && !isPostinstall;
118
-
119
- // ── Detect platform & defaults ────────────────────────────────────
120
-
121
- // Default: hostname-based identity, unique per machine. Prevents
122
- // the ghost-peer bug where two machines with the same default name
123
- // create phantom peers that absorb messages.
124
- const defaultNodeName = `claude-${os.hostname().toLowerCase().replace(/[^a-z0-9-]/g, '-')}`;
125
-
126
- // SYM_NODE_NAME from env wins over default
127
- const nodeName = process.env.SYM_NODE_NAME || defaultNodeName;
128
-
129
- // Capture the user's *explicit* group intent for this install, distinct
130
- // from "user didn't ask, use existing or default":
131
- // null → user didn't pass --group or SYM_GROUP
132
- // 'default' → user explicitly wants the global _sym._tcp mesh
133
- // (escape hatch: revert from a named group, with --force)
134
- // '<kebab-name>' → user explicitly wants this named group
135
- const explicitGroup = groupArg !== null ? groupArg
136
- : (process.env.SYM_GROUP || null);
137
-
138
- // resolveGroup: per-scope group resolution that respects both the user's
139
- // explicit intent AND the existing entry's persisted state.
140
- //
141
- // With --force AND an explicit value: flag/env wins. The user is
142
- // deliberately overriding state. `--force --group new-team` switches
143
- // groups; `--force --group default` reverts to the global mesh.
144
- //
145
- // Without --force, OR with --force but no explicit value: preserve
146
- // from the existing entry (heal-path job is to NOT lose user state).
147
- // Falls back to the explicit value, then to none.
148
- //
149
- // Returns the SYM_GROUP value to write, or null to omit the key entirely
150
- // (which the caller maps to "leave SYM_GROUP out of the env block, node
151
- // uses default _sym._tcp on launch").
152
- function resolveGroup(existingEntry) {
153
- const preserved = preserveGroup(existingEntry);
154
- if (force && explicitGroup !== null) {
155
- return explicitGroup === 'default' ? null : explicitGroup;
156
- }
157
- if (preserved) return preserved;
158
- if (explicitGroup && explicitGroup !== 'default') return explicitGroup;
159
- return null;
160
- }
161
-
162
- // ── Resolve server.js path ────────────────────────────────────────
163
-
164
- // Resolve server.js from the installed package location. require.resolve
165
- // returns the actual installed path regardless of where postinstall runs
166
- // from (npm on Windows may run postinstall from a temp staging directory).
167
- let serverJsPath;
168
- try {
169
- serverJsPath = require.resolve('@sym-bot/mesh-channel/server.js');
170
- } catch {
171
- // Fallback for local development / cloned repo
172
- serverJsPath = path.resolve(__dirname, '..', 'server.js');
173
- }
174
- if (!fs.existsSync(serverJsPath)) {
175
- process.stderr.write(`ERROR: cannot find server.js at ${serverJsPath}\n`);
176
- process.stderr.write('This installer must be run from a published @sym-bot/mesh-channel package.\n');
177
- process.exit(1);
178
- }
179
-
180
- // Shared timestamp for backup filenames
181
- const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
182
-
183
- // ── Project-scoped install (--project flag) ───────────────────────
184
- // Writes <cwd>/.mcp.json + merges <cwd>/.claude/settings.local.json
185
- // instead of touching ~/.claude.json. Use this when you want multiple
186
- // Claude Code sessions on one machine to appear as distinct mesh peers
187
- // (one per project), each with its own SYM_NODE_NAME. Project-level
188
- // .mcp.json overrides the global ~/.claude.json mcpServers entry when
189
- // Claude Code is launched from that directory.
190
-
191
- if (useProjectMode) {
192
- const projectDir = process.cwd();
193
- const mcpJsonPath = path.join(projectDir, '.mcp.json');
194
- const claudeDir = path.join(projectDir, '.claude');
195
- const settingsLocalPath = path.join(claudeDir, 'settings.local.json');
196
-
197
- // Read existing .mcp.json (if any)
198
- let mcpJson = null;
199
- if (fs.existsSync(mcpJsonPath)) {
200
- try {
201
- mcpJson = JSON.parse(fs.readFileSync(mcpJsonPath, 'utf8'));
202
- } catch (e) {
203
- process.stderr.write(`ERROR: ${mcpJsonPath} is not valid JSON: ${e.message}\n`);
204
- process.stderr.write('Refusing to overwrite a corrupt file. Fix or remove it and retry.\n');
205
- process.exit(1);
206
- }
207
- }
208
- mcpJson = mcpJson || {};
209
- if (!mcpJson.mcpServers) mcpJson.mcpServers = {};
210
-
211
- // Refuse to overwrite a LIVE claude-sym-mesh entry without --force.
212
- // Stale entries (args[0] missing on disk) are always rewritable —
213
- // see isStaleEntry comment above.
214
- const existingProjectEntry = mcpJson.mcpServers['claude-sym-mesh'];
215
- const projectEntryIsStale = isStaleEntry(existingProjectEntry);
216
- if (existingProjectEntry && !force && !projectEntryIsStale) {
217
- process.stderr.write(`'claude-sym-mesh' is already configured in ${mcpJsonPath}.\n`);
218
- process.stderr.write('Re-run with --force to overwrite, or remove the existing entry first.\n');
219
- process.exit(2);
220
- }
221
-
222
- // Preserve the prior node name on rewrite so mesh identity doesn't drift
223
- // back to the hostname default on every reinstall.
224
- const projectNodeName = preserveNodeName(existingProjectEntry) || nodeName;
225
-
226
- // Group resolution priority — see resolveGroup() at top of file.
227
- // Summary: --force + explicit flag/env wins; otherwise preserve, then
228
- // explicit, then omit. `--group default` with --force = revert to mesh.
229
- const projectGroup = resolveGroup(existingProjectEntry);
230
-
231
- // Build the MCP entry (identical shape to global mode)
232
- const projectEntry = {
233
- command: 'node',
234
- args: [serverJsPath],
235
- env: {
236
- SYM_NODE_NAME: projectNodeName,
237
- // Explicitly blank relay env vars — see comment on the global
238
- // install path below for why.
239
- SYM_RELAY_URL: '',
240
- SYM_RELAY_TOKEN: '',
241
- },
242
- };
243
- // SYM_GROUP is only written when explicitly set. Omitting it (rather than
244
- // writing an empty string) keeps the JSON file minimal for the common
245
- // single-team case AND avoids the "default group accidentally pinned"
246
- // failure mode where a blank value masks the server.js fallback.
247
- if (projectGroup) projectEntry.env.SYM_GROUP = projectGroup;
248
-
249
- // Backup existing .mcp.json if present
250
- let mcpBackupPath = null;
251
- if (fs.existsSync(mcpJsonPath)) {
252
- mcpBackupPath = `${mcpJsonPath}.bak-${ts}`;
253
- fs.copyFileSync(mcpJsonPath, mcpBackupPath);
254
- }
255
-
256
- mcpJson.mcpServers['claude-sym-mesh'] = projectEntry;
257
-
258
- // Atomic write .mcp.json
259
- const mcpSerialized = JSON.stringify(mcpJson, null, 2) + '\n';
260
- try { JSON.parse(mcpSerialized); } catch (e) {
261
- process.stderr.write(`ERROR: serialization produced invalid JSON: ${e.message}\n`);
262
- process.exit(1);
263
- }
264
- const mcpTmpPath = `${mcpJsonPath}.tmp-${process.pid}`;
265
- fs.writeFileSync(mcpTmpPath, mcpSerialized);
266
- fs.renameSync(mcpTmpPath, mcpJsonPath);
267
-
268
- // Merge <projectDir>/.claude/settings.local.json. Claude Code gates
269
- // loading of project-scoped MCP servers on the enabledMcpjsonServers
270
- // allowlist in this file — without the merge, the .mcp.json we just
271
- // wrote would not actually be loaded.
272
- if (!fs.existsSync(claudeDir)) {
273
- fs.mkdirSync(claudeDir, { recursive: true });
274
- }
275
-
276
- let existingSettings = null;
277
- if (fs.existsSync(settingsLocalPath)) {
278
- try {
279
- existingSettings = JSON.parse(fs.readFileSync(settingsLocalPath, 'utf8'));
280
- } catch (e) {
281
- process.stderr.write(`ERROR: ${settingsLocalPath} is not valid JSON: ${e.message}\n`);
282
- process.exit(1);
283
- }
284
- }
285
-
286
- // Snapshot serialized form BEFORE mutating so the change-detection
287
- // below can't be fooled by object aliasing (existingSettings and
288
- // settings point at the same object after the `|| {}`).
289
- const beforeSerialized = existingSettings ? JSON.stringify(existingSettings) : null;
290
- const settings = existingSettings || {};
291
-
292
- const enabled = new Set(Array.isArray(settings.enabledMcpjsonServers) ? settings.enabledMcpjsonServers : []);
293
- enabled.add('claude-sym-mesh');
294
- settings.enabledMcpjsonServers = Array.from(enabled);
295
- settings.enableAllProjectMcpServers = true;
296
-
297
- const afterSerialized = JSON.stringify(settings);
298
- const settingsChanged = beforeSerialized !== afterSerialized;
299
-
300
- let settingsBackupPath = null;
301
- if (settingsChanged) {
302
- if (existingSettings) {
303
- settingsBackupPath = `${settingsLocalPath}.bak-${ts}`;
304
- fs.copyFileSync(settingsLocalPath, settingsBackupPath);
305
- }
306
- const settingsSerialized = JSON.stringify(settings, null, 2) + '\n';
307
- const settingsTmpPath = `${settingsLocalPath}.tmp-${process.pid}`;
308
- fs.writeFileSync(settingsTmpPath, settingsSerialized);
309
- fs.renameSync(settingsTmpPath, settingsLocalPath);
310
- }
311
-
312
- // Print next steps
313
- const launchCmdProject = `claude --dangerously-load-development-channels server:claude-sym-mesh`;
314
- const lines = [
315
- '',
316
- `✓ sym-mesh-channel configured for project: ${projectDir}`,
317
- '',
318
- ` Node name: ${projectNodeName}${projectEntryIsStale ? ' (preserved from stale entry)' : ''}`,
319
- ` Mesh group: ${projectGroup || 'default (global _sym._tcp mesh)'}`,
320
- ` Server path: ${serverJsPath}`,
321
- ` Wrote: ${mcpJsonPath}`,
322
- ];
323
- if (mcpBackupPath) lines.push(` Backup: ${mcpBackupPath}`);
324
- if (settingsChanged) {
325
- lines.push(` Updated: ${settingsLocalPath}`);
326
- if (settingsBackupPath) lines.push(` Backup: ${settingsBackupPath}`);
327
- }
328
- lines.push(
329
- '',
330
- 'Launch Claude Code from this directory:',
331
- '',
332
- ` ${launchCmdProject}`,
333
- '',
334
- 'Project-level .mcp.json overrides the global ~/.claude.json entry',
335
- 'when Claude Code runs from this directory. To give each project its',
336
- 'own mesh identity, run `sym-mesh-channel init --project` from each',
337
- 'project root with a distinct SYM_NODE_NAME.',
338
- '',
339
- );
340
- console.log(lines.join('\n'));
341
- process.exit(0);
342
- }
343
-
344
- // ── Locate Claude Code settings file ──────────────────────────────
345
-
346
- const claudeJsonPath = path.join(os.homedir(), '.claude.json');
347
-
348
- if (!fs.existsSync(claudeJsonPath)) {
349
- if (isPostinstall) {
350
- // During postinstall, skip silently if Claude Code isn't installed yet
351
- console.log('sym-mesh-channel: ~/.claude.json not found — run `sym-mesh-channel init` after installing Claude Code.');
352
- process.exit(0);
353
- }
354
- process.stderr.write(`ERROR: ${claudeJsonPath} not found.\n`);
355
- process.stderr.write('Claude Code does not appear to be installed (or has not been launched yet).\n');
356
- process.stderr.write('Install Claude Code from https://claude.com/code first, launch it once, then re-run this installer.\n');
357
- process.exit(1);
358
- }
359
-
360
- // ── Read and back up ──────────────────────────────────────────────
361
-
362
- let claudeJson;
363
- try {
364
- const raw = fs.readFileSync(claudeJsonPath, 'utf8');
365
- claudeJson = JSON.parse(raw);
366
- } catch (e) {
367
- process.stderr.write(`ERROR: ${claudeJsonPath} is not valid JSON: ${e.message}\n`);
368
- process.stderr.write('Refusing to overwrite a corrupt Claude Code settings file.\n');
369
- process.exit(1);
370
- }
371
-
372
- // `ts` was defined above, shared with project-mode install
373
- const backupPath = `${claudeJsonPath}.bak-${ts}`;
374
- fs.copyFileSync(claudeJsonPath, backupPath);
375
-
376
- // ── Find the MCP servers entry to insert into ───────────────────
377
- // Write to global mcpServers (available in all Claude Code sessions),
378
- // not project-scoped. A mesh node should be available everywhere.
379
-
380
- if (!claudeJson.mcpServers) claudeJson.mcpServers = {};
381
-
382
- // ── doctor: report-only scan, no writes ──────────────────────────
383
- // Surface every claude-sym-mesh entry (user-global + every project-scope)
384
- // with whether its server.js is reachable and what node name it uses.
385
- // Useful when /mcp reports "Failed to reconnect" and the user wants to
386
- // inspect scope conflicts without mutating state.
387
-
388
- if (cmd === 'doctor') {
389
- const rows = [];
390
- const topEntry = claudeJson.mcpServers['claude-sym-mesh'];
391
- if (topEntry) {
392
- rows.push({
393
- scope: 'user-global',
394
- path: (topEntry.args || [])[0] || '(no path)',
395
- node: preserveNodeName(topEntry) || '(no SYM_NODE_NAME)',
396
- group: preserveGroup(topEntry) || 'default',
397
- live: !isStaleEntry(topEntry),
398
- });
399
- }
400
- const projects = claudeJson.projects && typeof claudeJson.projects === 'object' ? claudeJson.projects : {};
401
- for (const [projPath, proj] of Object.entries(projects)) {
402
- const e = proj && proj.mcpServers && proj.mcpServers['claude-sym-mesh'];
403
- if (!e) continue;
404
- rows.push({
405
- scope: `project ${projPath}`,
406
- path: (e.args || [])[0] || '(no path)',
407
- node: preserveNodeName(e) || '(no SYM_NODE_NAME)',
408
- group: preserveGroup(e) || 'default',
409
- live: !isStaleEntry(e),
410
- });
411
- }
412
- if (rows.length === 0) {
413
- console.log('No claude-sym-mesh entries found in ~/.claude.json.');
414
- console.log('Run `sym-mesh-channel init` to configure.');
415
- process.exit(0);
416
- }
417
- console.log('');
418
- console.log('claude-sym-mesh entries in ~/.claude.json:');
419
- console.log('');
420
- for (const r of rows) {
421
- console.log(` [${r.live ? 'live ' : 'STALE'}] ${r.scope}`);
422
- console.log(` node: ${r.node}`);
423
- console.log(` group: ${r.group}`);
424
- console.log(` path: ${r.path}`);
425
- }
426
- const staleCount = rows.filter((r) => !r.live).length;
427
-
428
- // Heuristic: if multiple entries reference the same Claude identity
429
- // (same machine) but disagree on group, peers will see each other as
430
- // disconnected — same incident pattern that cost ~24h of duplex outage
431
- // at SYM.BOT (CMO=default vs COO=sym-bot-team, 2026-05-02). Surface as
432
- // a warning so users can spot the mismatch before reaching for the
433
- // troubleshooting section.
434
- const groups = new Set(rows.map((r) => r.group));
435
- const groupMismatch = rows.length > 1 && groups.size > 1;
436
-
437
- console.log('');
438
- if (staleCount > 0) {
439
- console.log(`${staleCount} stale entr${staleCount === 1 ? 'y' : 'ies'} — run \`sym-mesh-channel init\` to heal.`);
440
- } else {
441
- console.log('All entries are live.');
442
- }
443
- if (groupMismatch) {
444
- console.log('');
445
- console.log(`⚠ Group mismatch across entries: ${Array.from(groups).join(', ')}.`);
446
- console.log(' Nodes in different groups cannot discover each other on Bonjour.');
447
- console.log(' If teammates expect to see each other, align the SYM_GROUP env var.');
448
- console.log(' See README "Team mesh groups → Persisting your group across restarts".');
449
- }
450
- process.exit(0);
451
- }
452
-
453
- // ── Classify the top-level entry ─────────────────────────────────
454
-
455
- const existingTopEntry = claudeJson.mcpServers['claude-sym-mesh'];
456
- const topEntryIsStale = isStaleEntry(existingTopEntry);
457
-
458
- // Refuse to overwrite a LIVE entry without --force. A stale entry is
459
- // always rewritable — see isStaleEntry comment at top of file.
460
- if (existingTopEntry && !force && !topEntryIsStale) {
461
- if (isPostinstall) {
462
- // During postinstall, silently skip if already configured and live
463
- console.log('sym-mesh-channel: already configured in ~/.claude.json (skipping)');
464
- process.exit(0);
465
- }
466
- process.stderr.write(`'claude-sym-mesh' is already configured in ~/.claude.json.\n`);
467
- process.stderr.write('Re-run with --force to overwrite, or remove the existing entry first.\n');
468
- process.exit(2);
469
- }
470
-
471
- // Preserve the prior node name on rewrite so mesh identity doesn't drift.
472
- const topNodeName = preserveNodeName(existingTopEntry) || nodeName;
473
-
474
- // Resolve SYM_GROUP for the global entry — see resolveGroup() at top.
475
- // Heal-path default preserves; --force lets the user explicitly switch
476
- // groups (or back to default mesh) in one command.
477
- const topGroup = resolveGroup(existingTopEntry);
478
-
479
- // ── Build the entry ───────────────────────────────────────────────
480
-
481
- const entry = {
482
- command: 'node',
483
- args: [serverJsPath],
484
- env: {
485
- SYM_NODE_NAME: topNodeName,
486
- // Explicitly blank the relay vars so the MCP doesn't inherit them
487
- // from the parent shell (e.g. ~/.zshrc exports). Claude Code's env
488
- // block is ADDITIVE — omitting a key doesn't remove it from the
489
- // child process. Setting to '' makes process.env.SYM_RELAY_URL
490
- // falsy in JS, so the SymNode skips the relay and runs LAN-only.
491
- //
492
- // To enable cross-network connectivity later, replace these empty
493
- // values with your relay URL and token (see README).
494
- SYM_RELAY_URL: '',
495
- SYM_RELAY_TOKEN: '',
496
- },
497
- };
498
- // SYM_GROUP only emitted when explicitly chosen — see project-mode comment
499
- // for the rationale. Omitted = node uses the global _sym._tcp default.
500
- if (topGroup) entry.env.SYM_GROUP = topGroup;
501
-
502
- claudeJson.mcpServers['claude-sym-mesh'] = entry;
503
-
504
- // ── Heal stale project-scoped entries ─────────────────────────────
505
- // ~/.claude.json can contain per-project mcpServers overrides under
506
- // claudeJson.projects[<path>].mcpServers. Claude Code prefers project-scoped
507
- // over user-global when launched from that directory, so a stale project
508
- // entry silently shadows a fresh user-global heal. Scan every project,
509
- // rewrite any claude-sym-mesh entry whose args[0] is missing on disk,
510
- // preserving the project's SYM_NODE_NAME.
511
-
512
- const healedProjects = [];
513
- const projects = claudeJson.projects && typeof claudeJson.projects === 'object' ? claudeJson.projects : {};
514
- for (const [projPath, proj] of Object.entries(projects)) {
515
- const projEntry = proj && proj.mcpServers && proj.mcpServers['claude-sym-mesh'];
516
- if (!projEntry) continue;
517
- if (!isStaleEntry(projEntry)) continue;
518
- const projNodeName = preserveNodeName(projEntry) || nodeName;
519
- // Preserve SYM_GROUP on stale-heal — same reason as preserveNodeName.
520
- // The user explicitly chose this group at some prior install; healing a
521
- // path issue must not silently revert their group membership.
522
- const projGroupName = preserveGroup(projEntry);
523
- const healedEntry = {
524
- command: 'node',
525
- args: [serverJsPath],
526
- env: {
527
- SYM_NODE_NAME: projNodeName,
528
- SYM_RELAY_URL: projEntry.env && typeof projEntry.env.SYM_RELAY_URL === 'string' ? projEntry.env.SYM_RELAY_URL : '',
529
- SYM_RELAY_TOKEN: projEntry.env && typeof projEntry.env.SYM_RELAY_TOKEN === 'string' ? projEntry.env.SYM_RELAY_TOKEN : '',
530
- },
531
- };
532
- if (projGroupName) healedEntry.env.SYM_GROUP = projGroupName;
533
- proj.mcpServers['claude-sym-mesh'] = healedEntry;
534
- healedProjects.push({ path: projPath, node: projNodeName, group: projGroupName });
535
- }
536
-
537
- // ── Atomic write ──────────────────────────────────────────────────
538
-
539
- const serialized = JSON.stringify(claudeJson, null, 2);
540
-
541
- // Validate round-trip parses
542
- try {
543
- JSON.parse(serialized);
544
- } catch (e) {
545
- process.stderr.write(`ERROR: serialization produced invalid JSON: ${e.message}\n`);
546
- process.stderr.write(`Backup is at ${backupPath} — your original file is unchanged.\n`);
547
- process.exit(1);
548
- }
549
-
550
- const tmpPath = `${claudeJsonPath}.tmp-${process.pid}`;
551
- try {
552
- fs.writeFileSync(tmpPath, serialized);
553
- fs.renameSync(tmpPath, claudeJsonPath);
554
- } catch (e) {
555
- // EBUSY on Windows when Claude Code has ~/.claude.json locked
556
- if (e.code === 'EBUSY' || e.code === 'EPERM') {
557
- try { fs.unlinkSync(tmpPath); } catch {}
558
- if (isPostinstall) {
559
- console.log('sym-mesh-channel: ~/.claude.json is locked (Claude Code may be running).');
560
- console.log('Run `sym-mesh-channel init` after quitting Claude Code.');
561
- process.exit(0);
562
- }
563
- process.stderr.write(`ERROR: ${claudeJsonPath} is locked — Claude Code may be running.\n`);
564
- process.stderr.write('Quit Claude Code, then re-run: sym-mesh-channel init\n');
565
- process.stderr.write(`Backup is at ${backupPath}\n`);
566
- process.exit(1);
567
- }
568
- throw e;
569
- }
570
-
571
- // ── Print next steps ──────────────────────────────────────────────
572
-
573
- const launchCmd = `claude --dangerously-load-development-channels server:claude-sym-mesh`;
574
-
575
- const healedLines = healedProjects.length
576
- ? '\n Healed stale project-scoped entries (now pointing at fresh server.js):\n' +
577
- healedProjects.map((p) => ` • ${p.path} (node: ${p.node}${p.group ? `, group: ${p.group}` : ''})`).join('\n') + '\n'
578
- : '';
579
-
580
- const nodeNameSuffix = topEntryIsStale ? ' (preserved from stale entry)' : '';
581
-
582
- console.log(`
583
- ✓ sym-mesh-channel configured globally in ~/.claude.json
584
-
585
- Node name: ${topNodeName}${nodeNameSuffix}
586
- Mesh group: ${topGroup || 'default (global _sym._tcp mesh)'}
587
- Server path: ${serverJsPath}
588
- Backup: ${backupPath}
589
- ${healedLines}
590
- Launch Claude Code with the Channels flag:
591
-
592
- ${launchCmd}
593
-
594
- Inside Claude Code, verify:
595
-
596
- sym_status → node id, relay state, peer count
597
- sym_peers → discovered peers via Bonjour or relay
598
- sym_send "hello mesh" → broadcast to all peers
599
-
600
- Troubleshoot a broken install with:
601
-
602
- sym-mesh-channel doctor
603
- `);
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * sym-mesh-channel install — interactive setup for the MCP server.
6
+ *
7
+ * Run: npx @sym-bot/mesh-channel init
8
+ *
9
+ * What it does:
10
+ * 1. Detects the platform and the host name suggestion (claude-mac /
11
+ * claude-win / claude-linux), or accepts an override.
12
+ * 2. Resolves the absolute path to the installed server.js so Claude
13
+ * Code can spawn it.
14
+ * 3. Reads ~/.claude.json (the Claude Code settings file), backs it
15
+ * up, adds an `mcpServers` entry under the current project for
16
+ * `claude-sym-mesh`, atomically writes the result.
17
+ * 4. Prints the launch command including the Channels dev flag.
18
+ *
19
+ * Safety:
20
+ * - Backs up ~/.claude.json to ~/.claude.json.bak-<timestamp> before
21
+ * any write.
22
+ * - Validates JSON parses round-trip before writing.
23
+ * - Atomic via write-to-tmp + rename.
24
+ * - Refuses to overwrite a LIVE claude-sym-mesh entry without --force.
25
+ * An entry whose args[0] server.js path no longer exists on disk is
26
+ * treated as STALE and rewritten in place — a stale entry guarantees
27
+ * a broken MCP transport, so "preserving" it is never what the user
28
+ * wants. SYM_NODE_NAME from the stale entry is preserved so the
29
+ * mesh identity doesn't drift to the hostname-based default.
30
+ * - Also scans every project-scoped mcpServers entry and rewrites any
31
+ * project entry whose claude-sym-mesh.args[0] path has gone stale,
32
+ * again preserving each project's SYM_NODE_NAME. This prevents the
33
+ * "ghost project" failure mode where user-global was fixed but
34
+ * project-scoped entries silently continue to point at the old path.
35
+ *
36
+ * Copyright (c) 2026 SYM.BOT. Apache 2.0 License.
37
+ */
38
+
39
+ const fs = require('fs');
40
+ const path = require('path');
41
+ const os = require('os');
42
+
43
+ const args = process.argv.slice(2);
44
+ const force = args.includes('--force');
45
+ const isPostinstall = args.includes('--postinstall');
46
+ const isProject = args.includes('--project');
47
+ const cmd = args.find((a) => !a.startsWith('--')) || 'init';
48
+
49
+ // --group <name>: persist a SYM_GROUP env entry into the written .mcp.json /
50
+ // ~/.claude.json so the node joins that group on every Claude Code launch.
51
+ // Without this flag, the env block omits SYM_GROUP and the node falls back
52
+ // to the default _sym._tcp mesh on startup. Runtime sym_join_group hot-swaps
53
+ // only last for the current session — without persistence, peers in named
54
+ // groups silently revert to default and become invisible to teammates.
55
+ const groupArgIdx = args.indexOf('--group');
56
+ const groupArg = groupArgIdx !== -1 ? args[groupArgIdx + 1] : null;
57
+
58
+ if (cmd !== 'init' && cmd !== 'doctor') {
59
+ process.stderr.write(`Unknown command: ${cmd}\nUsage: sym-mesh-channel init [--project] [--force] [--group <name>]\n sym-mesh-channel doctor\n`);
60
+ process.exit(1);
61
+ }
62
+
63
+ const KEBAB_CASE_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
64
+ function validateGroupValue(value, source) {
65
+ if (!value) return;
66
+ if (value === 'default') return;
67
+ if (!KEBAB_CASE_RE.test(value)) {
68
+ process.stderr.write(`ERROR: ${source} "${value}" must be kebab-case (e.g. backend-team) or "default".\n`);
69
+ process.exit(1);
70
+ }
71
+ }
72
+ validateGroupValue(groupArg, '--group');
73
+ // Apply the same gate to the env-var path. Pre-0.3.4-followup, a malformed
74
+ // SYM_GROUP=' ' or SYM_GROUP=Backend_Team value flowed through unvalidated
75
+ // and got written into the .mcp.json env block as-is, producing an mDNS
76
+ // service type the SymNode would silently fail to register on. Now both
77
+ // inputs share the validator with the same error message shape.
78
+ validateGroupValue(process.env.SYM_GROUP, 'SYM_GROUP');
79
+
80
+ // ── isStaleEntry: a claude-sym-mesh entry whose server.js path is gone ──
81
+ // Returns true when the entry exists but its args[0] path does not resolve
82
+ // to a file on disk. Such an entry can never spawn the MCP server — every
83
+ // launch yields "Failed to reconnect" in /mcp. Treating it as rewritable
84
+ // on postinstall means users who move or uninstall an old copy of the repo
85
+ // get healed automatically on the next `npm install -g @sym-bot/mesh-channel`
86
+ // without needing to know about --force.
87
+ function isStaleEntry(entry) {
88
+ if (!entry || !Array.isArray(entry.args) || entry.args.length === 0) return false;
89
+ const p = entry.args[0];
90
+ if (typeof p !== 'string' || !p) return false;
91
+ try { return !fs.existsSync(p); } catch { return true; }
92
+ }
93
+
94
+ // preserveNodeName: return the SYM_NODE_NAME from an existing entry's env
95
+ // so rewrites keep the mesh identity. Falls back to nothing if absent; the
96
+ // caller then uses the computed default.
97
+ function preserveNodeName(entry) {
98
+ if (!entry || !entry.env || typeof entry.env.SYM_NODE_NAME !== 'string') return null;
99
+ const n = entry.env.SYM_NODE_NAME.trim();
100
+ return n || null;
101
+ }
102
+
103
+ // preserveGroup: return the SYM_GROUP from an existing entry's env so
104
+ // rewrites keep the mesh group. Same shape as preserveNodeName — without
105
+ // this, healing a stale entry would drop a previously-persisted group
106
+ // and silently downgrade the node to the default _sym._tcp mesh,
107
+ // stranding teammates who stay in the named group.
108
+ function preserveGroup(entry) {
109
+ if (!entry || !entry.env || typeof entry.env.SYM_GROUP !== 'string') return null;
110
+ const g = entry.env.SYM_GROUP.trim();
111
+ return g || null;
112
+ }
113
+
114
+ // --postinstall always runs global install (npm postinstall runs from
115
+ // npm's staging directory, not the user's project dir). If both flags
116
+ // are passed, the --project flag is ignored during postinstall.
117
+ const useProjectMode = isProject && !isPostinstall;
118
+
119
+ // ── Detect platform & defaults ────────────────────────────────────
120
+
121
+ // Default: hostname-based identity, unique per machine. Prevents
122
+ // the ghost-peer bug where two machines with the same default name
123
+ // create phantom peers that absorb messages.
124
+ const defaultNodeName = `claude-${os.hostname().toLowerCase().replace(/[^a-z0-9-]/g, '-')}`;
125
+
126
+ // SYM_NODE_NAME from env wins over default
127
+ const nodeName = process.env.SYM_NODE_NAME || defaultNodeName;
128
+
129
+ // Capture the user's *explicit* group intent for this install, distinct
130
+ // from "user didn't ask, use existing or default":
131
+ // null → user didn't pass --group or SYM_GROUP
132
+ // 'default' → user explicitly wants the global _sym._tcp mesh
133
+ // (escape hatch: revert from a named group, with --force)
134
+ // '<kebab-name>' → user explicitly wants this named group
135
+ const explicitGroup = groupArg !== null ? groupArg
136
+ : (process.env.SYM_GROUP || null);
137
+
138
+ // resolveGroup: per-scope group resolution that respects both the user's
139
+ // explicit intent AND the existing entry's persisted state.
140
+ //
141
+ // With --force AND an explicit value: flag/env wins. The user is
142
+ // deliberately overriding state. `--force --group new-team` switches
143
+ // groups; `--force --group default` reverts to the global mesh.
144
+ //
145
+ // Without --force, OR with --force but no explicit value: preserve
146
+ // from the existing entry (heal-path job is to NOT lose user state).
147
+ // Falls back to the explicit value, then to none.
148
+ //
149
+ // Returns the SYM_GROUP value to write, or null to omit the key entirely
150
+ // (which the caller maps to "leave SYM_GROUP out of the env block, node
151
+ // uses default _sym._tcp on launch").
152
+ function resolveGroup(existingEntry) {
153
+ const preserved = preserveGroup(existingEntry);
154
+ if (force && explicitGroup !== null) {
155
+ return explicitGroup === 'default' ? null : explicitGroup;
156
+ }
157
+ if (preserved) return preserved;
158
+ if (explicitGroup && explicitGroup !== 'default') return explicitGroup;
159
+ return null;
160
+ }
161
+
162
+ // ── Resolve server.js path ────────────────────────────────────────
163
+
164
+ // Resolve server.js from the installed package location. require.resolve
165
+ // returns the actual installed path regardless of where postinstall runs
166
+ // from (npm on Windows may run postinstall from a temp staging directory).
167
+ let serverJsPath;
168
+ try {
169
+ serverJsPath = require.resolve('@sym-bot/mesh-channel/server.js');
170
+ } catch {
171
+ // Fallback for local development / cloned repo
172
+ serverJsPath = path.resolve(__dirname, '..', 'server.js');
173
+ }
174
+ if (!fs.existsSync(serverJsPath)) {
175
+ process.stderr.write(`ERROR: cannot find server.js at ${serverJsPath}\n`);
176
+ process.stderr.write('This installer must be run from a published @sym-bot/mesh-channel package.\n');
177
+ process.exit(1);
178
+ }
179
+
180
+ // Shared timestamp for backup filenames
181
+ const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
182
+
183
+ // ── Project-scoped install (--project flag) ───────────────────────
184
+ // Writes <cwd>/.mcp.json + merges <cwd>/.claude/settings.local.json
185
+ // instead of touching ~/.claude.json. Use this when you want multiple
186
+ // Claude Code sessions on one machine to appear as distinct mesh peers
187
+ // (one per project), each with its own SYM_NODE_NAME. Project-level
188
+ // .mcp.json overrides the global ~/.claude.json mcpServers entry when
189
+ // Claude Code is launched from that directory.
190
+
191
+ if (useProjectMode) {
192
+ const projectDir = process.cwd();
193
+ const mcpJsonPath = path.join(projectDir, '.mcp.json');
194
+ const claudeDir = path.join(projectDir, '.claude');
195
+ const settingsLocalPath = path.join(claudeDir, 'settings.local.json');
196
+
197
+ // Read existing .mcp.json (if any)
198
+ let mcpJson = null;
199
+ if (fs.existsSync(mcpJsonPath)) {
200
+ try {
201
+ mcpJson = JSON.parse(fs.readFileSync(mcpJsonPath, 'utf8'));
202
+ } catch (e) {
203
+ process.stderr.write(`ERROR: ${mcpJsonPath} is not valid JSON: ${e.message}\n`);
204
+ process.stderr.write('Refusing to overwrite a corrupt file. Fix or remove it and retry.\n');
205
+ process.exit(1);
206
+ }
207
+ }
208
+ mcpJson = mcpJson || {};
209
+ if (!mcpJson.mcpServers) mcpJson.mcpServers = {};
210
+
211
+ // Refuse to overwrite a LIVE claude-sym-mesh entry without --force.
212
+ // Stale entries (args[0] missing on disk) are always rewritable —
213
+ // see isStaleEntry comment above.
214
+ const existingProjectEntry = mcpJson.mcpServers['claude-sym-mesh'];
215
+ const projectEntryIsStale = isStaleEntry(existingProjectEntry);
216
+ if (existingProjectEntry && !force && !projectEntryIsStale) {
217
+ process.stderr.write(`'claude-sym-mesh' is already configured in ${mcpJsonPath}.\n`);
218
+ process.stderr.write('Re-run with --force to overwrite, or remove the existing entry first.\n');
219
+ process.exit(2);
220
+ }
221
+
222
+ // Preserve the prior node name on rewrite so mesh identity doesn't drift
223
+ // back to the hostname default on every reinstall.
224
+ const projectNodeName = preserveNodeName(existingProjectEntry) || nodeName;
225
+
226
+ // Group resolution priority — see resolveGroup() at top of file.
227
+ // Summary: --force + explicit flag/env wins; otherwise preserve, then
228
+ // explicit, then omit. `--group default` with --force = revert to mesh.
229
+ const projectGroup = resolveGroup(existingProjectEntry);
230
+
231
+ // Build the MCP entry (identical shape to global mode)
232
+ const projectEntry = {
233
+ command: 'node',
234
+ args: [serverJsPath],
235
+ env: {
236
+ SYM_NODE_NAME: projectNodeName,
237
+ // Explicitly blank relay env vars — see comment on the global
238
+ // install path below for why.
239
+ SYM_RELAY_URL: '',
240
+ SYM_RELAY_TOKEN: '',
241
+ },
242
+ };
243
+ // SYM_GROUP is only written when explicitly set. Omitting it (rather than
244
+ // writing an empty string) keeps the JSON file minimal for the common
245
+ // single-team case AND avoids the "default group accidentally pinned"
246
+ // failure mode where a blank value masks the server.js fallback.
247
+ if (projectGroup) projectEntry.env.SYM_GROUP = projectGroup;
248
+
249
+ // Backup existing .mcp.json if present
250
+ let mcpBackupPath = null;
251
+ if (fs.existsSync(mcpJsonPath)) {
252
+ mcpBackupPath = `${mcpJsonPath}.bak-${ts}`;
253
+ fs.copyFileSync(mcpJsonPath, mcpBackupPath);
254
+ }
255
+
256
+ mcpJson.mcpServers['claude-sym-mesh'] = projectEntry;
257
+
258
+ // Atomic write .mcp.json
259
+ const mcpSerialized = JSON.stringify(mcpJson, null, 2) + '\n';
260
+ try { JSON.parse(mcpSerialized); } catch (e) {
261
+ process.stderr.write(`ERROR: serialization produced invalid JSON: ${e.message}\n`);
262
+ process.exit(1);
263
+ }
264
+ const mcpTmpPath = `${mcpJsonPath}.tmp-${process.pid}`;
265
+ fs.writeFileSync(mcpTmpPath, mcpSerialized);
266
+ fs.renameSync(mcpTmpPath, mcpJsonPath);
267
+
268
+ // Merge <projectDir>/.claude/settings.local.json. Claude Code gates
269
+ // loading of project-scoped MCP servers on the enabledMcpjsonServers
270
+ // allowlist in this file — without the merge, the .mcp.json we just
271
+ // wrote would not actually be loaded.
272
+ if (!fs.existsSync(claudeDir)) {
273
+ fs.mkdirSync(claudeDir, { recursive: true });
274
+ }
275
+
276
+ let existingSettings = null;
277
+ if (fs.existsSync(settingsLocalPath)) {
278
+ try {
279
+ existingSettings = JSON.parse(fs.readFileSync(settingsLocalPath, 'utf8'));
280
+ } catch (e) {
281
+ process.stderr.write(`ERROR: ${settingsLocalPath} is not valid JSON: ${e.message}\n`);
282
+ process.exit(1);
283
+ }
284
+ }
285
+
286
+ // Snapshot serialized form BEFORE mutating so the change-detection
287
+ // below can't be fooled by object aliasing (existingSettings and
288
+ // settings point at the same object after the `|| {}`).
289
+ const beforeSerialized = existingSettings ? JSON.stringify(existingSettings) : null;
290
+ const settings = existingSettings || {};
291
+
292
+ const enabled = new Set(Array.isArray(settings.enabledMcpjsonServers) ? settings.enabledMcpjsonServers : []);
293
+ enabled.add('claude-sym-mesh');
294
+ settings.enabledMcpjsonServers = Array.from(enabled);
295
+ settings.enableAllProjectMcpServers = true;
296
+
297
+ const afterSerialized = JSON.stringify(settings);
298
+ const settingsChanged = beforeSerialized !== afterSerialized;
299
+
300
+ let settingsBackupPath = null;
301
+ if (settingsChanged) {
302
+ if (existingSettings) {
303
+ settingsBackupPath = `${settingsLocalPath}.bak-${ts}`;
304
+ fs.copyFileSync(settingsLocalPath, settingsBackupPath);
305
+ }
306
+ const settingsSerialized = JSON.stringify(settings, null, 2) + '\n';
307
+ const settingsTmpPath = `${settingsLocalPath}.tmp-${process.pid}`;
308
+ fs.writeFileSync(settingsTmpPath, settingsSerialized);
309
+ fs.renameSync(settingsTmpPath, settingsLocalPath);
310
+ }
311
+
312
+ // Print next steps
313
+ const launchCmdProject = `claude --dangerously-load-development-channels server:claude-sym-mesh`;
314
+ const lines = [
315
+ '',
316
+ `✓ sym-mesh-channel configured for project: ${projectDir}`,
317
+ '',
318
+ ` Node name: ${projectNodeName}${projectEntryIsStale ? ' (preserved from stale entry)' : ''}`,
319
+ ` Mesh group: ${projectGroup || 'default (global _sym._tcp mesh)'}`,
320
+ ` Server path: ${serverJsPath}`,
321
+ ` Wrote: ${mcpJsonPath}`,
322
+ ];
323
+ if (mcpBackupPath) lines.push(` Backup: ${mcpBackupPath}`);
324
+ if (settingsChanged) {
325
+ lines.push(` Updated: ${settingsLocalPath}`);
326
+ if (settingsBackupPath) lines.push(` Backup: ${settingsBackupPath}`);
327
+ }
328
+ lines.push(
329
+ '',
330
+ 'Launch Claude Code from this directory:',
331
+ '',
332
+ ` ${launchCmdProject}`,
333
+ '',
334
+ 'Project-level .mcp.json overrides the global ~/.claude.json entry',
335
+ 'when Claude Code runs from this directory. To give each project its',
336
+ 'own mesh identity, run `sym-mesh-channel init --project` from each',
337
+ 'project root with a distinct SYM_NODE_NAME.',
338
+ '',
339
+ );
340
+ console.log(lines.join('\n'));
341
+ process.exit(0);
342
+ }
343
+
344
+ // ── Locate Claude Code settings file ──────────────────────────────
345
+
346
+ const claudeJsonPath = path.join(os.homedir(), '.claude.json');
347
+
348
+ if (!fs.existsSync(claudeJsonPath)) {
349
+ if (isPostinstall) {
350
+ // During postinstall, skip silently if Claude Code isn't installed yet
351
+ console.log('sym-mesh-channel: ~/.claude.json not found — run `sym-mesh-channel init` after installing Claude Code.');
352
+ process.exit(0);
353
+ }
354
+ process.stderr.write(`ERROR: ${claudeJsonPath} not found.\n`);
355
+ process.stderr.write('Claude Code does not appear to be installed (or has not been launched yet).\n');
356
+ process.stderr.write('Install Claude Code from https://claude.com/code first, launch it once, then re-run this installer.\n');
357
+ process.exit(1);
358
+ }
359
+
360
+ // ── Read and back up ──────────────────────────────────────────────
361
+
362
+ let claudeJson;
363
+ try {
364
+ const raw = fs.readFileSync(claudeJsonPath, 'utf8');
365
+ claudeJson = JSON.parse(raw);
366
+ } catch (e) {
367
+ process.stderr.write(`ERROR: ${claudeJsonPath} is not valid JSON: ${e.message}\n`);
368
+ process.stderr.write('Refusing to overwrite a corrupt Claude Code settings file.\n');
369
+ process.exit(1);
370
+ }
371
+
372
+ // `ts` was defined above, shared with project-mode install
373
+ const backupPath = `${claudeJsonPath}.bak-${ts}`;
374
+ fs.copyFileSync(claudeJsonPath, backupPath);
375
+
376
+ // ── Find the MCP servers entry to insert into ───────────────────
377
+ // Write to global mcpServers (available in all Claude Code sessions),
378
+ // not project-scoped. A mesh node should be available everywhere.
379
+
380
+ if (!claudeJson.mcpServers) claudeJson.mcpServers = {};
381
+
382
+ // ── doctor: report-only scan, no writes ──────────────────────────
383
+ // Surface every claude-sym-mesh entry (user-global + every project-scope)
384
+ // with whether its server.js is reachable and what node name it uses.
385
+ // Useful when /mcp reports "Failed to reconnect" and the user wants to
386
+ // inspect scope conflicts without mutating state.
387
+
388
+ if (cmd === 'doctor') {
389
+ const rows = [];
390
+ const topEntry = claudeJson.mcpServers['claude-sym-mesh'];
391
+ if (topEntry) {
392
+ rows.push({
393
+ scope: 'user-global',
394
+ path: (topEntry.args || [])[0] || '(no path)',
395
+ node: preserveNodeName(topEntry) || '(no SYM_NODE_NAME)',
396
+ group: preserveGroup(topEntry) || 'default',
397
+ live: !isStaleEntry(topEntry),
398
+ });
399
+ }
400
+ const projects = claudeJson.projects && typeof claudeJson.projects === 'object' ? claudeJson.projects : {};
401
+ for (const [projPath, proj] of Object.entries(projects)) {
402
+ const e = proj && proj.mcpServers && proj.mcpServers['claude-sym-mesh'];
403
+ if (!e) continue;
404
+ rows.push({
405
+ scope: `project ${projPath}`,
406
+ path: (e.args || [])[0] || '(no path)',
407
+ node: preserveNodeName(e) || '(no SYM_NODE_NAME)',
408
+ group: preserveGroup(e) || 'default',
409
+ live: !isStaleEntry(e),
410
+ });
411
+ }
412
+ if (rows.length === 0) {
413
+ console.log('No claude-sym-mesh entries found in ~/.claude.json.');
414
+ console.log('Run `sym-mesh-channel init` to configure.');
415
+ process.exit(0);
416
+ }
417
+ console.log('');
418
+ console.log('claude-sym-mesh entries in ~/.claude.json:');
419
+ console.log('');
420
+ for (const r of rows) {
421
+ console.log(` [${r.live ? 'live ' : 'STALE'}] ${r.scope}`);
422
+ console.log(` node: ${r.node}`);
423
+ console.log(` group: ${r.group}`);
424
+ console.log(` path: ${r.path}`);
425
+ }
426
+ const staleCount = rows.filter((r) => !r.live).length;
427
+
428
+ // Heuristic: if multiple entries reference the same Claude identity
429
+ // (same machine) but disagree on group, peers will see each other as
430
+ // disconnected — same incident pattern that cost ~24h of duplex outage
431
+ // at SYM.BOT (CMO=default vs COO=sym-bot-team, 2026-05-02). Surface as
432
+ // a warning so users can spot the mismatch before reaching for the
433
+ // troubleshooting section.
434
+ const groups = new Set(rows.map((r) => r.group));
435
+ const groupMismatch = rows.length > 1 && groups.size > 1;
436
+
437
+ console.log('');
438
+ if (staleCount > 0) {
439
+ console.log(`${staleCount} stale entr${staleCount === 1 ? 'y' : 'ies'} — run \`sym-mesh-channel init\` to heal.`);
440
+ } else {
441
+ console.log('All entries are live.');
442
+ }
443
+ if (groupMismatch) {
444
+ console.log('');
445
+ console.log(`⚠ Group mismatch across entries: ${Array.from(groups).join(', ')}.`);
446
+ console.log(' Nodes in different groups cannot discover each other on Bonjour.');
447
+ console.log(' If teammates expect to see each other, align the SYM_GROUP env var.');
448
+ console.log(' See README "Team mesh groups → Persisting your group across restarts".');
449
+ }
450
+ process.exit(0);
451
+ }
452
+
453
+ // ── Classify the top-level entry ─────────────────────────────────
454
+
455
+ const existingTopEntry = claudeJson.mcpServers['claude-sym-mesh'];
456
+ const topEntryIsStale = isStaleEntry(existingTopEntry);
457
+
458
+ // Refuse to overwrite a LIVE entry without --force. A stale entry is
459
+ // always rewritable — see isStaleEntry comment at top of file.
460
+ if (existingTopEntry && !force && !topEntryIsStale) {
461
+ if (isPostinstall) {
462
+ // During postinstall, silently skip if already configured and live
463
+ console.log('sym-mesh-channel: already configured in ~/.claude.json (skipping)');
464
+ process.exit(0);
465
+ }
466
+ process.stderr.write(`'claude-sym-mesh' is already configured in ~/.claude.json.\n`);
467
+ process.stderr.write('Re-run with --force to overwrite, or remove the existing entry first.\n');
468
+ process.exit(2);
469
+ }
470
+
471
+ // Preserve the prior node name on rewrite so mesh identity doesn't drift.
472
+ const topNodeName = preserveNodeName(existingTopEntry) || nodeName;
473
+
474
+ // Resolve SYM_GROUP for the global entry — see resolveGroup() at top.
475
+ // Heal-path default preserves; --force lets the user explicitly switch
476
+ // groups (or back to default mesh) in one command.
477
+ const topGroup = resolveGroup(existingTopEntry);
478
+
479
+ // ── Build the entry ───────────────────────────────────────────────
480
+
481
+ const entry = {
482
+ command: 'node',
483
+ args: [serverJsPath],
484
+ env: {
485
+ SYM_NODE_NAME: topNodeName,
486
+ // Explicitly blank the relay vars so the MCP doesn't inherit them
487
+ // from the parent shell (e.g. ~/.zshrc exports). Claude Code's env
488
+ // block is ADDITIVE — omitting a key doesn't remove it from the
489
+ // child process. Setting to '' makes process.env.SYM_RELAY_URL
490
+ // falsy in JS, so the SymNode skips the relay and runs LAN-only.
491
+ //
492
+ // To enable cross-network connectivity later, replace these empty
493
+ // values with your relay URL and token (see README).
494
+ SYM_RELAY_URL: '',
495
+ SYM_RELAY_TOKEN: '',
496
+ },
497
+ };
498
+ // SYM_GROUP only emitted when explicitly chosen — see project-mode comment
499
+ // for the rationale. Omitted = node uses the global _sym._tcp default.
500
+ if (topGroup) entry.env.SYM_GROUP = topGroup;
501
+
502
+ claudeJson.mcpServers['claude-sym-mesh'] = entry;
503
+
504
+ // ── Heal stale project-scoped entries ─────────────────────────────
505
+ // ~/.claude.json can contain per-project mcpServers overrides under
506
+ // claudeJson.projects[<path>].mcpServers. Claude Code prefers project-scoped
507
+ // over user-global when launched from that directory, so a stale project
508
+ // entry silently shadows a fresh user-global heal. Scan every project,
509
+ // rewrite any claude-sym-mesh entry whose args[0] is missing on disk,
510
+ // preserving the project's SYM_NODE_NAME.
511
+
512
+ const healedProjects = [];
513
+ const projects = claudeJson.projects && typeof claudeJson.projects === 'object' ? claudeJson.projects : {};
514
+ for (const [projPath, proj] of Object.entries(projects)) {
515
+ const projEntry = proj && proj.mcpServers && proj.mcpServers['claude-sym-mesh'];
516
+ if (!projEntry) continue;
517
+ if (!isStaleEntry(projEntry)) continue;
518
+ const projNodeName = preserveNodeName(projEntry) || nodeName;
519
+ // Preserve SYM_GROUP on stale-heal — same reason as preserveNodeName.
520
+ // The user explicitly chose this group at some prior install; healing a
521
+ // path issue must not silently revert their group membership.
522
+ const projGroupName = preserveGroup(projEntry);
523
+ const healedEntry = {
524
+ command: 'node',
525
+ args: [serverJsPath],
526
+ env: {
527
+ SYM_NODE_NAME: projNodeName,
528
+ SYM_RELAY_URL: projEntry.env && typeof projEntry.env.SYM_RELAY_URL === 'string' ? projEntry.env.SYM_RELAY_URL : '',
529
+ SYM_RELAY_TOKEN: projEntry.env && typeof projEntry.env.SYM_RELAY_TOKEN === 'string' ? projEntry.env.SYM_RELAY_TOKEN : '',
530
+ },
531
+ };
532
+ if (projGroupName) healedEntry.env.SYM_GROUP = projGroupName;
533
+ proj.mcpServers['claude-sym-mesh'] = healedEntry;
534
+ healedProjects.push({ path: projPath, node: projNodeName, group: projGroupName });
535
+ }
536
+
537
+ // ── Atomic write ──────────────────────────────────────────────────
538
+
539
+ const serialized = JSON.stringify(claudeJson, null, 2);
540
+
541
+ // Validate round-trip parses
542
+ try {
543
+ JSON.parse(serialized);
544
+ } catch (e) {
545
+ process.stderr.write(`ERROR: serialization produced invalid JSON: ${e.message}\n`);
546
+ process.stderr.write(`Backup is at ${backupPath} — your original file is unchanged.\n`);
547
+ process.exit(1);
548
+ }
549
+
550
+ const tmpPath = `${claudeJsonPath}.tmp-${process.pid}`;
551
+ try {
552
+ fs.writeFileSync(tmpPath, serialized);
553
+ fs.renameSync(tmpPath, claudeJsonPath);
554
+ } catch (e) {
555
+ // EBUSY on Windows when Claude Code has ~/.claude.json locked
556
+ if (e.code === 'EBUSY' || e.code === 'EPERM') {
557
+ try { fs.unlinkSync(tmpPath); } catch {}
558
+ if (isPostinstall) {
559
+ console.log('sym-mesh-channel: ~/.claude.json is locked (Claude Code may be running).');
560
+ console.log('Run `sym-mesh-channel init` after quitting Claude Code.');
561
+ process.exit(0);
562
+ }
563
+ process.stderr.write(`ERROR: ${claudeJsonPath} is locked — Claude Code may be running.\n`);
564
+ process.stderr.write('Quit Claude Code, then re-run: sym-mesh-channel init\n');
565
+ process.stderr.write(`Backup is at ${backupPath}\n`);
566
+ process.exit(1);
567
+ }
568
+ throw e;
569
+ }
570
+
571
+ // ── Print next steps ──────────────────────────────────────────────
572
+
573
+ const launchCmd = `claude --dangerously-load-development-channels server:claude-sym-mesh`;
574
+
575
+ const healedLines = healedProjects.length
576
+ ? '\n Healed stale project-scoped entries (now pointing at fresh server.js):\n' +
577
+ healedProjects.map((p) => ` • ${p.path} (node: ${p.node}${p.group ? `, group: ${p.group}` : ''})`).join('\n') + '\n'
578
+ : '';
579
+
580
+ const nodeNameSuffix = topEntryIsStale ? ' (preserved from stale entry)' : '';
581
+
582
+ console.log(`
583
+ ✓ sym-mesh-channel configured globally in ~/.claude.json
584
+
585
+ Node name: ${topNodeName}${nodeNameSuffix}
586
+ Mesh group: ${topGroup || 'default (global _sym._tcp mesh)'}
587
+ Server path: ${serverJsPath}
588
+ Backup: ${backupPath}
589
+ ${healedLines}
590
+ Launch Claude Code with the Channels flag:
591
+
592
+ ${launchCmd}
593
+
594
+ Inside Claude Code, verify:
595
+
596
+ sym_status → node id, relay state, peer count
597
+ sym_peers → discovered peers via Bonjour or relay
598
+ sym_send "hello mesh" → broadcast to all peers
599
+
600
+ Troubleshoot a broken install with:
601
+
602
+ sym-mesh-channel doctor
603
+ `);