@nonbot/cli 0.10.14 → 0.10.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,25 @@
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.16 (2026-11-11)
7
+
8
+ - New: `nonbot mcp` shows the non.bot MCP in one place: its version and what's new since, every Claude app and config file it's set up in (and duplicates), a live start with its tool count by group, how much of each conversation its tool list uses, which tools your apps switch off, the token it uses (last four characters only), whether that token is valid and what it may do, and which tools Claude runs without asking. `--quick` skips the live start.
9
+ - Typing `nonbot` alone now has a **help** option under update: click it (or press h) and the full command list prints right below; any other key exits. Outside a real terminal it never waits. `nonbot help` also works.
10
+ - `nonbot doctor` opens with the gradient wordmark and reads as one tidy grid. It now starts the MCP too and shows the result ("170 tools in 281ms"), lists retired MCP servers on one row with `nonbot mcp clean`, and points at `nonbot mcp` for the full check.
11
+ - A project's own `.mcp.json` is never started until you approve it once (`nonbot mcp test --include-project` shows the exact command and asks). The approval is remembered for that exact command only, so a repo that later changes it has to be approved again.
12
+ - New: `nonbot mcp test` starts every configured copy and checks it answers; `nonbot mcp add` registers it in Claude Code; `nonbot mcp clean` removes retired MCP servers from the files they're in (it asks first, and keeps a backup of Claude Desktop's config).
13
+
14
+ ## 0.10.15 (2026-11-11)
15
+
16
+ - New: `nonbot logout` signs you out on this machine and stops the daemon.
17
+ - New: `nonbot update` installs the latest version. The daemon's update notice now points at it.
18
+ - 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.
19
+ - `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".
20
+ - `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).
21
+ - 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.
22
+ - 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.
23
+ - 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.
24
+
6
25
  ## 0.10.14 (2026-11-11)
7
26
 
8
27
  - The README now lists what the daemon sends from your machine and who can read it.
package/LICENSE CHANGED
@@ -6,24 +6,35 @@ This software (the "@nonbot/cli" package and all of its contents, the
6
6
  "Software") is the proprietary and confidential property of non.bot. It is
7
7
  published to the npm registry for the sole purpose of allowing authorized
8
8
  non.bot account holders to install and run it as the local host for the
9
- non.bot "Run" feature.
9
+ non.bot "Run" feature. Publication on the npm registry, or on any mirror, cache
10
+ or CDN that copies it, does not make the Software open source, does not place
11
+ it in the public domain, and grants no right beyond the limited license below.
10
12
 
11
13
  PERMITTED USE
12
14
 
