@nonbot/cli 0.10.14 → 0.10.16

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.
@@ -0,0 +1,409 @@
1
+ import { spawnSync as nodeSpawnSync } from 'node:child_process';
2
+ import { readFileSync, writeFileSync, copyFileSync, renameSync } from 'node:fs';
3
+ import { createInterface } from 'node:readline/promises';
4
+ import { homedir } from 'node:os';
5
+ import { VERSION } from '../version.js';
6
+ import { loadAuth } from '../lib/auth.js';
7
+ import { header, statusRow, isTTY, ANSI } from '../lib/output.js';
8
+ import { scanMcpConfigs, launchSpecOf, LEGACY_MCP_NAMES, } from '../lib/mcp-config.js';
9
+ import { nonbotMcpStatuses, mcpRow } from '../lib/mcp-status.js';
10
+ import { probeMcp, toolGroups, estimateTokens } from '../lib/mcp-probe.js';
11
+ import { tokenFor, maskToken, isReadOnly, checkToken, scopeGaps, preApprovals, fetchAppStates, appsOff, newerReleases, } from '../lib/mcp-account.js';
12
+ import { isRememberedTrust, rememberTrust, mayStartEntry } from '../lib/mcp-trust.js';
13
+ const W = 14;
14
+ const CLAUDE_CODE_SCOPES = new Set(['user', 'local', 'project']);
15
+ const SCOPE_WORD = { user: 'you (user)', local: 'this folder (local)', project: 'project', desktop: 'Claude Desktop' };
16
+ export async function runMcpCommand(args = [], deps = {}) {
17
+ const sub = args.find((a) => !a.startsWith('-'));
18
+ if (sub === 'test')
19
+ return runMcpTest(deps, args);
20
+ if (sub === 'add')
21
+ return runMcpAdd(args, deps);
22
+ if (sub === 'clean')
23
+ return runMcpClean(args, deps);
24
+ if (sub) {
25
+ ;
26
+ (deps.errLog ?? ((s) => process.stderr.write(s)))(`nonbot mcp: unknown subcommand "${sub}". Try: nonbot mcp · nonbot mcp test · nonbot mcp add · nonbot mcp clean\n`);
27
+ return 1;
28
+ }
29
+ return runMcpReadout(args, deps);
30
+ }
31
+ function io(deps) {
32
+ const log = deps.log ?? ((s) => process.stdout.write(s));
33
+ const errLog = deps.errLog ?? ((s) => process.stderr.write(s));
34
+ const row = (sig, label, detail = '') => log(statusRow(sig, label, detail, { labelWidth: W }) + '\n');
35
+ const hint = (text) => log(' '.repeat(3 + 1 + 2 + W + 1) + (isTTY(process.stdout) ? `${ANSI.dim}${text}${ANSI.reset}` : text) + '\n');
36
+ const section = (t) => log('\n' + (isTTY(process.stdout) ? ` ${ANSI.bold}${t}${ANSI.reset}` : ` ${t}`) + '\n');
37
+ const cmdRow = (c, what) => log(isTTY(process.stdout) ? ` ${ANSI.cyan}${c.padEnd(20)}${ANSI.reset}${ANSI.dim}${what}${ANSI.reset}\n` : ` ${c.padEnd(20)}${what}\n`);
38
+ return { log, errLog, row, hint, section, cmdRow };
39
+ }
40
+ function mayStart(entry, spec, deps) {
41
+ return mayStartEntry(entry, spec, deps.isRemembered ?? isRememberedTrust);
42
+ }
43
+ function commandLine(spec) {
44
+ return [spec.command ?? spec.url ?? '', ...spec.args].join(' ').replace(/pat_[A-Za-z0-9_-]+/g, 'pat_…');
45
+ }
46
+ function seconds(ms) {
47
+ return ms < 1000 ? `${ms}ms` : `${(ms / 1000).toFixed(1)}s`;
48
+ }
49
+ function kTokens(n) {
50
+ return n >= 1000 ? `~${Math.round(n / 1000)}k` : `~${n}`;
51
+ }
52
+ function plural(n, word) {
53
+ return `${n} ${word}${n === 1 ? '' : 's'}`;
54
+ }
55
+ function tilde(p, home) {
56
+ return p.startsWith(home) ? '~' + p.slice(home.length) : p;
57
+ }
58
+ export async function runMcpReadout(args, deps = {}) {
59
+ const { log, row, hint, section, cmdRow } = io(deps);
60
+ const quick = args.includes('--quick');
61
+ const home = deps.home ?? homedir();
62
+ const platform = deps.platform ?? process.platform;
63
+ const fetchImpl = deps.fetchImpl ?? fetch;
64
+ const now = deps.now ?? (() => Date.now());
65
+ const auth = await (deps.loadAuth ?? loadAuth)();
66
+ const statuses = await (deps.statuses ?? (() => nonbotMcpStatuses({ fetchImpl })))();
67
+ const scan = (deps.scan ?? (() => scanMcpConfigs()))();
68
+ const specOf = deps.launchSpec ?? launchSpecOf;
69
+ const primary = statuses.find((s) => CLAUDE_CODE_SCOPES.has(s.entry.scope ?? '')) ?? statuses[0];
70
+ const latest = statuses.find((s) => s.latest)?.latest ?? null;
71
+ const subtitle = primary?.installed
72
+ ? `v${primary.installed}${latest ? (primary.installed === latest ? ' (latest)' : ` · v${latest} is out`) : ''}`
73
+ : latest
74
+ ? `v${latest} on npm`
75
+ : `CLI v${VERSION}`;
76
+ log(header('non.bot MCP', subtitle) + '\n');
77
+ section('SETUP');
78
+ if (!primary) {
79
+ row('⚠', 'Not set up', 'no Claude app on this machine runs the non.bot MCP');
80
+ hint('add it: nonbot mcp add');
81
+ const retired = scan.locations.filter((l) => LEGACY_MCP_NAMES.includes(l.name));
82
+ log('\n');
83
+ cmdRow('nonbot mcp add', 'register it in Claude Code');
84
+ if (retired.length)
85
+ cmdRow('nonbot mcp clean', `remove ${plural(retired.length, 'retired server')}`);
86
+ return 0;
87
+ }
88
+ const r = mcpRow(primary, home);
89
+ row(r.sigil, 'Installed', r.detail);
90
+ if (r.hint)
91
+ hint(r.hint);
92
+ for (const s of statuses) {
93
+ const label = s.entry.scope === 'desktop' ? 'Claude Desktop' : 'Claude Code';
94
+ const where = s.entry.scope === 'desktop' ? 'configured' : `${SCOPE_WORD[s.entry.scope ?? 'user']} · ${s.entry.source}`;
95
+ row('✓', label, `${where}${s.entry.name !== 'nonbot' ? ` · as "${s.entry.name}"` : ''}`);
96
+ }
97
+ if (platform === 'darwin' && !statuses.some((s) => s.entry.scope === 'desktop'))
98
+ row('ℹ', 'Claude Desktop', 'not set up');
99
+ const codeNames = new Set(statuses.filter((s) => CLAUDE_CODE_SCOPES.has(s.entry.scope ?? '')).map((s) => s.entry.name));
100
+ if (codeNames.size > 1) {
101
+ row('⚠', 'Duplicates', `registered as ${[...codeNames].map((n) => `"${n}"`).join(' and ')} — Claude loads both`);
102
+ hint('keep one: claude mcp remove <name>');
103
+ }
104
+ if (primary.installed && latest) {
105
+ const releases = await newerReleases(primary.installed, latest, fetchImpl);
106
+ if (releases && releases.length) {
107
+ row('ℹ', "What's new", `${plural(releases.length, 'release')} since v${primary.installed}`);
108
+ hint(releases.slice(-4).map((x) => `${x.version} (${x.date})`).join(' · '));
109
+ }
110
+ }
111
+ section('HEALTH');
112
+ const spec = specOf(primary.entry);
113
+ let probe = null;
114
+ if (quick) {
115
+ row('ℹ', 'Starts', 'not checked (--quick)');
116
+ }
117
+ else if (spec && !mayStart(primary.entry, spec, deps) && !args.includes('--include-project')) {
118
+ row('ℹ', 'Starts', `not started — ${primary.entry.source} is a project file`);
119
+ hint('check the command and approve it once: nonbot mcp test --include-project');
120
+ }
121
+ else if (!spec) {
122
+ row('⚠', 'Starts', 'could not read how it is launched');
123
+ }
124
+ else {
125
+ probe = await (deps.probe ?? ((sp) => probeMcp(sp, { fetchImpl, fallbackBearer: auth?.pat })))(spec, primary.entry);
126
+ if (probe.ok) {
127
+ const who = probe.serverVersion ? ` · reports ${probe.serverName ?? 'server'} ${probe.serverVersion}` : '';
128
+ row('✓', 'Starts', `answered in ${seconds(probe.ms)}${who}`);
129
+ const groups = toolGroups(probe.tools);
130
+ const shown = groups.slice(0, 5).map(([g, n]) => `${g} ${n}`).join(' · ');
131
+ row('✓', 'Tools', `${probe.tools.length}${shown ? ` · ${shown}` : ''}${groups.length > 5 ? ' · …' : ''}`);
132
+ row('ℹ', 'Context cost', `${kTokens(estimateTokens(probe.toolBytes))} tokens of tool definitions per conversation`);
133
+ }
134
+ else {
135
+ row('✗', 'Starts', probe.error ?? 'it did not answer');
136
+ hint('see it fail directly: nonbot mcp test');
137
+ }
138
+ }
139
+ const t0 = now();
140
+ const base = auth?.baseUrl ?? 'https://non.bot';
141
+ try {
142
+ const res = await fetchImpl(`${base}/api/health`, { signal: AbortSignal.timeout(8_000) });
143
+ row(res.ok ? '✓' : '⚠', 'non.bot API', res.ok ? `answered in ${seconds(now() - t0)}` : `answered HTTP ${res.status}`);
144
+ }
145
+ catch {
146
+ row('✗', 'non.bot API', `could not reach ${base}`);
147
+ }
148
+ section('ACCOUNT');
149
+ const choice = tokenFor(primary.entry, spec, auth);
150
+ let check = null;
151
+ if (choice.source === 'none' || !choice.token) {
152
+ if (primary.entry.kind === 'remote')
153
+ row('ℹ', 'Token', 'Claude signs in to mcp.non.bot itself — no token on this machine');
154
+ else {
155
+ row('⚠', 'Token', 'none — the MCP has no token to use');
156
+ hint('sign in: nonbot login');
157
+ }
158
+ }
159
+ else {
160
+ check = await checkToken(base, choice.token, fetchImpl);
161
+ const from = choice.source === 'login' ? 'from nonbot login' : 'from the MCP config';
162
+ const state = check.valid === true ? 'valid' : check.valid === false ? 'REJECTED' : 'could not check';
163
+ row(check.valid === false ? '✗' : check.valid === true ? '✓' : '⚠', 'Token', `${maskToken(choice.token)} · ${from} · ${state}`);
164
+ if (check.valid === false)
165
+ hint(choice.source === 'login' ? 'sign in again: nonbot login' : `make a new one: ${base}/settings/connections`);
166
+ }
167
+ if (check?.valid && probe?.ok) {
168
+ const gaps = scopeGaps(check.scopes, probe.tools);
169
+ if (gaps.full)
170
+ row('✓', 'Permissions', `full access${check.name ? ` · token "${check.name}"` : ''}`);
171
+ else {
172
+ if (!gaps.blocked.length && !gaps.readOnly.length)
173
+ row('✓', 'Permissions', `every tool is covered${check.name ? ` · token "${check.name}"` : ''}`);
174
+ for (const b of gaps.blocked) {
175
+ row('⚠', 'Permissions', `${plural(b.tools, `${b.family} tool`)} can't run — needs ${b.needs}`);
176
+ }
177
+ for (const ro of gaps.readOnly)
178
+ row('ℹ', 'Permissions', `${ro.family} tools are read-only for this token (${ro.tools})`);
179
+ if (gaps.blocked.length)
180
+ hint(`a token with more access: ${base}/settings/connections`);
181
+ }
182
+ }
183
+ else if (check?.valid && !probe?.ok) {
184
+ row('ℹ', 'Permissions', `${check.scopes === null ? 'full access' : check.scopes.join(', ')}${quick ? '' : ' · tools not listed'}`);
185
+ }
186
+ if (isReadOnly(spec))
187
+ row('ℹ', 'Read-only mode', 'on — the MCP leaves out every write tool');
188
+ if (choice.token && probe?.ok) {
189
+ const apps = await fetchAppStates(base, choice.token, fetchImpl);
190
+ for (const off of apps ? appsOff(probe.tools, apps) : []) {
191
+ if (off.state === 'not offered')
192
+ continue;
193
+ const App = off.app === 'run' ? 'Run' : 'JobShop';
194
+ row('⚠', 'Off for you', `${plural(off.tools, `${App} tool`)} — ${App} is ${off.state === 'off' ? 'switched off' : 'not added'}`);
195
+ hint(`${off.state === 'off' ? 'turn it on' : 'add it'}: ${base}/whygraph/store?tab=apps&app=${off.app}`);
196
+ }
197
+ }
198
+ const dirs = [primary.entry.dir, process.cwd()].filter((d) => !!d);
199
+ const pre = preApprovals(primary.entry.name, dirs, home);
200
+ if (pre.denied === 'all')
201
+ row('⚠', 'Pre-approved', `every ${primary.entry.name} tool is DENIED in ${pre.files.join(', ')}`);
202
+ else if (pre.all)
203
+ row('✓', 'Pre-approved', `all ${primary.entry.name} tools run without asking (${pre.files.join(', ')})`);
204
+ else if (pre.some)
205
+ row('ℹ', 'Pre-approved', `${plural(pre.some, 'tool')} run without asking; Claude asks for the rest`);
206
+ else
207
+ row('ℹ', 'Pre-approved', 'none — Claude asks before each nonbot tool');
208
+ if (typeof pre.denied === 'number' && pre.denied > 0)
209
+ row('ℹ', 'Denied', plural(pre.denied, 'tool'));
210
+ section('COMMANDS');
211
+ cmdRow('nonbot mcp test', 'start it and check it answers');
212
+ cmdRow('nonbot update mcp', 'update it');
213
+ const retired = scan.locations.filter((l) => LEGACY_MCP_NAMES.includes(l.name));
214
+ if (retired.length)
215
+ cmdRow('nonbot mcp clean', `remove ${plural(retired.length, 'retired server')}`);
216
+ if (quick)
217
+ cmdRow('nonbot mcp', 'the full check (starts it)');
218
+ return 0;
219
+ }
220
+ export async function runMcpTest(deps = {}, args = []) {
221
+ const { log, row, hint } = io(deps);
222
+ const includeProject = args.includes('--include-project');
223
+ const fetchImpl = deps.fetchImpl ?? fetch;
224
+ const auth = await (deps.loadAuth ?? loadAuth)();
225
+ const scan = (deps.scan ?? (() => scanMcpConfigs()))();
226
+ const specOf = deps.launchSpec ?? launchSpecOf;
227
+ log(header('non.bot MCP test', `${plural(scan.nonbot.length, 'configured copy')}`.replace('copys', 'copies')) + '\n\n');
228
+ if (scan.nonbot.length === 0) {
229
+ row('✗', 'Not set up', 'nothing to start — add it: nonbot mcp add');
230
+ return 1;
231
+ }
232
+ let failed = 0;
233
+ for (const entry of scan.nonbot) {
234
+ const spec = specOf(entry);
235
+ const label = entry.scope === 'desktop' ? 'Claude Desktop' : entry.name;
236
+ if (!spec) {
237
+ row('✗', label, 'could not read how it is launched');
238
+ failed++;
239
+ continue;
240
+ }
241
+ if (!mayStart(entry, spec, deps)) {
242
+ if (!includeProject) {
243
+ row('ℹ', label, `not started — ${entry.source} is a project file`);
244
+ hint('check the command and approve it once: nonbot mcp test --include-project');
245
+ continue;
246
+ }
247
+ hint(`command: ${commandLine(spec)}`);
248
+ hint(`from: ${entry.source}`);
249
+ if (deps.stdinIsTTY ?? !!process.stdin.isTTY) {
250
+ const yes = await (deps.confirm ?? defaultConfirm)(' Start it, and remember this exact command? [y/N] ');
251
+ if (!yes) {
252
+ row('ℹ', label, 'not started');
253
+ continue;
254
+ }
255
+ ;
256
+ (deps.remember ?? rememberTrust)(entry, spec);
257
+ }
258
+ }
259
+ if (entry.kind === 'npx')
260
+ hint('npx may download it first — this can take a while');
261
+ const p = await (deps.probe ?? ((sp) => probeMcp(sp, { fetchImpl, fallbackBearer: auth?.pat })))(spec, entry);
262
+ if (p.ok)
263
+ row('✓', label, `${p.tools.length} tools in ${seconds(p.ms)}${p.serverVersion ? ` · v${p.serverVersion}` : ''} · ${entry.source}`);
264
+ else {
265
+ row('✗', label, `${p.error ?? 'did not answer'} · ${entry.source}`);
266
+ failed++;
267
+ }
268
+ }
269
+ return failed ? 1 : 0;
270
+ }
271
+ export const MCP_ADD_ARGS = (scope) => ['mcp', 'add', '-s', scope, 'nonbot', '--', 'npx', '-y', '@nonbot/mcp'];
272
+ export async function runMcpAdd(args, deps = {}) {
273
+ const { log, errLog, row, hint } = io(deps);
274
+ const spawn = deps.spawnSync ?? ((cmd, a, o) => nodeSpawnSync(cmd, a, { ...o, encoding: 'utf-8' }));
275
+ const i = args.indexOf('--scope');
276
+ const scope = i >= 0 ? args[i + 1] ?? '' : 'user';
277
+ if (!['user', 'local', 'project'].includes(scope)) {
278
+ errLog('nonbot mcp add: --scope is user, local or project\n');
279
+ return 1;
280
+ }
281
+ log(header('non.bot MCP add', `scope: ${scope}`) + '\n\n');
282
+ const scan = (deps.scan ?? (() => scanMcpConfigs()))();
283
+ const cwd = deps.cwd ?? process.cwd();
284
+ const code = scan.nonbot.filter((e) => CLAUDE_CODE_SCOPES.has(e.scope ?? ''));
285
+ const everywhere = code.find((e) => e.scope === 'user');
286
+ const here = code.find((e) => e.dir && (cwd === e.dir || cwd.startsWith(e.dir + '/')));
287
+ if (!args.includes('--force')) {
288
+ if (everywhere) {
289
+ row('✓', 'Already set up', `for every project · ${everywhere.source}`);
290
+ return 0;
291
+ }
292
+ if (here && scope !== 'user') {
293
+ row('✓', 'Already set up', `for this project · ${here.source}`);
294
+ return 0;
295
+ }
296
+ if (here) {
297
+ row('ℹ', 'This project', `already has it (${here.source}) — adding it for every project too`);
298
+ }
299
+ }
300
+ const auth = await (deps.loadAuth ?? loadAuth)();
301
+ if (!auth) {
302
+ row('⚠', 'Not signed in', 'the MCP uses your nonbot login — sign in first');
303
+ hint('nonbot login, then: nonbot mcp add');
304
+ return 1;
305
+ }
306
+ const argv = MCP_ADD_ARGS(scope);
307
+ const res = spawn('claude', argv, { stdio: 'inherit' });
308
+ if (res.error) {
309
+ row('✗', 'Claude Code', 'the claude command is not on PATH');
310
+ hint(`run it yourself: claude ${argv.join(' ')}`);
311
+ return 1;
312
+ }
313
+ if (res.status !== 0) {
314
+ row('✗', 'Add failed', `claude exited ${res.status} — see its output above`);
315
+ return 1;
316
+ }
317
+ row('✓', 'Added', `"nonbot" · ${SCOPE_WORD[scope]} · npx @nonbot/mcp (updates itself)`);
318
+ hint('restart Claude Code, or run /mcp, to load it');
319
+ return 0;
320
+ }
321
+ function dedupe(locs) {
322
+ const seen = new Set();
323
+ return locs.filter((l) => {
324
+ const k = `${l.name}|${l.scope}|${l.dir ?? ''}|${l.file}`;
325
+ if (seen.has(k))
326
+ return false;
327
+ seen.add(k);
328
+ return true;
329
+ });
330
+ }
331
+ export function removeFromDesktopConfig(file, name, stamp = Date.now()) {
332
+ const text = readFileSync(file, 'utf-8');
333
+ const json = JSON.parse(text);
334
+ if (!json.mcpServers || !(name in json.mcpServers))
335
+ return false;
336
+ copyFileSync(file, `${file}.nonbot-backup-${stamp}`);
337
+ delete json.mcpServers[name];
338
+ const tmp = `${file}.nonbot-tmp`;
339
+ writeFileSync(tmp, JSON.stringify(json, null, 2) + '\n');
340
+ renameSync(tmp, file);
341
+ return true;
342
+ }
343
+ async function defaultConfirm(question) {
344
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
345
+ try {
346
+ const answer = await rl.question(question);
347
+ return /^y(es)?$/i.test(answer.trim());
348
+ }
349
+ finally {
350
+ rl.close();
351
+ }
352
+ }
353
+ export async function runMcpClean(args, deps = {}) {
354
+ const { log, row, hint } = io(deps);
355
+ const home = deps.home ?? homedir();
356
+ const spawn = deps.spawnSync ?? ((cmd, a, o) => nodeSpawnSync(cmd, a, { ...o, encoding: 'utf-8' }));
357
+ const scan = (deps.scan ?? (() => scanMcpConfigs()))();
358
+ const retired = dedupe(scan.locations.filter((l) => LEGACY_MCP_NAMES.includes(l.name)));
359
+ log(header('non.bot MCP clean', 'retired servers') + '\n\n');
360
+ if (retired.length === 0) {
361
+ row('✓', 'Nothing to do', 'no retired MCP servers are configured');
362
+ return 0;
363
+ }
364
+ for (const l of retired)
365
+ row('ℹ', l.name, `${SCOPE_WORD[l.scope]} · ${tilde(l.file, home)}`);
366
+ log('\n');
367
+ const yes = args.includes('--yes') || args.includes('-y');
368
+ if (!yes) {
369
+ const tty = deps.stdinIsTTY ?? !!process.stdin.isTTY;
370
+ if (!tty) {
371
+ hint('not removed — re-run with --yes to remove them');
372
+ return 1;
373
+ }
374
+ const ok = await (deps.confirm ?? defaultConfirm)(` Remove ${plural(retired.length, 'server')}? [y/N] `);
375
+ if (!ok) {
376
+ hint('nothing removed');
377
+ return 0;
378
+ }
379
+ }
380
+ let failed = 0;
381
+ for (const l of retired) {
382
+ if (l.scope === 'desktop') {
383
+ try {
384
+ const done = removeFromDesktopConfig(l.file, l.name);
385
+ row(done ? '✓' : 'ℹ', l.name, done ? 'removed from Claude Desktop (backup kept next to the file)' : 'already gone');
386
+ }
387
+ catch {
388
+ row('✗', l.name, 'could not edit the Claude Desktop config');
389
+ failed++;
390
+ }
391
+ continue;
392
+ }
393
+ const res = spawn('claude', ['mcp', 'remove', '-s', l.scope, l.name], { cwd: l.dir ?? home, stdio: 'pipe' });
394
+ if (res.error) {
395
+ row('✗', l.name, 'the claude command is not on PATH');
396
+ hint(`${l.dir ? `cd ${tilde(l.dir, home)} && ` : ''}claude mcp remove -s ${l.scope} ${l.name}`);
397
+ failed++;
398
+ }
399
+ else if (res.status !== 0) {
400
+ row('✗', l.name, `claude mcp remove failed (exit ${res.status})`);
401
+ failed++;
402
+ }
403
+ else
404
+ row('✓', l.name, `removed · ${SCOPE_WORD[l.scope]}`);
405
+ }
406
+ if (!failed)
407
+ hint('restart Claude Code to drop them from open sessions');
408
+ return failed ? 1 : 0;
409
+ }
@@ -0,0 +1,157 @@
1
+ import { spawnSync as nodeSpawnSync } from 'node:child_process';
2
+ import { realpathSync } from 'node:fs';
3
+ import { dirname, sep } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { VERSION } from '../version.js';
6
+ import { fetchLatestVersion, compareVersions } from '../lib/update-check.js';
7
+ import { readPidFile } from '../lib/daemon-lifecycle.js';
8
+ import { header, statusRow } from '../lib/output.js';
9
+ import { nonbotMcpStatuses, mcpRow, globalMcpIsLinked } from '../lib/mcp-status.js';
10
+ export const UPDATE_INSTALL_ARGS = ['install', '-g', '@nonbot/cli@latest'];
11
+ function defaultEntryPath() {
12
+ const here = fileURLToPath(import.meta.url);
13
+ try {
14
+ return realpathSync(here);
15
+ }
16
+ catch {
17
+ return here;
18
+ }
19
+ }
20
+ export function isLinkedCheckout(entry) {
21
+ const marker = `${sep}node_modules${sep}@nonbot${sep}cli${sep}`;
22
+ return !entry.split('/').join(sep).includes(marker);
23
+ }
24
+ function checkoutRoot(entry) {
25
+ let d = dirname(entry);
26
+ while (d !== dirname(d)) {
27
+ if (d.endsWith(`${sep}dist`))
28
+ return dirname(d);
29
+ d = dirname(d);
30
+ }
31
+ return dirname(entry);
32
+ }
33
+ function defaultRunNpm(args) {
34
+ const r = nodeSpawnSync('npm', [...args], {
35
+ stdio: 'inherit',
36
+ shell: process.platform === 'win32',
37
+ });
38
+ if (r.error)
39
+ return 127;
40
+ return r.status ?? 1;
41
+ }
42
+ function defaultDaemonRunning() {
43
+ const pid = readPidFile();
44
+ if (pid === null)
45
+ return false;
46
+ try {
47
+ process.kill(pid, 0);
48
+ return true;
49
+ }
50
+ catch {
51
+ return false;
52
+ }
53
+ }
54
+ export async function runUpdateCommand(args = [], deps = {}) {
55
+ const log = deps.log ?? ((s) => process.stdout.write(s));
56
+ const errLog = deps.errLog ?? ((s) => process.stderr.write(s));
57
+ const positional = args.filter((a) => !a.startsWith('-'));
58
+ if (positional[0] === 'mcp')
59
+ return runUpdateMcpCommand(args.slice(1), deps);
60
+ if (positional.length > 0) {
61
+ errLog(`nonbot update: unknown target "${positional[0]}". Try: nonbot update (this CLI) · nonbot update mcp (the non.bot MCP)\n`);
62
+ return 1;
63
+ }
64
+ const current = deps.current ?? VERSION;
65
+ const fetchLatest = deps.fetchLatest ?? (() => fetchLatestVersion());
66
+ const entry = (deps.entryPath ?? defaultEntryPath)();
67
+ const runNpm = deps.runNpm ?? defaultRunNpm;
68
+ const daemonRunning = deps.daemonRunning ?? defaultDaemonRunning;
69
+ const force = args.includes('--force');
70
+ log(header('non.bot update', `v${current}`) + '\n\n');
71
+ const latest = await fetchLatest();
72
+ if (!latest) {
73
+ errLog(statusRow('⚠', 'Registry', 'could not read the latest version from npm — check your connection', { stream: process.stderr }) + '\n');
74
+ errLog(` or install it directly: npm ${UPDATE_INSTALL_ARGS.join(' ')}\n`);
75
+ return 1;
76
+ }
77
+ if (compareVersions(latest, current) <= 0) {
78
+ log(statusRow('✓', 'Up to date', `v${current} is the latest`) + '\n');
79
+ return 0;
80
+ }
81
+ log(statusRow('ℹ', 'Update', `v${current} → v${latest}`) + '\n');
82
+ if (isLinkedCheckout(entry) && !force) {
83
+ const root = checkoutRoot(entry);
84
+ log(statusRow('⚠', 'Linked copy', `this nonbot runs from a source checkout: ${root}`) + '\n');
85
+ log(` update the checkout: cd ${root} && git pull && npm run build\n`);
86
+ log(` or replace the link with the published v${latest}: nonbot update --force\n`);
87
+ return 0;
88
+ }
89
+ log(`\n npm ${UPDATE_INSTALL_ARGS.join(' ')}\n\n`);
90
+ const code = runNpm(UPDATE_INSTALL_ARGS);
91
+ log('\n');
92
+ if (code !== 0) {
93
+ errLog(statusRow('✗', 'Update failed', `npm exited ${code} — see its output above`, { stream: process.stderr }) + '\n');
94
+ return 1;
95
+ }
96
+ log(statusRow('✓', 'Updated', `v${latest} installed`) + '\n');
97
+ if (daemonRunning()) {
98
+ log(statusRow('ℹ', 'Daemon', 'still running the old version — restart it to use the new one:') + '\n');
99
+ log(' Ctrl-C in its window (or: nonbot daemon stop), then: nonbot daemon\n');
100
+ }
101
+ return 0;
102
+ }
103
+ export const MCP_INSTALL_ARGS = ['install', '-g', '@nonbot/mcp@latest'];
104
+ export async function runUpdateMcpCommand(args = [], deps = {}) {
105
+ const log = deps.log ?? ((s) => process.stdout.write(s));
106
+ const errLog = deps.errLog ?? ((s) => process.stderr.write(s));
107
+ const statusesOf = deps.mcpStatuses ?? (() => nonbotMcpStatuses());
108
+ const runNpm = deps.runNpm ?? defaultRunNpm;
109
+ const linked = deps.globalMcpLinked ?? (() => globalMcpIsLinked());
110
+ const force = args.includes('--force');
111
+ const W = 14;
112
+ const row = (sig, label, detail = '') => log(statusRow(sig, label, detail, { labelWidth: W }) + '\n');
113
+ const hint = (text) => log(' '.repeat(3 + 1 + 2 + W + 1) + text + '\n');
114
+ const statuses = await statusesOf();
115
+ const latest = statuses.find((s) => s.latest)?.latest ?? null;
116
+ log(header('non.bot update mcp', latest ? `v${latest} on npm` : 'npm not reachable') + '\n\n');
117
+ if (statuses.length === 0) {
118
+ row('⚠', 'nonbot MCP', 'not set up in Claude Code');
119
+ hint('add it: claude mcp add nonbot -- npx -y @nonbot/mcp');
120
+ return 0;
121
+ }
122
+ let changed = false;
123
+ let failed = false;
124
+ let globalDone = false;
125
+ for (const s of statuses) {
126
+ const r = mcpRow(s);
127
+ row(r.sigil, 'nonbot MCP', r.detail);
128
+ hint(`in ${s.entry.source}`);
129
+ const behind = !!(s.installed && s.latest && compareVersions(s.latest, s.installed) > 0);
130
+ if (s.entry.kind === 'global' && (behind || !s.installed) && !globalDone) {
131
+ globalDone = true;
132
+ if (linked() && !force) {
133
+ hint('the global @nonbot/mcp is linked to a source checkout — left alone (nonbot update mcp --force replaces it)');
134
+ continue;
135
+ }
136
+ log(`\n npm ${MCP_INSTALL_ARGS.join(' ')}\n\n`);
137
+ const code = runNpm(MCP_INSTALL_ARGS);
138
+ log('\n');
139
+ if (code === 0) {
140
+ row('✓', 'Updated', latest ? `@nonbot/mcp v${latest} installed` : '@nonbot/mcp installed');
141
+ changed = true;
142
+ }
143
+ else {
144
+ errLog(statusRow('✗', 'Update failed', `npm exited ${code} — see its output above`, { labelWidth: W, stream: process.stderr }) + '\n');
145
+ failed = true;
146
+ }
147
+ }
148
+ else if (r.hint) {
149
+ hint(r.hint);
150
+ }
151
+ }
152
+ if (changed) {
153
+ log('\n');
154
+ row('ℹ', 'Load it', 'restart Claude Code, or run /mcp → Reconnect');
155
+ }
156
+ return failed ? 1 : 0;
157
+ }