@nonbot/cli 0.10.14 → 0.10.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,226 @@
1
+ import { spawn as nodeSpawn } from 'node:child_process';
2
+ import { VERSION } from '../version.js';
3
+ import { isNonbotRemoteUrl } from './mcp-config.js';
4
+ export const MCP_PROTOCOL_VERSION = '2025-06-18';
5
+ const INIT_PARAMS = {
6
+ protocolVersion: MCP_PROTOCOL_VERSION,
7
+ capabilities: {},
8
+ clientInfo: { name: 'nonbot-cli', version: VERSION },
9
+ };
10
+ const LOADER_ENV = /^(NODE_OPTIONS|NODE_PATH|BASH_ENV|ENV|PROMPT_COMMAND|DYLD_.*|LD_.*)$/i;
11
+ export function safeConfigEnv(env) {
12
+ const out = {};
13
+ for (const [k, v] of Object.entries(env))
14
+ if (!LOADER_ENV.test(k))
15
+ out[k] = v;
16
+ return out;
17
+ }
18
+ function toolsFrom(result) {
19
+ const r = (result ?? {});
20
+ const tools = Array.isArray(r.tools) ? r.tools.filter((t) => t && typeof t === 'object') : [];
21
+ return { tools, next: typeof r.nextCursor === 'string' && r.nextCursor ? r.nextCursor : undefined };
22
+ }
23
+ function serverInfo(result) {
24
+ const info = (result ?? {}).serverInfo ?? {};
25
+ return {
26
+ name: typeof info.name === 'string' ? info.name.slice(0, 80) : undefined,
27
+ version: typeof info.version === 'string' ? info.version.slice(0, 40) : undefined,
28
+ };
29
+ }
30
+ function finish(started, now, parts) {
31
+ return { ok: false, ms: now() - started, tools: [], toolBytes: 0, ...parts };
32
+ }
33
+ export async function probeStdio(spec, deps = {}) {
34
+ const now = deps.now ?? (() => Date.now());
35
+ const started = now();
36
+ if (!spec.command)
37
+ return finish(started, now, { error: 'no command in the config' });
38
+ const spawnImpl = deps.spawnImpl ?? ((cmd, args, opts) => nodeSpawn(cmd, args, { ...opts, stdio: ['pipe', 'pipe', 'ignore'], shell: process.platform === 'win32' }));
39
+ const timeoutMs = deps.timeoutMs ?? 45_000;
40
+ let child;
41
+ try {
42
+ child = spawnImpl(spec.command, spec.args, { cwd: spec.cwd, env: { ...process.env, ...safeConfigEnv(spec.env) } });
43
+ }
44
+ catch {
45
+ return finish(started, now, { error: `could not start "${spec.command}"` });
46
+ }
47
+ return new Promise((resolve) => {
48
+ let done = false;
49
+ let buf = '';
50
+ let nextId = 1;
51
+ const waiting = new Map();
52
+ const end = (r) => {
53
+ if (done)
54
+ return;
55
+ done = true;
56
+ clearTimeout(timer);
57
+ try {
58
+ child.stdin?.end();
59
+ }
60
+ catch { }
61
+ try {
62
+ child.kill('SIGTERM');
63
+ }
64
+ catch { }
65
+ resolve(r);
66
+ };
67
+ const timer = setTimeout(() => end(finish(started, now, { error: `no answer within ${Math.round(timeoutMs / 1000)}s` })), timeoutMs);
68
+ child.on('error', () => end(finish(started, now, { error: `could not start "${spec.command}"` })));
69
+ child.on('exit', (code) => end(finish(started, now, { error: `it exited (code ${code ?? '?'}) before answering` })));
70
+ child.stdout?.setEncoding('utf-8');
71
+ child.stdout?.on('data', (chunk) => {
72
+ buf += chunk;
73
+ if (buf.length > 8_000_000)
74
+ return end(finish(started, now, { error: 'answer too large' }));
75
+ let nl;
76
+ while ((nl = buf.indexOf('\n')) >= 0) {
77
+ const line = buf.slice(0, nl).trim();
78
+ buf = buf.slice(nl + 1);
79
+ if (!line)
80
+ continue;
81
+ let msg;
82
+ try {
83
+ msg = JSON.parse(line);
84
+ }
85
+ catch {
86
+ continue;
87
+ }
88
+ if (typeof msg.id === 'number' && waiting.has(msg.id)) {
89
+ const cb = waiting.get(msg.id);
90
+ waiting.delete(msg.id);
91
+ cb(msg);
92
+ }
93
+ }
94
+ });
95
+ const send = (method, params) => new Promise((res) => {
96
+ const id = nextId++;
97
+ waiting.set(id, res);
98
+ child.stdin?.write(JSON.stringify({ jsonrpc: '2.0', id, method, ...(params ? { params } : {}) }) + '\n');
99
+ });
100
+ const notify = (method) => child.stdin?.write(JSON.stringify({ jsonrpc: '2.0', method }) + '\n');
101
+ (async () => {
102
+ const init = await send('initialize', INIT_PARAMS);
103
+ if (init.error)
104
+ return end(finish(started, now, { error: 'it refused the handshake' }));
105
+ const info = serverInfo(init.result);
106
+ notify('notifications/initialized');
107
+ const all = [];
108
+ let cursor;
109
+ for (let page = 0; page < 20; page++) {
110
+ const res = await send('tools/list', cursor ? { cursor } : undefined);
111
+ if (res.error)
112
+ return end(finish(started, now, { serverName: info.name, serverVersion: info.version, error: 'tools/list failed' }));
113
+ const { tools, next } = toolsFrom(res.result);
114
+ all.push(...tools);
115
+ if (!next)
116
+ break;
117
+ cursor = next;
118
+ }
119
+ end({
120
+ ok: true,
121
+ ms: now() - started,
122
+ serverName: info.name,
123
+ serverVersion: info.version,
124
+ tools: all.map((t) => ({ name: String(t.name ?? '') })).filter((t) => t.name),
125
+ toolBytes: Buffer.byteLength(JSON.stringify({ tools: all })),
126
+ });
127
+ })().catch(() => end(finish(started, now, { error: 'unexpected answer' })));
128
+ });
129
+ }
130
+ export async function probeRemote(spec, deps = {}) {
131
+ const now = deps.now ?? (() => Date.now());
132
+ const fetchImpl = deps.fetchImpl ?? fetch;
133
+ const started = now();
134
+ if (!spec.url)
135
+ return finish(started, now, { error: 'no url in the config' });
136
+ const headers = {
137
+ 'Content-Type': 'application/json',
138
+ Accept: 'application/json, text/event-stream',
139
+ ...spec.headers,
140
+ };
141
+ if (!Object.keys(headers).some((k) => k.toLowerCase() === 'authorization') && deps.fallbackBearer && isNonbotRemoteUrl(spec.url)) {
142
+ headers.Authorization = `Bearer ${deps.fallbackBearer}`;
143
+ }
144
+ let nextId = 1;
145
+ const call = async (method, params, isNotification = false) => {
146
+ const body = isNotification
147
+ ? { jsonrpc: '2.0', method }
148
+ : { jsonrpc: '2.0', id: nextId++, method, ...(params ? { params } : {}) };
149
+ const res = await fetchImpl(spec.url, {
150
+ method: 'POST',
151
+ headers,
152
+ body: JSON.stringify(body),
153
+ redirect: 'manual',
154
+ signal: AbortSignal.timeout(deps.timeoutMs ?? 15_000),
155
+ });
156
+ const session = res.headers.get('mcp-session-id');
157
+ if (session)
158
+ headers['Mcp-Session-Id'] = session;
159
+ if (isNotification)
160
+ return null;
161
+ if (res.status === 401 || res.status === 403)
162
+ throw new Error(`auth ${res.status}`);
163
+ if (!res.ok)
164
+ throw new Error(`HTTP ${res.status}`);
165
+ const text = await res.text();
166
+ if ((res.headers.get('content-type') ?? '').includes('text/event-stream')) {
167
+ for (const line of text.split('\n')) {
168
+ if (!line.startsWith('data:'))
169
+ continue;
170
+ try {
171
+ const msg = JSON.parse(line.slice(5).trim());
172
+ if (msg.result !== undefined || msg.error)
173
+ return msg;
174
+ }
175
+ catch { }
176
+ }
177
+ return { error: { message: 'no result' } };
178
+ }
179
+ return JSON.parse(text);
180
+ };
181
+ try {
182
+ const init = await call('initialize', INIT_PARAMS);
183
+ if (!init || init.error)
184
+ return finish(started, now, { error: 'it refused the handshake' });
185
+ const info = serverInfo(init.result);
186
+ await call('notifications/initialized', undefined, true).catch(() => null);
187
+ const all = [];
188
+ let cursor;
189
+ for (let page = 0; page < 20; page++) {
190
+ const res = await call('tools/list', cursor ? { cursor } : undefined);
191
+ if (!res || res.error)
192
+ return finish(started, now, { serverName: info.name, serverVersion: info.version, error: 'tools/list failed' });
193
+ const { tools, next } = toolsFrom(res.result);
194
+ all.push(...tools);
195
+ if (!next)
196
+ break;
197
+ cursor = next;
198
+ }
199
+ return {
200
+ ok: true,
201
+ ms: now() - started,
202
+ serverName: info.name,
203
+ serverVersion: info.version,
204
+ tools: all.map((t) => ({ name: String(t.name ?? '') })).filter((t) => t.name),
205
+ toolBytes: Buffer.byteLength(JSON.stringify({ tools: all })),
206
+ };
207
+ }
208
+ catch (e) {
209
+ const m = e.message;
210
+ return finish(started, now, { error: /^auth/.test(m) ? 'the server refused the token' : /^HTTP/.test(m) ? `the server answered ${m}` : 'could not reach it' });
211
+ }
212
+ }
213
+ export function probeMcp(spec, deps = {}) {
214
+ return spec.url && !spec.command ? probeRemote(spec, deps) : probeStdio(spec, deps);
215
+ }
216
+ export function toolGroups(tools) {
217
+ const counts = new Map();
218
+ for (const t of tools) {
219
+ const g = t.name.includes('_') ? t.name.slice(0, t.name.indexOf('_')) : t.name;
220
+ counts.set(g, (counts.get(g) ?? 0) + 1);
221
+ }
222
+ return [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
223
+ }
224
+ export function estimateTokens(bytes) {
225
+ return Math.round(bytes / 4);
226
+ }
@@ -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
+ }
@@ -0,0 +1,63 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { existsSync, mkdirSync, readFileSync, realpathSync, renameSync, writeFileSync, chmodSync } from 'node:fs';
3
+ import { homedir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ function configDir() {
6
+ const override = process.env.NONBOT_CONFIG_DIR;
7
+ return override && override.length > 0 ? override : join(homedir(), '.config', 'nonbot');
8
+ }
9
+ export function trustFilePath(dir = configDir()) {
10
+ return join(dir, 'mcp-trust.json');
11
+ }
12
+ function realDir(dir) {
13
+ try {
14
+ return realpathSync(dir);
15
+ }
16
+ catch {
17
+ return dir;
18
+ }
19
+ }
20
+ export function launchFingerprint(spec) {
21
+ const env = Object.keys(spec.env).sort().map((k) => [k, spec.env[k]]);
22
+ const payload = JSON.stringify({ command: spec.command ?? null, args: spec.args, url: spec.url ?? null, env, cwd: realDir(spec.cwd) });
23
+ return createHash('sha256').update(payload).digest('hex');
24
+ }
25
+ function readRecords(file) {
26
+ try {
27
+ if (!existsSync(file))
28
+ return [];
29
+ const json = JSON.parse(readFileSync(file, 'utf-8'));
30
+ return Array.isArray(json.approved)
31
+ ? json.approved.filter((r) => r && typeof r === 'object' && typeof r.fingerprint === 'string')
32
+ : [];
33
+ }
34
+ catch {
35
+ return [];
36
+ }
37
+ }
38
+ export function isRememberedTrust(entry, spec, file = trustFilePath()) {
39
+ if (!entry.dir)
40
+ return false;
41
+ const dir = realDir(entry.dir);
42
+ const fp = launchFingerprint(spec);
43
+ return readRecords(file).some((r) => r.dir === dir && r.name === entry.name && r.fingerprint === fp);
44
+ }
45
+ export function mayStartEntry(entry, spec, isRemembered = (e, s) => isRememberedTrust(e, s)) {
46
+ if (entry.approved !== false)
47
+ return true;
48
+ return isRemembered(entry, spec);
49
+ }
50
+ export function rememberTrust(entry, spec, file = trustFilePath(), now = new Date()) {
51
+ if (!entry.dir)
52
+ return;
53
+ const dir = realDir(entry.dir);
54
+ const records = readRecords(file).filter((r) => !(r.dir === dir && r.name === entry.name));
55
+ records.push({ dir, name: entry.name, fingerprint: launchFingerprint(spec), approvedAt: now.toISOString() });
56
+ const parent = join(file, '..');
57
+ mkdirSync(parent, { recursive: true, mode: 0o700 });
58
+ const tmp = `${file}.tmp`;
59
+ writeFileSync(tmp, JSON.stringify({ approved: records.slice(-200) }, null, 2) + '\n', { mode: 0o600 });
60
+ chmodSync(tmp, 0o600);
61
+ renameSync(tmp, file);
62
+ chmodSync(file, 0o600);
63
+ }
@@ -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(' ');
@@ -0,0 +1,86 @@
1
+ const MOUSE_ON = '\x1b[?1000h\x1b[?1006h';
2
+ const MOUSE_OFF = '\x1b[?1000l\x1b[?1006l';
3
+ const ASK_CURSOR = '\x1b[6n';
4
+ const CURSOR_REPORT = /\x1b\[(\d+);(\d+)R/;
5
+ const MOUSE_SGR = /\x1b\[<(\d+);(\d+);(\d+)([Mm])/g;
6
+ export function interpretInput(chunk, state, linesBelowHelp) {
7
+ let rest = chunk;
8
+ const cur = CURSOR_REPORT.exec(rest);
9
+ if (cur) {
10
+ state.helpRow = Number(cur[1]) - linesBelowHelp;
11
+ rest = rest.replace(CURSOR_REPORT, '');
12
+ }
13
+ let sawMouse = false;
14
+ for (const m of rest.matchAll(MOUSE_SGR)) {
15
+ sawMouse = true;
16
+ const button = Number(m[1]);
17
+ const y = Number(m[3]);
18
+ const press = m[4] === 'M';
19
+ if (press && button === 0 && state.helpRow !== null && y === state.helpRow)
20
+ return 'help';
21
+ }
22
+ rest = rest.replace(MOUSE_SGR, '');
23
+ if (rest === '')
24
+ return null;
25
+ if (sawMouse && rest.trim() === '')
26
+ return null;
27
+ if (/^[hH][\r\n]*$/.test(rest))
28
+ return 'help';
29
+ return 'exit';
30
+ }
31
+ export function linesBelow(printed, marker) {
32
+ const at = printed.indexOf(marker);
33
+ if (at < 0)
34
+ return 0;
35
+ const lineStart = printed.lastIndexOf('\n', at) + 1;
36
+ return (printed.slice(lineStart).match(/\n/g) ?? []).length;
37
+ }
38
+ export function menuIsInteractive(io, env = process.env) {
39
+ if (!io.stdin.isTTY || !io.stdout.isTTY)
40
+ return false;
41
+ if (env.CI || env.NONBOT_NO_MENU === '1')
42
+ return false;
43
+ return typeof io.stdin.setRawMode === 'function';
44
+ }
45
+ export function waitForMenuChoice(io, linesBelowHelp) {
46
+ const { stdin, stdout } = io;
47
+ const state = { helpRow: null };
48
+ return new Promise((resolve) => {
49
+ let done = false;
50
+ const finish = (choice) => {
51
+ if (done)
52
+ return;
53
+ done = true;
54
+ stdin.off('data', onData);
55
+ process.off('SIGINT', onSig);
56
+ process.off('SIGTERM', onSig);
57
+ try {
58
+ stdout.write(MOUSE_OFF);
59
+ }
60
+ catch { }
61
+ try {
62
+ stdin.setRawMode(false);
63
+ }
64
+ catch { }
65
+ stdin.pause();
66
+ resolve(choice);
67
+ };
68
+ const onData = (buf) => {
69
+ const choice = interpretInput(buf.toString('utf-8'), state, linesBelowHelp);
70
+ if (choice)
71
+ finish(choice);
72
+ };
73
+ const onSig = () => finish('exit');
74
+ try {
75
+ stdin.setRawMode(true);
76
+ }
77
+ catch {
78
+ return resolve('exit');
79
+ }
80
+ stdin.resume();
81
+ stdin.on('data', onData);
82
+ process.once('SIGINT', onSig);
83
+ process.once('SIGTERM', onSig);
84
+ stdout.write(MOUSE_ON + ASK_CURSOR);
85
+ });
86
+ }
@@ -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.16';
package/package.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "@nonbot/cli",
3
- "version": "0.10.14",
3
+ "version": "0.10.16",
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
- "license": "UNLICENSED",
6
+ "license": "SEE LICENSE IN LICENSE",
7
7
  "bin": {
8
8
  "nonbot": "dist/index.js"
9
9
  },