@nonbot/cli 0.10.15 → 0.10.17
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 +15 -0
- package/LICENSE +22 -11
- package/README.md +14 -1
- package/dist/commands/doctor.js +67 -24
- package/dist/commands/logs.js +1 -1
- package/dist/commands/mcp.js +409 -0
- package/dist/commands/run.js +1 -1
- package/dist/index.js +47 -15
- package/dist/lib/local-status.js +54 -0
- 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/commands/run.js
CHANGED
|
@@ -16,7 +16,7 @@ export async function runRunCommand(args = [], deps = {}) {
|
|
|
16
16
|
}
|
|
17
17
|
const id = args.find((a) => !a.startsWith('-'));
|
|
18
18
|
if (!id) {
|
|
19
|
-
errLog('
|
|
19
|
+
errLog('To use: nonbot run <activation-id> [--headless]\n');
|
|
20
20
|
return 1;
|
|
21
21
|
}
|
|
22
22
|
const auth = await loader();
|
package/dist/index.js
CHANGED
|
@@ -8,12 +8,15 @@ 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';
|
|
14
16
|
import { runRunMcpCommand } from './commands/run-mcp.js';
|
|
15
17
|
import { runRunPromptHookCommand } from './commands/run-prompt-hook.js';
|
|
16
18
|
import { setActiveProfile } from './lib/auth.js';
|
|
19
|
+
import { localStatus, statusLine, localMcpLabel, versionsLine } from './lib/local-status.js';
|
|
17
20
|
import { header, kvRow, ANSI, isTTY, wordmarkArt, WORDMARK_WIDTH } from './lib/output.js';
|
|
18
21
|
const COMMANDS = [
|
|
19
22
|
{
|
|
@@ -53,6 +56,12 @@ const COMMANDS = [
|
|
|
53
56
|
group: 'check',
|
|
54
57
|
run: (args) => runDoctorCommand(args),
|
|
55
58
|
},
|
|
59
|
+
{
|
|
60
|
+
name: 'mcp',
|
|
61
|
+
description: 'The non.bot MCP: health + version · also: test, add, clean',
|
|
62
|
+
group: 'check',
|
|
63
|
+
run: (args) => runMcpCommand(args),
|
|
64
|
+
},
|
|
56
65
|
{
|
|
57
66
|
name: 'logs',
|
|
58
67
|
description: 'Runs launched on this machine (--limit <n>, --json, -f)',
|
|
@@ -107,14 +116,26 @@ const COMMANDS = [
|
|
|
107
116
|
function printVersion() {
|
|
108
117
|
process.stdout.write(VERSION + '\n');
|
|
109
118
|
}
|
|
110
|
-
function
|
|
119
|
+
function roomForWordmark() {
|
|
120
|
+
const cols = process.stdout.columns ?? 0;
|
|
121
|
+
return cols === 0 || cols >= WORDMARK_WIDTH + 4;
|
|
122
|
+
}
|
|
123
|
+
function printHelp(opts = {}) {
|
|
111
124
|
const tty = isTTY(process.stdout);
|
|
112
125
|
const head = (t) => (tty ? ` ${ANSI.bold}${t}${ANSI.reset}` : ` ${t}`);
|
|
113
126
|
const KEY = 18;
|
|
114
127
|
const lines = [];
|
|
115
|
-
|
|
128
|
+
if (opts.wordmark !== false && roomForWordmark()) {
|
|
129
|
+
lines.push('');
|
|
130
|
+
lines.push(wordmarkArt({ indent: ' ' }));
|
|
131
|
+
lines.push('');
|
|
132
|
+
lines.push(kvRow('', versionsLine(VERSION, opts.mcpLabel ?? 'not checked'), { keyWidth: 8 }));
|
|
133
|
+
}
|
|
134
|
+
else {
|
|
135
|
+
lines.push(header('nonbot', `non.bot CLI · v${VERSION}`));
|
|
136
|
+
}
|
|
116
137
|
lines.push('');
|
|
117
|
-
lines.push(kvRow('
|
|
138
|
+
lines.push(kvRow('To use', 'nonbot <command> [args]', { keyWidth: KEY }));
|
|
118
139
|
const section = (title, group) => {
|
|
119
140
|
lines.push('');
|
|
120
141
|
lines.push(head(title));
|
|
@@ -138,29 +159,40 @@ function printHelp() {
|
|
|
138
159
|
lines.push('');
|
|
139
160
|
process.stdout.write(lines.join('\n') + '\n');
|
|
140
161
|
}
|
|
141
|
-
function printQuickStart() {
|
|
162
|
+
async function printQuickStart() {
|
|
142
163
|
const KEY = 8;
|
|
164
|
+
const io = { stdin: process.stdin, stdout: process.stdout };
|
|
165
|
+
const interactive = menuIsInteractive(io);
|
|
143
166
|
const lines = [];
|
|
144
|
-
|
|
145
|
-
if (cols === 0 || cols >= WORDMARK_WIDTH + 4) {
|
|
167
|
+
if (roomForWordmark()) {
|
|
146
168
|
lines.push('');
|
|
147
169
|
lines.push(wordmarkArt({ indent: ' ' }));
|
|
148
170
|
lines.push('');
|
|
149
|
-
lines.push(kvRow('',
|
|
171
|
+
lines.push(kvRow('', versionsLine(VERSION, await localMcpLabel()), { keyWidth: KEY }));
|
|
150
172
|
}
|
|
151
173
|
else {
|
|
152
174
|
lines.push(header('non.bot CLI', `v${VERSION}`));
|
|
153
175
|
}
|
|
176
|
+
const status = await localStatus();
|
|
177
|
+
lines.push(statusLine(status, KEY));
|
|
154
178
|
lines.push('');
|
|
155
|
-
lines.push(kvRow('login', 'sign in', { keyWidth: KEY }));
|
|
156
|
-
lines.push(kvRow('daemon', 'listen for runs — Ctrl-C stops it', { keyWidth: KEY }));
|
|
179
|
+
lines.push(kvRow('login', status.email ? 'switch account' : 'sign in', { keyWidth: KEY }));
|
|
180
|
+
lines.push(kvRow('daemon', status.daemon ? 'running · Ctrl-C in its window stops it' : 'listen for runs — Ctrl-C stops it', { keyWidth: KEY }));
|
|
157
181
|
lines.push(kvRow('doctor', 'check that everything is set up', { keyWidth: KEY }));
|
|
158
182
|
lines.push(kvRow('logout', 'sign out and stop the daemon', { keyWidth: KEY }));
|
|
159
183
|
lines.push(kvRow('update', 'install the latest version', { keyWidth: KEY }));
|
|
184
|
+
const HELP_MARK = 'all commands';
|
|
185
|
+
lines.push(kvRow('help', interactive ? `${HELP_MARK} ← click, or press h` : `${HELP_MARK} · nonbot help`, { keyWidth: KEY }));
|
|
160
186
|
lines.push('');
|
|
161
|
-
lines.push(kvRow('', 'type nonbot <command> · nonbot
|
|
187
|
+
lines.push(kvRow('', interactive ? 'press h for help · any other key to exit' : 'type nonbot <command> · nonbot help lists everything', { keyWidth: KEY }));
|
|
162
188
|
lines.push('');
|
|
163
|
-
|
|
189
|
+
const printed = lines.join('\n') + '\n';
|
|
190
|
+
process.stdout.write(printed);
|
|
191
|
+
if (!interactive)
|
|
192
|
+
return;
|
|
193
|
+
const choice = await waitForMenuChoice(io, linesBelow(printed, HELP_MARK));
|
|
194
|
+
if (choice === 'help')
|
|
195
|
+
printHelp({ wordmark: false });
|
|
164
196
|
}
|
|
165
197
|
function extractProfileFlag(args) {
|
|
166
198
|
let profile;
|
|
@@ -195,7 +227,7 @@ async function main(argv) {
|
|
|
195
227
|
}
|
|
196
228
|
}
|
|
197
229
|
if (args.length === 0) {
|
|
198
|
-
printQuickStart();
|
|
230
|
+
await printQuickStart();
|
|
199
231
|
return 0;
|
|
200
232
|
}
|
|
201
233
|
const first = args[0];
|
|
@@ -203,14 +235,14 @@ async function main(argv) {
|
|
|
203
235
|
printVersion();
|
|
204
236
|
return 0;
|
|
205
237
|
}
|
|
206
|
-
if (first === '-h' || first === '--help') {
|
|
207
|
-
printHelp();
|
|
238
|
+
if (first === '-h' || first === '--help' || first === 'help') {
|
|
239
|
+
printHelp({ mcpLabel: await localMcpLabel() });
|
|
208
240
|
return 0;
|
|
209
241
|
}
|
|
210
242
|
const cmd = COMMANDS.find((c) => c.name === first);
|
|
211
243
|
if (!cmd) {
|
|
212
244
|
process.stderr.write(`nonbot: unknown command "${first}"\n\n`);
|
|
213
|
-
printHelp();
|
|
245
|
+
printHelp({ mcpLabel: await localMcpLabel() });
|
|
214
246
|
return 1;
|
|
215
247
|
}
|
|
216
248
|
const result = await cmd.run(args.slice(1));
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { loadAuth } from './auth.js';
|
|
2
|
+
import { readPidFile } from './daemon-lifecycle.js';
|
|
3
|
+
import { ANSI, isTTY } from './output.js';
|
|
4
|
+
import { scanMcpConfigs } from './mcp-config.js';
|
|
5
|
+
import { installedMcpVersion } from './mcp-status.js';
|
|
6
|
+
export async function localMcpLabel(scan = () => scanMcpConfigs().nonbot, versionOf = (e) => installedMcpVersion(e)) {
|
|
7
|
+
let entries = [];
|
|
8
|
+
try {
|
|
9
|
+
entries = scan();
|
|
10
|
+
}
|
|
11
|
+
catch {
|
|
12
|
+
entries = [];
|
|
13
|
+
}
|
|
14
|
+
const primary = entries.find((e) => e.scope && e.scope !== 'desktop') ?? entries[0];
|
|
15
|
+
if (!primary)
|
|
16
|
+
return 'not set up';
|
|
17
|
+
if (primary.kind === 'remote')
|
|
18
|
+
return 'remote';
|
|
19
|
+
const v = await versionOf(primary).catch(() => null);
|
|
20
|
+
return v ? `v${v}` : 'set up';
|
|
21
|
+
}
|
|
22
|
+
export function versionsLine(cliVersion, mcpLabel) {
|
|
23
|
+
return `non.bot CLI v${cliVersion} · non.bot MCP ${mcpLabel}`;
|
|
24
|
+
}
|
|
25
|
+
export async function localStatus(deps = {}) {
|
|
26
|
+
let email = null;
|
|
27
|
+
try {
|
|
28
|
+
email = (await (deps.loadAuth ?? loadAuth)())?.email ?? null;
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
email = null;
|
|
32
|
+
}
|
|
33
|
+
const pid = (deps.readPid ?? (() => readPidFile()))();
|
|
34
|
+
const alive = deps.isAlive ??
|
|
35
|
+
((p) => {
|
|
36
|
+
try {
|
|
37
|
+
process.kill(p, 0);
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
return { email, daemon: pid !== null && alive(pid) };
|
|
45
|
+
}
|
|
46
|
+
export function statusLine(s, keyWidth, stream = process.stdout) {
|
|
47
|
+
const tty = isTTY(stream);
|
|
48
|
+
const pad = ' '.repeat(3 + keyWidth + 1);
|
|
49
|
+
const mark = (ok) => (tty ? (ok ? `${ANSI.green}✓${ANSI.reset}` : `${ANSI.dim}○${ANSI.reset}`) : ok ? '✓' : '○');
|
|
50
|
+
const dim = (t) => (tty ? `${ANSI.dim}${t}${ANSI.reset}` : t);
|
|
51
|
+
if (!s.email)
|
|
52
|
+
return `${pad}${mark(false)} ${dim('not signed in — start with: nonbot login')}`;
|
|
53
|
+
return `${pad}${mark(true)} ${dim(`signed in as ${s.email} · daemon ${s.daemon ? 'running' : 'not running'}`)}`;
|
|
54
|
+
}
|