13
- Subject to your compliance with the non.bot Terms of Service, you are granted
14
- a limited, non-exclusive, non-transferable, revocable license to download,
15
- install, and execute unmodified copies of the Software on machines you control,
16
- solely to connect to and operate the non.bot service.
15
+ Subject to your compliance with the non.bot Terms of Service
16
+ (https://non.bot/terms-of-service), you are granted a limited, non-exclusive,
17
+ non-transferable, non-sublicensable, revocable license to download, install,
18
+ and execute unmodified copies of the Software on machines you control, solely
19
+ to connect to and operate the non.bot service. This license ends automatically
20
+ if you breach it or the Terms of Service, or when your non.bot account ends;
21
+ you must then stop using the Software and delete every copy of it.
17
22
 
18
23
  PROHIBITED WITHOUT PRIOR WRITTEN PERMISSION FROM non.bot
19
24
 
20
- - Copying, redistributing, sublicensing, selling, or publishing the Software
21
- or any portion of it;
25
+ - Copying, redistributing, sublicensing, selling, renting, or publishing the
26
+ Software or any portion of it;
22
27
  - Modifying, adapting, translating, or creating derivative works;
23
- - Reverse-engineering, decompiling, or disassembling the Software except to
24
- the limited extent such restriction is prohibited by applicable law;
25
- - Using the Software, or any technique, method, or design embodied in it, to
26
- build, train, or operate a competing product or service.
28
+ - Reverse-engineering, decompiling, or disassembling the Software, except to
29
+ the limited extent applicable law expressly permits despite this
30
+ restriction;
31
+ - Using the Software, or any technique, method, prompt, or design embodied in
32
+ it, to build, train, or operate a competing product or service;
33
+ - Using the Software, or any part or output of it, to train, fine-tune,
34
+ evaluate, or otherwise improve any machine-learning or artificial-
35
+ intelligence model, dataset, or system;
36
+ - Removing, obscuring, or altering any copyright, license, or proprietary
37
+ notice.
27
38
 
28
39
  NO PATENT OR OTHER IP LICENSE
29
40
 
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,19 @@ 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
81
+ nonbot mcp The non.bot MCP: version, where it's set up, a live start, tools,
82
+ your token's permissions (add --quick to skip starting it)
83
+ nonbot mcp test Start every configured copy of the MCP and check it answers
84
+ nonbot mcp add Register the MCP in Claude Code (claude mcp add … @nonbot/mcp)
85
+ nonbot mcp clean Remove retired MCP servers (portfolio-mcp, context-graph, jobshop-mcp)
80
86
  nonbot logs Show recent local Run launches
87
+ nonbot update Install the latest version (npm i -g @nonbot/cli@latest)
88
+ nonbot update mcp Show the MCP's version and update it
81
89
  nonbot profiles List configured accounts
82
90
  nonbot choir <session> Coordinated-run engine — one agent per git worktree, merged back by hand
83
91
  nonbot run-mcp Per-pane MCP server over stdio (launched by .mcp.json, not by hand)
@@ -95,7 +103,7 @@ nonbot --version Print the version
95
103
  | `--profile <name>` | Use a named account instead of the default (works anywhere in the command) |
96
104
  | `daemon --install` / `--uninstall` | Run the daemon automatically at login (macOS launchd / Linux systemd user unit; not available on Windows) |
97
105
  | `daemon --cloud` | Containerized runner mode — see **Cloud runners** below |
98
- | `daemon stop` | Stop the local daemon; `--machine <id>` / `--all` stop daemons on other machines |
106
+ | `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
107
 
100
108
  ## Cost ceilings
101
109
 
@@ -133,7 +141,7 @@ daemon asks the npm registry for the latest `@nonbot/cli`. If it is newer than t
133
141
  running, it prints a single line:
134
142
 
135
143
  ```
136
- Update available: @nonbot/cli 0.9.13 → 0.9.14 (npm i -g @nonbot/cli)
144
+ Update available: @nonbot/cli 0.9.13 → 0.9.14 (run: nonbot update)
137
145
  ```
138
146
 
139
147
  The probe has a 3-second timeout and any failure is silent. Set
@@ -181,6 +189,13 @@ are redacted. Redaction is pattern-based; treat a snapshot as terminal output.
181
189
  saved end-of-run output in the local history (`nonbot logs`). A cloud runner
182
190
  (`--cloud`) additionally registers its machine id and its allowed repository origins.
183
191
 
192
+ **When you run `nonbot mcp` or `nonbot update mcp`:** your token goes to non.bot to
193
+ check it is valid and read its permissions and which apps are on; the CLI asks the npm
194
+ registry for `@nonbot/mcp`'s versions and fetches the mcp.non.bot home page to read
195
+ its version. `nonbot mcp` (not `--quick`) also starts the MCP the way Claude does,
196
+ which makes the MCP's own requests to non.bot. The token is shown only as its last
197
+ four characters.
198
+
184
199
  To show a neutral name instead of the host name, set `NONBOT_MACHINE_NAME` before
185
200
  starting the daemon (up to 64 characters):
186
201
 
@@ -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,19 @@ 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, LEGACY_MCP_NAMES } from '../lib/mcp-config.js';
7
+ import { nonbotMcpStatuses, mcpRow } from '../lib/mcp-status.js';
8
+ import { launchSpecOf } from '../lib/mcp-config.js';
9
+ import { probeMcp } from '../lib/mcp-probe.js';
10
+ import { mayStartEntry } from '../lib/mcp-trust.js';
6
11
  import { resolveTerminal } from '../lib/terminal.js';
7
12
  import { VERSION } from '../version.js';
8
- import { header, statusRow, errorBlock } from '../lib/output.js';
13
+ import { header, statusRow, errorBlock, isTTY, ANSI, wordmarkArt, WORDMARK_WIDTH } from '../lib/output.js';
9
14
  const HEARTBEAT_FRESH_MS = 35_000;
10
15
  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
- ];
16
+ export const NONBOT_MCP_ADD_HINT = 'claude mcp add nonbot -- npx -y @nonbot/mcp';
17
+ const LEGACY_MCPS = LEGACY_MCP_NAMES;
38
18
  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
19
  function isSkillInstalled(skillName) {
67
20
  const base = join(homedir(), '.claude');
68
21
  const candidates = [];
@@ -136,7 +89,12 @@ function probe(cmd, args, interpret = (out) => ({
136
89
  function defaultCheckBinary(bin) {
137
90
  if (bin.startsWith('mcp:')) {
138
91
  const name = bin.slice('mcp:'.length);
139
- return { installed: readConfiguredMcpNames().has(name) };
92
+ const scan = scanMcpConfigs();
93
+ if (name === 'nonbot') {
94
+ const first = scan.nonbot[0];
95
+ return first ? { installed: true, version: describeNonbotMcp(first) } : { installed: false };
96
+ }
97
+ return scan.names.has(name) ? { installed: true, version: scan.where.get(name) } : { installed: false };
140
98
  }
141
99
  if (bin.startsWith('skill:')) {
142
100
  const name = bin.slice('skill:'.length);
@@ -160,6 +118,15 @@ function defaultCheckBinary(bin) {
160
118
  return { installed: false };
161
119
  }
162
120
  }
121
+ export function bareVersion(raw) {
122
+ if (!raw)
123
+ return '';
124
+ const first = raw.split('\n')[0].trim();
125
+ const m = first.match(/\d+(?:\.\d+)+[0-9A-Za-z.+-]*/);
126
+ if (m)
127
+ return m[0];
128
+ return first.length > 40 ? first.slice(0, 39) + '…' : first;
129
+ }
163
130
  export async function runDoctorCommand(args = [], deps = {}) {
164
131
  void args;
165
132
  const log = deps.log ?? ((s) => process.stdout.write(s));
@@ -169,46 +136,50 @@ export async function runDoctorCommand(args = [], deps = {}) {
169
136
  const now = deps.now ?? (() => Date.now());
170
137
  const checkBinary = deps.checkBinary ?? defaultCheckBinary;
171
138
  let exitCode = 0;
172
- log(header('non.bot doctor', `v${VERSION} · ${process.platform}/${process.arch}`) + '\n');
173
- log('\n');
139
+ const row = (sig, label, detail = '') => log(statusRow(sig, label, detail, { labelWidth: LABEL_WIDTH }) + '\n');
140
+ const errRow = (sig, label, detail = '') => errLog(statusRow(sig, label, detail, { labelWidth: LABEL_WIDTH, stream: process.stderr }) + '\n');
141
+ const hint = (text) => log(' '.repeat(HINT_INDENT) + paintDim(text) + '\n');
142
+ const section = (title) => log('\n' + paintSection(title) + '\n');
143
+ const cols = deps.columns ?? process.stdout.columns ?? 0;
144
+ const sub = `doctor · v${VERSION} · ${process.platform}/${process.arch}`;
145
+ if (cols === 0 || cols >= WORDMARK_WIDTH + 4) {
146
+ log('\n' + wordmarkArt({ indent: ' ' }) + '\n\n');
147
+ log(` ${paintDim(sub)}\n`);
148
+ }
149
+ else {
150
+ log(header('non.bot doctor', `v${VERSION} · ${process.platform}/${process.arch}`) + '\n');
151
+ }
174
152
  const auth = await loader();
175
153
  if (!auth) {
154
+ log('\n');
176
155
  log(errorBlock('Not logged in', 'Run `nonbot login` to authenticate.'));
177
156
  return 1;
178
157
  }
179
- log(statusRow('✓', 'Auth', `logged in as ${auth.email}`) + '\n');
180
- let serverOk = false;
158
+ section('ACCOUNT');
159
+ row('✓', 'Signed in', auth.email);
160
+ const headers = {
161
+ Authorization: `Bearer ${auth.pat}`,
162
+ 'X-Requested-With': 'ConradPM-Native',
163
+ 'X-CLI-Version': VERSION,
164
+ };
181
165
  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
- });
166
+ const res = await fetchImpl(`${auth.baseUrl}/api/auth/me`, { headers });
189
167
  if (res.ok) {
190
- serverOk = true;
191
- log(statusRow('✓', 'Server reachable', `${auth.baseUrl} · PAT valid`) + '\n');
168
+ row('✓', 'Server', `${auth.baseUrl.replace(/^https?:\/\//, '')} · token accepted`);
192
169
  }
193
170
  else if (res.status === 401) {
194
- errLog(statusRow('✗', 'Server', `PAT rejected (HTTP 401) — re-run: nonbot login`, { stream: process.stderr }) + '\n');
171
+ errRow('✗', 'Server', 'token rejected (HTTP 401) — sign in again: nonbot login');
195
172
  exitCode = 1;
196
173
  }
197
174
  else {
198
- log(statusRow('⚠', 'Server', `returned HTTP ${res.status} for /api/auth/me`) + '\n');
175
+ row('⚠', 'Server', `${auth.baseUrl} answered HTTP ${res.status}`);
199
176
  }
200
177
  }
201
178
  catch (e) {
202
- log(statusRow('⚠', 'Server', `Could not reach ${auth.baseUrl}: ${e.message}`) + '\n');
179
+ row('⚠', 'Server', `Could not reach ${auth.baseUrl} (${e.message})`);
203
180
  }
204
181
  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
- });
182
+ const res = await fetchImpl(`${auth.baseUrl}/api/cli/daemon-status`, { headers });
212
183
  if (res.ok) {
213
184
  const body = (await res.json());
214
185
  const last = body.lastSeenAt ?? body.lastHeartbeatAt;
@@ -216,86 +187,172 @@ export async function runDoctorCommand(args = [], deps = {}) {
216
187
  (typeof last === 'number' && now() - last <= HEARTBEAT_FRESH_MS);
217
188
  if (fresh) {
218
189
  const detail = typeof last === 'number'
219
- ? `last heartbeat ${Math.round((now() - last) / 1000)}s ago`
220
- : 'connected';
221
- log(statusRow('✓', 'Daemon running', detail) + '\n');
190
+ ? `running · last heartbeat ${Math.round((now() - last) / 1000)}s ago`
191
+ : 'running';
192
+ row('✓', 'Daemon', detail);
222
193
  }
223
194
  else {
224
- log(statusRow('✗', 'Daemon not running', 'no recent heartbeat — start with: nonbot daemon') + '\n');
195
+ row('✗', 'Daemon', 'not running — start it: nonbot daemon');
225
196
  }
226
197
  }
227
198
  else if (res.status === 401) {
228
- log(statusRow('⚠', 'Daemon status unknown', '(auth rejected)') + '\n');
199
+ row('⚠', 'Daemon', 'status unknown (auth rejected)');
200
+ }
201
+ else if (res.status === 403) {
202
+ let gate = {};
203
+ try {
204
+ gate = (await res.json());
205
+ }
206
+ catch {
207
+ }
208
+ const storeUrl = `${auth.baseUrl}/whygraph/store?tab=apps&app=${gate.app ?? 'run'}`;
209
+ if (gate.error === 'app_disabled') {
210
+ row('✗', 'Run app', 'switched off — the daemon gets no runs');
211
+ hint(`turn it on: ${storeUrl}`);
212
+ }
213
+ else if (gate.error === 'app_not_installed') {
214
+ row('✗', 'Run app', 'not added — the daemon gets no runs until it is');
215
+ hint(`add it: ${storeUrl}`);
216
+ }
217
+ else {
218
+ row('⚠', 'Daemon', 'status unknown (HTTP 403)');
219
+ }
229
220
  }
230
221
  else {
231
- log(statusRow('⚠', 'Daemon status unknown', `(HTTP ${res.status})`) + '\n');
222
+ row('⚠', 'Daemon', `status unknown (HTTP ${res.status})`);
232
223
  }
233
224
  }
