@kortix/agent-tunnel 0.12.7 → 0.13.0

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/src/agent/cli.ts CHANGED
@@ -1,252 +1,69 @@
1
1
  import '../node-ws-polyfill';
2
- import { loadConfig, type TunnelConfig } from './config';
2
+ import { existsSync, readFileSync, writeFileSync } from 'fs';
3
+ import { hostname } from 'os';
4
+ import { join } from 'path';
5
+
3
6
  import { TunnelAgent } from './agent';
7
+ import { printStartupBanner } from './banner';
4
8
  import { createEnabledCapabilityRegistry } from './capabilities/enabled-registry';
9
+ import { loadConfig, type TunnelConfig } from './config';
10
+ import { CONFIG_FILE, clearSavedCredentials, saveCredentials } from './credential-store';
11
+ import { probeCredentials } from './credential-probe';
12
+ import {
13
+ InvalidDeviceAuthResponseError,
14
+ awaitDeviceAuthorization,
15
+ openBrowser,
16
+ requestDeviceAuthorization,
17
+ } from './device-auth';
18
+ import { collapseRepeatedLines, isShellStartupNoise } from './log-format';
19
+ import { anyFlag, isInteractiveTerminal, isTruthyFlag, promptYesNo } from './prompts';
5
20
  import {
6
21
  DEFAULT_INSTALL_BACKGROUND_SERVICE,
22
+ TERMINAL_SERVICE_EXIT_CODE,
7
23
  getServicePaths,
8
24
  getServiceStatus,
9
- installService,
10
- restartService,
11
- startService,
12
- stopService,
13
- uninstallService,
25
+ rotateServiceLogs,
26
+ serviceLogFiles,
14
27
  } from './service';
