hivecode-mcp 0.12.2 → 0.12.4

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/hive-setup.js CHANGED
@@ -1,445 +1,548 @@
1
- // hive-setup.js -- `npx -y hivecode-mcp@latest setup ["<join link>"] [--name You-AI]`
2
- //
3
- // ONE COMMAND FROM NOTHING TO AN AGENT THAT CAN JOIN. It exists because setup cost a real
4
- // tester an hour on 24 Sep 2026, and when it was measured the next day on a cold machine the
5
- // hour had concrete causes, none of them in the relay (hive_join itself took 0.5s):
6
- //
7
- // 1. The README's config was {"command":"npx", ...}. Agent apps start MCP servers WITHOUT a
8
- // shell, and on Windows npx is npx.cmd, so the launch fails with `spawn npx ENOENT` -- the
9
- // agent just never gets the hive_* tools, and nothing on screen says why.
10
- // 2. The first `npx hivecode-mcp@latest` start took 36.7s, downloading. Claude Code gives an
11
- // MCP server 30s, so a new user's FIRST launch fails, and the second one may not.
12
- // 3. hive_join's default folder was './workspace', relative to whatever directory the agent
13
- // app happened to start the server in -- sometimes the user's project, sometimes a
14
- // directory nobody can write to.
15
- //
16
- // So this does the four things a person was doing by hand, and then CHECKS them:
17
- // - installs a runtime copy under ~/.hivecode/runtime/<version>, so launching needs no
18
- // download, no network and no npx;
19
- // - writes the hivecode entry into every agent config it finds, with the absolute path of
20
- // THIS node, which also dodges GUI apps on macOS not seeing an nvm PATH;
21
- // - launches each entry exactly the way the agent app will -- read back from the file, no
22
- // shell -- and does a real MCP handshake with it;
23
- // - prints the one sentence to paste into the agent.
24
- //
25
- // THE FAILURE THIS MUST NEVER HAVE is the one every defect in this repo has had: reporting
26
- // success while the result is wrong. An agent whose config could not be written, or whose entry
27
- // does not start, is printed as FAILED and makes the exit code non-zero. "Configured" is only
28
- // ever printed about an entry that was read back from disk and answered a handshake.
29
- //
30
- // WHAT IT COSTS WHEN WRONG: these are the user's own config files, holding their other MCP
31
- // servers. So a file is parsed or refused (JSON with comments is refused, not rewritten), the
32
- // original is backed up once before the first change, only the `hivecode` key is touched, and
33
- // every other key survives byte-for-byte as a value.
34
- import fs from 'fs'
35
- import os from 'os'
36
- import path from 'path'
37
- import { spawn } from 'child_process'
38
- import { fileURLToPath } from 'url'
39
- import { VERSION } from './hive-version.js'
40
- import { currentRelayFor, PUBLIC_DEFAULT_RELAY } from './hive-privacy.js'
41
- import { validateMemberName } from './hive-mailbox.js'
42
-
43
- export const SERVER_KEY = 'hivecode'
44
- const BACKUP_SUFFIX = '.before-hivecode'
45
-
46
- /** Every agent app we know how to configure, and where each keeps its MCP servers. */
47
- export function agentTargets(home = os.homedir(), env = process.env, platform = process.platform) {
48
- const claudeDesktopDir = platform === 'win32' ? path.join(env.APPDATA || path.join(home, 'AppData', 'Roaming'), 'Claude')
49
- : platform === 'darwin' ? path.join(home, 'Library', 'Application Support', 'Claude')
50
- : path.join(env.XDG_CONFIG_HOME || path.join(home, '.config'), 'Claude')
51
- // `present` is how we decide the app is installed. It is deliberately the app's OWN directory
52
- // (or, where the app creates it on first run, its own file) -- never "the file we would write",
53
- // which would make every run create configs for apps the user does not have.
54
- return [
55
- { id: 'claude-code', label: 'Claude Code', file: path.join(home, '.claude.json'), present: path.join(home, '.claude.json'), claudeCode: true,
56
- restart: 'start a new Claude Code session (or restart `claude`)' },
57
- { id: 'antigravity', label: 'Antigravity', file: path.join(home, '.gemini', 'antigravity', 'mcp_config.json'), present: path.join(home, '.gemini', 'antigravity'),
58
- restart: 'Settings > Skills & Customizations > MCP Servers, then click refresh on hivecode (or restart Antigravity)' },
59
- // Antigravity also reads this one. It is only touched when it ALREADY has a hivecode entry --
60
- // a stale one there could shadow the fresh one above -- and never created.
61
- { id: 'antigravity-2', label: 'Antigravity (2nd)', file: path.join(home, '.gemini', 'config', 'mcp_config.json'), present: path.join(home, '.gemini', 'config', 'mcp_config.json'),
62
- onlyIfHasEntry: true, restart: 'same refresh as above' },
63
- { id: 'cursor', label: 'Cursor', file: path.join(home, '.cursor', 'mcp.json'), present: path.join(home, '.cursor'),
64
- restart: 'Cursor Settings > MCP, then turn hivecode off and on (or restart Cursor)' },
65
- { id: 'windsurf', label: 'Windsurf', file: path.join(home, '.codeium', 'windsurf', 'mcp_config.json'), present: path.join(home, '.codeium', 'windsurf'),
66
- restart: 'Windsurf > MCP servers > Refresh (or restart Windsurf)' },
67
- { id: 'claude-desktop', label: 'Claude Desktop', file: path.join(claudeDesktopDir, 'claude_desktop_config.json'), present: claudeDesktopDir,
68
- restart: 'quit Claude Desktop fully (tray icon > Quit) and open it again' },
69
- // The Gemini CLI shares ~/.gemini with Antigravity, so its directory proves nothing. Its own
70
- // settings file does: the CLI writes it on first run.
71
- { id: 'gemini-cli', label: 'Gemini CLI', file: path.join(home, '.gemini', 'settings.json'), present: path.join(home, '.gemini', 'settings.json'),
72
- restart: 'restart `gemini`' },
73
- ]
74
- }
75
-
76
- /**
77
- * The node to write into configs: an absolute path (a bare "node" fails with ENOENT in GUI apps
78
- * started from the macOS Dock, which do not see an nvm or Homebrew PATH), but the most STABLE
79
- * absolute path that is still this same program. process.execPath on Homebrew is the versioned
80
- * Cellar path, which `brew upgrade node` deletes; /opt/homebrew/bin/node is a link that follows
81
- * the upgrade. A candidate is only taken when its realpath is exactly this node, so the choice
82
- * can never change WHICH node runs -- and verifyEntry launches whatever was chosen anyway.
83
- */
84
- export function stableNodePath(execPath = process.execPath, env = process.env) {
85
- const real = (p) => { try { return fs.realpathSync(p) } catch { return null } }
86
- const target = real(execPath)
87
- if (!target) return execPath
88
- const exe = process.platform === 'win32' ? 'node.exe' : 'node'
89
- const versioned = /[\\/]v?\d+\.\d+\.\d+[\\/]|[\\/]Cellar[\\/]/
90
- const fromPath = (env.PATH || env.Path || '').split(path.delimiter).filter(Boolean).map((d) => path.join(d, exe))
91
- const cands = ['/opt/homebrew/bin/node', '/usr/local/bin/node', ...fromPath.filter((c) => !versioned.test(c))]
92
- for (const c of cands) if (c !== execPath && fs.existsSync(c) && real(c) === target) return c
93
- return execPath
94
- }
95
-
96
- /**
97
- * Parse the JSON-with-comments that Cursor, VS Code and friends accept: // and /* *\/ comments and
98
- * trailing commas, all outside strings. Returns the plain-JSON text.
99
- */
100
- export function stripJsonc(src) {
101
- let out = ''
102
- for (let i = 0, inStr = false; i < src.length; i++) {
103
- const c = src[i]
104
- if (inStr) { out += c; if (c === '\\') out += src[++i] ?? ''; else if (c === '"') inStr = false; continue }
105
- if (c === '"') { inStr = true; out += c; continue }
106
- if (c === '/' && src[i + 1] === '/') { while (i < src.length && src[i] !== '\n') i++; out += '\n'; continue }
107
- if (c === '/' && src[i + 1] === '*') { const e = src.indexOf('*/', i + 2); i = e < 0 ? src.length : e + 1; continue }
108
- out += c
109
- }
110
- let res = ''
111
- for (let i = 0, inStr = false; i < out.length; i++) {
112
- const c = out[i]
113
- if (inStr) { res += c; if (c === '\\') res += out[++i] ?? ''; else if (c === '"') inStr = false; continue }
114
- if (c === '"') { inStr = true; res += c; continue }
115
- if (c === ',') { let j = i + 1; while (j < out.length && /\s/.test(out[j])) j++; if (out[j] === '}' || out[j] === ']') continue }
116
- res += c
117
- }
118
- return res
119
- }
120
-
121
- /** The entry every agent gets: this node, by absolute path, running hive-mcp.js directly. */
122
- export function serverEntry(runtimeJs, { claudeCode = false, previous = null, node = stableNodePath() } = {}) {
123
- const entry = { command: node, args: [runtimeJs] }
124
- // What the user added to an existing entry is theirs: a token, a relay, a working directory.
125
- // Only the launch itself -- command and args -- is ours to replace.
126
- if (previous && typeof previous === 'object') {
127
- if (previous.env && typeof previous.env === 'object') entry.env = previous.env
128
- if (typeof previous.cwd === 'string') entry.cwd = previous.cwd
129
- }
130
- if (claudeCode) return { type: 'stdio', ...entry, env: entry.env || {} }
131
- return entry
132
- }
133
-
134
- /**
135
- * Write the hivecode entry into one config file. Returns { status, detail }, where status is one
136
- * of: 'written', 'unchanged', 'refused' (the file exists but is not plain JSON), 'error'.
137
- */
138
- export function writeEntry(target, runtimeJs, { dryRun = false } = {}) {
139
- let raw = ''
140
- try { raw = fs.readFileSync(target.file, 'utf8') } catch (e) { if (e.code !== 'ENOENT') return { status: 'error', detail: e.message } }
141
- let cfg = {}
142
- let droppedComments = false
143
- if (raw.trim()) {
144
- const text = raw.replace(/^/, '')
145
- try { cfg = JSON.parse(text) } catch {
146
- // JSON with comments is legal in the files Cursor and VS Code write. Refusing it would hand
147
- // the user back the hand-editing this command exists to remove; the original is kept.
148
- try { cfg = JSON.parse(stripJsonc(text)); droppedComments = true } catch {
149
- return { status: 'refused', detail: 'not valid JSON even allowing comments, so it was left exactly as it is' }
150
- }
151
- }
152
- if (!cfg || typeof cfg !== 'object' || Array.isArray(cfg)) return { status: 'refused', detail: 'the file is not a JSON object' }
153
- }
154
- const servers = cfg.mcpServers && typeof cfg.mcpServers === 'object' && !Array.isArray(cfg.mcpServers) ? cfg.mcpServers : {}
155
- const previous = servers[SERVER_KEY] || null
156
- if (target.onlyIfHasEntry && !previous) return { status: 'skipped' }
157
- const entry = serverEntry(runtimeJs, { claudeCode: !!target.claudeCode, previous })
158
- const next = { ...cfg, mcpServers: { ...servers, [SERVER_KEY]: entry } }
159
- // CLAUDE CODE'S OTHER SCOPE. `claude mcp add` stores a server under projects[<folder>] unless
160
- // told otherwise, and that entry OVERRIDES the user-wide one inside that folder. Everyone who
161
- // followed the old README therefore has a broken npx entry that would silently win over the one
162
- // written above -- and this command would have printed READY about it. Those are ours by name.
163
- let projectFixes = 0
164
- if (target.claudeCode && cfg.projects && typeof cfg.projects === 'object') {
165
- for (const [p, v] of Object.entries(cfg.projects)) {
166
- const old = v && v.mcpServers && v.mcpServers[SERVER_KEY]
167
- if (!old || typeof old !== 'object') continue
168
- const fixed = serverEntry(runtimeJs, { claudeCode: true, previous: old })
169
- if (JSON.stringify(fixed) === JSON.stringify(old)) continue
170
- if (next.projects === cfg.projects) next.projects = { ...cfg.projects }
171
- next.projects[p] = { ...v, mcpServers: { ...v.mcpServers, [SERVER_KEY]: fixed } }
172
- projectFixes++
173
- }
174
- }
175
- if (previous && !projectFixes && JSON.stringify(previous) === JSON.stringify(entry)) return { status: 'unchanged', detail: 'already set up' }
176
- if (dryRun) return { status: 'written', detail: 'dry run: would write', entry }
177
- try {
178
- fs.mkdirSync(path.dirname(target.file), { recursive: true })
179
- // The backup is taken ONCE, of the file as it was before Hivecode ever touched it, so a
180
- // second or tenth run can never overwrite the one copy worth going back to.
181
- if (raw && !fs.existsSync(target.file + BACKUP_SUFFIX)) fs.writeFileSync(target.file + BACKUP_SUFFIX, raw)
182
- const tmp = target.file + '.hivecode-tmp'
183
- fs.writeFileSync(tmp, JSON.stringify(next, null, 2) + '\n')
184
- try { fs.renameSync(tmp, target.file) } catch {
185
- // A locked destination (an app holding it, an antivirus scan) refuses the rename on
186
- // Windows. Writing in place is less atomic but still leaves a whole file behind.
187
- fs.writeFileSync(target.file, JSON.stringify(next, null, 2) + '\n'); try { fs.rmSync(tmp, { force: true }) } catch {}
188
- }
189
- } catch (e) { return { status: 'error', detail: e.message } }
190
- let detail = previous ? 'replaced the old hivecode entry' : 'added hivecode'
191
- if (projectFixes) detail += `, and fixed ${projectFixes} per-folder entr${projectFixes === 1 ? 'y' : 'ies'} that would have overridden it`
192
- if (droppedComments) detail += ` (its comments were dropped; the original is ${path.basename(target.file)}${BACKUP_SUFFIX})`
193
- return { status: 'written', detail }
194
- }
195
-
196
- /**
197
- * Every hivecode entry in a written file must launch the same thing -- including Claude Code's
198
- * per-folder ones, which are never verified by launching separately. Returns the first entry
199
- * that disagrees with `entry`, or null.
200
- */
201
- export function strayEntry(file, entry) {
202
- let cfg
203
- try { cfg = JSON.parse(fs.readFileSync(file, 'utf8').replace(/^/, '')) } catch { return null }
204
- for (const [p, v] of Object.entries((cfg && cfg.projects) || {})) {
205
- const e = v && v.mcpServers && v.mcpServers[SERVER_KEY]
206
- if (e && (e.command !== entry.command || JSON.stringify(e.args) !== JSON.stringify(entry.args))) return p
207
- }
208
- return null
209
- }
210
-
211
- /** Read the entry back FROM DISK -- the thing the agent app will actually launch. */
212
- export function readEntry(file) {
213
- try { return JSON.parse(fs.readFileSync(file, 'utf8').replace(/^/, '')).mcpServers[SERVER_KEY] || null } catch { return null }
214
- }
215
-
216
- /**
217
- * Launch an entry exactly as an MCP client does -- no shell, the entry's own env and cwd -- and
218
- * complete a real `initialize` handshake. Anything short of a hivecode server answering is a
219
- * failure with the reason attached.
220
- */
221
- export function verifyEntry(entry, { timeoutMs = 20000 } = {}) {
222
- return new Promise((resolve) => {
223
- if (!entry || !entry.command) return resolve({ ok: false, error: 'no hivecode entry in the file' })
224
- const t0 = Date.now()
225
- const cwd = entry.cwd && fs.existsSync(entry.cwd) ? entry.cwd : os.tmpdir()
226
- let child
227
- try {
228
- child = spawn(entry.command, entry.args || [], { cwd, env: { ...process.env, ...(entry.env || {}) }, shell: false, windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'] })
229
- } catch (e) { return resolve({ ok: false, error: e.message }) }
230
- let out = ''
231
- let errTail = ''
232
- let settled = false
233
- const done = (r) => { if (settled) return; settled = true; clearTimeout(timer); try { child.kill() } catch {}; resolve({ ms: Date.now() - t0, ...r }) }
234
- const timer = setTimeout(() => done({ ok: false, error: `no answer within ${Math.round(timeoutMs / 1000)}s${errTail ? ': ' + errTail.trim().split('\n').pop() : ''}` }), timeoutMs)
235
- child.on('error', (e) => done({ ok: false, error: `${e.code || ''} ${e.message}`.trim() }))
236
- child.on('exit', (code) => done({ ok: false, error: `the server exited (code ${code}) before answering${errTail ? ': ' + errTail.trim().split('\n').pop() : ''}` }))
237
- child.stderr.on('data', (d) => { errTail = (errTail + d).slice(-400) })
238
- child.stdout.on('data', (d) => {
239
- out += d
240
- let i
241
- while ((i = out.indexOf('\n')) >= 0) {
242
- const line = out.slice(0, i); out = out.slice(i + 1)
243
- let m; try { m = JSON.parse(line) } catch { continue }
244
- if (m.id !== 1) continue
245
- const info = m.result && m.result.serverInfo
246
- if (info && info.name === 'hivecode') done({ ok: true, version: info.version })
247
- else done({ ok: false, error: 'something answered, but it is not the hivecode server' })
248
- }
249
- })
250
- child.stdin.write(JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'hivecode-setup', version: VERSION } } }) + '\n')
251
- })
252
- }
253
-
254
- /**
255
- * Put a runtime where agent apps can launch it with no download. When this file is running out
256
- * of a checkout (development), the checkout IS the runtime. When it is running out of an
257
- * installed package (npx), the package and its dependency closure are copied under
258
- * ~/.hivecode/runtime/<version> -- the npx cache is not a place to point a config at, because
259
- * npm prunes it and the config would break months later with nothing to connect it to setup.
260
- */
261
- export function installRuntime(home = os.homedir(), here = fileURLToPath(import.meta.url)) {
262
- const pkgDir = path.dirname(here)
263
- const nm = path.dirname(pkgDir)
264
- if (path.basename(nm) !== 'node_modules') return { mode: 'checkout', js: path.join(pkgDir, 'hive-mcp.js') }
265
- const dest = path.join(home, '.hivecode', 'runtime', VERSION)
266
- const js = path.join(dest, 'node_modules', 'hivecode-mcp', 'hive-mcp.js')
267
- if (fs.existsSync(js)) return { mode: 'installed', js, dir: dest, reused: true }
268
- // ONLY the dependency closure is copied, never the whole node_modules around us: that would
269
- // be every global package on a machine where hivecode-mcp was installed with `npm i -g`.
270
- const closure = new Set()
271
- const seen = new Set()
272
- const visit = (name, fromDir) => {
273
- // Node's own resolution: <dir>/node_modules/<name> for each directory walking up from the
274
- // requiring package, skipping directories that ARE node_modules (Node never looks in
275
- // node_modules/node_modules), and stopping at the folder that holds our node_modules.
276
- for (let d = fromDir; ;) {
277
- if (path.basename(d) !== 'node_modules') {
278
- const cand = path.join(d, 'node_modules', name)
279
- if (fs.existsSync(path.join(cand, 'package.json'))) {
280
- if (seen.has(cand)) return // dependency cycles are real (and legal) in npm trees
281
- seen.add(cand)
282
- // Nested copies travel inside their parent's directory; only top-level ones are listed.
283
- if (path.relative(nm, cand) === path.join(...name.split('/'))) closure.add(name)
284
- let pj = {}
285
- try { pj = JSON.parse(fs.readFileSync(path.join(cand, 'package.json'), 'utf8')) } catch {}
286
- for (const dep of Object.keys({ ...pj.dependencies, ...pj.optionalDependencies, ...pj.peerDependencies })) visit(dep, cand)
287
- return
288
- }
289
- }
290
- const up = path.dirname(d)
291
- if (up === d || d === path.dirname(nm)) return // a missing optional or peer dependency
292
- d = up
293
- }
294
- }
295
- closure.add('hivecode-mcp')
296
- let rootPj = {}
297
- try { rootPj = JSON.parse(fs.readFileSync(path.join(pkgDir, 'package.json'), 'utf8')) } catch {}
298
- for (const dep of Object.keys({ ...rootPj.dependencies, ...rootPj.optionalDependencies })) visit(dep, pkgDir)
299
- const tmp = `${dest}.tmp-${process.pid}`
300
- fs.rmSync(tmp, { recursive: true, force: true })
301
- for (const name of closure) {
302
- fs.cpSync(path.join(nm, name), path.join(tmp, 'node_modules', name), { recursive: true, dereference: true })
303
- }
304
- fs.rmSync(dest, { recursive: true, force: true })
305
- fs.renameSync(tmp, dest)
306
- return { mode: 'installed', js, dir: dest, packages: closure.size }
307
- }
308
-
309
- function defaultName() {
310
- let base = 'agent'
311
- try { base = os.userInfo().username || base } catch {}
312
- const cand = (base.replace(/[^A-Za-z0-9_-]+/g, '-').replace(/^-+|-+$/g, '') || 'agent') + '-AI'
313
- const v = validateMemberName(cand)
314
- return v.ok ? cand : v.suggested
315
- }
316
-
317
- async function wakeRelay(relay) {
318
- const url = relay.replace(/^wss:/, 'https:').replace(/^ws:/, 'http:').replace(/\/+$/, '') + '/health'
319
- const t0 = Date.now()
320
- try {
321
- const r = await fetch(url, { signal: AbortSignal.timeout(75000) })
322
- return { ok: r.ok, ms: Date.now() - t0, status: r.status }
323
- } catch (e) { return { ok: false, ms: Date.now() - t0, error: e.name === 'TimeoutError' ? 'no answer in 75s' : e.message } }
324
- }
325
-
326
- function parseArgs(argv) {
327
- const o = { link: '', name: '', dryRun: false, help: false }
328
- for (let i = 0; i < argv.length; i++) {
329
- const a = argv[i]
330
- if (a === '--name') o.name = argv[++i] || ''
331
- else if (a.startsWith('--name=')) o.name = a.slice(7)
332
- else if (a === '--dry-run') o.dryRun = true
333
- else if (a === '-h' || a === '--help') o.help = true
334
- else if (!a.startsWith('-') && !o.link) o.link = a
335
- }
336
- return o
337
- }
338
-
339
- const pad = (s, n) => (s + ' '.repeat(n)).slice(0, Math.max(n, s.length))
340
-
341
- /** Returns the process exit code: 0 only when nothing that exists failed. */
342
- export async function main(argv = [], { home = os.homedir(), log = console.log } = {}) {
343
- const o = parseArgs(argv)
344
- if (o.help) {
345
- log('Usage: npx -y hivecode-mcp@latest setup ["<join link>"] [--name You-AI] [--dry-run]')
346
- return 0
347
- }
348
- log(`\nHivecode setup (hivecode-mcp ${VERSION})\n`)
349
- const major = Number(process.versions.node.split('.')[0])
350
- if (major < 18) { log(` FAILED Node ${process.versions.node} is too old. Install Node 18 or newer from https://nodejs.org and run this again.`); return 1 }
351
-
352
- let name = o.name || defaultName()
353
- const nv = validateMemberName(name)
354
- if (!nv.ok) { log(` note "${name}" cannot be a room name (${nv.error.split('.')[0]}); using "${nv.suggested}".`); name = nv.suggested }
355
-
356
- let runtime
357
- try { runtime = o.dryRun ? { mode: 'dry-run', js: path.join(home, '.hivecode', 'runtime', VERSION, 'node_modules', 'hivecode-mcp', 'hive-mcp.js') } : installRuntime(home) }
358
- catch (e) { log(` FAILED could not install the runtime under ${path.join(home, '.hivecode')}: ${e.message}`); return 1 }
359
- log(` runtime ${runtime.js}`)
360
- log(` node ${process.execPath}\n`)
361
-
362
- const results = []
363
- for (const t of agentTargets(home)) {
364
- // A second config path of an app that IS installed is not an app of its own: printing
365
- // "Antigravity (2nd) not installed" made a stranger ask what part of Antigravity was missing.
366
- if (!fs.existsSync(t.present)) { if (!t.onlyIfHasEntry) results.push({ t, status: 'absent' }); continue }
367
- const w = writeEntry(t, runtime.js, { dryRun: o.dryRun })
368
- if (w.status !== 'skipped') results.push({ t, ...w })
369
- }
370
- // Verify what is ON DISK, once per distinct launch -- the entries are normally identical.
371
- const cache = new Map()
372
- for (const r of results) {
373
- if (r.status !== 'written' && r.status !== 'unchanged') continue
374
- if (o.dryRun) { r.verify = { ok: true, ms: 0, skipped: true }; continue }
375
- const entry = readEntry(r.t.file)
376
- const key = JSON.stringify(entry)
377
- if (!cache.has(key)) cache.set(key, await verifyEntry(entry))
378
- r.verify = cache.get(key)
379
- const stray = r.verify.ok && entry && strayEntry(r.t.file, entry)
380
- if (stray) r.verify = { ok: false, error: `the entry for the folder ${stray} still launches something else, and it wins inside that folder` }
381
- }
382
- // A project's own .mcp.json outranks everything written above, inside that project. It is a
383
- // shared, committed file, so it is reported rather than rewritten with this machine's paths.
384
- const projectMcp = path.join(process.cwd(), '.mcp.json')
385
- if (readEntry(projectMcp)) log(` WARNING ${projectMcp} defines its own hivecode server, which overrides this setup inside this folder. Remove that entry.\n`)
386
-
387
- let failed = 0
388
- const ready = []
389
- for (const r of results) {
390
- const where = r.t.file
391
- if (r.status === 'absent') { log(` ${pad(r.t.label, 15)} not installed`); continue }
392
- if (r.status === 'refused' || r.status === 'error') {
393
- failed++
394
- log(` ${pad(r.t.label, 15)} FAILED ${where}\n ${' '.repeat(15)} ${r.detail}. Add the hivecode entry below by hand.`)
395
- continue
396
- }
397
- if (!r.verify.ok) {
398
- failed++
399
- log(` ${pad(r.t.label, 15)} FAILED ${where}\n ${' '.repeat(15)} written, but it does not start: ${r.verify.error}`)
400
- continue
401
- }
402
- ready.push(r)
403
- const how = r.verify.skipped ? 'dry run, nothing written' : `starts in ${(r.verify.ms / 1000).toFixed(1)}s`
404
- log(` ${pad(r.t.label, 15)} READY ${r.status === 'unchanged' ? 'already set up' : r.detail} (${how})`)
405
- }
406
-
407
- const manual = { mcpServers: { [SERVER_KEY]: serverEntry(runtime.js) } }
408
- if (failed || !ready.length) {
409
- log(`\n ${ready.length ? 'For the agents above that FAILED' : 'No supported agent app was found. For any MCP agent'}, add this to its MCP settings:\n`)
410
- log(JSON.stringify(manual, null, 2).split('\n').map((l) => ' ' + l).join('\n'))
411
- }
412
-
413
- let link = o.link.trim()
414
- if (link) {
415
- const [relayPart, ...rest] = link.split('|')
416
- const { relay, migratedFrom } = currentRelayFor(relayPart.trim() || PUBLIC_DEFAULT_RELAY)
417
- if (migratedFrom) { link = [relay, ...rest].join('|'); log(`\n note that link used a retired relay; it now points at ${relay}`) }
418
- if (!rest.length || !rest[0].trim()) {
419
- // THE PIPE TRAP. Unquoted, `setup wss://relay|room-x` is two commands to every shell: this
420
- // one gets `wss://relay`, and our stdout is piped into a program called room-x. So the
421
- // explanation also goes to stderr, the one stream still reaching the terminal.
422
- const hint = `\n WARNING "${o.link}" has no room in it -- the part after | was cut off. Put the whole link in quotes, or leave it out: you give the link to your agent, not to this command.`
423
- log(hint)
424
- if (!process.stdout.isTTY) console.error(hint)
425
- }
426
- const w = await wakeRelay(relay)
427
- log(w.ok ? `\n relay ${relay} is up (answered in ${(w.ms / 1000).toFixed(1)}s)` : `\n WARNING ${relay} did not answer (${w.error || 'HTTP ' + w.status}). Joining will fail until it does.`)
428
- }
429
-
430
- if (ready.length) {
431
- log(`\nNEXT\n`)
432
- ready.forEach((r, i) => log(` ${i + 1}. ${r.t.label}: ${r.t.restart}.`))
433
- if (link) {
434
- log(`\n Then paste this to your agent:\n`)
435
- log(` Use your hive_join tool to join the Hivecode room ${link} as ${name}. Then follow the rules it gives you.`)
436
- } else {
437
- log(`\n Then paste ONE of these to your agent:\n`)
438
- log(` Use your hive_join tool to join the Hivecode room <the link you were sent> as ${name}. Then follow the rules it gives you.`)
439
- log(` Use your hive_join tool to host a new Hivecode room as ${name}, then give me the join link.`)
440
- }
441
- log(`\n Its files go in ${path.join(home, 'hivecode', '<room>', name)} unless you name a folder.`)
442
- log(` People join from a browser, no install: ${PUBLIC_DEFAULT_RELAY.replace(/^wss:/, 'https:')}/control\n`)
443
- }
444
- return failed ? 1 : 0
445
- }
1
+ // hive-setup.js -- `npx -y hivecode-mcp@latest setup ["<join link>"] [--name You-AI]`
2
+ //
3
+ // ONE COMMAND FROM NOTHING TO AN AGENT THAT CAN JOIN. It exists because setup cost a real
4
+ // tester an hour on 24 Sep 2026, and when it was measured the next day on a cold machine the
5
+ // hour had concrete causes, none of them in the relay (hive_join itself took 0.5s):
6
+ //
7
+ // 1. The README's config was {"command":"npx", ...}. Agent apps start MCP servers WITHOUT a
8
+ // shell, and on Windows npx is npx.cmd, so the launch fails with `spawn npx ENOENT` -- the
9
+ // agent just never gets the hive_* tools, and nothing on screen says why.
10
+ // 2. The first `npx hivecode-mcp@latest` start took 36.7s, downloading. Claude Code gives an
11
+ // MCP server 30s, so a new user's FIRST launch fails, and the second one may not.
12
+ // 3. hive_join's default folder was './workspace', relative to whatever directory the agent
13
+ // app happened to start the server in -- sometimes the user's project, sometimes a
14
+ // directory nobody can write to.
15
+ //
16
+ // So this does the four things a person was doing by hand, and then CHECKS them:
17
+ // - installs a runtime copy under ~/.hivecode/runtime/<version>, so launching needs no
18
+ // download, no network and no npx;
19
+ // - writes the hivecode entry into every agent config it finds, with the absolute path of
20
+ // THIS node, which also dodges GUI apps on macOS not seeing an nvm PATH;
21
+ // - launches each entry exactly the way the agent app will -- read back from the file, no
22
+ // shell -- and does a real MCP handshake with it;
23
+ // - prints the one sentence to paste into the agent.
24
+ //
25
+ // THE FAILURE THIS MUST NEVER HAVE is the one every defect in this repo has had: reporting
26
+ // success while the result is wrong. An agent whose config could not be written, or whose entry
27
+ // does not start, is printed as FAILED and makes the exit code non-zero. "Configured" is only
28
+ // ever printed about an entry that was read back from disk and answered a handshake.
29
+ //
30
+ // WHAT IT COSTS WHEN WRONG: these are the user's own config files, holding their other MCP
31
+ // servers. So a file is parsed or refused (JSON with comments is refused, not rewritten), the
32
+ // original is backed up once before the first change, only the `hivecode` key is touched, and
33
+ // every other key survives byte-for-byte as a value.
34
+ import fs from 'fs'
35
+ import os from 'os'
36
+ import path from 'path'
37
+ import { spawn, spawnSync } from 'child_process'
38
+ import { fileURLToPath } from 'url'
39
+ import { VERSION } from './hive-version.js'
40
+ import { currentRelayFor, controlUrl, PUBLIC_DEFAULT_RELAY } from './hive-privacy.js'
41
+ import { validateMemberName } from './hive-mailbox.js'
42
+ import { installStopHook, installAntigravityStopHook } from './hive-hook-install.js'
43
+
44
+ export const SERVER_KEY = 'hivecode'
45
+ const BACKUP_SUFFIX = '.before-hivecode'
46
+
47
+ /** Every agent app we know how to configure, and where each keeps its MCP servers. */
48
+ export function agentTargets(home = os.homedir(), env = process.env, platform = process.platform) {
49
+ const claudeDesktopDir = platform === 'win32' ? path.join(env.APPDATA || path.join(home, 'AppData', 'Roaming'), 'Claude')
50
+ : platform === 'darwin' ? path.join(home, 'Library', 'Application Support', 'Claude')
51
+ : path.join(env.XDG_CONFIG_HOME || path.join(home, '.config'), 'Claude')
52
+ // `present` is how we decide the app is installed. It is deliberately the app's OWN directory
53
+ // (or, where the app creates it on first run, its own file) -- never "the file we would write",
54
+ // which would make every run create configs for apps the user does not have.
55
+ return [
56
+ { id: 'claude-code', label: 'Claude Code', file: path.join(home, '.claude.json'), present: path.join(home, '.claude.json'), claudeCode: true,
57
+ restart: 'start a new Claude Code session (or restart `claude`)' },
58
+ { id: 'antigravity', label: 'Antigravity', file: path.join(home, '.gemini', 'antigravity', 'mcp_config.json'), present: path.join(home, '.gemini', 'antigravity'),
59
+ restart: 'Settings > Skills & Customizations > MCP Servers, then click refresh on hivecode (or restart Antigravity)' },
60
+ // Antigravity also reads this one. It is only touched when it ALREADY has a hivecode entry --
61
+ // a stale one there could shadow the fresh one above -- and never created.
62
+ { id: 'antigravity-2', label: 'Antigravity (2nd)', file: path.join(home, '.gemini', 'config', 'mcp_config.json'), present: path.join(home, '.gemini', 'config', 'mcp_config.json'),
63
+ onlyIfHasEntry: true, secondaryOf: 'antigravity', restart: 'same refresh as above' },
64
+ { id: 'cursor', label: 'Cursor', file: path.join(home, '.cursor', 'mcp.json'), present: path.join(home, '.cursor'),
65
+ restart: 'Cursor Settings > MCP, then turn hivecode off and on (or restart Cursor)' },
66
+ { id: 'windsurf', label: 'Windsurf', file: path.join(home, '.codeium', 'windsurf', 'mcp_config.json'), present: path.join(home, '.codeium', 'windsurf'),
67
+ restart: 'Windsurf > MCP servers > Refresh (or restart Windsurf)' },
68
+ { id: 'claude-desktop', label: 'Claude Desktop', file: path.join(claudeDesktopDir, 'claude_desktop_config.json'), present: claudeDesktopDir,
69
+ restart: 'quit Claude Desktop fully (tray icon > Quit) and open it again' },
70
+ // The Gemini CLI shares ~/.gemini with Antigravity, so its directory proves nothing. Its own
71
+ // settings file does: the CLI writes it on first run.
72
+ { id: 'gemini-cli', label: 'Gemini CLI', file: path.join(home, '.gemini', 'settings.json'), present: path.join(home, '.gemini', 'settings.json'),
73
+ restart: 'restart `gemini`' },
74
+ ]
75
+ }
76
+
77
+ /**
78
+ * The node to write into configs: an absolute path (a bare "node" fails with ENOENT in GUI apps
79
+ * started from the macOS Dock, which do not see an nvm or Homebrew PATH), but the most STABLE
80
+ * absolute path that is still this same program. process.execPath on Homebrew is the versioned
81
+ * Cellar path, which `brew upgrade node` deletes; /opt/homebrew/bin/node is a link that follows
82
+ * the upgrade. A candidate is only taken when its realpath is exactly this node, so the choice
83
+ * can never change WHICH node runs -- and verifyEntry launches whatever was chosen anyway.
84
+ */
85
+ export function stableNodePath(execPath = process.execPath, env = process.env) {
86
+ const real = (p) => { try { return fs.realpathSync(p) } catch { return null } }
87
+ const target = real(execPath)
88
+ if (!target) return execPath
89
+ const exe = process.platform === 'win32' ? 'node.exe' : 'node'
90
+ const versioned = /[\\/]v?\d+\.\d+\.\d+[\\/]|[\\/]Cellar[\\/]/
91
+ const fromPath = (env.PATH || env.Path || '').split(path.delimiter).filter(Boolean).map((d) => path.join(d, exe))
92
+ const cands = ['/opt/homebrew/bin/node', '/usr/local/bin/node', ...fromPath.filter((c) => !versioned.test(c))]
93
+ for (const c of cands) if (c !== execPath && fs.existsSync(c) && real(c) === target) return c
94
+ return execPath
95
+ }
96
+
97
+ /**
98
+ * Parse the JSON-with-comments that Cursor, VS Code and friends accept: // and /* *\/ comments and
99
+ * trailing commas, all outside strings. Returns the plain-JSON text.
100
+ */
101
+ export function stripJsonc(src) {
102
+ let out = ''
103
+ for (let i = 0, inStr = false; i < src.length; i++) {
104
+ const c = src[i]
105
+ if (inStr) { out += c; if (c === '\\') out += src[++i] ?? ''; else if (c === '"') inStr = false; continue }
106
+ if (c === '"') { inStr = true; out += c; continue }
107
+ if (c === '/' && src[i + 1] === '/') { while (i < src.length && src[i] !== '\n') i++; out += '\n'; continue }
108
+ if (c === '/' && src[i + 1] === '*') { const e = src.indexOf('*/', i + 2); i = e < 0 ? src.length : e + 1; continue }
109
+ out += c
110
+ }
111
+ let res = ''
112
+ for (let i = 0, inStr = false; i < out.length; i++) {
113
+ const c = out[i]
114
+ if (inStr) { res += c; if (c === '\\') res += out[++i] ?? ''; else if (c === '"') inStr = false; continue }
115
+ if (c === '"') { inStr = true; res += c; continue }
116
+ if (c === ',') { let j = i + 1; while (j < out.length && /\s/.test(out[j])) j++; if (out[j] === '}' || out[j] === ']') continue }
117
+ res += c
118
+ }
119
+ return res
120
+ }
121
+
122
+ /** The entry every agent gets: this node, by absolute path, running hive-mcp.js directly. */
123
+ export function serverEntry(runtimeJs, { claudeCode = false, previous = null, node = stableNodePath() } = {}) {
124
+ const entry = { command: node, args: [runtimeJs] }
125
+ // What the user added to an existing entry is theirs: a token, a relay, a working directory.
126
+ // Only the launch itself -- command and args -- is ours to replace.
127
+ if (previous && typeof previous === 'object') {
128
+ if (previous.env && typeof previous.env === 'object') entry.env = previous.env
129
+ if (typeof previous.cwd === 'string') entry.cwd = previous.cwd
130
+ }
131
+ if (claudeCode) return { type: 'stdio', ...entry, env: entry.env || {} }
132
+ return entry
133
+ }
134
+
135
+ /**
136
+ * Write the hivecode entry into one config file. Returns { status, detail }, where status is one
137
+ * of: 'written', 'unchanged', 'refused' (the file exists but is not plain JSON), 'error'.
138
+ */
139
+ export function writeEntry(target, runtimeJs, { dryRun = false } = {}) {
140
+ let raw = ''
141
+ try { raw = fs.readFileSync(target.file, 'utf8') } catch (e) { if (e.code !== 'ENOENT') return { status: 'error', detail: e.message } }
142
+ let cfg = {}
143
+ let droppedComments = false
144
+ if (raw.trim()) {
145
+ const text = raw.replace(/^/, '')
146
+ try { cfg = JSON.parse(text) } catch {
147
+ // JSON with comments is legal in the files Cursor and VS Code write. Refusing it would hand
148
+ // the user back the hand-editing this command exists to remove; the original is kept.
149
+ try { cfg = JSON.parse(stripJsonc(text)); droppedComments = true } catch {
150
+ return { status: 'refused', detail: 'not valid JSON even allowing comments, so it was left exactly as it is' }
151
+ }
152
+ }
153
+ if (!cfg || typeof cfg !== 'object' || Array.isArray(cfg)) return { status: 'refused', detail: 'the file is not a JSON object' }
154
+ }
155
+ const servers = cfg.mcpServers && typeof cfg.mcpServers === 'object' && !Array.isArray(cfg.mcpServers) ? cfg.mcpServers : {}
156
+ const previous = servers[SERVER_KEY] || null
157
+ if (target.onlyIfHasEntry && !previous) return { status: 'skipped' }
158
+ const entry = serverEntry(runtimeJs, { claudeCode: !!target.claudeCode, previous })
159
+ const next = { ...cfg, mcpServers: { ...servers, [SERVER_KEY]: entry } }
160
+ // CLAUDE CODE'S OTHER SCOPE. `claude mcp add` stores a server under projects[<folder>] unless
161
+ // told otherwise, and that entry OVERRIDES the user-wide one inside that folder. Everyone who
162
+ // followed the old README therefore has a broken npx entry that would silently win over the one
163
+ // written above -- and this command would have printed READY about it. Those are ours by name.
164
+ let projectFixes = 0
165
+ if (target.claudeCode && cfg.projects && typeof cfg.projects === 'object') {
166
+ for (const [p, v] of Object.entries(cfg.projects)) {
167
+ const old = v && v.mcpServers && v.mcpServers[SERVER_KEY]
168
+ if (!old || typeof old !== 'object') continue
169
+ const fixed = serverEntry(runtimeJs, { claudeCode: true, previous: old })
170
+ if (JSON.stringify(fixed) === JSON.stringify(old)) continue
171
+ if (next.projects === cfg.projects) next.projects = { ...cfg.projects }
172
+ next.projects[p] = { ...v, mcpServers: { ...v.mcpServers, [SERVER_KEY]: fixed } }
173
+ projectFixes++
174
+ }
175
+ }
176
+ if (previous && !projectFixes && JSON.stringify(previous) === JSON.stringify(entry)) return { status: 'unchanged', detail: 'already set up' }
177
+ if (dryRun) return { status: 'written', detail: 'dry run: would write', entry }
178
+ try {
179
+ fs.mkdirSync(path.dirname(target.file), { recursive: true })
180
+ // The backup is taken ONCE, of the file as it was before Hivecode ever touched it, so a
181
+ // second or tenth run can never overwrite the one copy worth going back to.
182
+ if (raw && !fs.existsSync(target.file + BACKUP_SUFFIX)) fs.writeFileSync(target.file + BACKUP_SUFFIX, raw)
183
+ const tmp = target.file + '.hivecode-tmp'
184
+ fs.writeFileSync(tmp, JSON.stringify(next, null, 2) + '\n')
185
+ try { fs.renameSync(tmp, target.file) } catch {
186
+ // A locked destination (an app holding it, an antivirus scan) refuses the rename on
187
+ // Windows. Writing in place is less atomic but still leaves a whole file behind.
188
+ fs.writeFileSync(target.file, JSON.stringify(next, null, 2) + '\n'); try { fs.rmSync(tmp, { force: true }) } catch {}
189
+ }
190
+ } catch (e) { return { status: 'error', detail: e.message } }
191
+ let detail = previous ? 'replaced the old hivecode entry' : 'added hivecode'
192
+ if (projectFixes) detail += `, and fixed ${projectFixes} per-folder entr${projectFixes === 1 ? 'y' : 'ies'} that would have overridden it`
193
+ if (droppedComments) detail += ` (its comments were dropped; the original is ${path.basename(target.file)}${BACKUP_SUFFIX})`
194
+ return { status: 'written', detail }
195
+ }
196
+
197
+ /**
198
+ * Every hivecode entry in a written file must launch the same thing -- including Claude Code's
199
+ * per-folder ones, which are never verified by launching separately. Returns the first entry
200
+ * that disagrees with `entry`, or null.
201
+ */
202
+ export function strayEntry(file, entry) {
203
+ let cfg
204
+ try { cfg = JSON.parse(fs.readFileSync(file, 'utf8').replace(/^/, '')) } catch { return null }
205
+ for (const [p, v] of Object.entries((cfg && cfg.projects) || {})) {
206
+ const e = v && v.mcpServers && v.mcpServers[SERVER_KEY]
207
+ if (e && (e.command !== entry.command || JSON.stringify(e.args) !== JSON.stringify(entry.args))) return p
208
+ }
209
+ return null
210
+ }
211
+
212
+ /** Read the entry back FROM DISK -- the thing the agent app will actually launch. */
213
+ export function readEntry(file) {
214
+ try { return JSON.parse(fs.readFileSync(file, 'utf8').replace(/^/, '')).mcpServers[SERVER_KEY] || null } catch { return null }
215
+ }
216
+
217
+ /**
218
+ * Launch an entry exactly as an MCP client does -- no shell, the entry's own env and cwd -- and
219
+ * complete a real `initialize` handshake. Anything short of a hivecode server answering is a
220
+ * failure with the reason attached.
221
+ */
222
+ export function verifyEntry(entry, { timeoutMs = 20000 } = {}) {
223
+ return new Promise((resolve) => {
224
+ if (!entry || !entry.command) return resolve({ ok: false, error: 'no hivecode entry in the file' })
225
+ const t0 = Date.now()
226
+ const cwd = entry.cwd && fs.existsSync(entry.cwd) ? entry.cwd : os.tmpdir()
227
+ let child
228
+ try {
229
+ child = spawn(entry.command, entry.args || [], { cwd, env: { ...process.env, ...(entry.env || {}) }, shell: false, windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'] })
230
+ } catch (e) { return resolve({ ok: false, error: e.message }) }
231
+ let out = ''
232
+ let errTail = ''
233
+ let settled = false
234
+ const done = (r) => { if (settled) return; settled = true; clearTimeout(timer); try { child.kill() } catch {}; resolve({ ms: Date.now() - t0, ...r }) }
235
+ const timer = setTimeout(() => done({ ok: false, error: `no answer within ${Math.round(timeoutMs / 1000)}s${errTail ? ': ' + errTail.trim().split('\n').pop() : ''}` }), timeoutMs)
236
+ child.on('error', (e) => done({ ok: false, error: `${e.code || ''} ${e.message}`.trim() }))
237
+ child.on('exit', (code) => done({ ok: false, error: `the server exited (code ${code}) before answering${errTail ? ': ' + errTail.trim().split('\n').pop() : ''}` }))
238
+ child.stderr.on('data', (d) => { errTail = (errTail + d).slice(-400) })
239
+ child.stdout.on('data', (d) => {
240
+ out += d
241
+ let i
242
+ while ((i = out.indexOf('\n')) >= 0) {
243
+ const line = out.slice(0, i); out = out.slice(i + 1)
244
+ let m; try { m = JSON.parse(line) } catch { continue }
245
+ if (m.id !== 1) continue
246
+ const info = m.result && m.result.serverInfo
247
+ if (info && info.name === 'hivecode') done({ ok: true, version: info.version })
248
+ else done({ ok: false, error: 'something answered, but it is not the hivecode server' })
249
+ }
250
+ })
251
+ child.stdin.write(JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'hivecode-setup', version: VERSION } } }) + '\n')
252
+ })
253
+ }
254
+
255
+ /**
256
+ * Put a runtime where agent apps can launch it with no download. When this file is running out
257
+ * of a checkout (development), the checkout IS the runtime. When it is running out of an
258
+ * installed package (npx), the package and its dependency closure are copied under
259
+ * ~/.hivecode/runtime/<version> -- the npx cache is not a place to point a config at, because
260
+ * npm prunes it and the config would break months later with nothing to connect it to setup.
261
+ */
262
+ export function installRuntime(home = os.homedir(), here = fileURLToPath(import.meta.url)) {
263
+ const pkgDir = path.dirname(here)
264
+ const nm = path.dirname(pkgDir)
265
+ if (path.basename(nm) !== 'node_modules') return { mode: 'checkout', js: path.join(pkgDir, 'hive-mcp.js') }
266
+ const dest = path.join(home, '.hivecode', 'runtime', VERSION)
267
+ const js = path.join(dest, 'node_modules', 'hivecode-mcp', 'hive-mcp.js')
268
+ if (fs.existsSync(js)) return { mode: 'installed', js, dir: dest, reused: true }
269
+ // ONLY the dependency closure is copied, never the whole node_modules around us: that would
270
+ // be every global package on a machine where hivecode-mcp was installed with `npm i -g`.
271
+ const closure = new Set()
272
+ const seen = new Set()
273
+ const visit = (name, fromDir) => {
274
+ // Node's own resolution: <dir>/node_modules/<name> for each directory walking up from the
275
+ // requiring package, skipping directories that ARE node_modules (Node never looks in
276
+ // node_modules/node_modules), and stopping at the folder that holds our node_modules.
277
+ for (let d = fromDir; ;) {
278
+ if (path.basename(d) !== 'node_modules') {
279
+ const cand = path.join(d, 'node_modules', name)
280
+ if (fs.existsSync(path.join(cand, 'package.json'))) {
281
+ if (seen.has(cand)) return // dependency cycles are real (and legal) in npm trees
282
+ seen.add(cand)
283
+ // Nested copies travel inside their parent's directory; only top-level ones are listed.
284
+ if (path.relative(nm, cand) === path.join(...name.split('/'))) closure.add(name)
285
+ let pj = {}
286
+ try { pj = JSON.parse(fs.readFileSync(path.join(cand, 'package.json'), 'utf8')) } catch {}
287
+ for (const dep of Object.keys({ ...pj.dependencies, ...pj.optionalDependencies, ...pj.peerDependencies })) visit(dep, cand)
288
+ return
289
+ }
290
+ }
291
+ const up = path.dirname(d)
292
+ if (up === d || d === path.dirname(nm)) return // a missing optional or peer dependency
293
+ d = up
294
+ }
295
+ }
296
+ closure.add('hivecode-mcp')
297
+ let rootPj = {}
298
+ try { rootPj = JSON.parse(fs.readFileSync(path.join(pkgDir, 'package.json'), 'utf8')) } catch {}
299
+ for (const dep of Object.keys({ ...rootPj.dependencies, ...rootPj.optionalDependencies })) visit(dep, pkgDir)
300
+ const tmp = `${dest}.tmp-${process.pid}`
301
+ fs.rmSync(tmp, { recursive: true, force: true })
302
+ for (const name of closure) {
303
+ fs.cpSync(path.join(nm, name), path.join(tmp, 'node_modules', name), { recursive: true, dereference: true })
304
+ }
305
+ fs.rmSync(dest, { recursive: true, force: true })
306
+ fs.renameSync(tmp, dest)
307
+ return { mode: 'installed', js, dir: dest, packages: closure.size }
308
+ }
309
+
310
+ /** The project this was run in, when there is one to offer: a git work tree with at least one
311
+ * commit, whose top is not the home folder (a home kept in git is a dotfiles repo, not a project;
312
+ * this machine's is one). Returns the repo's top folder, or null. Any git failure is null: setup
313
+ * must never fail because git is missing. */
314
+ export function projectRepo(cwd = process.cwd(), home = os.homedir()) {
315
+ const git = (...args) => {
316
+ try {
317
+ const r = spawnSync('git', ['-C', cwd, ...args], { encoding: 'utf8', timeout: 10000, windowsHide: true })
318
+ return r.status === 0 ? String(r.stdout || '').trim() : ''
319
+ } catch { return '' }
320
+ }
321
+ const top = git('rev-parse', '--show-toplevel')
322
+ if (!top || !git('rev-parse', '--verify', '-q', 'HEAD^{commit}')) return null
323
+ const norm = (p) => (process.platform === 'win32' ? path.resolve(p).toLowerCase() : path.resolve(p))
324
+ return norm(top) === norm(home) ? null : path.resolve(top)
325
+ }
326
+
327
+ function defaultName() {
328
+ let base = 'agent'
329
+ try { base = os.userInfo().username || base } catch {}
330
+ const cand = (base.replace(/[^A-Za-z0-9_-]+/g, '-').replace(/^-+|-+$/g, '') || 'agent') + '-AI'
331
+ const v = validateMemberName(cand)
332
+ return v.ok ? cand : v.suggested
333
+ }
334
+
335
+ async function wakeRelay(relay) {
336
+ const url = relay.replace(/^wss:/, 'https:').replace(/^ws:/, 'http:').replace(/\/+$/, '') + '/health'
337
+ const t0 = Date.now()
338
+ try {
339
+ const r = await fetch(url, { signal: AbortSignal.timeout(75000) })
340
+ return { ok: r.ok, ms: Date.now() - t0, status: r.status }
341
+ } catch (e) { return { ok: false, ms: Date.now() - t0, error: e.name === 'TimeoutError' ? 'no answer in 75s' : e.message } }
342
+ }
343
+
344
+ function parseArgs(argv) {
345
+ const o = { link: '', name: '', dryRun: false, help: false }
346
+ for (let i = 0; i < argv.length; i++) {
347
+ const a = argv[i]
348
+ if (a === '--name') o.name = argv[++i] || ''
349
+ else if (a.startsWith('--name=')) o.name = a.slice(7)
350
+ else if (a === '--dry-run') o.dryRun = true
351
+ else if (a === '-h' || a === '--help') o.help = true
352
+ else if (!a.startsWith('-') && !o.link) o.link = a
353
+ }
354
+ return o
355
+ }
356
+
357
+ const pad = (s, n) => (s + ' '.repeat(n)).slice(0, Math.max(n, s.length))
358
+
359
+ /** Returns the process exit code: 0 only when nothing that exists failed. */
360
+ export async function main(argv = [], { home = os.homedir(), log = console.log } = {}) {
361
+ const o = parseArgs(argv)
362
+ if (o.help) {
363
+ log('Usage: npx -y hivecode-mcp@latest setup ["<join link>"] [--name You-AI] [--dry-run]')
364
+ return 0
365
+ }
366
+ log(`\nHivecode setup (hivecode-mcp ${VERSION})\n`)
367
+ const major = Number(process.versions.node.split('.')[0])
368
+ if (major < 18) { log(` FAILED Node ${process.versions.node} is too old. Install Node 18 or newer from https://nodejs.org and run this again.`); return 1 }
369
+
370
+ let name = o.name || defaultName()
371
+ const nv = validateMemberName(name)
372
+ if (!nv.ok) { log(` note "${name}" cannot be a room name (${nv.error.split('.')[0]}); using "${nv.suggested}".`); name = nv.suggested }
373
+
374
+ let runtime
375
+ try { runtime = o.dryRun ? { mode: 'dry-run', js: path.join(home, '.hivecode', 'runtime', VERSION, 'node_modules', 'hivecode-mcp', 'hive-mcp.js') } : installRuntime(home) }
376
+ catch (e) { log(` FAILED could not install the runtime under ${path.join(home, '.hivecode')}: ${e.message}`); return 1 }
377
+ log(` runtime ${runtime.js}`)
378
+ log(` node ${process.execPath}\n`)
379
+
380
+ const results = []
381
+ for (const t of agentTargets(home)) {
382
+ // A second config path of an app that IS installed is not an app of its own: printing
383
+ // "Antigravity (2nd) not installed" made a stranger ask what part of Antigravity was missing.
384
+ if (!fs.existsSync(t.present)) { if (!t.onlyIfHasEntry) results.push({ t, status: 'absent' }); continue }
385
+ const w = writeEntry(t, runtime.js, { dryRun: o.dryRun })
386
+ if (w.status !== 'skipped') results.push({ t, ...w })
387
+ }
388
+ // Verify what is ON DISK, once per distinct launch -- the entries are normally identical.
389
+ const cache = new Map()
390
+ for (const r of results) {
391
+ if (r.status !== 'written' && r.status !== 'unchanged') continue
392
+ if (o.dryRun) { r.verify = { ok: true, ms: 0, skipped: true }; continue }
393
+ const entry = readEntry(r.t.file)
394
+ const key = JSON.stringify(entry)
395
+ if (!cache.has(key)) cache.set(key, await verifyEntry(entry))
396
+ r.verify = cache.get(key)
397
+ const stray = r.verify.ok && entry && strayEntry(r.t.file, entry)
398
+ if (stray) r.verify = { ok: false, error: `the entry for the folder ${stray} still launches something else, and it wins inside that folder` }
399
+ }
400
+ // A project's own .mcp.json outranks everything written above, inside that project. It is a
401
+ // shared, committed file, so it is reported rather than rewritten with this machine's paths.
402
+ const projectMcp = path.join(process.cwd(), '.mcp.json')
403
+ if (readEntry(projectMcp)) log(` WARNING ${projectMcp} defines its own hivecode server, which overrides this setup inside this folder. Remove that entry.\n`)
404
+
405
+ let failed = 0
406
+ const ready = []
407
+ // A second config file of an app is not an app of its own. A READY one folds into its app's line
408
+ // ("Antigravity (2nd) READY" under "Antigravity READY" made a stranger ask which one was real);
409
+ // a FAILED one is never folded -- it keeps a line of its own.
410
+ const isReady = (r) => (r.status === 'written' || r.status === 'unchanged') && r.verify && r.verify.ok
411
+ const folded = new Set(results.filter((r) => r.t.secondaryOf && isReady(r) && results.some((p) => p.t.id === r.t.secondaryOf && isReady(p))))
412
+ for (const r of results) {
413
+ if (folded.has(r)) continue
414
+ const where = r.t.file
415
+ if (r.status === 'absent') { log(` ${pad(r.t.label, 15)} not installed`); continue }
416
+ if (r.status === 'refused' || r.status === 'error') {
417
+ failed++
418
+ log(` ${pad(r.t.label, 15)} FAILED ${where}\n ${' '.repeat(15)} ${r.detail}. Add the hivecode entry below by hand.`)
419
+ continue
420
+ }
421
+ if (!r.verify.ok) {
422
+ failed++
423
+ log(` ${pad(r.t.label, 15)} FAILED ${where}\n ${' '.repeat(15)} written, but it does not start: ${r.verify.error}`)
424
+ continue
425
+ }
426
+ const extra = results.filter((x) => folded.has(x) && x.t.secondaryOf === r.t.id)
427
+ r.changed = r.status === 'written' || extra.some((x) => x.status === 'written')
428
+ ready.push(r)
429
+ const how = r.verify.skipped ? 'dry run, nothing written' : `starts in ${(r.verify.ms / 1000).toFixed(1)}s`
430
+ const what = r.status === 'written' ? r.detail : r.changed ? 'updated its second config file' : 'already set up'
431
+ log(` ${pad(r.t.label, 15)} READY ${what} (${how}${extra.length ? `, ${extra.length + 1} config files` : ''})`)
432
+ }
433
+
434
+ // THE STOP GATE, FOR CLAUDE CODE (4 Oct 2026). An agent in a room went deaf the moment it ended a
435
+ // turn with no listener running, and the gate that stops that (hive-stop-hook.js) was a separate
436
+ // command nobody ran. Setup configures the agent apps, so it arms the gate too: in
437
+ // ~/.claude/settings.json beside any Stop hook already there, merged, never replaced. Only once
438
+ // the hook has been seen to run, and only for a Claude Code setup made READY -- a hook that cannot
439
+ // start fails on every turn of every session on the machine.
440
+ const cc = ready.find((r) => r.t.claudeCode)
441
+ if (cc) {
442
+ const node = stableNodePath().replace(/\\/g, '/')
443
+ const hookJs = path.join(path.dirname(runtime.js), 'hive-stop-hook.js').replace(/\\/g, '/')
444
+ const settings = path.join(home, '.claude', 'settings.json')
445
+ // stop_hook_active: the hook loads everything it imports, then lets the stop through untouched.
446
+ const ran = o.dryRun ? { status: 0 } : spawnSync(node, [hookJs], { input: JSON.stringify({ stop_hook_active: true }), encoding: 'utf8', timeout: 15000, windowsHide: true })
447
+ if (ran.status !== 0) {
448
+ log(` ${pad('stop gate', 15)} NOT added: the hook did not run (${(ran.error && ran.error.message) || (ran.stderr || '').trim().split('\n')[0] || 'exit ' + ran.status}).`)
449
+ } else {
450
+ const h = installStopHook(settings, `"${node}" "${hookJs}"`, { dryRun: o.dryRun })
451
+ if (h.status === 'refused') log(` ${pad('stop gate', 15)} NOT added: ${h.detail}. Fix that file, then run setup again.`)
452
+ else if (h.status === 'unchanged') log(` ${pad('stop gate', 15)} already in ${settings}`)
453
+ else {
454
+ cc.changed = true // a hook loads only when Claude Code starts, so it is on the restart list
455
+ log(` ${pad('stop gate', 15)} ${o.dryRun ? 'would be added to' : 'added to'} ${settings}: in a room, Claude Code is not let end a turn with no listener running${h.others ? ` (${h.others} other Stop hook${h.others > 1 ? 's' : ''} left as they were)` : ''}`)
456
+ }
457
+ }
458
+ }
459
+
460
+ const agy = ready.find((r) => r.t.id === 'antigravity')
461
+ if (agy) {
462
+ const node = stableNodePath().replace(/\\/g, '/')
463
+ const hookJs = path.join(path.dirname(runtime.js), 'hive-stop-hook.js').replace(/\\/g, '/')
464
+ const hooksPath = path.join(home, '.gemini', 'config', 'hooks.json')
465
+ const cmd = `"${node}" "${hookJs}" --app=antigravity`
466
+ const ran = o.dryRun ? { status: 0 } : spawnSync(node, [hookJs, '--app=antigravity'], { input: JSON.stringify({ stop_hook_active: true }), encoding: 'utf8', timeout: 15000, windowsHide: true })
467
+ if (ran.status !== 0) {
468
+ log(` ${pad('stop gate', 15)} NOT added for Antigravity: the hook did not run (${(ran.error && ran.error.message) || (ran.stderr || '').trim().split('\n')[0] || 'exit ' + ran.status}).`)
469
+ } else {
470
+ const h = installAntigravityStopHook(hooksPath, cmd, { dryRun: o.dryRun })
471
+ if (h.status === 'refused') log(` ${pad('stop gate', 15)} NOT added for Antigravity: ${h.detail}. Fix that file, then run setup again.`)
472
+ else if (h.status === 'unchanged') log(` ${pad('stop gate', 15)} already in ${hooksPath}`)
473
+ else {
474
+ agy.changed = true
475
+ log(` ${pad('stop gate', 15)} ${o.dryRun ? 'would be added to' : 'added to'} ${hooksPath}: in a room, Antigravity is not let end a turn with no listener running${h.others ? ` (${h.others} other hook group${h.others > 1 ? 's' : ''} left as they were)` : ''}`)
476
+ }
477
+ }
478
+ }
479
+
480
+ const manual = { mcpServers: { [SERVER_KEY]: serverEntry(runtime.js) } }
481
+ if (failed || !ready.length) {
482
+ log(`\n ${ready.length ? 'For the agents above that FAILED' : 'No supported agent app was found. For any MCP agent'}, add this to its MCP settings:\n`)
483
+ log(JSON.stringify(manual, null, 2).split('\n').map((l) => ' ' + l).join('\n'))
484
+ }
485
+
486
+ let link = o.link.trim()
487
+ if (link) {
488
+ const [relayPart, ...rest] = link.split('|')
489
+ const { relay, migratedFrom } = currentRelayFor(relayPart.trim() || PUBLIC_DEFAULT_RELAY)
490
+ if (migratedFrom) { link = [relay, ...rest].join('|'); log(`\n note that link used a retired relay; it now points at ${relay}`) }
491
+ if (!rest.length || !rest[0].trim()) {
492
+ // THE PIPE TRAP. Unquoted, `setup wss://relay|room-x` is two commands to every shell: this
493
+ // one gets `wss://relay`, and our stdout is piped into a program called room-x. So the
494
+ // explanation also goes to stderr, the one stream still reaching the terminal.
495
+ const hint = `\n WARNING "${o.link}" has no room in it -- the part after | was cut off. Put the whole link in quotes, or leave it out: you give the link to your agent, not to this command.`
496
+ log(hint)
497
+ if (!process.stdout.isTTY) console.error(hint)
498
+ }
499
+ const w = await wakeRelay(relay)
500
+ log(w.ok ? `\n relay ${relay} is up (answered in ${(w.ms / 1000).toFixed(1)}s)` : `\n WARNING ${relay} did not answer (${w.error || 'HTTP ' + w.status}). Joining will fail until it does.`)
501
+ }
502
+
503
+ if (ready.length) {
504
+ log(`\nNEXT\n`)
505
+ // Numbered steps only where this run wrote something. An entry that was already right says
506
+ // nothing about whether the RUNNING app has loaded it -- "run setup again, then reload the agent"
507
+ // is the README's first fix for a missing hive_join -- so those apps get one line, never silence.
508
+ const toRestart = ready.filter((r) => r.changed)
509
+ const already = ready.filter((r) => !r.changed)
510
+ toRestart.forEach((r, i) => log(` ${i + 1}. ${r.t.label}: ${r.t.restart}.`))
511
+ if (already.length) log(` Already set up: ${already.map((r) => r.t.label).join(', ')}. Restart ${already.length === 1 ? 'it' : 'them'} only if the hivecode tools are missing there.`)
512
+ if (link) {
513
+ log(`\n Then paste this to your agent:\n`)
514
+ log(` Use your hive_join tool to join the Hivecode room ${link} as ${name}. Then follow the rules it gives you.`)
515
+ } else {
516
+ // THE EMPTY-ROOM TRAP (onboarding audit, 2 Oct 2026). A team runs setup in its project and
517
+ // is told to host a room, which is new and empty: its code is not in it, and nothing said how
518
+ // to bring it in. `branch` does, so inside a repo it comes first -- with the privacy line
519
+ // BEFORE the command, because running it publishes the repo's committed files. The name is
520
+ // a real one: `<name>` pasted into PowerShell is a parse error, not a placeholder.
521
+ const repo = projectRepo(process.cwd(), home)
522
+ if (repo) {
523
+ const relay = process.env.HIVE_RELAY || PUBLIC_DEFAULT_RELAY
524
+ log(`\n You're in a git repo (${repo}). To give your agents its code, run this here first:\n`)
525
+ log(` npx -y hivecode-mcp@latest branch hive-work\n`)
526
+ log(` It cuts a new branch, hive-work, without switching your checkout, puts its committed files in a room,`)
527
+ log(` and prints the line for your agents and for teammates.${currentRelayFor(relay).relay === PUBLIC_DEFAULT_RELAY ? ' This uses the public relay: anyone with the room\'s link can read those files.' : ''}`)
528
+ }
529
+ // Inside a repo the empty-room line is the trap itself: Antigravity1's scripted stranger walk
530
+ // (3 Oct) found a stranger skips the command above and pastes "host a new, empty room". Only
531
+ // the line for someone who was SENT a link stays; the README still documents an empty room.
532
+ if (repo) {
533
+ log(`\n If you were sent a link instead, paste this to your agent:\n`)
534
+ log(` Use your hive_join tool to join the Hivecode room <the link you were sent> as ${name}. Then follow the rules it gives you.`)
535
+ } else {
536
+ log(`\n Then paste ONE of these to your agent:\n`)
537
+ log(` Use your hive_join tool to join the Hivecode room <the link you were sent> as ${name}. Then follow the rules it gives you.`)
538
+ log(` Use your hive_join tool to host a new, empty Hivecode room as ${name}, then give me the join link.`)
539
+ }
540
+ }
541
+ log(`\n Its files go in ${path.join(home, 'hivecode', '<room>', name)} unless you name a folder.`)
542
+ // With a link, the room itself: its relay's Control Room with the link in the hash, so a person
543
+ // never pastes wss:// into an address bar. Without one, the panel they paste a link into.
544
+ const panel = (link && controlUrl(link)) || `${(process.env.HIVE_RELAY || PUBLIC_DEFAULT_RELAY).replace(/^ws/, 'http').replace(/\/+$/, '')}/control`
545
+ log(` People join from a browser, no install: ${panel}\n`)
546
+ }
547
+ return failed ? 1 : 0
548
+ }