234
225
  catch (e) {
235
- log(statusRow('⚠', 'Daemon status unknown', e.message) + '\n');
226
+ row('⚠', 'Daemon', `status unknown (${e.message})`);
236
227
  }
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
- }
228
+ section('AGENTS');
229
+ const providers = PROVIDER_CLIS.map((bin) => ({ bin, r: checkBinary(bin) }));
230
+ const anyProvider = providers.some((p) => p.r.installed);
231
+ const ordered = [...providers.filter((p) => p.r.installed), ...providers.filter((p) => !p.r.installed)];
232
+ for (const { bin, r } of ordered) {
233
+ if (r.installed)
234
+ row('✓', bin, bareVersion(r.version));
235
+ else if (anyProvider)
236
+ row('ℹ', bin, 'not installed (optional)');
237
+ else
238
+ row('⚠', bin, 'not on PATH');
245
239
  }
240
+ if (!anyProvider)
241
+ hint('Runs need one of these — install Claude Code: npm i -g @anthropic-ai/claude-code');
242
+ section('TERMINAL');
246
243
  const profile = resolveTerminal(undefined);
247
244
  const { cmd } = profile.launch('/tmp/nonbot-doctor-probe.sh');
248
245
  const launcher = checkBinary(cmd);
249
246
  if (launcher.installed) {
250
- log(statusRow('✓', 'Terminal', `${profile.displayName} (launches via "${cmd}")`) + '\n');
247
+ row('✓', 'Opens runs in', `${profile.displayName} (via ${cmd})`);
251
248
  }
