@nonbot/cli 0.5.13

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 ADDED
@@ -0,0 +1,35 @@
1
+ # @nonbot/cli changelog
2
+
3
+ ## 0.5.10
4
+
5
+ - Build now strips source comments from the published output, so no internal
6
+ file paths or developer environment details ship in the package.
7
+
8
+ ## 0.5.9
9
+
10
+ - Docs: rewrote the README as clean end-user docs; removed internal
11
+ implementation and security-mechanism detail not meant for a public package.
12
+ - Scrubbed an example email address from the `nonbot status` banner output.
13
+ - First release published via npm trusted publishing (OIDC) — no token.
14
+
15
+ ## 0.5.8
16
+
17
+ - Runs are now marked complete automatically when their terminal closes, so the
18
+ Recent Activations view on non.bot stays accurate.
19
+
20
+ ## 0.5.x
21
+
22
+ - `nonbot doctor` health check (auth, server, provider CLIs, terminal).
23
+ - Headless mode (`--headless`) to run inline without opening a window.
24
+ - Multiple accounts via `--profile`.
25
+ - Local launch history (`nonbot logs`).
26
+ - Run the daemon at login (`daemon --install` / `--uninstall`).
27
+ - tmux support: Runs open as tmux windows when the daemon runs inside tmux, and can be
28
+ stopped from non.bot.
29
+ - Adaptive polling — the daemon checks more often while a Run is active and backs off
30
+ when idle.
31
+
32
+ ## 0.2.0 – 0.4.0
33
+
34
+ - Initial release: `login`, `daemon`, `run`, `status`, and cross-platform terminal
35
+ launching (macOS, Linux, Windows).
package/LICENSE ADDED
@@ -0,0 +1,43 @@
1
+ Copyright (c) 2026 non.bot. All rights reserved.
2
+
3
+ PROPRIETARY SOFTWARE — NOT OPEN SOURCE
4
+
5
+ This software (the "@nonbot/cli" package and all of its contents, the
6
+ "Software") is the proprietary and confidential property of non.bot. It is
7
+ published to the npm registry for the sole purpose of allowing authorized
8
+ non.bot account holders to install and run it as the local host for the
9
+ non.bot "Run" feature.
10
+
11
+ PERMITTED USE
12
+
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.
17
+
18
+ PROHIBITED WITHOUT PRIOR WRITTEN PERMISSION FROM non.bot
19
+
20
+ - Copying, redistributing, sublicensing, selling, or publishing the Software
21
+ or any portion of it;
22
+ - 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.
27
+
28
+ NO PATENT OR OTHER IP LICENSE
29
+
30
+ No license to any patent, patent application, trademark, trade secret, or other
31
+ intellectual-property right of non.bot is granted, whether expressly, by
32
+ implication, by estoppel, or otherwise, except for the limited execution right
33
+ stated above. All such rights are expressly reserved.
34
+
35
+ NO WARRANTY
36
+
37
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
38
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
39
+ FITNESS FOR A PARTICULAR PURPOSE, AND NONINFRINGEMENT. IN NO EVENT SHALL
40
+ non.bot BE LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER LIABILITY ARISING FROM, OUT
41
+ OF, OR IN CONNECTION WITH THE SOFTWARE OR ITS USE.
42
+
43
+ Contact: legal@non.bot
package/README.md ADDED
@@ -0,0 +1,93 @@
1
+ # @nonbot/cli
2
+
3
+ The local host for non.bot **▶ Run**. Install it, log in once, and leave the daemon
4
+ running — when you click ▶ Run on a story in non.bot, a terminal opens on your machine
5
+ and starts the work in your linked repo.
6
+
7
+ ```bash
8
+ npm install -g @nonbot/cli
9
+ nonbot login
10
+ nonbot daemon
11
+ ```
12
+
13
+ ## How it works
14
+
15
+ The daemon runs as an ordinary process in your shell. It polls non.bot for Runs you've
16
+ started, and when one arrives it opens a terminal in your linked repository and launches
17
+ your CLI agent (`claude`, `codex`, or `gemini`). Because it's a plain Node process, it
18
+ uses your existing `PATH` and whatever CLI logins you already have — no extra setup.
19
+
20
+ ## Getting started
21
+
22
+ ```bash
23
+ # 1. Install
24
+ npm install -g @nonbot/cli
25
+
26
+ # 2. Create a Personal Access Token on non.bot
27
+ # Settings → Connections → API tokens → "New token"
28
+
29
+ # 3. Log in (paste the token when prompted)
30
+ nonbot login
31
+
32
+ # 4. Leave the daemon running in a terminal tab
33
+ nonbot daemon
34
+
35
+ # 5. Click ▶ Run on non.bot — a terminal opens and starts the work.
36
+ ```
37
+
38
+ Non-interactive login (for CI or scripting):
39
+
40
+ ```bash
41
+ nonbot login --token "$NONBOT_PAT"
42
+ ```
43
+
44
+ ## Commands
45
+
46
+ ```
47
+ nonbot login Log in with a Personal Access Token
48
+ nonbot daemon Run the poll loop and launch terminals (the main command)
49
+ nonbot run <id> Launch a single Run by id, then exit
50
+ nonbot status Show login + daemon status
51
+ nonbot doctor Health check — auth, server, providers, terminal
52
+ nonbot logs Show recent local Run launches
53
+ nonbot profiles List configured accounts
54
+ nonbot test Print a local diagnostic banner (no server needed)
55
+ nonbot --help Full command list
56
+ nonbot --version Print the version
57
+ ```
58
+
59
+ ### Useful flags
60
+
61
+ | Flag | Effect |
62
+ |---|---|
63
+ | `--headless` | Run inline without opening a terminal window (`daemon`, `run`) |
64
+ | `--profile <name>` | Use a named account instead of the default |
65
+ | `daemon --install` / `--uninstall` | Run the daemon automatically at login |
66
+
67
+ ## Terminals
68
+
69
+ On macOS the daemon launches Terminal or iTerm; on Linux it uses your system terminal;
70
+ on Windows it uses Windows Terminal. If you run the daemon inside a `tmux` session, Runs
71
+ open as new tmux windows instead of separate terminal windows.
72
+
73
+ Override the terminal with `NONBOT_TERMINAL` (e.g. `NONBOT_TERMINAL=iterm`), or set a
74
+ preferred terminal in your non.bot settings.
75
+
76
+ ## Configuration
77
+
78
+ Credentials are stored in `~/.config/nonbot/auth.json` (file mode `0600`). Override the
79
+ config directory with `NONBOT_CONFIG_DIR`.
80
+
81
+ If a token is lost or compromised, rotate it at
82
+ **non.bot → Settings → Connections → API tokens**.
83
+
84
+ ## Requirements
85
+
86
+ - Node.js 18 or newer
87
+ - A non.bot account with a linked repository
88
+
89
+ ## License
90
+
91
+ Proprietary — all rights reserved. See [LICENSE](./LICENSE). This package is
92
+ published to npm solely to function as the local host for non.bot ▶ Run; it is
93
+ not open-source software.
@@ -0,0 +1,286 @@
1
+ import { spawnSync as nodeSpawnSync } from 'node:child_process';
2
+ import { loadAuth, getActiveProfile } from '../lib/auth.js';
3
+ import * as activations from '../lib/activations.js';
4
+ import { fireActivation, makeHeadlessSpawner, } from '../lib/activations.js';
5
+ import { checkCompletions } from '../lib/completion.js';
6
+ import { installService, uninstallService } from '../lib/service.js';
7
+ import { detectTmuxSession, inTmuxSession, nonbotTmuxOptOut, resolveTerminal, } from '../lib/terminal.js';
8
+ import { VERSION } from '../version.js';
9
+ import { errorBlock, statusRow, daemonOpener, daemonCloser, pollTick, activationCard, c, } from '../lib/output.js';
10
+ export const POLL_FAST_MS = 2000;
11
+ export const POLL_MAX_MS = 30000;
12
+ export const POLL_INTERVAL_MS = POLL_FAST_MS;
13
+ export function applyNonbotTmuxConfig(deps = {}) {
14
+ const spawnSync = deps.spawnSync ?? nodeSpawnSync;
15
+ const stdoutWrite = deps.stdoutWrite ?? ((s) => process.stdout.write(s));
16
+ const env = deps.env ?? process.env;
17
+ const stdoutIsTTY = deps.stdoutIsTTY ?? (process.stdout?.isTTY !== false);
18
+ const tmux = (...args) => {
19
+ try {
20
+ spawnSync('tmux', args, { encoding: 'utf-8', timeout: 1000, windowsHide: true });
21
+ }
22
+ catch {
23
+ }
24
+ };
25
+ tmux('set-option', '-wg', 'automatic-rename', 'off');
26
+ tmux('set-option', '-wg', 'allow-rename', 'off');
27
+ tmux('rename-window', 'NONBOT TMUX');
28
+ tmux('set-option', 'set-titles', 'on');
29
+ tmux('set-option', 'set-titles-string', 'NONBOT TMUX │ #W');
30
+ tmux('refresh-client', '-S');
31
+ if (!stdoutIsTTY)
32
+ return;
33
+ const insideTmux = inTmuxSession(env);
34
+ if (insideTmux) {
35
+ tmux('set-option', '-p', 'allow-passthrough', 'on');
36
+ }
37
+ const NONBOT_TMUX_OSC = '\x1b]2;NONBOT TMUX\x07';
38
+ const payload = insideTmux
39
+ ? `\x1bPtmux;\x1b${NONBOT_TMUX_OSC}\x1b\\`
40
+ : NONBOT_TMUX_OSC;
41
+ try {
42
+ stdoutWrite(payload);
43
+ }
44
+ catch {
45
+ }
46
+ }
47
+ export function maybeReexecIntoTmux(deps = {}) {
48
+ const env = deps.env ?? process.env;
49
+ const spawn = deps.spawnSync ?? nodeSpawnSync;
50
+ const argv = deps.argv ?? process.argv;
51
+ const stdinIsTTY = deps.stdinIsTTY ?? (process.stdin?.isTTY !== false);
52
+ const exit = deps.exit ?? ((code) => process.exit(code));
53
+ const log = deps.log ?? ((s) => process.stderr.write(s));
54
+ if (env.VITEST)
55
+ return 'skipped';
56
+ if (inTmuxSession(env))
57
+ return 'in-tmux';
58
+ const userArgs = argv.slice(2);
59
+ if (userArgs.includes('--no-tmux') || nonbotTmuxOptOut(env))
60
+ return 'skipped';
61
+ if (userArgs.includes('--headless') || userArgs.includes('--no-terminal'))
62
+ return 'skipped';
63
+ if (!stdinIsTTY)
64
+ return 'skipped';
65
+ let probeStatus = null;
66
+ try {
67
+ const probe = spawn('tmux', ['-V'], { stdio: 'ignore' });
68
+ probeStatus = probe.status;
69
+ }
70
+ catch {
71
+ probeStatus = 1;
72
+ }
73
+ if (probeStatus !== 0) {
74
+ log('▶ tmux not installed — running daemon bare (each activation will open a new terminal window).\n');
75
+ log(' Install tmux for the multi-pane experience: brew install tmux\n');
76
+ return 'skipped';
77
+ }
78
+ log("▶ wrapping daemon in tmux session 'nonbot' — activations will land as tabs in this window.\n");
79
+ log(" (use `nonbot daemon --no-tmux` if you want bare iTerm windows instead.)\n");
80
+ const wrap = spawn('tmux', ['new-session', '-A', '-s', 'nonbot', '--', ...argv], { stdio: 'inherit' });
81
+ exit(wrap.status ?? 0);
82
+ return 'reexeced';
83
+ }
84
+ export async function runDaemonCommand(args = [], options = {}, deps = {}) {
85
+ const log = deps.log ?? ((s) => process.stdout.write(s));
86
+ const errLog = deps.errLog ?? ((s) => process.stderr.write(s));
87
+ const fetchImpl = deps.fetchImpl ?? fetch;
88
+ const loader = deps.loadAuth ?? loadAuth;
89
+ const sleep = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
90
+ const fixedInterval = options.pollIntervalMs;
91
+ const maxIntervalMs = options.maxPollIntervalMs ?? POLL_MAX_MS;
92
+ let currentInterval = fixedInterval ?? POLL_FAST_MS;
93
+ if (args.includes('--install')) {
94
+ const install = deps.installService ?? installService;
95
+ const res = await install();
96
+ if (res.ok)
97
+ log(statusRow('✓', 'Service installed', res.message) + '\n');
98
+ else
99
+ errLog(statusRow('✗', 'Service install failed', res.message, { stream: process.stderr }) + '\n');
100
+ return res.ok ? 0 : 1;
101
+ }
102
+ if (args.includes('--uninstall')) {
103
+ const uninstall = deps.uninstallService ?? uninstallService;
104
+ const res = await uninstall();
105
+ if (res.ok)
106
+ log(statusRow('✓', 'Service removed', res.message) + '\n');
107
+ else
108
+ errLog(statusRow('✗', 'Service uninstall failed', res.message, { stream: process.stderr }) + '\n');
109
+ return res.ok ? 0 : 1;
110
+ }
111
+ const autoWrap = deps.maybeReexecIntoTmux ?? maybeReexecIntoTmux;
112
+ const wrapResult = autoWrap();
113
+ if (wrapResult === 'reexeced')
114
+ return 0;
115
+ const headless = args.includes('--headless') || args.includes('--no-terminal');
116
+ if (headless) {
117
+ deps.headless = true;
118
+ if (!deps.spawnTerminal) {
119
+ deps.spawnTerminal = makeHeadlessSpawner({ wait: false, log, errLog });
120
+ }
121
+ }
122
+ const auth = await loader();
123
+ if (!auth) {
124
+ errLog(errorBlock('Not logged in', 'Run `nonbot login` to authenticate.', { stream: process.stderr }));
125
+ return 1;
126
+ }
127
+ const detectTmux = deps.detectTmuxSession ?? detectTmuxSession;
128
+ const tmuxSessionName = detectTmux();
129
+ if (tmuxSessionName) {
130
+ applyNonbotTmuxConfig({ spawnSync: deps.spawnSync });
131
+ }
132
+ const seen = new Set();
133
+ const trackedPanes = new Map();
134
+ const killedByStop = new Set();
135
+ let running = true;
136
+ let sigHandler;
137
+ if (!options.oneShot) {
138
+ sigHandler = () => {
139
+ running = false;
140
+ log('\n' + statusRow('✓', 'daemon stopped', 'Ctrl-C received') + '\n');
141
+ process.exit(0);
142
+ };
143
+ process.on('SIGINT', sigHandler);
144
+ }
145
+ const hostUrl = new URL(auth.baseUrl).host;
146
+ const profileName = getActiveProfile();
147
+ const pollingLabel = fixedInterval !== undefined
148
+ ? `${fixedInterval}ms fixed`
149
+ : `${Math.round(POLL_FAST_MS / 1000)}s -> ${Math.round(maxIntervalMs / 1000)}s adaptive`;
150
+ let terminalLabel;
151
+ if (headless) {
152
+ terminalLabel = 'headless (inline, no terminal window)';
153
+ }
154
+ else {
155
+ const profile = resolveTerminal(undefined);
156
+ terminalLabel = tmuxSessionName
157
+ ? `${profile.displayName} · tmux pane (in session '${tmuxSessionName}')`
158
+ : profile.displayName;
159
+ }
160
+ const badgeSublines = [
161
+ `listening on ${auth.baseUrl}`,
162
+ `poll ${Math.round(POLL_FAST_MS / 1000)}s · ${Math.round(maxIntervalMs / 1000)}s on idle · adaptive`,
163
+ ];
164
+ if (tmuxSessionName)
165
+ badgeSublines.push(`tmux host: ${tmuxSessionName}`);
166
+ log(daemonOpener(`v${VERSION}`, {
167
+ state: 'IDLE',
168
+ badgeColor: 'green',
169
+ badgeLabel: 'CONNECTED',
170
+ badgeSublines,
171
+ }) + '\n\n');
172
+ const headerMarker = c.green('[ + ]');
173
+ const headerLabel = c.white('LISTENING · READY FOR ACTIVATIONS');
174
+ log(`${headerMarker} ${c.bold(headerLabel)}\n`);
175
+ const ctxRows = [
176
+ ['YOU', auth.email],
177
+ ['LISTENING', `${hostUrl}/api/cli/activations/pending`],
178
+ ['PROFILE', profileName],
179
+ ['LANDING', terminalLabel],
180
+ ];
181
+ const LABEL_WIDTH = 9;
182
+ for (const [k, v] of ctxRows) {
183
+ const label = c.cyan(k.padEnd(LABEL_WIDTH, ' '));
184
+ const value = c.white(v);
185
+ log(` ${label} ${value}\n`);
186
+ }
187
+ log('\n');
188
+ log(`${c.bold(c.white('LIVESTREAM'))}\n`);
189
+ log('\n');
190
+ log(daemonCloser({
191
+ inTmux: tmuxSessionName !== null,
192
+ }) + '\n');
193
+ log('\n');
194
+ while (running) {
195
+ let firedThisPoll = false;
196
+ try {
197
+ const headers = {
198
+ Authorization: `Bearer ${auth.pat}`,
199
+ 'X-Requested-With': 'ConradPM-Native',
200
+ 'X-CLI-Version': VERSION,
201
+ };
202
+ if (tmuxSessionName)
203
+ headers['X-Tmux-Session'] = tmuxSessionName;
204
+ const res = await fetchImpl(`${auth.baseUrl}/api/cli/activations/pending`, {
205
+ headers,
206
+ });
207
+ if (res.status === 401) {
208
+ errLog(errorBlock('Auth failed (HTTP 401)', 'Re-run: nonbot login', { stream: process.stderr }));
209
+ if (sigHandler)
210
+ process.off('SIGINT', sigHandler);
211
+ return 1;
212
+ }
213
+ if (res.ok) {
214
+ const body = (await res.json());
215
+ const pendingKills = body?.pendingKills ?? [];
216
+ if (pendingKills.length > 0) {
217
+ for (const k of pendingKills) {
218
+ log(activationCard({
219
+ marker: '■',
220
+ color: 'amber',
221
+ id: k.activationId,
222
+ headerSuffix: 'STOPPING',
223
+ kv: [
224
+ ['PANE', `${k.tmuxPaneId} · ^C -> 2s grace -> kill-pane`],
225
+ ['REASON', 'stop received from web canvas'],
226
+ ],
227
+ }) + '\n');
228
+ }
229
+ for (const k of pendingKills)
230
+ killedByStop.add(k.activationId);
231
+ void activations.executePendingKills(pendingKills, auth.baseUrl, auth.pat);
232
+ }
233
+ const acts = body?.activations ?? [];
234
+ for (const act of acts) {
235
+ if (!act?.id || seen.has(act.id))
236
+ continue;
237
+ seen.add(act.id);
238
+ firedThisPoll = true;
239
+ const outcome = await fireActivation(auth, act, deps, log, errLog);
240
+ if (outcome.status === 'launched' && outcome.kind === 'real' && outcome.tmuxPaneId) {
241
+ trackedPanes.set(outcome.id, outcome.tmuxPaneId);
242
+ }
243
+ }
244
+ if (trackedPanes.size > 0) {
245
+ await checkCompletions({
246
+ tracked: trackedPanes,
247
+ killed: killedByStop,
248
+ baseUrl: auth.baseUrl,
249
+ pat: auth.pat,
250
+ spawnImpl: deps.spawnSync,
251
+ fetchImpl,
252
+ log,
253
+ });
254
+ }
255
+ if (!firedThisPoll && pendingKills.length === 0) {
256
+ log(pollTick({
257
+ pending: 0,
258
+ sleepMs: fixedInterval ?? currentInterval,
259
+ backingOff: fixedInterval === undefined && currentInterval > POLL_FAST_MS,
260
+ }) + '\n');
261
+ }
262
+ if (seen.size > 500)
263
+ seen.clear();
264
+ }
265
+ }
266
+ catch (e) {
267
+ errLog(statusRow('⚠', 'poll error', e.message, { stream: process.stderr }) + '\n');
268
+ }
269
+ if (options.oneShot)
270
+ break;
271
+ if (fixedInterval !== undefined) {
272
+ await sleep(fixedInterval);
273
+ }
274
+ else {
275
+ if (firedThisPoll)
276
+ currentInterval = POLL_FAST_MS;
277
+ await sleep(currentInterval);
278
+ if (!firedThisPoll) {
279
+ currentInterval = Math.min(currentInterval * 2, maxIntervalMs);
280
+ }
281
+ }
282
+ }
283
+ if (sigHandler)
284
+ process.off('SIGINT', sigHandler);
285
+ return 0;
286
+ }