@nonbot/cli 0.10.14 → 0.10.15

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 CHANGED
@@ -3,6 +3,17 @@
3
3
  What changed for the person running the CLI, newest first. Update with
4
4
  `npm i -g @nonbot/cli@latest`.
5
5
 
6
+ ## 0.10.15 (2026-11-11)
7
+
8
+ - New: `nonbot logout` signs you out on this machine and stops the daemon.
9
+ - New: `nonbot update` installs the latest version. The daemon's update notice now points at it.
10
+ - The daemon's startup list now says how to stop it (Ctrl-C in its window, or `nonbot daemon stop`) and how to sign out.
11
+ - `nonbot doctor` is easier to read: grouped sections, bare version numbers, no wrapped lines, no rows for retired MCP servers, and a short list of commands at the end. If Run is switched off for your account, it now says so and links to where you switch it on, instead of showing "HTTP 403".
12
+ - `nonbot doctor` now finds the non.bot MCP in a project's `.mcp.json` and in Claude Desktop, not only in `~/.claude.json`, and shows its version and how it is installed (npx, remote, global or a source checkout).
13
+ - New: `nonbot update mcp` shows the non.bot MCP's version and updates it. A global install is updated with npm. The npx setup updates itself each time Claude Code starts it and the remote server is always current, so for those it tells you what to do instead of running anything.
14
+ - The non.bot block wordmark is back with a shaded aqua → violet → orchid gradient, on the daemon and on the bare `nonbot` screen. The dot takes the same gradient as the letters beside it.
15
+ - Typing `nonbot` alone shows the wordmark and a short start-here list (login · daemon · doctor · logout · update). `nonbot --help` is grouped, fits 80 columns, and no longer lists the commands only Claude Code runs.
16
+
6
17
  ## 0.10.14 (2026-11-11)
7
18
 
8
19
  - The README now lists what the daemon sends from your machine and who can read it.
package/README.md CHANGED
@@ -58,10 +58,10 @@ nonbot daemon
58
58
  ```
59
59
 
60
60
  `nonbot doctor` is the one-stop health check and the first thing to run when something
61
- looks off. It reports your platform, login state, server reachability, the last daemon
62
- heartbeat, which provider CLIs are on `PATH`, which terminal will be launched and whether
63
- its launcher binary exists, the macOS Automation state for iTerm, and the MCP servers
64
- and skill bundles the agents expect.
61
+ looks off. It reports your login, server reachability, whether the Run app is on for
62
+ your account, the last daemon heartbeat, which agent CLIs are installed, which terminal
63
+ Runs open in, the macOS Automation state for iTerm, and whether the `nonbot` MCP server
64
+ is set up in Claude Code — then lists how to start, stop and sign out.
65
65
 
66
66
  Non-interactive login (for CI or scripting):
67
67
 
@@ -73,11 +73,13 @@ nonbot login --token "$NONBOT_PAT"
73
73
 
74
74
  ```
75
75
  nonbot login Log in with a Personal Access Token
76
+ nonbot logout Sign out on this machine and stop the daemon
76
77
  nonbot daemon Run the poll loop and launch terminals (the main command)
77
78
  nonbot run <id> Launch a single Run by id, then exit
78
79
  nonbot status Show login + daemon status
79
80
  nonbot doctor Health check — platform, auth, server, providers, terminal, MCP
80
81
  nonbot logs Show recent local Run launches
82
+ nonbot update Install the latest version (npm i -g @nonbot/cli@latest)
81
83
  nonbot profiles List configured accounts
82
84
  nonbot choir <session> Coordinated-run engine — one agent per git worktree, merged back by hand
83
85
  nonbot run-mcp Per-pane MCP server over stdio (launched by .mcp.json, not by hand)
@@ -95,7 +97,7 @@ nonbot --version Print the version
95
97
  | `--profile <name>` | Use a named account instead of the default (works anywhere in the command) |
96
98
  | `daemon --install` / `--uninstall` | Run the daemon automatically at login (macOS launchd / Linux systemd user unit; not available on Windows) |
97
99
  | `daemon --cloud` | Containerized runner mode — see **Cloud runners** below |