252
249
  else {
253
- log(statusRow('⚠', 'Terminal', `${profile.displayName} — launcher "${cmd}" not found on PATH`) + '\n');
250
+ row('⚠', 'Terminal', `${profile.displayName} — launcher "${cmd}" not found on PATH`);
254
251
  }
255
252
  if (process.platform === 'win32') {
256
253
  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
- }
254
+ if (bash.installed)
255
+ row('✓', 'bash', 'found · Windows via WSL or Git Bash — runs are #!/bin/bash');
256
+ else
257
+ row('⚠', 'bash', 'not found · Windows via WSL or Git Bash — runs are #!/bin/bash');
263
258
  }
264
259
  if (process.platform === 'darwin') {
265
260
  const running = checkBinary('iterm-running');
266
- if (running.installed) {
267
- log(statusRow('✓', 'iTerm running', '') + '\n');
261
+ if (!running.installed) {
262
+ row('ℹ', 'iTerm', 'not running — only needed if runs open in iTerm');
268
263
  }
269
264
  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');
275
- }
276
- 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');
265
+ const appl = checkBinary('iterm-applescript');
266
+ if (appl.installed) {
267
+ const v = bareVersion(appl.version);
268
+ row('✓', 'iTerm', v ? `${v} · AppleScript OK` : 'AppleScript OK');
269
+ }
270
+ else {
271
+ row('⚠', 'iTerm', appl.version ? `AppleScript blocked: ${appl.version}` : 'AppleScript blocked');
272
+ hint('System Settings → Privacy & Security → Automation → allow your terminal to control iTerm');
273
+ }
279
274
  }