15
- import { hostname, platform, arch, release } from 'os';
16
- import { chmodSync, existsSync, mkdirSync, writeFileSync, readFileSync, renameSync } from 'fs';
17
- import { join } from 'path';
18
- import { homedir } from 'os';
19
- import { spawn } from 'child_process';
20
- import { createInterface } from 'readline/promises';
21
-
22
- const c = {
23
- reset: '\x1b[0m',
24
- bold: '\x1b[1m',
25
- dim: '\x1b[2m',
26
- italic: '\x1b[3m',
27
- cyan: '\x1b[36m',
28
- blue: '\x1b[34m',
29
- green: '\x1b[32m',
30
- yellow: '\x1b[33m',
31
- red: '\x1b[31m',
32
- magenta: '\x1b[35m',
33
- white: '\x1b[97m',
34
- gray: '\x1b[90m',
35
- bgCyan: '\x1b[46m',
36
- bgBlue: '\x1b[44m',
37
- };
38
-
39
- function parseArgs(argv: string[]): { command: string; flags: Record<string, string> } {
40
- const command = argv[2] || 'help';
41
- const flags: Record<string, string> = {};
42
-
28
+ import {
29
+ SERVICE_ACTIONS,
30
+ type ServiceAction,
31
+ acquireTunnelLease,
32
+ describeService,
33
+ renderServiceAction,
34
+ } from './service-control';
35
+ import { blankLine, c, clearScreen, field, glyph, stripAnsi } from './terminal';
36
+ import { agentTunnelVersion } from './version';
37
+
38
+ const ALL_CAPABILITIES = ['filesystem', 'shell', 'desktop'] as const;
39
+ const BACKGROUND_FLAGS = ['daemon', 'service', 'background', 'always-online'] as const;
40
+ const FOREGROUND_FLAGS = ['foreground', 'no-daemon', 'no-service', 'no-background'] as const;
41
+ const DEFAULT_LOG_LINES = 60;
42
+
43
+ type Flags = Record<string, string>;
44
+
45
+ function parseArgs(argv: string[]): { command: string; flags: Flags } {
46
+ const flags: Flags = {};
43
47
  for (let i = 3; i < argv.length; i++) {
44
48
  const arg = argv[i];
45
- if (arg.startsWith('--')) {
46
- const key = arg.slice(2);
47
- const value = argv[i + 1] && !argv[i + 1].startsWith('--') ? argv[++i] : 'true';
48
- flags[key] = value;
49
- }
50
- }
51
-
52
- return { command, flags };
53
- }
54
-
55
- function clearScreen(): void {
56
- process.stdout.write('\x1b[2J\x1b[3J\x1b[H');
57
- }
58
-
59
- const sleep = (ms: number) => new Promise<void>(r => setTimeout(r, ms));
60
-
61
- type ConnectMode = {
62
- background: boolean;
63
- };
64
-
65
- type ApprovedDeviceCredentials = {
66
- tunnelId: string;
67
- token: string;
68
- };
69
-
70
- type DeviceAuthChallenge = {
71
- deviceCode: string;
72
- deviceSecret: string;
73
- verificationUrl: string;
74
- expiresAt: string;
75
- pollIntervalMs: number;
76
- };
77
-
78
- const TUNNEL_ID_PATTERN =
79
- /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
80
- const SETUP_TOKEN_PATTERN = /^kortix_tnl_[A-Za-z0-9_-]{32,64}$/;
81
-
82
- class InvalidDeviceAuthResponseError extends Error {
83
- constructor(message: string) {
84
- super(message);
85
- this.name = 'InvalidDeviceAuthResponseError';
49
+ if (!arg.startsWith('--')) continue;
50
+ const next = argv[i + 1];
51
+ flags[arg.slice(2)] = next && !next.startsWith('--') ? argv[++i] : 'true';
86
52
  }
53
+ return { command: argv[2] || 'help', flags };
87
54
  }
88
55
 
89
- function isJsonRecord(value: unknown): value is Record<string, unknown> {
90
- return value !== null && typeof value === 'object' && !Array.isArray(value);
56
+ function fail(message: string): never {
57
+ console.error(` ${glyph.bad} ${message}`);
58
+ process.exit(1);
91
59
  }
92
60
 
93
- function parseApprovedDeviceCredentials(
94
- value: Record<string, unknown>,
95
- ): ApprovedDeviceCredentials {
96
- const { tunnelId, token } = value;
97
- if (typeof tunnelId !== 'string' || !TUNNEL_ID_PATTERN.test(tunnelId)) {
98
- throw new InvalidDeviceAuthResponseError(
99
- 'Authorization server returned an invalid tunnel ID',
100
- );
101
- }
102
- if (typeof token !== 'string' || !SETUP_TOKEN_PATTERN.test(token)) {
103
- throw new InvalidDeviceAuthResponseError(
104
- 'Authorization server returned an invalid setup token',
105
- );
106
- }
107
- return { tunnelId, token };
61
+ function shortenHomePath(path: string): string {
62
+ const home = process.env.HOME ?? '';
63
+ return home && path.startsWith(home) ? `~${path.slice(home.length)}` : path;
108
64
  }
109
65
 
110
- function parseDeviceAuthChallenge(value: unknown): DeviceAuthChallenge {
111
- if (!isJsonRecord(value)) {
112
- throw new InvalidDeviceAuthResponseError(
113
- 'Authorization server returned an invalid challenge',
114
- );
115
- }
116
- const { deviceCode, deviceSecret, verificationUrl, expiresAt, pollIntervalMs } = value;
117
- if (typeof deviceCode !== 'string' || !/^[A-Z]{4}-[0-9]{4}$/.test(deviceCode)) {
118
- throw new InvalidDeviceAuthResponseError(
119
- 'Authorization server returned an invalid device code',
120
- );
121
- }
122
- if (typeof deviceSecret !== 'string' || !/^[A-Za-z0-9]{32}$/.test(deviceSecret)) {
123
- throw new InvalidDeviceAuthResponseError(
124
- 'Authorization server returned an invalid device secret',
125
- );
126
- }
127
- if (typeof verificationUrl !== 'string' || verificationUrl.length > 2048) {
128
- throw new InvalidDeviceAuthResponseError(
129
- 'Authorization server returned an invalid verification URL',
130
- );
131
- }
132
- const browserUrl = normalizeBrowserUrl(verificationUrl);
133
- if (!browserUrl) {
134
- throw new InvalidDeviceAuthResponseError(
135
- 'Authorization server returned an invalid verification URL',
136
- );
137
- }
138
- const parsedVerificationUrl = new URL(browserUrl);
139
- const loopback =
140
- parsedVerificationUrl.hostname === 'localhost' ||
141
- parsedVerificationUrl.hostname === '127.0.0.1' ||
142
- parsedVerificationUrl.hostname === '[::1]' ||
143
- parsedVerificationUrl.hostname === '::1';
144
- if (
145
- parsedVerificationUrl.username ||
146
- parsedVerificationUrl.password ||
147
- (parsedVerificationUrl.protocol !== 'https:' && !loopback)
148
- ) {
149
- throw new InvalidDeviceAuthResponseError(
150
- 'Authorization server returned an unsafe verification URL',
151
- );
152
- }
153
- if (typeof expiresAt !== 'string') {
154
- throw new InvalidDeviceAuthResponseError(
155
- 'Authorization server returned an invalid expiration',
156
- );
157
- }
158
- const expiresAtMs = Date.parse(expiresAt);
159
- const now = Date.now();
160
- if (!Number.isFinite(expiresAtMs) || expiresAtMs <= now || expiresAtMs > now + 10 * 60_000) {
161
- throw new InvalidDeviceAuthResponseError(
162
- 'Authorization server returned an invalid expiration',
163
- );
164
- }
165
- if (
166
- !Number.isSafeInteger(pollIntervalMs) ||
167
- (pollIntervalMs as number) < 250 ||
168
- (pollIntervalMs as number) > 10_000
169
- ) {
170
- throw new InvalidDeviceAuthResponseError(
171
- 'Authorization server returned an invalid poll interval',
172
- );
173
- }
174
- return {
175
- deviceCode,
176
- deviceSecret,
177
- verificationUrl: browserUrl,
178
- expiresAt,
179
- pollIntervalMs: pollIntervalMs as number,
180
- };
181
- }
182
-
183
- async function printStartup(config: { tunnelId: string; apiUrl: string }, capabilities: string[], version: string): Promise<void> {
184
- const machine = hostname();
185
- const plat = `${platform()} ${arch()}`;
186
-
187
- const truncate = (s: string, max: number) => s.length > max ? s.slice(0, max) + '…' : s;
188
- const tunnelDisplay = truncate(config.tunnelId, 40);
189
- const apiDisplay = truncate(config.apiUrl, 40);
190
- const machineDisplay = truncate(machine, 28);
191
-
192
- // ── ASCII art ───────────────────────────────────────────
193
- console.log('');
194
- console.log(` ${c.cyan}▄▀█ █▀▀ █▀▀ █▄ █ ▀█▀${c.reset} ${c.cyan}▀█▀ █ █ █▄ █ █▄ █ █▀▀ █ ${c.reset}`);
195
- console.log(` ${c.cyan}█▀█ █▄█ ██▄ █ ▀█ █${c.reset} ${c.cyan} █ █▄█ █ ▀█ █ ▀█ ██▄ █▄▄${c.reset}`);
196
- console.log('');
197
-
198
- // ── Tunnel connection animation ─────────────────────────
199
- const barW = 50;
200
- const frames = 14;
201
-
202
- for (let i = 0; i <= frames; i++) {
203
- const filled = Math.round((i / frames) * barW);
204
- const empty = barW - filled;
205
- process.stdout.write(
206
- `\r ${c.cyan}◇${c.reset} ${c.cyan}${'═'.repeat(filled)}${c.reset}${c.gray}${'─'.repeat(empty)}${c.reset} `,
207
- );
208
- await sleep(20);
209
- }
210
- process.stdout.write(`\r ${c.cyan}◇ ${'═'.repeat(barW)} ◆${c.reset} \n`);
211
- await sleep(120);
212
-
213
- // ── Info box ────────────────────────────────────────────
214
- const W = 60;
215
- const vLen = (s: string) => s.replace(/\x1b\[[0-9;]*m/g, '').length;
216
-
217
- const row = (content: string) => {
218
- const pad = Math.max(0, W - vLen(content));
219
- console.log(` ${c.gray}│${c.reset}${content}${' '.repeat(pad)}${c.gray}│${c.reset}`);
220
- };
221
-
222
- const blank = () => console.log(` ${c.gray}│${c.reset}${' '.repeat(W)}${c.gray}│${c.reset}`);
223
-
224
- const titleL = ` ${c.cyan}◆${c.reset} ${c.bold}${c.white}Agent Tunnel${c.reset}`;
225
- const titleR = `${c.dim}v${version}${c.reset} `;
226
- const titleLLen = 18;
227
- const titleRLen = 1 + version.length + 3;
228
- const titlePad = Math.max(1, W - titleLLen - titleRLen);
229
-
230
- const capStr = capabilities
231
- .map(name => `${c.green}●${c.reset} ${c.white}${name}${c.reset}`)
232
- .join(' ');
233
-
234
- const brand = 'created by kortix';
235
- const brandFill = W - brand.length - 3;
236
-
237
- console.log('');
238
- console.log(` ${c.gray}╭${'─'.repeat(W)}╮${c.reset}`);
239
- blank();
240
- row(`${titleL}${' '.repeat(titlePad)}${titleR}`);
241
- row(` ${c.dim}Bridge between AI agents & local machines${c.reset}`);
242
- blank();
243
- row(` ${c.dim}tunnel${c.reset} ${c.white}${tunnelDisplay}${c.reset}`);
244
- row(` ${c.dim}relay${c.reset} ${c.white}${apiDisplay}${c.reset}`);
245
- row(` ${c.dim}machine${c.reset} ${c.white}${machineDisplay}${c.reset} ${c.dim}(${plat})${c.reset}`);
246
- blank();
247
- console.log(` ${c.gray}╰${'─'.repeat(brandFill)} ${c.dim}created by ${c.cyan}kortix${c.reset} ${c.gray}─╯${c.reset}`);
248
- console.log('');
249
- }
66
+ // ── running the agent ────────────────────────────────────────────────────────
250
67
 
251
68
  function startAgent(config: TunnelConfig, options: { service?: boolean } = {}): void {
252
69
  const registry = createEnabledCapabilityRegistry(config);
@@ -256,14 +73,27 @@ function startAgent(config: TunnelConfig, options: { service?: boolean } = {}):
256
73
  );
257
74
  }
258
75
 
259
- if (!options.service) {
260
- clearScreen();
261
- printStartup(config, registry.getCapabilityNames(), '0.1.2');
262
- } else {
76
+ if (options.service) {
263
77
  console.log(`[agent-tunnel] service starting: ${config.tunnelId} -> ${config.apiUrl}`);
78
+ } else {
79
+ clearScreen();
80
+ void printStartupBanner({
81
+ tunnelId: config.tunnelId,
82
+ apiUrl: config.apiUrl,
83
+ capabilities: registry.getCapabilityNames(),
84
+ version: agentTunnelVersion(),
85
+ });
264
86
  }
265
87
 
266
- const agent = new TunnelAgent(config, registry);
88
+ const agent = new TunnelAgent(config, registry, {
89
+ onTerminalClose: ({ reason }) => {
90
+ if (!options.service) return;
91
+ // Staying alive would leave a supervised process connected to nothing that
92
+ // the supervisor never restarts. Exit cleanly so the service stops.
93
+ console.log(`[agent-tunnel] stopping service: ${reason}`);
94
+ process.exit(TERMINAL_SERVICE_EXIT_CODE);
95
+ },
96
+ });
267
97
  agent.connect();
268
98
 
269
99
  const shutdown = () => {
@@ -271,523 +101,431 @@ function startAgent(config: TunnelConfig, options: { service?: boolean } = {}):
271
101
  agent.disconnect();
272
102
  process.exit(0);
273
103
  };
274
-
275
104
  process.on('SIGTERM', shutdown);
276
105
  process.on('SIGINT', shutdown);
277
106
  }
278
107
 
279
- function normalizeBrowserUrl(value: string): string | null {
280
- try {
281
- const url = new URL(value);
282
- return url.protocol === 'https:' || url.protocol === 'http:' ? url.toString() : null;
283
- } catch {
284
- return null;
285
- }
286
- }
108
+ // ── pairing ──────────────────────────────────────────────────────────────────
287
109
 
288
- function openBrowser(url: string): void {
289
- if (process.env.KORTIX_AGENT_TUNNEL_NO_BROWSER === '1') return;
290
- const safeUrl = normalizeBrowserUrl(url);
291
- if (!safeUrl) return;
292
- try {
293
- const plat = platform();
294
- let command: string;
295
- let args: string[];
296
- if (plat === 'darwin') {
297
- command = 'open';
298
- args = [safeUrl];
299
- } else if (plat === 'win32') {
300
- command = 'rundll32.exe';
301
- args = ['url.dll,FileProtocolHandler', safeUrl];
302
- } else {
303
- command = 'xdg-open';
304
- args = [safeUrl];
305
- }
306
- const child = spawn(command, args, { detached: true, stdio: 'ignore' });
307
- child.unref();
308
- } catch {}
309
- }
110
+ async function chooseBackgroundMode(flags: Flags): Promise<boolean> {
111
+ if (anyFlag(flags, BACKGROUND_FLAGS)) return true;
112
+ if (anyFlag(flags, FOREGROUND_FLAGS)) return false;
113
+ if (!isInteractiveTerminal()) return false;
310
114
 
311
- const CONFIG_DIR = join(homedir(), '.agent-tunnel');
312
- const CONFIG_FILE = join(CONFIG_DIR, 'config.json');
313
-
314
- function isSetupTunnelToken(token: string): boolean {
315
- return token.startsWith('kortix_tnl_') || token.startsWith('tnl_');
316
- }
317
-
318
- function isTruthyFlag(value: string | undefined): boolean {
319
- return value === 'true' || value === '1' || value === 'yes';
320
- }
115
+ blankLine();
116
+ console.log(` ${glyph.warn} ${c.bold}Security note${c.reset}`);
117
+ console.log(` ${c.dim}Background mode starts at login, continues after this terminal closes, and restarts after failures.${c.reset}`);
118
+ console.log(` ${c.dim}The computer must remain powered on, awake, and connected to the internet.${c.reset}`);
119
+ blankLine();
321
120
 
322
- function isInteractiveTerminal(): boolean {
323
- return process.stdin.isTTY === true && process.stdout.isTTY === true;
121
+ return promptYesNo(' Install the background service now?', DEFAULT_INSTALL_BACKGROUND_SERVICE);
324
122
  }
325
123
 
326
- async function promptYesNo(question: string, defaultValue: boolean): Promise<boolean> {
327
- const suffix = defaultValue ? ' [Y/n] ' : ' [y/N] ';
328
- const rl = createInterface({ input: process.stdin, output: process.stdout });
329
- try {
330
- for (;;) {
331
- const answer = (await rl.question(`${question}${suffix}`)).trim().toLowerCase();
332
- if (!answer) return defaultValue;
333
- if (['y', 'yes'].includes(answer)) return true;
334
- if (['n', 'no'].includes(answer)) return false;
335
- console.log(` ${c.yellow}!${c.reset} Please answer yes or no.`);
336
- }
337
- } catch (error) {
338
- if (error instanceof Error && error.name === 'AbortError') {
339
- process.stdout.write('\n');
340
- process.exit(130);
341
- }
342
- throw error;
343
- } finally {
344
- rl.close();
124
+ /** Starts the agent the way the caller asked for, and returns. */
125
+ async function launch(config: TunnelConfig, flags: Flags, lease?: { serviceWasActive: boolean }): Promise<void> {
126
+ if (await chooseBackgroundMode(flags)) {
127
+ saveCredentials(config.tunnelId, config.token, config.apiUrl);
128
+ renderServiceAction('install', SERVICE_ACTIONS.install.run());
129
+ return;
345
130
  }
346
- }
347
131
 
348
- async function chooseConnectMode(flags: Record<string, string>): Promise<ConnectMode> {
349
- const explicitBackground =
350
- isTruthyFlag(flags.daemon) ||
351
- isTruthyFlag(flags.service) ||
352
- isTruthyFlag(flags.background) ||
353
- isTruthyFlag(flags['always-online']);
354
- const explicitForeground =
355
- isTruthyFlag(flags.foreground) ||
356
- isTruthyFlag(flags['no-daemon']) ||
357
- isTruthyFlag(flags['no-service']) ||
358
- isTruthyFlag(flags['no-background']);
359
-
360
- if (explicitBackground) {
361
- return { background: true };
362
- }
363
- if (explicitForeground) {
364
- return { background: false };
132
+ if (lease?.serviceWasActive) {
133
+ console.log(` ${c.dim}Background service stays paused while this terminal holds the tunnel.${c.reset}`);
134
+ console.log(` ${c.dim}Resume it with${c.reset} ${c.white}agent-tunnel start${c.reset}${c.dim}, or leave it — it starts again at login.${c.reset}`);
365
135
  }
366
- if (!isInteractiveTerminal()) {
367
- return { background: false };
368
- }
369
-
370
- console.log('');
371
- console.log(` ${c.yellow}!${c.reset} ${c.bold}Security note${c.reset}`);
372
- console.log(` ${c.dim}Background mode starts at login, continues after this terminal closes, and restarts after failures.${c.reset}`);
373
- console.log(` ${c.dim}The computer must remain powered on, awake, and connected to the internet.${c.reset}`);
374
- console.log('');
375
-
376
- const background = await promptYesNo(
377
- ' Install the background service now?',
378
- DEFAULT_INSTALL_BACKGROUND_SERVICE,
379
- );
380
- return { background };
136
+ startAgent(config);
381
137
  }
382
138
 
383
- function installBackgroundService(): void {
384
- const status = installService();
385
- console.log('');
386
- console.log(` ${c.green}●${c.reset} ${c.bold}Background service installed${c.reset}`);
387
- if (status.path) console.log(` ${c.dim}${status.path}${c.reset}`);
388
- console.log(` ${c.dim}Starts at login and restarts after failures.${c.reset}`);
389
- if (status.detail) console.log(` ${c.gray}${status.detail}${c.reset}`);
390
- console.log('');
391
- }
139
+ async function pairThisMachine(apiUrl: string, flags: Flags): Promise<void> {
140
+ blankLine();
141
+ console.log(` ${glyph.mark} ${c.bold}Device Authorization${c.reset}`);
142
+ blankLine();
392
143
 
393
- function saveCredentials(
394
- tunnelId: string,
395
- token: string,
396
- apiUrl: string,
397
- enabledCapabilities?: string[],
398
- ): void {
399
- mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
400
- try { chmodSync(CONFIG_DIR, 0o700); } catch {}
401
- let existing: Record<string, unknown> = {};
402
- if (existsSync(CONFIG_FILE)) {
403
- try { existing = JSON.parse(readFileSync(CONFIG_FILE, 'utf-8')); } catch {}
144
+ let challenge;
145
+ try {
146
+ challenge = await requestDeviceAuthorization(apiUrl);
147
+ } catch (error) {
148
+ fail(
149
+ error instanceof InvalidDeviceAuthResponseError
150
+ ? error.message
151
+ : 'Failed to start device authorization',
152
+ );
404
153
  }
405
- const tmpFile = join(CONFIG_DIR, `config.${process.pid}.${Date.now()}.tmp`);
406
- const next = {
407
- ...existing,
408
- tunnelId,
409
- token,
410
- apiUrl,
411
- ...(enabledCapabilities !== undefined ? { enabledCapabilities } : {}),
412
- };
413
- // The device-auth response passes strict UUID and token-format validation.
414
- // The destination is a fixed private file under the current user's home.
415
- // lgtm[js/http-to-file-access]
416
- writeFileSync(tmpFile, JSON.stringify(next, null, 2), { mode: 0o600, flag: 'wx' });
417
- try { chmodSync(tmpFile, 0o600); } catch {}
418
- renameSync(tmpFile, CONFIG_FILE);
419
- try { chmodSync(CONFIG_FILE, 0o600); } catch {}
420
- }
421
154
 
422
- async function commandConnectDeviceAuth(config: TunnelConfig, flags: Record<string, string>): Promise<void> {
423
- console.log('');
424
- console.log(` ${c.cyan}◆${c.reset} ${c.bold}Device Authorization${c.reset}`);
425
- console.log('');
426
-
427
- // Step 1: Create device auth request
428
- let deviceCode: string;
429
- let deviceSecret: string;
430
- let verificationUrl: string;
431
- let expiresAt: string;
432
- let pollIntervalMs: number;
155
+ console.log(` ${c.dim}Code:${c.reset} ${c.bold}${c.white}${challenge.deviceCode}${c.reset}`);
156
+ blankLine();
157
+ console.log(` ${c.dim}Open this URL on any device to approve:${c.reset}`);
158
+ console.log(` ${c.cyan}${challenge.verificationUrl}${c.reset}`);
159
+ blankLine();
160
+ openBrowser(challenge.verificationUrl);
433
161
 
162
+ let outcome;
434
163
  try {
435
- const res = await fetch(`${config.apiUrl}/device-auth`, {
436
- method: 'POST',
437
- headers: { 'Content-Type': 'application/json' },
438
- body: JSON.stringify({ machineHostname: hostname() }),
164
+ outcome = await awaitDeviceAuthorization(apiUrl, challenge, {
165
+ onWaiting: (secondsRemaining) => {
166
+ const minutes = Math.floor(secondsRemaining / 60);
167
+ const seconds = String(secondsRemaining % 60).padStart(2, '0');
168
+ process.stdout.write(`\r ${c.dim}Waiting for approval... ${c.white}${minutes}:${seconds}${c.reset} `);
169
+ },
439
170
  });
440
- if (!res.ok) {
441
- const text = await res.text().catch(() => '');
442
- console.error(` ${c.red}✗${c.reset} Failed to create device auth request: ${res.status} ${text.slice(0, 200)}`);
443
- process.exit(1);
444
- }
445
- const challenge = parseDeviceAuthChallenge(await res.json());
446
- deviceCode = challenge.deviceCode;
447
- deviceSecret = challenge.deviceSecret;
448
- verificationUrl = challenge.verificationUrl;
449
- expiresAt = challenge.expiresAt;
450
- pollIntervalMs = challenge.pollIntervalMs;
451
- } catch (err) {
452
- const detail = err instanceof InvalidDeviceAuthResponseError ? `: ${err.message}` : '';
453
- console.error(` ${c.red}✗${c.reset} Failed to start device authorization${detail}`);
171
+ } catch (error) {
172
+ process.stdout.write(`\r${' '.repeat(60)}\r`);
173
+ fail(error instanceof Error ? error.message : 'Device authorization failed');
174
+ }
175
+ process.stdout.write(`\r${' '.repeat(60)}\r`);
176
+
177
+ if (outcome.status === 'denied') fail('Authorization denied.');
178
+ if (outcome.status === 'expired') fail('Authorization expired. Please try again.');
179
+ if (outcome.status === 'approved-without-token') {
180
+ fail('Authorization was approved, but the setup token was not available. Run connect again.');
181
+ }
182
+
183
+ // The approved set is a ceiling only re-pairing can widen. Saving an empty one
184
+ // yields a tunnel that connects, reports success, and can do nothing.
185
+ if (outcome.capabilities.length === 0) {
186
+ console.log(` ${glyph.bad} ${c.bold}No capabilities were approved${c.reset}`);
187
+ blankLine();
188
+ console.log(` ${c.dim}A tunnel with no capabilities connects but cannot act, and the${c.reset}`);
189
+ console.log(` ${c.dim}approved set can only be changed by pairing again. Nothing was saved.${c.reset}`);
190
+ blankLine();
191
+ console.log(` ${c.dim}Run connect again and approve at least one of${c.reset} ${c.white}${ALL_CAPABILITIES.join(', ')}${c.reset}${c.dim}.${c.reset}`);
192
+ blankLine();
454
193
  process.exit(1);
455
- return;
456
194
  }
457
195
 
458
- // Step 2: Display code and open browser
459
- console.log(` ${c.dim}Code:${c.reset} ${c.bold}${c.white}${deviceCode}${c.reset}`);
460
- console.log('');
461
- console.log(` ${c.dim}Open this URL on any device to approve:${c.reset}`);
462
- console.log(` ${c.cyan}${verificationUrl}${c.reset}`);
463
- console.log('');
464
-
465
- openBrowser(verificationUrl);
466
-
467
- // Step 3: Poll for approval
468
- const expiresAtMs = new Date(expiresAt).getTime();
196
+ console.log(` ${glyph.on} ${c.bold}Authorized${c.reset}`);
197
+ saveCredentials(outcome.tunnelId, outcome.token, apiUrl, outcome.capabilities);
198
+ console.log(` ${c.dim}Saved to ${CONFIG_FILE}${c.reset}`);
199
+ console.log(` ${c.dim}Access: ${outcome.capabilities.join(', ')}${c.reset}`);
200
+ blankLine();
469
201
 
470
- while (true) {
471
- const remaining = Math.max(0, Math.floor((expiresAtMs - Date.now()) / 1000));
472
- if (remaining <= 0) {
473
- console.log(`\n ${c.red}✗${c.reset} Authorization expired. Please try again.`);
474
- process.exit(1);
475
- }
476
-
477
- const min = Math.floor(remaining / 60);
478
- const sec = remaining % 60;
479
- process.stdout.write(`\r ${c.dim}Waiting for approval... ${c.white}${min}:${sec.toString().padStart(2, '0')}${c.reset} `);
480
-
481
- try {
482
- const res = await fetch(`${config.apiUrl}/device-auth/${deviceCode}/status`, {
483
- headers: { Authorization: `Bearer ${deviceSecret}` },
484
- });
485
- if (res.ok) {
486
- const data: unknown = await res.json();
487
-
488
- if (!isJsonRecord(data) || typeof data.status !== 'string') {
489
- throw new InvalidDeviceAuthResponseError(
490
- 'Authorization server returned an invalid status response',
491
- );
492
- }
493
-
494
- if (data.status === 'approved' && data.tunnelId && data.token) {
495
- const credentials = parseApprovedDeviceCredentials(data);
496
- process.stdout.write('\r' + ' '.repeat(60) + '\r');
497
- console.log(` ${c.green}●${c.reset} ${c.bold}Authorized!${c.reset}`);
498
- console.log('');
499
-
500
- const enabledCapabilities = Array.isArray(data.capabilities)
501
- ? [...new Set(data.capabilities)].filter(
502
- (capability): capability is string =>
503
- typeof capability === 'string' &&
504
- ['filesystem', 'shell', 'desktop'].includes(capability),
505
- )
506
- : [];
507
-
508
- // Persist the browser-approved capabilities as a local ceiling. A
509
- // later server grant cannot silently enable another capability.
510
- saveCredentials(
511
- credentials.tunnelId,
512
- credentials.token,
513
- config.apiUrl,
514
- enabledCapabilities,
515
- );
516
- console.log(` ${c.dim}Credentials saved to ${CONFIG_FILE}${c.reset}`);
517
- console.log(
518
- ` ${c.dim}Local capabilities: ${enabledCapabilities.join(', ') || 'none'}${c.reset}`,
519
- );
520
- console.log('');
521
-
522
- const mode = await chooseConnectMode(flags);
523
- if (mode.background) {
524
- installBackgroundService();
525
- return;
526
- }
527
-
528
- // Connect with received credentials
529
- const fullConfig = loadConfig({
530
- token: credentials.token,
531
- tunnelId: credentials.tunnelId,
532
- apiUrl: config.apiUrl,
533
- });
534
- startAgent(fullConfig);
535
- return;
536
- }
537
-
538
- if (data.status === 'approved') {
539
- process.stdout.write('\r' + ' '.repeat(60) + '\r');
540
- console.log(` ${c.red}✗${c.reset} Authorization was approved, but the setup token was not available.`);
541
- console.log(` ${c.dim}Run the connect command again to create a fresh device authorization code.${c.reset}`);
542
- process.exit(1);
543
- }
544
-
545
- if (data.status === 'denied') {
546
- process.stdout.write('\r' + ' '.repeat(60) + '\r');
547
- console.log(` ${c.red}✗${c.reset} Authorization denied.`);
548
- process.exit(1);
549
- }
550
-
551
- if (data.status === 'expired') {
552
- process.stdout.write('\r' + ' '.repeat(60) + '\r');
553
- console.log(` ${c.red}✗${c.reset} Authorization expired. Please try again.`);
554
- process.exit(1);
555
- }
556
- }
557
- } catch (error) {
558
- if (error instanceof InvalidDeviceAuthResponseError) {
559
- process.stdout.write('\r' + ' '.repeat(60) + '\r');
560
- console.error(` ${c.red}✗${c.reset} ${error.message}`);
561
- process.exit(1);
562
- }
563
- }
564
-
565
- await sleep(pollIntervalMs);
566
- }
202
+ await launch(loadConfig({ apiUrl }), flags);
567
203
  }
568
204
 
569
- async function commandConnect(flags: Record<string, string>): Promise<void> {
205
+ async function commandConnect(flags: Flags): Promise<void> {
570
206
  const config = loadConfig({
571
207
  token: flags.token,
572
208
  tunnelId: flags['tunnel-id'],
573
209
  apiUrl: flags['api-url'],
574
210
  });
211
+ // Credentials typed on the command line, as opposed to ones loadConfig()
212
+ // restored from disk. Only the latter may be discarded and re-paired.
213
+ const explicitCredentials = Boolean(flags.token && flags['tunnel-id']);
575
214
 
576
- // If both token and tunnelId are provided, connect directly
577
- if (config.token && config.tunnelId) {
578
- const mode = await chooseConnectMode(flags);
579
- if (mode.background) {
580
- saveCredentials(config.tunnelId, config.token, config.apiUrl);
581
- installBackgroundService();
582
- return;
583
- }
584
- startAgent(config);
215
+ if (Boolean(config.token) !== Boolean(config.tunnelId)) {
216
+ fail('Provide both --token and --tunnel-id, or neither (for device auth)');
217
+ }
218
+
219
+ if (!config.token) {
220
+ await pairThisMachine(config.apiUrl, flags);
585
221
  return;
586
222
  }
587
223
 
588
- // If neither is provided, use device auth flow
589
- if (!config.token && !config.tunnelId) {
590
- await commandConnectDeviceAuth(config, flags);
224
+ if (isTruthyFlag(flags.reauth) && !explicitCredentials) {
225
+ clearSavedCredentials();
226
+ await pairThisMachine(config.apiUrl, flags);
591
227
  return;
592
228
  }
593
229
 
594
- // Partial error
595
- console.error(`${c.red}${c.bold} error${c.reset} Provide both --token and --tunnel-id, or neither (for device auth)`);
596
- process.exit(1);
597
- }
230
+ // Take the credential from the background service before probing, so the two
231
+ // never race for the single connection the relay allows.
232
+ const lease = acquireTunnelLease();
233
+ blankLine();
234
+ console.log(` ${glyph.mark} ${c.dim}Checking saved credentials…${c.reset}`);
598
235
 
599
- async function commandRun(flags: Record<string, string>): Promise<void> {
600
- const config = loadConfig({
601
- token: flags.token,
602
- tunnelId: flags['tunnel-id'],
603
- apiUrl: flags['api-url'],
604
- });
236
+ let probe;
237
+ try {
238
+ probe = await probeCredentials(config, {
239
+ capabilities: createEnabledCapabilityRegistry(config).getCapabilityNames(),
240
+ });
241
+ } catch (error) {
242
+ lease.resumeService();
243
+ throw error;
244
+ }
605
245
 
606
- if (!config.token || !config.tunnelId) {
607
- console.error(`${c.red}${c.bold} error${c.reset} No saved tunnel credentials found. Run \`agent-tunnel connect\` first.`);
608
- process.exit(1);
246
+ if (probe === 'unreachable') {
247
+ // The credential is unproven, so restore exactly what was running before.
248
+ lease.resumeService();
249
+ fail(`Cannot reach the relay at ${config.apiUrl}. Check your network, then run connect again.`);
250
+ }
251
+
252
+ if (probe === 'rejected') {
253
+ if (explicitCredentials) fail('The supplied --token was rejected for this tunnel.');
254
+ console.log(` ${glyph.warn} ${c.dim}Saved token rejected — re-authorizing${c.reset}`);
255
+ clearSavedCredentials();
256
+ await pairThisMachine(config.apiUrl, flags);
257
+ return;
609
258
  }
610
259
 
611
- startAgent(config, { service: flags.service === 'true' });
260
+ await launch(config, flags, lease);
612
261
  }
613
262
 
614
- async function commandStatus(flags: Record<string, string>): Promise<void> {
263
+ // ── other commands ───────────────────────────────────────────────────────────
264
+
265
+ function commandRun(flags: Flags): void {
615
266
  const config = loadConfig({
616
267
  token: flags.token,
617
268
  tunnelId: flags['tunnel-id'],
618
269
  apiUrl: flags['api-url'],
619
270
  });
271
+ const asService = flags.service === 'true';
620
272
 
621
273
  if (!config.token || !config.tunnelId) {
622
- console.error('Error: --token and --tunnel-id are required');
623
- process.exit(1);
274
+ console.error(` ${glyph.bad} No saved tunnel credentials found. Run \`agent-tunnel connect\` first.`);
275
+ // Restarting cannot conjure a credential. Under a supervisor this exits
276
+ // cleanly so the service stops instead of respawning forever.
277
+ process.exit(asService ? TERMINAL_SERVICE_EXIT_CODE : 1);
278
+ }
279
+
280
+ if (asService) rotateServiceLogs();
281
+ startAgent(config, { service: asService });
282
+ }
283
+
284
+ /** Last agent line in the service log, so status reports evidence not a guess. */
285
+ function lastServiceActivity(): string | null {
286
+ try {
287
+ const lines = readFileSync(join(getServicePaths().logDir, 'agent-tunnel.out.log'), 'utf8')
288
+ .split(/\r?\n/)
289
+ .map((line) => stripAnsi(line).trim())
290
+ .filter((line) => line.length > 0);
291
+ return lines.at(-1) ?? null;
292
+ } catch {
293
+ return null;
624
294
  }
295
+ }
625
296
 
626
- if (isSetupTunnelToken(config.token)) {
297
+ function commandStatus(flags: Flags): void {
298
+ const config = loadConfig({ apiUrl: flags['api-url'] });
299
+ const service = getServiceStatus();
300
+ const paired = Boolean(config.token && config.tunnelId);
301
+ const approved = new Set(config.enabledCapabilities ?? []);
302
+
303
+ if (isTruthyFlag(flags.json)) {
627
304
  console.log(JSON.stringify({
628
- tunnelId: config.tunnelId,
305
+ paired,
306
+ tunnelId: paired ? config.tunnelId : null,
629
307
  apiUrl: config.apiUrl,
630
- credential: 'device-setup-token',
631
- note: 'Saved device credentials authenticate the local WebSocket agent. HTTP live status requires a user or sandbox API key.',
632
- service: getServiceStatus(),
308
+ capabilities: [...approved],
309
+ version: agentTunnelVersion(),
310
+ service,
311
+ lastActivity: lastServiceActivity(),
633
312
  }, null, 2));
634
313
  return;
635
314
  }
636
315
 
637
- try {
638
- const res = await fetch(`${config.apiUrl}/connections/${config.tunnelId}`, {
639
- headers: { Authorization: `Bearer ${config.token}` },
640
- });
641
-
642
- if (!res.ok) {
643
- console.error(`Error: ${res.status} ${await res.text()}`);
644
- process.exit(1);
645
- }
316
+ blankLine();
317
+ console.log(` ${glyph.mark} ${c.bold}${c.white}Agent Tunnel${c.reset} ${c.dim}v${agentTunnelVersion()}${c.reset} ${c.dim}${hostname()}${c.reset}`);
318
+ blankLine();
646
319
 
647
- const data = await res.json();
648
- console.log(JSON.stringify(data, null, 2));
649
- } catch (err) {
650
- console.error('Error:', err);
651
- process.exit(1);
320
+ if (!paired) {
321
+ console.log(` ${glyph.off} ${c.bold}Not paired${c.reset}`);
322
+ blankLine();
323
+ console.log(` ${c.dim}Pair this machine:${c.reset} ${c.white}agent-tunnel connect --api-url <url>${c.reset}`);
324
+ blankLine();
325
+ return;
652
326
  }
653
- }
654
327
 
655
- function commandInstallService(flags: Record<string, string>): void {
656
- const config = loadConfig({
657
- token: flags.token,
658
- tunnelId: flags['tunnel-id'],
659
- apiUrl: flags['api-url'],
660
- });
328
+ field('tunnel', `${c.white}${config.tunnelId}${c.reset}`);
329
+ field('relay', `${c.white}${config.apiUrl}${c.reset}`);
330
+ field('capabilities', ALL_CAPABILITIES
331
+ .map((name) => approved.has(name) ? `${glyph.on} ${c.white}${name}${c.reset}` : `${c.gray}○ ${name}${c.reset}`)
332
+ .join(' '));
333
+ blankLine();
334
+ field('service', describeService(service));
335
+ if (service.installed && service.path) field('', `${c.dim}${shortenHomePath(service.path)}${c.reset}`);
661
336
 
662
- if (!config.token || !config.tunnelId) {
663
- console.error(`${c.red}${c.bold} error${c.reset} No saved tunnel credentials found. Run \`agent-tunnel connect\` first, or pass --token and --tunnel-id.`);
664
- process.exit(1);
665
- }
337
+ const activity = lastServiceActivity();
338
+ if (activity) field('last log', `${c.dim}${activity}${c.reset}`);
339
+ blankLine();
666
340
 
667
- if (flags.token && flags['tunnel-id']) {
668
- saveCredentials(config.tunnelId, config.token, config.apiUrl);
341
+ if (approved.size === 0) {
342
+ console.log(` ${glyph.warn} ${c.dim}No capabilities approved — this tunnel cannot act.${c.reset}`);
343
+ console.log(` ${c.dim}Pair again with${c.reset} ${c.white}agent-tunnel connect --reauth${c.reset}`);
344
+ blankLine();
669
345
  }
670
-
671
- const status = installService();
672
- console.log(JSON.stringify(status, null, 2));
346
+ console.log(` ${c.dim}Recent logs:${c.reset} ${c.white}agent-tunnel logs${c.reset}`);
347
+ blankLine();
673
348
  }
674
349
 
675
- function commandUninstallService(): void {
676
- console.log(JSON.stringify(uninstallService(), null, 2));
350
+ function commandLogout(flags: Flags): void {
351
+ const removed = clearSavedCredentials();
352
+ const keepService = isTruthyFlag(flags['keep-service']);
353
+ if (!keepService) SERVICE_ACTIONS.uninstall.run();
354
+
355
+ blankLine();
356
+ console.log(removed
357
+ ? ` ${glyph.on} ${c.bold}Signed out${c.reset} ${c.dim}(credentials cleared from ${CONFIG_FILE})${c.reset}`
358
+ : ` ${glyph.off} ${c.dim}No saved credentials to clear${c.reset}`);
359
+ console.log(keepService
360
+ ? ` ${glyph.warn} ${c.dim}Background service kept — it cannot authenticate until you connect again${c.reset}`
361
+ : ` ${c.dim}Background service removed${c.reset}`);
362
+ blankLine();
363
+ console.log(` ${c.dim}Pair again with:${c.reset} ${c.white}agent-tunnel connect --api-url <url>${c.reset}`);
364
+ blankLine();
677
365
  }
678
366
 
679
- function commandStartService(): void {
680
- console.log(JSON.stringify(startService(), null, 2));
681
- }
367
+ function commandLogs(flags: Flags): void {
368
+ const paths = getServicePaths();
682
369
 
683
- function commandStopService(): void {
684
- console.log(JSON.stringify(stopService(), null, 2));
685
- }
370
+ if (isTruthyFlag(flags.clear)) {
371
+ for (const file of serviceLogFiles(paths)) {
372
+ try { writeFileSync(file, '', { mode: 0o600 }); } catch {}
373
+ }
374
+ blankLine();
375
+ console.log(` ${glyph.on} ${c.dim}Service logs cleared${c.reset}`);
376
+ blankLine();
377
+ return;
378
+ }
686
379
 
687
- function commandRestartService(): void {
688
- console.log(JSON.stringify(restartService(), null, 2));
689
- }
380
+ const requested = Number.parseInt(flags.lines ?? '', 10);
381
+ const limit = Number.isSafeInteger(requested) && requested > 0 ? requested : DEFAULT_LOG_LINES;
382
+ const showAll = isTruthyFlag(flags.all);
690
383
 
691
- function commandServiceStatus(): void {
692
- console.log(JSON.stringify(getServiceStatus(), null, 2));
693
- }
384
+ for (const [label, file] of [
385
+ ['output', join(paths.logDir, 'agent-tunnel.out.log')],
386
+ ['errors', join(paths.logDir, 'agent-tunnel.err.log')],
387
+ ] as const) {
388
+ blankLine();
389
+ console.log(` ${c.bold}${c.white}${label}${c.reset} ${c.dim}${shortenHomePath(file)}${c.reset}`);
694
390
 
695
- function commandLogs(): void {
696
- const paths = getServicePaths();
697
- const files = [
698
- join(paths.logDir, 'agent-tunnel.out.log'),
699
- join(paths.logDir, 'agent-tunnel.err.log'),
700
- ];
701
- for (const file of files) {
702
- console.log(`\n${c.bold}${file}${c.reset}`);
703
391
  if (!existsSync(file)) {
704
- console.log(`${c.dim}not created yet${c.reset}`);
392
+ console.log(` ${c.dim}not created yet${c.reset}`);
705
393
  continue;
706
394
  }
707
- const body = readFileSync(file, 'utf8');
708
- const lines = body.split(/\r?\n/).slice(-120).join('\n').trim();
709
- console.log(lines || `${c.dim}empty${c.reset}`);
395
+
396
+ const kept = readFileSync(file, 'utf8')
397
+ .split(/\r?\n/)
398
+ .map((line) => line.trimEnd())
399
+ .filter((line) => line.trim().length > 0)
400
+ .filter((line) => showAll || !isShellStartupNoise(line));
401
+
402
+ const lines = collapseRepeatedLines(kept).slice(-limit);
403
+ if (lines.length === 0) {
404
+ console.log(` ${c.dim}empty${c.reset}`);
405
+ continue;
406
+ }
407
+ for (const line of lines) console.log(` ${line}`);
408
+ }
409
+ blankLine();
410
+ console.log(` ${c.dim}--lines <n> to show more, --all to keep shell noise, --clear to empty them.${c.reset}`);
411
+ blankLine();
412
+ }
413
+
414
+ function commandServiceAction(action: ServiceAction, flags: Flags): void {
415
+ if (action === 'install') {
416
+ const config = loadConfig({
417
+ token: flags.token,
418
+ tunnelId: flags['tunnel-id'],
419
+ apiUrl: flags['api-url'],
420
+ });
421
+ if (!config.token || !config.tunnelId) {
422
+ fail('No saved tunnel credentials found. Run `agent-tunnel connect` first, or pass --token and --tunnel-id.');
423
+ }
424
+ if (flags.token && flags['tunnel-id']) {
425
+ saveCredentials(config.tunnelId, config.token, config.apiUrl);
426
+ }
710
427
  }
428
+ renderServiceAction(action, SERVICE_ACTIONS[action].run());
429
+ }
430
+
431
+ // ── dispatch ─────────────────────────────────────────────────────────────────
432
+
433
+ interface Command {
434
+ summary: string;
435
+ run: (flags: Flags) => void | Promise<void>;
436
+ aliases?: readonly string[];
437
+ hidden?: boolean;
711
438
  }
712
439
 
440
+ const COMMANDS: Record<string, Command> = {
441
+ connect: {
442
+ summary: 'Pair this machine, then run it in the background or this terminal',
443
+ run: commandConnect,
444
+ },
445
+ status: { summary: 'Show pairing, capabilities, and service state (--json)', run: commandStatus },
446
+ logs: { summary: 'Show recent service logs (--lines <n>, --all, --clear)', run: commandLogs },
447
+ start: { summary: 'Start the background service', run: (f) => commandServiceAction('start', f) },
448
+ stop: {
449
+ summary: 'Stop the background service (keeps it installed)',
450
+ run: (f) => commandServiceAction('stop', f),
451
+ aliases: ['disable'],
452
+ },
453
+ restart: { summary: 'Restart the background service', run: (f) => commandServiceAction('restart', f) },
454
+ 'install-service': {
455
+ summary: 'Install and start the background service',
456
+ run: (f) => commandServiceAction('install', f),
457
+ },
458
+ 'uninstall-service': {
459
+ summary: 'Stop and remove the background service',
460
+ run: (f) => commandServiceAction('uninstall', f),
461
+ },
462
+ 'service-status': {
463
+ summary: 'Show the background service state (same view as status)',
464
+ run: commandStatus,
465
+ },
466
+ logout: { summary: 'Clear saved credentials and remove the service', run: commandLogout },
467
+ run: { summary: 'Run using saved credentials (used by the service)', run: commandRun },
468
+ 'start-service': { summary: '', run: (f) => commandServiceAction('start', f), hidden: true },
469
+ 'stop-service': { summary: '', run: (f) => commandServiceAction('stop', f), hidden: true },
470
+ 'restart-service': { summary: '', run: (f) => commandServiceAction('restart', f), hidden: true },
471
+ 'sign-out': { summary: '', run: commandLogout, hidden: true },
472
+ unpair: { summary: '', run: commandLogout, hidden: true },
473
+ };
474
+
475
+ const OPTIONS: ReadonlyArray<readonly [string, string]> = [
476
+ ['--api-url <url>', 'Relay API URL'],
477
+ ['--token <token> --tunnel-id <id>', 'Skip device auth and use an explicit credential'],
478
+ ['--reauth', 'With connect: discard the saved credential and pair again'],
479
+ ['--daemon / --foreground', 'With connect: skip the prompt and choose the mode'],
480
+ ['--json', 'With status: machine-readable output'],
481
+ ['--keep-service', 'With logout: keep the background service installed'],
482
+ ];
483
+
713
484
  function showHelp(): void {
714
- console.log('');
485
+ blankLine();
715
486
  console.log(` ${c.cyan}▄▀█ █▀▀ █▀▀ █▄ █ ▀█▀${c.reset} ${c.cyan}▀█▀ █ █ █▄ █ █▄ █ █▀▀ █ ${c.reset}`);
716
487
  console.log(` ${c.cyan}█▀█ █▄█ ██▄ █ ▀█ █${c.reset} ${c.cyan} █ █▄█ █ ▀█ █ ▀█ ██▄ █▄▄${c.reset}`);
717
- console.log('');
488
+ blankLine();
718
489
  console.log(` ${c.dim}Secure bridge between AI agents & local machines${c.reset}`);
719
- console.log('');
490
+ blankLine();
720
491
  console.log(` ${c.bold}Usage${c.reset} ${c.dim}npx --yes @kortix/agent-tunnel@latest <command> [options]${c.reset}`);
721
- console.log('');
492
+ blankLine();
493
+
722
494
  console.log(`${c.gray} ── Commands ────────────────────────────────────────${c.reset}`);
723
- console.log(` ${c.cyan}connect${c.reset} Connect via device auth; interactively choose foreground/background`);
724
- console.log(` ${c.cyan}run${c.reset} Run using saved credentials ${c.dim}(used by service)${c.reset}`);
725
- console.log(` ${c.cyan}install-service${c.reset} Install/start a persistent background service`);
726
- console.log(` ${c.cyan}start${c.reset} Start the installed background service`);
727
- console.log(` ${c.cyan}stop${c.reset} Stop the installed background service ${c.dim}(keeps it installed)${c.reset}`);
728
- console.log(` ${c.cyan}restart${c.reset} Restart the installed background service`);
729
- console.log(` ${c.cyan}service-status${c.reset} Check persistent service status`);
730
- console.log(` ${c.cyan}logs${c.reset} Show recent service logs`);
731
- console.log(` ${c.cyan}uninstall-service${c.reset} Stop/remove the persistent service`);
732
- console.log(` ${c.cyan}status${c.reset} Check tunnel connection status`);
733
- console.log(` ${c.cyan}help${c.reset} Show this help message`);
734
- console.log('');
495
+ const visible = Object.entries(COMMANDS).filter(([, command]) => !command.hidden);
496
+ const width = Math.max(...visible.map(([name]) => name.length)) + 2;
497
+ for (const [name, command] of visible) {
498
+ console.log(` ${c.cyan}${name.padEnd(width)}${c.reset}${command.summary}`);
499
+ }
500
+ blankLine();
501
+
735
502
  console.log(`${c.gray} ── Options ─────────────────────────────────────────${c.reset}`);
736
- console.log(` ${c.white}--token${c.reset} ${c.dim}<token>${c.reset} Skip device auth, connect directly`);
737
- console.log(` ${c.white}--tunnel-id${c.reset} ${c.dim}<id>${c.reset} Tunnel ID ${c.dim}(required with --token)${c.reset}`);
738
- console.log(` ${c.white}--api-url${c.reset} ${c.dim}<url>${c.reset} API URL ${c.dim}(default: http://localhost:8080)${c.reset}`);
739
- console.log(` ${c.white}--daemon${c.reset} With connect: skip the prompt and install the background service`);
740
- console.log(` ${c.white}--foreground${c.reset} With connect: skip prompts and run only in this terminal`);
741
- console.log('');
742
- console.log(` ${c.dim}Config: ~/.agent-tunnel/config.json${c.reset}`);
503
+ const optionWidth = Math.max(...OPTIONS.map(([flag]) => flag.length)) + 2;
504
+ for (const [flag, description] of OPTIONS) {
505
+ console.log(` ${c.white}${flag.padEnd(optionWidth)}${c.reset}${c.dim}${description}${c.reset}`);
506
+ }
507
+ blankLine();
508
+ console.log(` ${c.dim}Config: ${CONFIG_FILE}${c.reset}`);
743
509
  console.log(` ${c.dim}powered by ${c.cyan}kortix${c.reset}`);
744
- console.log('');
510
+ blankLine();
745
511
  }
746
512
 
747
513
  const { command, flags } = parseArgs(process.argv);
748
514
 
749
515
  if (Object.prototype.hasOwnProperty.call(flags, 'keep-awake')) {
750
- console.error(`${c.red}${c.bold} error${c.reset} --keep-awake is not supported. Configure sleep behavior in the operating system.`);
516
+ console.error(` ${glyph.bad} --keep-awake is not supported. Configure sleep behavior in the operating system.`);
751
517
  process.exit(2);
752
518
  }
753
519
 
754
- switch (command) {
755
- case 'connect':
756
- commandConnect(flags);
757
- break;
758
- case 'run':
759
- commandRun(flags);
760
- break;
761
- case 'install-service':
762
- commandInstallService(flags);
763
- break;
764
- case 'start':
765
- case 'start-service':
766
- commandStartService();
767
- break;
768
- case 'stop':
769
- case 'stop-service':
770
- case 'disable':
771
- commandStopService();
772
- break;
773
- case 'restart':
774
- case 'restart-service':
775
- commandRestartService();
776
- break;
777
- case 'service-status':
778
- commandServiceStatus();
779
- break;
780
- case 'logs':
781
- commandLogs();
782
- break;
783
- case 'uninstall-service':
784
- commandUninstallService();
785
- break;
786
- case 'status':
787
- commandStatus(flags);
788
- break;
789
- case 'help':
790
- default:
791
- showHelp();
792
- break;
520
+ const resolved =
521
+ COMMANDS[command] ??
522
+ Object.values(COMMANDS).find((entry) => entry.aliases?.includes(command));
523
+
524
+ if (!resolved) {
525
+ showHelp();
526
+ } else {
527
+ void Promise.resolve(resolved.run(flags)).catch((error: unknown) => {
528
+ console.error(` ${glyph.bad} ${error instanceof Error ? error.message : String(error)}`);
529
+ process.exit(1);
530
+ });
793
531
  }