98
- | `daemon stop` | Stop the local daemon; `--machine <id>` / `--all` stop daemons on other machines |
100
+ | `daemon stop` | Stop the local daemon from another terminal (or press Ctrl-C in the daemon's own window); `--machine <id>` / `--all` stop daemons on other machines |
99
101
 
100
102
  ## Cost ceilings
101
103
 
@@ -572,6 +572,8 @@ export async function runDaemonCommand(args = [], options = {}, deps = {}) {
572
572
  ['LISTENING', `${hostUrl}/api/cli/activations/pending`],
573
573
  ['PROFILE', profileName],
574
574
  ['LANDING', terminalLabel],
575
+ ['STOP', 'Ctrl-C in this window · or nonbot daemon stop from another terminal'],
576
+ ['SIGN OUT', 'nonbot logout · signs out on this machine and stops the daemon'],
575
577
  ];
576
578
  const LABEL_WIDTH = 9;
577
579
  for (const [k, v] of ctxRows) {
@@ -3,66 +3,16 @@ import { readFileSync, readdirSync, existsSync, statSync } from 'node:fs';
3
3
  import { homedir } from 'node:os';
4
4
  import { join } from 'node:path';
5
5
  import { loadAuth } from '../lib/auth.js';
6
+ import { scanMcpConfigs, describeNonbotMcp } from '../lib/mcp-config.js';
7
+ import { nonbotMcpStatuses, mcpRow } from '../lib/mcp-status.js';
6
8
  import { resolveTerminal } from '../lib/terminal.js';
7
9
  import { VERSION } from '../version.js';
8
- import { header, statusRow, errorBlock } from '../lib/output.js';
10
+ import { header, statusRow, errorBlock, isTTY, ANSI } from '../lib/output.js';
9
11
  const HEARTBEAT_FRESH_MS = 35_000;
10
12
  const PROVIDER_CLIS = ['claude', 'codex', 'gemini'];
11
- const NONBOT_MCP_ADD_HINT = 'claude mcp add nonbot -e NON_BOT_API_TOKEN=pat_<token> -- npx -y @nonbot/mcp';
12
- const EXPECTED_MCPS = [
13
- {
14
- name: 'nonbot',
15
- required: true,
16
- present: '@nonbot/mcp',
17
- absent: `not configured in ~/.claude.json — ${NONBOT_MCP_ADD_HINT}`,
18
- },
19
- {
20
- name: 'portfolio-mcp',
21
- required: false,
22
- present: 'legacy — superseded by @nonbot/mcp',
23
- absent: 'optional — not configured (legacy, superseded by @nonbot/mcp)',
24
- },
25
- {
26
- name: 'context-graph',
27
- required: false,
28
- present: 'legacy — superseded by @nonbot/mcp',
29
- absent: 'optional — not configured (legacy, superseded by @nonbot/mcp)',
30
- },
31
- {
32
- name: 'jobshop-mcp',
33
- required: false,
34
- present: 'optional — JobShop is hidden for launch',
35
- absent: 'optional — not configured (JobShop is hidden for launch)',
36
- },
37
- ];
13
+ export const NONBOT_MCP_ADD_HINT = 'claude mcp add nonbot -- npx -y @nonbot/mcp';
14
+ const LEGACY_MCPS = ['portfolio-mcp', 'context-graph', 'jobshop-mcp'];
38
15
  const EXPECTED_SKILLS = [];
39
- function readConfiguredMcpNames() {
40
- const out = new Set();
41
- try {
42
- const path = join(homedir(), '.claude.json');
43
- if (!existsSync(path))
44
- return out;
45
- const json = JSON.parse(readFileSync(path, 'utf-8'));
46
- const projects = json?.projects && typeof json.projects === 'object' ? json.projects : null;
47
- if (projects) {
48
- for (const proj of Object.values(projects)) {
49
- const servers = proj?.mcpServers;
50
- if (servers && typeof servers === 'object') {
51
- for (const name of Object.keys(servers))
52
- out.add(name);
53
- }
54
- }
55
- }
56
- const top = json?.mcpServers;
57
- if (top && typeof top === 'object') {
58
- for (const name of Object.keys(top))
59
- out.add(name);
60
- }
61
- }
62
- catch {
63
- }
64
- return out;
65
- }
66
16
  function isSkillInstalled(skillName) {
67
17
  const base = join(homedir(), '.claude');
68
18
  const candidates = [];
@@ -136,7 +86,12 @@ function probe(cmd, args, interpret = (out) => ({
136
86
  function defaultCheckBinary(bin) {
137
87
  if (bin.startsWith('mcp:')) {
138
88
  const name = bin.slice('mcp:'.length);
139
- return { installed: readConfiguredMcpNames().has(name) };
89
+ const scan = scanMcpConfigs();
90
+ if (name === 'nonbot') {
91
+ const first = scan.nonbot[0];
92
+ return first ? { installed: true, version: describeNonbotMcp(first) } : { installed: false };
93
+ }
94
+ return scan.names.has(name) ? { installed: true, version: scan.where.get(name) } : { installed: false };
140
95
  }
141
96
  if (bin.startsWith('skill:')) {
142
97
  const name = bin.slice('skill:'.length);
@@ -160,6 +115,15 @@ function defaultCheckBinary(bin) {
160
115
  return { installed: false };
161
116
  }
162
117
  }
118
+ export function bareVersion(raw) {
119
+ if (!raw)
120
+ return '';
121
+ const first = raw.split('\n')[0].trim();
122
+ const m = first.match(/\d+(?:\.\d+)+[0-9A-Za-z.+-]*/);
123
+ if (m)
124
+ return m[0];
125
+ return first.length > 40 ? first.slice(0, 39) + '…' : first;
126
+ }
163
127
  export async function runDoctorCommand(args = [], deps = {}) {
164
128
  void args;
165
129
  const log = deps.log ?? ((s) => process.stdout.write(s));
@@ -169,46 +133,42 @@ export async function runDoctorCommand(args = [], deps = {}) {
169
133
  const now = deps.now ?? (() => Date.now());
170
134
  const checkBinary = deps.checkBinary ?? defaultCheckBinary;
171
135
  let exitCode = 0;
136
+ const row = (sig, label, detail = '') => log(statusRow(sig, label, detail, { labelWidth: LABEL_WIDTH }) + '\n');
137
+ const errRow = (sig, label, detail = '') => errLog(statusRow(sig, label, detail, { labelWidth: LABEL_WIDTH, stream: process.stderr }) + '\n');
138
+ const hint = (text) => log(' '.repeat(HINT_INDENT) + paintDim(text) + '\n');
139
+ const section = (title) => log('\n' + paintSection(title) + '\n');
172
140
  log(header('non.bot doctor', `v${VERSION} · ${process.platform}/${process.arch}`) + '\n');
173
- log('\n');
174
141
  const auth = await loader();
175
142
  if (!auth) {
143
+ log('\n');
176
144
  log(errorBlock('Not logged in', 'Run `nonbot login` to authenticate.'));
177
145
  return 1;
178
146
  }
179
- log(statusRow('✓', 'Auth', `logged in as ${auth.email}`) + '\n');
180
- let serverOk = false;
147
+ section('ACCOUNT');
148
+ row('✓', 'Signed in', auth.email);
149
+ const headers = {
150
+ Authorization: `Bearer ${auth.pat}`,
151
+ 'X-Requested-With': 'ConradPM-Native',
152
+ 'X-CLI-Version': VERSION,
153
+ };
181
154
  try {
182
- const res = await fetchImpl(`${auth.baseUrl}/api/auth/me`, {
183
- headers: {
184
- Authorization: `Bearer ${auth.pat}`,
185
- 'X-Requested-With': 'ConradPM-Native',
186
- 'X-CLI-Version': VERSION,
187
- },
188
- });
155
+ const res = await fetchImpl(`${auth.baseUrl}/api/auth/me`, { headers });
189
156
  if (res.ok) {
190
- serverOk = true;
191
- log(statusRow('✓', 'Server reachable', `${auth.baseUrl} · PAT valid`) + '\n');
157
+ row('✓', 'Server', `${auth.baseUrl} · token accepted`);
192
158
  }
193
159
  else if (res.status === 401) {
194
- errLog(statusRow('✗', 'Server', `PAT rejected (HTTP 401) — re-run: nonbot login`, { stream: process.stderr }) + '\n');
160
+ errRow('✗', 'Server', 'token rejected (HTTP 401) — sign in again: nonbot login');
195
161
  exitCode = 1;
196
162
  }
197
163
  else {
198
- log(statusRow('⚠', 'Server', `returned HTTP ${res.status} for /api/auth/me`) + '\n');
164
+ row('⚠', 'Server', `${auth.baseUrl} answered HTTP ${res.status}`);
199
165
  }
200
166
  }
201
167
  catch (e) {
202
- log(statusRow('⚠', 'Server', `Could not reach ${auth.baseUrl}: ${e.message}`) + '\n');
168
+ row('⚠', 'Server', `Could not reach ${auth.baseUrl} (${e.message})`);
203
169
  }
204
170
  try {
205
- const res = await fetchImpl(`${auth.baseUrl}/api/cli/daemon-status`, {
206
- headers: {
207
- Authorization: `Bearer ${auth.pat}`,
208
- 'X-Requested-With': 'ConradPM-Native',
209
- 'X-CLI-Version': VERSION,
210
- },
211
- });
171
+ const res = await fetchImpl(`${auth.baseUrl}/api/cli/daemon-status`, { headers });
212
172
  if (res.ok) {
213
173
  const body = (await res.json());
214
174
  const last = body.lastSeenAt ?? body.lastHeartbeatAt;
@@ -216,86 +176,140 @@ export async function runDoctorCommand(args = [], deps = {}) {
216
176
  (typeof last === 'number' && now() - last <= HEARTBEAT_FRESH_MS);
217
177
  if (fresh) {
218
178
  const detail = typeof last === 'number'
219
- ? `last heartbeat ${Math.round((now() - last) / 1000)}s ago`
220
- : 'connected';
221
- log(statusRow('✓', 'Daemon running', detail) + '\n');
179
+ ? `running · last heartbeat ${Math.round((now() - last) / 1000)}s ago`
180
+ : 'running';
181
+ row('✓', 'Daemon', detail);
222
182
  }
223
183
  else {
224
- log(statusRow('✗', 'Daemon not running', 'no recent heartbeat — start with: nonbot daemon') + '\n');
184
+ row('✗', 'Daemon', 'not running — start it: nonbot daemon');
225
185
  }
226
186
  }
227
187
  else if (res.status === 401) {
228
- log(statusRow('⚠', 'Daemon status unknown', '(auth rejected)') + '\n');
188
+ row('⚠', 'Daemon', 'status unknown (auth rejected)');
189
+ }
190
+ else if (res.status === 403) {
191
+ let gate = {};
192
+ try {
193
+ gate = (await res.json());
194
+ }
195
+ catch {
196
+ }
197
+ const storeUrl = `${auth.baseUrl}/whygraph/store?tab=apps&app=${gate.app ?? 'run'}`;
198
+ if (gate.error === 'app_disabled') {
199
+ row('✗', 'Run app', 'switched off — the daemon gets no runs until it is on');
200
+ hint(`turn it on: ${storeUrl}`);
201
+ }
202
+ else if (gate.error === 'app_not_installed') {
203
+ row('✗', 'Run app', 'not added — the daemon gets no runs until it is');
204
+ hint(`add it: ${storeUrl}`);
205
+ }
206
+ else {
207
+ row('⚠', 'Daemon', 'status unknown (HTTP 403)');
208
+ }
229
209
  }
230
210
  else {
231
- log(statusRow('⚠', 'Daemon status unknown', `(HTTP ${res.status})`) + '\n');
211
+ row('⚠', 'Daemon', `status unknown (HTTP ${res.status})`);
232
212
  }
233
213
  }
234
214
  catch (e) {
235
- log(statusRow('⚠', 'Daemon status unknown', e.message) + '\n');
215
+ row('⚠', 'Daemon', `status unknown (${e.message})`);
236
216
  }
237
- for (const bin of PROVIDER_CLIS) {
238
- const r = checkBinary(bin);
239
- if (r.installed) {
240
- log(statusRow('✓', bin, r.version ? `(${r.version})` : '') + '\n');
241
- }
242
- else {
243
- log(statusRow('⚠', bin, 'not on PATH') + '\n');
244
- }
217
+ section('AGENTS');
218
+ const providers = PROVIDER_CLIS.map((bin) => ({ bin, r: checkBinary(bin) }));
219
+ const anyProvider = providers.some((p) => p.r.installed);
220
+ for (const { bin, r } of providers) {
221
+ if (r.installed)
222
+ row('✓', bin, bareVersion(r.version));
223
+ else if (anyProvider)
224
+ row('ℹ', bin, 'not installed (optional)');
225
+ else
226
+ row('⚠', bin, 'not on PATH');
245
227
  }
228
+ if (!anyProvider)
229
+ hint('Runs need one of these — install Claude Code: npm i -g @anthropic-ai/claude-code');
230
+ section('TERMINAL');
246
231
  const profile = resolveTerminal(undefined);
247
232
  const { cmd } = profile.launch('/tmp/nonbot-doctor-probe.sh');
248
233
  const launcher = checkBinary(cmd);
249
234
  if (launcher.installed) {
250
- log(statusRow('✓', 'Terminal', `${profile.displayName} (launches via "${cmd}")`) + '\n');
235
+ row('✓', 'Opens runs in', `${profile.displayName} · via ${cmd}`);
251
236
  }
252
237
  else {
253
- log(statusRow('⚠', 'Terminal', `${profile.displayName} — launcher "${cmd}" not found on PATH`) + '\n');
238
+ row('⚠', 'Terminal', `${profile.displayName} — launcher "${cmd}" not found on PATH`);
254
239
  }
255
240
  if (process.platform === 'win32') {
256
241
  const bash = checkBinary('bash');
257
- if (bash.installed) {
258
- log(statusRow('✓', 'Windows via WSL or Git Bash', 'bash found on PATH — Run scripts are #!/bin/bash') + '\n');
259
- }
260
- else {
261
- log(statusRow('⚠', 'Windows via WSL or Git Bash', 'bash not found on PATH — install WSL or Git Bash; Run scripts are #!/bin/bash') + '\n');
262
- }
242
+ if (bash.installed)
243
+ row('✓', 'bash', 'found · Windows via WSL or Git Bash — runs are #!/bin/bash');
244
+ else
245
+ row('⚠', 'bash', 'not found · Windows via WSL or Git Bash — runs are #!/bin/bash');
263
246
  }
264
247
  if (process.platform === 'darwin') {
265
248
  const running = checkBinary('iterm-running');
266
- if (running.installed) {
267
- log(statusRow('✓', 'iTerm running', '') + '\n');
268
- }
269
- else {
270
- log(statusRow('⚠', 'iTerm not running', 'to start iTerm: open -a iTerm') + '\n');
271
- }
272
- const appl = checkBinary('iterm-applescript');
273
- if (appl.installed) {
274
- log(statusRow('✓', 'iTerm AppleScript reachable', appl.version ?? '') + '\n');
249
+ if (!running.installed) {
250
+ row('ℹ', 'iTerm', 'not running — only needed if runs open in iTerm');
275
251
  }
276
252
  else {
277
- log(statusRow('⚠', 'iTerm AppleScript', appl.version ? `probe failed: ${appl.version}` : 'probe failed') + '\n');
278
- log(' ' + 'System Settings → Privacy & Security → Automation → allow your terminal to control iTerm.' + '\n');
253
+ const appl = checkBinary('iterm-applescript');
254
+ if (appl.installed) {
255
+ const v = bareVersion(appl.version);
256
+ row('✓', 'iTerm', v ? `${v} · running · AppleScript reachable` : 'running · AppleScript reachable');
257
+ }
258
+ else {
259
+ row('⚠', 'iTerm', appl.version ? `AppleScript blocked: ${appl.version}` : 'AppleScript blocked');
260
+ hint('System Settings → Privacy & Security → Automation → allow your terminal to control iTerm');
261
+ }
279
262
  }
280
263
  }
281
- for (const mcp of EXPECTED_MCPS) {
282
- const r = checkBinary(`mcp:${mcp.name}`);
283
- if (r.installed) {
284
- log(statusRow('✓', `MCP ${mcp.name}`, mcp.present) + '\n');
285
- }
286
- else {
287
- log(statusRow(mcp.required ? '⚠' : 'ℹ', `MCP ${mcp.name}`, mcp.absent) + '\n');
264
+ section('CLAUDE CODE');
265
+ const statusSource = deps.nonbotMcp ?? (deps.checkBinary ? null : () => nonbotMcpStatuses({ fetchImpl: deps.fetchImpl }));
266
+ const statuses = statusSource ? await statusSource().catch(() => []) : null;
267
+ const nonbotMcp = statuses ? { installed: statuses.length > 0 } : checkBinary('mcp:nonbot');
268
+ if (statuses && statuses.length > 0) {
269
+ const r = mcpRow(statuses[0]);
270
+ row(r.sigil, 'nonbot MCP', r.detail);
271
+ if (r.hint)
272
+ hint(r.hint);
273
+ }
274
+ else if (nonbotMcp.installed) {
275
+ const how = nonbotMcp.version ?? 'configured (@nonbot/mcp)';
276
+ row(/pinned/.test(how) ? '⚠' : '✓', 'nonbot MCP', how);
277
+ }
278
+ else {
279
+ row('⚠', 'nonbot MCP', 'not set up — runs cannot report back without it');
280
+ hint(`add it: ${NONBOT_MCP_ADD_HINT}`);
281
+ }
282
+ for (const name of LEGACY_MCPS) {
283
+ const legacy = checkBinary(`mcp:${name}`);
284
+ if (legacy.installed) {
285
+ row('ℹ', name, legacy.version ? `retired — safe to delete from ${legacy.version}` : 'retired — safe to remove');
288
286
  }
289
287
  }
290
288
  for (const name of EXPECTED_SKILLS) {
291
289
  const r = checkBinary(`skill:${name}`);
292
- if (r.installed) {
293
- log(statusRow('✓', `Skill ${name}`, '') + '\n');
294
- }
295
- else {
296
- log(statusRow('⚠', `Skill ${name}`, 'not installed under ~/.claude/skills/') + '\n');
297
- }
290
+ if (r.installed)
291
+ row('✓', `Skill ${name}`);
292
+ else
293
+ row('⚠', `Skill ${name}`, 'not installed under ~/.claude/skills/');
298
294
  }
299
- void serverOk;
295
+ section('COMMANDS');
296
+ log(commandRow('nonbot daemon', 'start listening for runs'));
297
+ log(commandRow('Ctrl-C', 'stop the daemon (in its window)'));
298
+ log(commandRow('nonbot daemon stop', 'stop it from any other terminal'));
299
+ log(commandRow('nonbot logout', 'sign out on this machine and stop the daemon'));
300
300
  return exitCode;
301
301
  }
302
+ const LABEL_WIDTH = 14;
303
+ const HINT_INDENT = 3 + 1 + 2 + LABEL_WIDTH + 1;
304
+ function paintDim(s) {
305
+ return isTTY(process.stdout) ? `${ANSI.dim}${s}${ANSI.reset}` : s;
306
+ }
307
+ function paintSection(title) {
308
+ return isTTY(process.stdout) ? ` ${ANSI.bold}${title}${ANSI.reset}` : ` ${title}`;
309
+ }
310
+ function commandRow(cmd, what) {
311
+ const padded = cmd.padEnd(LABEL_WIDTH + 6, ' ');
312
+ if (!isTTY(process.stdout))
313
+ return ` ${padded}${what}\n`;
314
+ return ` ${ANSI.cyan}${padded}${ANSI.reset}${ANSI.dim}${what}${ANSI.reset}\n`;
315
+ }
@@ -0,0 +1,78 @@
1
+ import { spawnSync as nodeSpawnSync } from 'node:child_process';
2
+ import { loadAuth, clearAuth, getActiveProfile } from '../lib/auth.js';
3
+ import { readPidFile, stopDaemonLocally } from '../lib/daemon-lifecycle.js';
4
+ import { header, statusRow } from '../lib/output.js';
5
+ function defaultDaemonRunning() {
6
+ const pid = readPidFile();
7
+ if (pid !== null) {
8
+ try {
9
+ process.kill(pid, 0);
10
+ return true;
11
+ }
12
+ catch {
13
+ }
14
+ }
15
+ try {
16
+ const r = nodeSpawnSync('tmux', ['has-session', '-t', 'nonbot'], {
17
+ encoding: 'utf-8',
18
+ timeout: 2000,
19
+ windowsHide: true,
20
+ });
21
+ return r.status === 0;
22
+ }
23
+ catch {
24
+ return false;
25
+ }
26
+ }
27
+ export async function runLogoutCommand(args = [], deps = {}) {
28
+ void args;
29
+ const log = deps.log ?? ((s) => process.stdout.write(s));
30
+ const errLog = deps.errLog ?? ((s) => process.stderr.write(s));
31
+ const loader = deps.loadAuth ?? loadAuth;
32
+ const clear = deps.clearAuth ?? clearAuth;
33
+ const daemonRunning = deps.daemonRunning ?? defaultDaemonRunning;
34
+ const stopDaemon = deps.stopDaemon ?? ((l, e) => stopDaemonLocally({ log: l, errLog: e }));
35
+ const profile = getActiveProfile();
36
+ log(header('non.bot logout', profile === 'default' ? '' : `profile ${profile}`) + '\n\n');
37
+ const auth = await loader();
38
+ const running = daemonRunning();
39
+ if (!auth && !running) {
40
+ log(statusRow('✓', 'Already signed out', 'nothing saved on this machine, no daemon running') + '\n');
41
+ return 0;
42
+ }
43
+ let exitCode = 0;
44
+ if (auth) {
45
+ try {
46
+ await clear();
47
+ log(statusRow('✓', 'Signed out', `${auth.email} · token removed from this machine`) + '\n');
48
+ }
49
+ catch (e) {
50
+ errLog(statusRow('✗', 'Sign-out failed', `could not remove the saved token: ${e.message}`, { stream: process.stderr }) + '\n');
51
+ exitCode = 1;
52
+ }
53
+ }
54
+ else {
55
+ log(statusRow('✓', 'Signed out', 'no token was saved on this machine') + '\n');
56
+ }
57
+ if (running) {
58
+ const said = [];
59
+ const code = await stopDaemon((s) => said.push(s), (s) => said.push(s));
60
+ if (code === 0) {
61
+ log(statusRow('✓', 'Daemon stopped', '') + '\n');
62
+ }
63
+ else {
64
+ const why = said.join(' ').replace(/\s+/g, ' ').replace(/^[✓✗⚠]\s*/, '').trim();
65
+ errLog(statusRow('⚠', 'Daemon still running', why || 'stop it with Ctrl-C in its window', { stream: process.stderr }) + '\n');
66
+ exitCode = 1;
67
+ }
68
+ }
69
+ else {
70
+ log(statusRow('✓', 'Daemon', 'was not running') + '\n');
71
+ }
72
+ if (auth) {
73
+ log('\n');
74
+ log(statusRow('ℹ', 'Token', `still exists on non.bot — delete it for good at ${auth.baseUrl}/settings/connections`) + '\n');
75
+ log(statusRow('ℹ', 'Sign back in', 'nonbot login') + '\n');
76
+ }
77
+ return exitCode;
78
+ }
@@ -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
+ }
package/dist/index.js CHANGED
@@ -6,95 +6,159 @@ import { runDaemonCommand } from './commands/daemon.js';
6
6
  import { runLoginCommand } from './commands/login.js';
7
7
  import { runRunCommand } from './commands/run.js';
8
8
  import { runDoctorCommand } from './commands/doctor.js';
9
+ import { runLogoutCommand } from './commands/logout.js';
10
+ import { runUpdateCommand } from './commands/update.js';
9
11
  import { runLogsCommand } from './commands/logs.js';
10
12
  import { runProfilesCommand } from './commands/profiles.js';
11
13
  import { runChoirCommand } from './commands/choir.js';
12
14
  import { runRunMcpCommand } from './commands/run-mcp.js';
13
15
  import { runRunPromptHookCommand } from './commands/run-prompt-hook.js';
14
16
  import { setActiveProfile } from './lib/auth.js';
15
- import { header, kvRow, ANSI, isTTY } from './lib/output.js';
17
+ import { header, kvRow, ANSI, isTTY, wordmarkArt, WORDMARK_WIDTH } from './lib/output.js';
16
18
  const COMMANDS = [
17
19
  {
18
20
  name: 'login',
19
- description: 'Paste a PAT from /settings/connections; saves to ~/.config/nonbot/auth.json.',
21
+ description: 'Sign in with a token from non.bot Settings → Connections',
22
+ group: 'start',
20
23
  run: (args) => runLoginCommand(args),
21
24
  },
25
+ {
26
+ name: 'logout',
27
+ description: 'Sign out on this machine and stop the daemon',
28
+ group: 'start',
29
+ run: (args) => runLogoutCommand(args),
30
+ },
22
31
  {
23
32
  name: 'daemon',
24
- description: 'Long-running listener — polls /api/cli/activations/pending every 2s. `nonbot daemon stop` stops it (local; --machine <id> / --all go remote); `nonbot daemon attach` reattaches a lost tmux window.',
33
+ description: 'Listen for runs from non.bot; each opens a terminal here',
34
+ group: 'start',
25
35
  run: (args) => runDaemonCommand(args),
26
36
  },
37
+ {
38
+ name: 'update',
39
+ description: 'Install the latest version · update mcp: the non.bot MCP',
40
+ usage: 'update [mcp]',
41
+ group: 'start',
42
+ run: (args) => runUpdateCommand(args),
43
+ },
44
+ {
45
+ name: 'status',
46
+ description: 'Who is signed in and whether the daemon is connected',
47
+ group: 'check',
48
+ run: (args) => runStatusCommand(args),
49
+ },
50
+ {
51
+ name: 'doctor',
52
+ description: 'Health check: account, agents, terminal, Claude Code',
53
+ group: 'check',
54
+ run: (args) => runDoctorCommand(args),
55
+ },
56
+ {
57
+ name: 'logs',
58
+ description: 'Runs launched on this machine (--limit <n>, --json, -f)',
59
+ group: 'check',
60
+ run: (args) => runLogsCommand(args),
61
+ },
27
62
  {
28
63
  name: 'run',
29
- description: 'One-shot: fire a single activation by id. Usage: nonbot run <activation-id>',
64
+ usage: 'run <id>',
65
+ description: 'Launch one run by id, then exit',
66
+ group: 'more',
30
67
  run: (args) => runRunCommand(args),
31
68
  },
32
69
  {
33
70
  name: 'choir',
34
- description: 'Coordinated-run engine: launch a fleet — one agent per git worktree, merged back by hand. Usage: nonbot choir <session> [--panes N] [--base <b>] [--join <mode>]',
71
+ description: 'Coordinated-run engine — one agent per git worktree',
72
+ usage: 'choir <session>',
73
+ group: 'more',
35
74
  run: (args) => runChoirCommand(args),
36
75
  },
76
+ {
77
+ name: 'profiles',
78
+ description: 'List signed-in accounts (pick one with --profile <name>)',
79
+ group: 'more',
80
+ run: (args) => runProfilesCommand(args),
81
+ },
82
+ {
83
+ name: 'test',
84
+ description: 'Print a local diagnostic banner (no server needed)',
85
+ group: 'more',
86
+ run: () => runTestCommand(),
87
+ },
37
88
  {
38
89
  name: 'run-mcp',
39
90
  description: 'Per-pane fleet MCP server over stdio. Launched by .mcp.json — not run by hand.',
91
+ group: 'hidden',
40
92
  run: () => runRunMcpCommand(),
41
93
  },
42
94
  {
43
95
  name: 'choir-mcp',
44
96
  description: 'Deprecated alias for run-mcp.',
97
+ group: 'hidden',
45
98
  run: () => runRunMcpCommand(),
46
99
  },
47
100
  {
48
101
  name: 'run-prompt-hook',
49
102
  description: "Claude Code Notification-hook handler — surfaces a Run's mid-run prompt. Not run by hand.",
103
+ group: 'hidden',
50
104
  run: () => runRunPromptHookCommand(),
51
105
  },
52
- {
53
- name: 'status',
54
- description: 'Report login state + last daemon heartbeat.',
55
- run: (args) => runStatusCommand(args),
56
- },
57
- {
58
- name: 'doctor',
59
- description: 'Health check — auth, server, daemon, provider CLIs, terminal.',
60
- run: (args) => runDoctorCommand(args),
61
- },
62
- {
63
- name: 'logs',
64
- description: 'Local activation history. Flags: --limit <n>, --json, --follow/-f.',
65
- run: (args) => runLogsCommand(args),
66
- },
67
- {
68
- name: 'profiles',
69
- description: 'List multi-account auth profiles; marks the active one.',
70
- run: (args) => runProfilesCommand(args),
71
- },
72
- {
73
- name: 'test',
74
- description: 'Print the diagnostic banner locally — no server roundtrip.',
75
- run: () => runTestCommand(),
76
- },
77
106
  ];
78
107
  function printVersion() {
79
108
  process.stdout.write(VERSION + '\n');
80
109
  }
81
110
  function printHelp() {
82
111
  const tty = isTTY(process.stdout);
112
+ const head = (t) => (tty ? ` ${ANSI.bold}${t}${ANSI.reset}` : ` ${t}`);
113
+ const KEY = 18;
83
114
  const lines = [];
84
- lines.push(header('nonbot', `non.bot CLI host · v${VERSION}`));
115
+ lines.push(header('nonbot', `non.bot CLI · v${VERSION}`));
116
+ lines.push('');
117
+ lines.push(kvRow('Usage', 'nonbot <command> [args]', { keyWidth: KEY }));
118
+ const section = (title, group) => {
119
+ lines.push('');
120
+ lines.push(head(title));
121
+ for (const cmd of COMMANDS.filter((c) => c.group === group)) {
122
+ lines.push(kvRow(cmd.usage ?? cmd.name, cmd.description, { keyWidth: KEY }));
123
+ }
124
+ };
125
+ section('Get started', 'start');
126
+ lines.push('');
127
+ lines.push(head('Stop the daemon'));
128
+ lines.push(kvRow('Ctrl-C', 'in the daemon\'s window', { keyWidth: KEY }));
129
+ lines.push(kvRow('daemon stop', 'from another terminal (--machine <id> / --all)', { keyWidth: KEY }));
130
+ lines.push(kvRow('daemon attach', 'bring back a closed daemon window', { keyWidth: KEY }));
131
+ section('Check', 'check');
132
+ section('More', 'more');
85
133
  lines.push('');
86
- lines.push(tty ? ` ${ANSI.cyan}Usage${ANSI.reset} ${ANSI.dim}nonbot <command> [args]${ANSI.reset}` : ' Usage nonbot <command> [args]');
134
+ lines.push(head('Flags'));
135
+ lines.push(kvRow('--profile <name>', 'use a named account (works with any command)', { keyWidth: KEY }));
136
+ lines.push(kvRow('-v, --version', 'print the version', { keyWidth: KEY }));
137
+ lines.push(kvRow('-h, --help', 'print this help', { keyWidth: KEY }));
87
138
  lines.push('');
88
- lines.push(tty ? ` ${ANSI.cyan}Commands${ANSI.reset}` : ' Commands');
89
- const w = COMMANDS.reduce((m, c) => Math.max(m, c.name.length), 0);
90
- for (const cmd of COMMANDS) {
91
- lines.push(kvRow(cmd.name, cmd.description, { keyWidth: w }));
139
+ process.stdout.write(lines.join('\n') + '\n');
140
+ }
141
+ function printQuickStart() {
142
+ const KEY = 8;
143
+ const lines = [];
144
+ const cols = process.stdout.columns ?? 0;
145
+ if (cols === 0 || cols >= WORDMARK_WIDTH + 4) {
146
+ lines.push('');
147
+ lines.push(wordmarkArt({ indent: ' ' }));
148
+ lines.push('');
149
+ lines.push(kvRow('', `the non.bot CLI · v${VERSION}`, { keyWidth: KEY }));
92
150
  }
151
+ else {
152
+ lines.push(header('non.bot CLI', `v${VERSION}`));
153
+ }
154
+ 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 }));
157
+ lines.push(kvRow('doctor', 'check that everything is set up', { keyWidth: KEY }));
158
+ lines.push(kvRow('logout', 'sign out and stop the daemon', { keyWidth: KEY }));
159
+ lines.push(kvRow('update', 'install the latest version', { keyWidth: KEY }));
93
160
  lines.push('');
94
- lines.push(tty ? ` ${ANSI.cyan}Flags${ANSI.reset}` : ' Flags');
95
- lines.push(kvRow('--profile <name>', 'Use a named auth profile (multi-account). Default: "default".', { keyWidth: 18 }));
96
- lines.push(kvRow('-v, --version', 'Print CLI version and exit.', { keyWidth: 18 }));
97
- lines.push(kvRow('-h, --help', 'Print this help and exit.', { keyWidth: 18 }));
161
+ lines.push(kvRow('', 'type nonbot <command> · nonbot --help lists everything', { keyWidth: KEY }));
98
162
  lines.push('');
99
163
  process.stdout.write(lines.join('\n') + '\n');
100
164
  }
@@ -131,7 +195,7 @@ async function main(argv) {
131
195
  }
132
196
  }
133
197
  if (args.length === 0) {
134
- printHelp();
198
+ printQuickStart();
135
199
  return 0;
136
200
  }
137
201
  const first = args[0];
@@ -0,0 +1,124 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { basename, dirname, join, resolve, isAbsolute } from 'node:path';
4
+ function defaultReadFile(path) {
5
+ try {
6
+ return existsSync(path) ? readFileSync(path, 'utf-8') : null;
7
+ }
8
+ catch {
9
+ return null;
10
+ }
11
+ }
12
+ function tilde(path, home) {
13
+ return path.startsWith(home) ? '~' + path.slice(home.length) : path;
14
+ }
15
+ export function classifyServer(name, server, baseDir) {
16
+ const args = Array.isArray(server.args) ? server.args.filter((a) => typeof a === 'string') : [];
17
+ const command = typeof server.command === 'string' ? server.command : '';
18
+ const url = typeof server.url === 'string' ? server.url : '';
19
+ if (/(^|\/\/|\.)mcp\.non\.bot\b/.test(url) || args.some((a) => /mcp\.non\.bot/.test(a))) {
20
+ return { kind: 'remote' };
21
+ }
22
+ const spec = args.find((a) => /^@nonbot\/mcp(@[^\s]+)?$/.test(a));
23
+ if (spec) {
24
+ const at = spec.indexOf('@', 1);
25
+ const version = at > 0 ? spec.slice(at + 1) : undefined;
26
+ return { kind: 'npx', pinned: version && version !== 'latest' ? version : undefined };
27
+ }
28
+ if (basename(command) === 'nonbot-mcp' || basename(command) === 'nonbot-mcp.cmd') {
29
+ return { kind: 'global' };
30
+ }
31
+ const script = [command, ...args].find((a) => /nonbot-mcp[\\/]+dist[\\/]+stdio\.js$/.test(a));
32
+ if (script) {
33
+ const abs = isAbsolute(script) ? script : resolve(baseDir, script);
34
+ return { kind: 'checkout', checkoutDir: dirname(dirname(abs)) };
35
+ }
36
+ if (name === 'nonbot')
37
+ return { kind: 'unknown' };
38
+ return null;
39
+ }
40
+ function serversIn(json) {
41
+ const servers = json?.mcpServers;
42
+ if (!servers || typeof servers !== 'object')
43
+ return [];
44
+ return Object.entries(servers).filter(([, v]) => v !== null && typeof v === 'object');
45
+ }
46
+ function parse(text) {
47
+ if (!text)
48
+ return null;
49
+ try {
50
+ return JSON.parse(text);
51
+ }
52
+ catch {
53
+ return null;
54
+ }
55
+ }
56
+ export function scanMcpConfigs(deps = {}) {
57
+ const home = deps.home ?? homedir();
58
+ const cwd = deps.cwd ?? process.cwd();
59
+ const platform = deps.platform ?? process.platform;
60
+ const read = deps.readFile ?? defaultReadFile;
61
+ const names = new Set();
62
+ const where = new Map();
63
+ const nonbot = [];
64
+ const seenFiles = new Set();
65
+ const take = (source, json, baseDir) => {
66
+ for (const [name, server] of serversIn(json)) {
67
+ names.add(name);
68
+ if (!where.has(name))
69
+ where.set(name, source);
70
+ const c = classifyServer(name, server, baseDir);
71
+ if (c)
72
+ nonbot.push({ source, name, ...c });
73
+ }
74
+ };
75
+ const claudeJsonPath = join(home, '.claude.json');
76
+ const claudeJson = parse(read(claudeJsonPath));
77
+ const projectDirs = [];
78
+ if (claudeJson) {
79
+ take('~/.claude.json', claudeJson, home);
80
+ if (claudeJson.projects && typeof claudeJson.projects === 'object') {
81
+ for (const [dir, proj] of Object.entries(claudeJson.projects)) {
82
+ take(`~/.claude.json (${tilde(dir, home)})`, proj, dir);
83
+ projectDirs.push(dir);
84
+ }
85
+ }
86
+ }
87
+ const mcpJsonDirs = [];
88
+ for (let d = resolve(cwd);; d = dirname(d)) {
89
+ mcpJsonDirs.push(d);
90
+ if (d === dirname(d) || d === home)
91
+ break;
92
+ }
93
+ mcpJsonDirs.push(...projectDirs);
94
+ for (const dir of mcpJsonDirs) {
95
+ const file = join(dir, '.mcp.json');
96
+ if (seenFiles.has(file))
97
+ continue;
98
+ seenFiles.add(file);
99
+ const json = parse(read(file));
100
+ if (json)
101
+ take(tilde(file, home), json, dir);
102
+ }
103
+ if (platform === 'darwin') {
104
+ const desktop = join(home, 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json');
105
+ const json = parse(read(desktop));
106
+ if (json)
107
+ take('Claude Desktop', json, home);
108
+ }
109
+ return { names, where, nonbot };
110
+ }
111
+ export function describeNonbotMcp(e, home = homedir()) {
112
+ switch (e.kind) {
113
+ case 'remote':
114
+ return 'remote (mcp.non.bot) · always current';
115
+ case 'npx':
116
+ return e.pinned ? `npx, pinned to ${e.pinned} · gets no updates` : 'npx @nonbot/mcp · updates itself';
117
+ case 'global':
118
+ return 'global install · update: npm i -g @nonbot/mcp';
119
+ case 'checkout':
120
+ return `source checkout ${e.checkoutDir ? tilde(e.checkoutDir, home) : ''}`.trim();
121
+ default:
122
+ return 'configured';
123
+ }
124
+ }
@@ -0,0 +1,127 @@
1
+ import { existsSync, readFileSync, readdirSync, lstatSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { dirname, join } from 'node:path';
4
+ import { fetchLatestVersion, compareVersions, parseSemver } from './update-check.js';
5
+ import { scanMcpConfigs, describeNonbotMcp } from './mcp-config.js';
6
+ export const MCP_LATEST_URL = 'https://registry.npmjs.org/@nonbot/mcp/latest';
7
+ export const MCP_REMOTE_URL = 'https://mcp.non.bot/';
8
+ function readVersion(pkgJsonPath) {
9
+ try {
10
+ if (!existsSync(pkgJsonPath))
11
+ return null;
12
+ const v = JSON.parse(readFileSync(pkgJsonPath, 'utf-8')).version;
13
+ return parseSemver(v) ? String(v) : null;
14
+ }
15
+ catch {
16
+ return null;
17
+ }
18
+ }
19
+ export function defaultGlobalRoot(execPath = process.execPath, platform = process.platform) {
20
+ return platform === 'win32'
21
+ ? join(dirname(execPath), 'node_modules')
22
+ : join(dirname(dirname(execPath)), 'lib', 'node_modules');
23
+ }
24
+ export function globalMcpIsLinked(globalRoot = defaultGlobalRoot()) {
25
+ try {
26
+ return lstatSync(join(globalRoot, '@nonbot', 'mcp')).isSymbolicLink();
27
+ }
28
+ catch {
29
+ return false;
30
+ }
31
+ }
32
+ export function npxCachedMcpVersion(env = process.env, home = homedir()) {
33
+ const root = join(env.npm_config_cache || join(home, '.npm'), '_npx');
34
+ let best = null;
35
+ let dirs = [];
36
+ try {
37
+ dirs = readdirSync(root);
38
+ }
39
+ catch {
40
+ return null;
41
+ }
42
+ for (const hash of dirs) {
43
+ const v = readVersion(join(root, hash, 'node_modules', '@nonbot', 'mcp', 'package.json'));
44
+ if (v && (best === null || compareVersions(v, best) > 0))
45
+ best = v;
46
+ }
47
+ return best;
48
+ }
49
+ export async function remoteMcpVersion(fetchImpl = fetch) {
50
+ try {
51
+ const res = await fetchImpl(MCP_REMOTE_URL, {
52
+ headers: { Accept: 'text/html' },
53
+ signal: AbortSignal.timeout(3_000),
54
+ });
55
+ if (!res.ok)
56
+ return null;
57
+ const m = /version\s+(\d{1,6}\.\d{1,6}\.\d{1,6})\b/.exec(await res.text());
58
+ return m ? m[1] : null;
59
+ }
60
+ catch {
61
+ return null;
62
+ }
63
+ }
64
+ export async function installedMcpVersion(entry, deps = {}) {
65
+ switch (entry.kind) {
66
+ case 'checkout':
67
+ return entry.checkoutDir ? readVersion(join(entry.checkoutDir, 'package.json')) : null;
68
+ case 'npx':
69
+ return entry.pinned && parseSemver(entry.pinned) ? entry.pinned : npxCachedMcpVersion(deps.env, deps.home);
70
+ case 'global':
71
+ return readVersion(join(deps.globalRoot ?? defaultGlobalRoot(), '@nonbot', 'mcp', 'package.json'));
72
+ case 'remote':
73
+ return remoteMcpVersion(deps.fetchImpl);
74
+ default:
75
+ return null;
76
+ }
77
+ }
78
+ export async function nonbotMcpStatuses(deps = {}) {
79
+ const entries = scanMcpConfigs(deps).nonbot;
80
+ if (entries.length === 0)
81
+ return [];
82
+ const [latest, installed] = await Promise.all([
83
+ fetchLatestVersion({ url: MCP_LATEST_URL, fetchImpl: deps.fetchImpl }),
84
+ Promise.all(entries.map((e) => installedMcpVersion(e, deps))),
85
+ ]);
86
+ return entries.map((entry, i) => ({ entry, installed: installed[i] ?? null, latest }));
87
+ }
88
+ export function mcpRow(s, home = homedir()) {
89
+ const how = describeNonbotMcp(s.entry, home);
90
+ const v = s.installed ? `v${s.installed} · ` : '';
91
+ const behind = !!(s.installed && s.latest && compareVersions(s.latest, s.installed) > 0);
92
+ const e = s.entry;
93
+ if (e.kind === 'npx' && e.pinned) {
94
+ return {
95
+ sigil: '⚠',
96
+ detail: `${v}npx, pinned · gets no updates`,
97
+ hint: 'unpin it: claude mcp remove nonbot && claude mcp add nonbot -- npx -y @nonbot/mcp',
98
+ };
99
+ }
100
+ if (!behind) {
101
+ if (e.kind === 'npx' && !s.installed) {
102
+ return { sigil: '✓', detail: `npx @nonbot/mcp · fetches ${s.latest ? `v${s.latest}` : 'the latest'} on first start` };
103
+ }
104
+ return { sigil: '✓', detail: `${v}${how}` };
105
+ }
106
+ const out = `v${s.installed} · ${shortKind(e.kind)} · v${s.latest} is out`;
107
+ switch (e.kind) {
108
+ case 'npx':
109
+ return { sigil: 'ℹ', detail: out, hint: 'restart Claude Code (or /mcp → Reconnect) and npx fetches it' };
110
+ case 'global':
111
+ return { sigil: '⚠', detail: out, hint: 'update it: nonbot update mcp' };
112
+ case 'checkout':
113
+ return {
114
+ sigil: 'ℹ',
115
+ detail: out,
116
+ hint: `update the checkout: cd ${e.checkoutDir ? tildePath(e.checkoutDir, home) : '<checkout>'} && git pull && npm run build`,
117
+ };
118
+ default:
119
+ return { sigil: 'ℹ', detail: out };
120
+ }
121
+ }
122
+ function shortKind(kind) {
123
+ return kind === 'npx' ? 'npx @nonbot/mcp' : kind === 'checkout' ? 'source checkout' : kind === 'global' ? 'global install' : kind;
124
+ }
125
+ function tildePath(p, home) {
126
+ return p.startsWith(home) ? '~' + p.slice(home.length) : p;
127
+ }
@@ -144,11 +144,40 @@ const WORDMARK_LINES = [
144
144
  export const WORDMARK_WIDTH = 63;
145
145
  export const WORDMARK_HEIGHT = 6;
146
146
  export const WORDMARK_DOT_COLS = [31, 32, 33];
147
+ export const WORDMARK_GRADIENT_STOPS = [
148
+ [0x5e, 0xd8, 0xf0],
149
+ [0x6c, 0x8c, 0xff],
150
+ [0x9b, 0x6c, 0xf0],
151
+ [0xe0, 0x7a, 0xd8],
152
+ ];
153
+ const WORDMARK_SHEEN = [0.24, 0.15, 0.07, 0, 0, 0];
154
+ const WORDMARK_SHADOW = 0.55;
155
+ function lerp(a, b, t) {
156
+ return a + (b - a) * t;
157
+ }
158
+ export function wordmarkCellColor(col, row, shadow = false) {
159
+ const span = WORDMARK_WIDTH - 1 + (WORDMARK_HEIGHT - 1) * 1.5;
160
+ const t = Math.min(1, Math.max(0, (col + row * 1.5) / span));
161
+ const stops = WORDMARK_GRADIENT_STOPS;
162
+ const seg = Math.min(stops.length - 2, Math.floor(t * (stops.length - 1)));
163
+ const local = t * (stops.length - 1) - seg;
164
+ const a = stops[seg];
165
+ const b = stops[seg + 1];
166
+ let rgb = [lerp(a[0], b[0], local), lerp(a[1], b[1], local), lerp(a[2], b[2], local)];
167
+ if (shadow) {
168
+ rgb = [rgb[0] * WORDMARK_SHADOW, rgb[1] * WORDMARK_SHADOW, rgb[2] * WORDMARK_SHADOW];
169
+ }
170
+ else {
171
+ const sheen = WORDMARK_SHEEN[row] ?? 0;
172
+ rgb = [lerp(rgb[0], 255, sheen), lerp(rgb[1], 255, sheen), lerp(rgb[2], 255, sheen)];
173
+ }
174
+ return [Math.round(rgb[0]), Math.round(rgb[1]), Math.round(rgb[2])];
175
+ }
147
176
  function hexFg(hex) {
148
177
  const n = parseInt(hex.slice(1), 16);
149
178
  return `\x1b[38;2;${(n >> 16) & 255};${(n >> 8) & 255};${n & 255}m`;
150
179
  }
151
- function paintWordmarkLine(line, stream = process.stdout) {
180
+ function paintWordmarkLine(line, row, stream = process.stdout) {
152
181
  if (!isTTY(stream))
153
182
  return line;
154
183
  const chars = Array.from(line);
@@ -159,11 +188,16 @@ function paintWordmarkLine(line, stream = process.stdout) {
159
188
  out += ' ';
160
189
  continue;
161
190
  }
162
- const colour = WORDMARK_DOT_COLS.includes(i) ? BRAND.blue : BRAND.cream;
163
- out += BRAND.bold + colour + ch + BRAND.reset;
191
+ const [r, g, b] = wordmarkCellColor(i, row, ch !== '█');
192
+ out += `${BRAND.bold}\x1b[38;2;${r};${g};${b}m${ch}${BRAND.reset}`;
164
193
  }
165
194
  return out;
166
195
  }
196
+ export function wordmarkArt(opts = {}) {
197
+ const stream = opts.stream ?? process.stdout;
198
+ const indent = opts.indent ?? '';
199
+ return WORDMARK_LINES.map((line, row) => indent + paintWordmarkLine(line, row, stream)).join('\n');
200
+ }
167
201
  export function terminalWidth(width) {
168
202
  const cols = width ?? (process.stdout && process.stdout.columns) ?? 80;
169
203
  const min = WORDMARK_WIDTH + 6;
@@ -180,7 +214,7 @@ function trackedCaps(s) {
180
214
  export function wordmark(version, opts = {}) {
181
215
  const stream = opts.stream ?? process.stdout;
182
216
  const state = opts.state;
183
- const headLines = WORDMARK_LINES.map((line) => paintWordmarkLine(line, stream));
217
+ const headLines = WORDMARK_LINES.map((line, row) => paintWordmarkLine(line, row, stream));
184
218
  const parts = [c.cyan(trackedCaps('DAEMON'), stream)];
185
219
  if (version) {
186
220
  parts.push(c.muted('·', stream));
@@ -207,7 +241,7 @@ export function renderWordmarkWithBadge(opts = {}) {
207
241
  red: (s) => c.red(s, stream),
208
242
  muted: (s) => c.muted(s, stream),
209
243
  };
210
- const lines = WORDMARK_LINES.map((wm) => paintWordmarkLine(wm, stream));
244
+ const lines = WORDMARK_LINES.map((wm, row) => paintWordmarkLine(wm, row, stream));
211
245
  lines.push('');
212
246
  lines.push(` ${dotColored[color]('●')} ${dotColored[color](label)}`);
213
247
  for (const s of sublines) {
@@ -360,7 +394,7 @@ export function compactMasthead(version, opts = {}) {
360
394
  const badgeColor = opts.badgeColor ?? 'green';
361
395
  const badgeLabel = opts.badgeLabel ?? 'CONNECTED';
362
396
  const dotGlyph = asciiOnly() ? '*' : '●';
363
- const brand = `${c.bold(c.cream('non.bot', stream), stream)}${c.blue('.', stream)}`;
397
+ const brand = c.bold(c.cream('non.bot', stream), stream);
364
398
  const badge = colorize(`${dotGlyph} ${badgeLabel}`, badgeColor, stream);
365
399
  const sep = c.muted('·', stream);
366
400
  const tail = [c.muted(`daemon ${version}`, stream), sep, c.muted(state, stream)].join(' ');
@@ -35,7 +35,7 @@ export function isNewerVersion(latest, current) {
35
35
  return compareVersions(latest, current) > 0;
36
36
  }
37
37
  export function updateAvailableLine(current, latest) {
38
- return `Update available: @nonbot/cli ${current} → ${latest} (npm i -g @nonbot/cli)`;
38
+ return `Update available: @nonbot/cli ${current} → ${latest} (run: nonbot update)`;
39
39
  }
40
40
  export async function fetchLatestVersion(opts = {}) {
41
41
  const fetchImpl = opts.fetchImpl ?? fetch;
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const VERSION = '0.10.14';
1
+ export const VERSION = '0.10.15';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nonbot/cli",
3
- "version": "0.10.14",
3
+ "version": "0.10.15",
4
4
  "type": "module",
5
5
  "description": "The local host for non.bot ▶ Run — opens a terminal on your machine and starts the work in your linked repo.",
6
6
  "license": "UNLICENSED",