280
275
  }
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');
276
+ section('MCP');
277
+ const statusSource = deps.nonbotMcp ?? (deps.checkBinary ? null : () => nonbotMcpStatuses({ fetchImpl: deps.fetchImpl }));
278
+ const statuses = statusSource ? await statusSource().catch(() => []) : null;
279
+ const nonbotMcp = statuses ? { installed: statuses.length > 0 } : checkBinary('mcp:nonbot');
280
+ if (statuses && statuses.length > 0) {
281
+ const primary = statuses.find((s) => s.entry.scope && s.entry.scope !== 'desktop') ?? statuses[0];
282
+ const r = mcpRow(primary);
283
+ row(r.sigil, 'Installed', r.detail);
284
+ if (r.hint)
285
+ hint(r.hint);
286
+ const startSource = deps.mcpStart ??
287
+ (deps.checkBinary
288
+ ? null
289
+ : async (entry) => {
290
+ const spec = launchSpecOf(entry);
291
+ if (!spec)
292
+ return { state: 'unknown' };
293
+ if (!mayStartEntry(entry, spec))
294
+ return { state: 'unapproved' };
295
+ return { state: 'started', result: await probeMcp(spec, { fetchImpl: deps.fetchImpl, fallbackBearer: auth.pat, timeoutMs: 20_000 }) };
296
+ });
297
+ if (startSource) {
298
+ const s = await startSource(primary.entry).catch(() => ({ state: 'unknown' }));
299
+ if (s.state === 'started' && s.result.ok) {
300
+ const took = s.result.ms < 1000 ? `${s.result.ms}ms` : `${(s.result.ms / 1000).toFixed(1)}s`;
301
+ const v = s.result.serverVersion;
302
+ const differs = v && primary.installed && v !== primary.installed ? ` · but reports v${v}` : '';
303
+ row(differs ? '⚠' : '✓', 'Starts', `${s.result.tools.length} tools in ${took}${differs}`);
304
+ }
305
+ else if (s.state === 'started') {
306
+ row('✗', 'Starts', s.result.error ?? 'it did not answer');
307
+ hint('see it fail: nonbot mcp test');
308
+ }
309
+ else if (s.state === 'unapproved') {
310
+ row('ℹ', 'Starts', `not started — ${primary.entry.source} is a project file`);
311
+ hint('approve it once: nonbot mcp test --include-project');
312
+ }
313
+ else {
314
+ row('ℹ', 'Starts', 'not checked — could not read how it is launched');
315
+ }
288
316
  }
289
317
  }
