@nonbot/cli 0.10.15 → 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.
- package/CHANGELOG.md +8 -0
- package/LICENSE +22 -11
- package/README.md +14 -1
- package/dist/commands/doctor.js +67 -24
- package/dist/commands/mcp.js +409 -0
- package/dist/index.js +23 -5
- package/dist/lib/mcp-account.js +232 -0
- package/dist/lib/mcp-config.js +89 -11
- package/dist/lib/mcp-probe.js +226 -0
- package/dist/lib/mcp-trust.js +63 -0
- package/dist/lib/quickstart-menu.js +86 -0
- package/dist/version.js +1 -1
- package/package.json +2 -2
|
@@ -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
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -8,6 +8,8 @@ import { runRunCommand } from './commands/run.js';
|
|
|
8
8
|
import { runDoctorCommand } from './commands/doctor.js';
|
|
9
9
|
import { runLogoutCommand } from './commands/logout.js';
|
|
10
10
|
import { runUpdateCommand } from './commands/update.js';
|
|
11
|
+
import { runMcpCommand } from './commands/mcp.js';
|
|
12
|
+
import { menuIsInteractive, waitForMenuChoice, linesBelow } from './lib/quickstart-menu.js';
|
|
11
13
|
import { runLogsCommand } from './commands/logs.js';
|
|
12
14
|
import { runProfilesCommand } from './commands/profiles.js';
|
|
13
15
|
import { runChoirCommand } from './commands/choir.js';
|
|
@@ -53,6 +55,12 @@ const COMMANDS = [
|
|
|
53
55
|
group: 'check',
|
|
54
56
|
run: (args) => runDoctorCommand(args),
|
|
55
57
|
},
|
|
58
|
+
{
|
|
59
|
+
name: 'mcp',
|
|
60
|
+
description: 'The non.bot MCP: health + version · also: test, add, clean',
|
|
61
|
+
group: 'check',
|
|
62
|
+
run: (args) => runMcpCommand(args),
|
|
63
|
+
},
|
|
56
64
|
{
|
|
57
65
|
name: 'logs',
|
|
58
66
|
description: 'Runs launched on this machine (--limit <n>, --json, -f)',
|
|
@@ -138,8 +146,10 @@ function printHelp() {
|
|
|
138
146
|
lines.push('');
|
|
139
147
|
process.stdout.write(lines.join('\n') + '\n');
|
|
140
148
|
}
|
|
141
|
-
function printQuickStart() {
|
|
149
|
+
async function printQuickStart() {
|
|
142
150
|
const KEY = 8;
|
|
151
|
+
const io = { stdin: process.stdin, stdout: process.stdout };
|
|
152
|
+
const interactive = menuIsInteractive(io);
|
|
143
153
|
const lines = [];
|
|
144
154
|
const cols = process.stdout.columns ?? 0;
|
|
145
155
|
if (cols === 0 || cols >= WORDMARK_WIDTH + 4) {
|
|
@@ -157,10 +167,18 @@ function printQuickStart() {
|
|
|
157
167
|
lines.push(kvRow('doctor', 'check that everything is set up', { keyWidth: KEY }));
|
|
158
168
|
lines.push(kvRow('logout', 'sign out and stop the daemon', { keyWidth: KEY }));
|
|
159
169
|
lines.push(kvRow('update', 'install the latest version', { keyWidth: KEY }));
|
|
170
|
+
const HELP_MARK = 'all commands';
|
|
171
|
+
lines.push(kvRow('help', interactive ? `${HELP_MARK} ← click, or press h` : `${HELP_MARK} · nonbot help`, { keyWidth: KEY }));
|
|
160
172
|
lines.push('');
|
|
161
|
-
lines.push(kvRow('', 'type nonbot <command> · nonbot
|
|
173
|
+
lines.push(kvRow('', interactive ? 'press h for help · any other key to exit' : 'type nonbot <command> · nonbot help lists everything', { keyWidth: KEY }));
|
|
162
174
|
lines.push('');
|
|
163
|
-
|
|
175
|
+
const printed = lines.join('\n') + '\n';
|
|
176
|
+
process.stdout.write(printed);
|
|
177
|
+
if (!interactive)
|
|
178
|
+
return;
|
|
179
|
+
const choice = await waitForMenuChoice(io, linesBelow(printed, HELP_MARK));
|
|
180
|
+
if (choice === 'help')
|
|
181
|
+
printHelp();
|
|
164
182
|
}
|
|
165
183
|
function extractProfileFlag(args) {
|
|
166
184
|
let profile;
|
|
@@ -195,7 +213,7 @@ async function main(argv) {
|
|
|
195
213
|
}
|
|
196
214
|
}
|
|
197
215
|
if (args.length === 0) {
|
|
198
|
-
printQuickStart();
|
|
216
|
+
await printQuickStart();
|
|
199
217
|
return 0;
|
|
200
218
|
}
|
|
201
219
|
const first = args[0];
|
|
@@ -203,7 +221,7 @@ async function main(argv) {
|
|
|
203
221
|
printVersion();
|
|
204
222
|
return 0;
|
|
205
223
|
}
|
|
206
|
-
if (first === '-h' || first === '--help') {
|
|
224
|
+
if (first === '-h' || first === '--help' || first === 'help') {
|
|
207
225
|
printHelp();
|
|
208
226
|
return 0;
|
|
209
227
|
}
|