318
+ else if (nonbotMcp.installed) {
319
+ const how = nonbotMcp.version ?? 'configured (@nonbot/mcp)';
320
+ row(/pinned/.test(how) ? '⚠' : '✓', 'Installed', how);
321
+ }
322
+ else {
323
+ row('⚠', 'Not set up', 'runs cannot report back without the non.bot MCP');
324
+ hint('add it: nonbot mcp add');
325
+ }
326
+ const legacy = LEGACY_MCPS.map((name) => ({ name, r: checkBinary(`mcp:${name}`) })).filter((l) => l.r.installed);
327
+ if (legacy.length) {
328
+ const files = [...new Set(legacy.map((l) => l.r.version).filter((v) => !!v))];
329
+ row('ℹ', 'Retired', `${legacy.map((l) => l.name).join(', ')}${files.length === 1 ? ` in ${files[0]}` : ''} · remove: nonbot mcp clean`);
330
+ }
290
331
  for (const name of EXPECTED_SKILLS) {
291
332
  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
- }
333
+ if (r.installed)
334
+ row('✓', `Skill ${name}`);
335
+ else
336
+ row('⚠', `Skill ${name}`, 'not installed under ~/.claude/skills/');
298
337
  }
299
- void serverOk;
338
+ section('COMMANDS');
339
+ log(commandRow('nonbot daemon', 'start listening for runs'));
340
+ log(commandRow('Ctrl-C', 'stop it · from another terminal: nonbot daemon stop'));
341
+ log(commandRow('nonbot mcp', 'the full MCP check'));
342
+ log(commandRow('nonbot logout', 'sign out and stop the daemon'));
300
343
  return exitCode;
301
344
  }
345
+ const LABEL_WIDTH = 14;
346
+ const HINT_INDENT = 3 + 1 + 2 + LABEL_WIDTH + 1;
347
+ function paintDim(s) {
348
+ return isTTY(process.stdout) ? `${ANSI.dim}${s}${ANSI.reset}` : s;
349
+ }
350
+ function paintSection(title) {
351
+ return isTTY(process.stdout) ? ` ${ANSI.bold}${title}${ANSI.reset}` : ` ${title}`;
352
+ }
353
+ function commandRow(cmd, what) {
354
+ const padded = cmd.padEnd(LABEL_WIDTH + 1, ' ');
355
+ if (!isTTY(process.stdout))
356
+ return ` ${padded}${what}\n`;
357
+ return ` ${ANSI.cyan}${padded}${ANSI.reset}${ANSI.dim}${what}${ANSI.reset}\n`;
358
+ }
@@ -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
+ }