ai-remote 0.1.0 → 0.3.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.
Files changed (47) hide show
  1. package/dist/cli.mjs +8065 -0
  2. package/dist/index.js +6 -6
  3. package/dist/protocols/index.js +2 -2
  4. package/dist/protocols/rdp/caps.js +1 -1
  5. package/dist/protocols/rdp/cert.js +1 -1
  6. package/dist/protocols/rdp/client.js +18 -16
  7. package/dist/protocols/rdp/client.js.map +2 -2
  8. package/dist/protocols/rdp/cliprdr.js +1 -1
  9. package/dist/protocols/rdp/credssp.js +2 -2
  10. package/dist/protocols/rdp/display.js +2 -2
  11. package/dist/protocols/rdp/gcc.js +2 -2
  12. package/dist/protocols/rdp/mcs.js +2 -2
  13. package/dist/protocols/rdp/ntlm.js +2 -2
  14. package/dist/protocols/rdp/pdu.js +1 -1
  15. package/dist/protocols/rdp/rail.js +1 -1
  16. package/dist/protocols/rdp/sec.js +3 -3
  17. package/dist/protocols/rdp/session.js +3 -3
  18. package/dist/protocols/rdp/tls.js +3 -3
  19. package/dist/protocols/rdp/vchannel.js +1 -1
  20. package/dist/protocols/rdp/x224.js +1 -1
  21. package/dist/protocols/ssh/kex.js +1 -1
  22. package/dist/protocols/ssh/session.js +4 -3
  23. package/dist/protocols/ssh/session.js.map +2 -2
  24. package/dist/protocols/ssh/transport.js +8 -6
  25. package/dist/protocols/ssh/transport.js.map +2 -2
  26. package/dist/protocols/vnc/session.js +3 -3
  27. package/dist/shared/connection.js +1 -1
  28. package/dist/shared/hosts.js +1 -1
  29. package/package.json +13 -3
  30. package/src/cli/cli.ts +451 -0
  31. package/src/cli/daemon.ts +291 -0
  32. package/src/cli/framebuffer.ts +115 -0
  33. package/src/cli/generated/viewer-bundle.ts +5 -0
  34. package/src/cli/ipc.ts +78 -0
  35. package/src/cli/paths.ts +28 -0
  36. package/src/cli/png.ts +106 -0
  37. package/src/cli/session.ts +263 -0
  38. package/src/cli/shell.ts +171 -0
  39. package/src/cli/transport.ts +86 -0
  40. package/src/cli/viewer-client/assets.d.ts +4 -0
  41. package/src/cli/viewer-client/main.ts +300 -0
  42. package/src/cli/viewer-page.ts +102 -0
  43. package/src/cli/viewer.ts +269 -0
  44. package/src/cli/wsserver.ts +144 -0
  45. package/src/protocols/rdp/client.ts +9 -1
  46. package/src/protocols/ssh/session.ts +1 -0
  47. package/src/protocols/ssh/transport.ts +15 -2
package/package.json CHANGED
@@ -1,8 +1,12 @@
1
1
  {
2
2
  "name": "ai-remote",
3
- "version": "0.1.0",
4
- "description": "Pure TypeScript protocol engines (RDP, VNC, SSH) and remote desktop vocabulary for AI agents, Node.js, and web browsers",
3
+ "version": "0.3.0",
4
+ "description": "RDP, VNC and SSH protocol engines that run in Node, a Worker or a browser -- plus `npx ai-remote`, a CLI that drives a machine and shows you what it is doing",
5
5
  "type": "module",
6
+ "bin": {
7
+ "ai-remote": "./dist/cli.mjs",
8
+ "remotectl": "./dist/cli.mjs"
9
+ },
6
10
  "license": "MIT",
7
11
  "author": {
8
12
  "name": "Leon Guo",
@@ -21,7 +25,10 @@
21
25
  "ai-agent",
22
26
  "remote-control",
23
27
  "novnc",
24
- "xterm"
28
+ "xterm",
29
+ "cli",
30
+ "npx",
31
+ "agent"
25
32
  ],
26
33
  "main": "./dist/index.js",
27
34
  "types": "./src/index.ts",
@@ -78,5 +85,8 @@
78
85
  "@xterm/xterm": "^6.0.0",
79
86
  "esbuild": "^0.28.2",
80
87
  "typescript": "^7.0.2"
88
+ },
89
+ "engines": {
90
+ "node": ">=22"
81
91
  }
82
92
  }
package/src/cli/cli.ts ADDED
@@ -0,0 +1,451 @@
1
+ /**
2
+ * ai-remote -- drive a remote machine from the command line.
3
+ *
4
+ * Connects straight to the host over TCP: no gateway in the path, and no
5
+ * browser in the path either. The first command opens a session that stays
6
+ * open; the ones after it attach to that same session, so a click and the
7
+ * screenshot of what the click did are part of one conversation with the host.
8
+ */
9
+
10
+ import { basename, resolve as resolvePath } from 'node:path';
11
+ import { spawn } from 'node:child_process';
12
+ import { readFileSync, readdirSync, existsSync, openSync, unlinkSync } from 'node:fs';
13
+ import { Shell } from './shell';
14
+ import { runDaemon } from './daemon';
15
+ import { request } from './ipc';
16
+ import { ensureRunDir, logPath, metaPath, RUN_DIR, sessionName, socketPath } from './paths';
17
+
18
+ /** The tail of a session's log, for when it did not start. */
19
+ function readLog(name: string): string {
20
+ try {
21
+ return readFileSync(logPath(name), 'utf8')
22
+ .split('\n')
23
+ .filter((line) => line.trim() && !/^\s/.test(line) && !line.startsWith('[RDP] '))
24
+ .slice(-4)
25
+ .join('\n');
26
+ } catch { return ''; }
27
+ }
28
+
29
+ /**
30
+ * Whichever name this was invoked by. The package installs two -- `ai-remote`
31
+ * to match itself, so `npx ai-remote` works, and `remotectl` because it is the
32
+ * better thing to type once it is on a PATH -- and help that names the other
33
+ * one sends people to a command they did not run.
34
+ */
35
+ const NAME = (() => {
36
+ const invoked = basename(process.argv[1] ?? '').replace(/\.mjs$/, '');
37
+ return invoked === 'ai-remote' || invoked === 'remotectl' ? invoked : 'ai-remote';
38
+ })();
39
+
40
+ const USAGE = `${NAME} -- drive a remote desktop, with a window you can watch
41
+
42
+ ${NAME} open <host[:port]> connect, show a window, and stay open
43
+ ${NAME} shot <host[:port]> screenshot the desktop
44
+ ${NAME} click <host[:port]> X,Y click, --button right|middle, --double
45
+ ${NAME} type <host[:port]> "text" type into whatever has focus
46
+ ${NAME} key <host[:port]> <Code> press keys, e.g. MetaLeft, ControlLeft+KeyA
47
+ ${NAME} exec <host[:port]> "cmd" run a command in the terminal (SSH)
48
+ ${NAME} shell <host[:port]> an interactive terminal
49
+ ${NAME} do <host[:port]> "steps" several actions in one go
50
+ ${NAME} view <host[:port]> open the window on a running session
51
+ ${NAME} view <host[:port]> --close close the window, session keeps running
52
+ ${NAME} status | list | close [<host>]
53
+ ${NAME} probe <host[:port]> is anything listening?
54
+
55
+ Steps for "do", separated by semicolons:
56
+ move X,Y · click X,Y[,right|middle] · dblclick X,Y · scroll X,Y,DX,DY
57
+ type TEXT · key Code[+Code...] · cad · wait MS · shot FILE
58
+
59
+ Options
60
+ -u, --user NAME account on the host
61
+ -d, --domain NAME Windows domain or computer name
62
+ --ssh-user NAME account for the terminal (default: --user)
63
+ --ssh-port N SSH port (default: 22)
64
+ -s, --security MODE auto | nla | tls | rdp (default: auto)
65
+ -W, --width N desktop width (default: 1280)
66
+ -H, --height N desktop height (default: 800)
67
+ -o, --out FILE where a screenshot goes (default: screen.png)
68
+ --max-edge N shrink a screenshot to fit N
69
+ --view-port N port for the window (default: 7373)
70
+ --idle MINUTES close an unused session (default: 0, never)
71
+ --headless no window (same as --no-view)
72
+ --no-view no window
73
+ --no-open window, but do not launch a browser
74
+ --no-reuse a fresh session, ignoring any that is already open
75
+ --json machine-readable result on stdout
76
+
77
+ The password comes from AI_REMOTE_PASSWORD (or REMOTECTL_PASSWORD). There is no
78
+ --password flag: a command line is visible to every process on the machine.
79
+
80
+ Exit codes: 0 ok · 10 unreachable · 11 auth rejected · 12 bad usage · 13 timeout
81
+ `;
82
+
83
+ interface Args {
84
+ command: string;
85
+ target: string;
86
+ rest: string[];
87
+ flags: Record<string, string | boolean>;
88
+ }
89
+
90
+ const VALUE_FLAGS = new Set([
91
+ 'u', 'user', 'd', 'domain', 's', 'security', 'W', 'width', 'H', 'height',
92
+ 'o', 'out', 'at', 'button', 'max-edge', 'view-port', 'idle', 'name',
93
+ 'ssh-user', 'ssh-port', 'settle',
94
+ ]);
95
+
96
+ function parse(argv: string[]): Args {
97
+ const flags: Record<string, string | boolean> = {};
98
+ const positional: string[] = [];
99
+
100
+ for (let i = 0; i < argv.length; i++) {
101
+ const token = argv[i];
102
+ if (!token.startsWith('-') || token === '-') { positional.push(token); continue; }
103
+ const name = token.replace(/^--?/, '');
104
+ if (VALUE_FLAGS.has(name)) flags[name] = argv[++i] ?? '';
105
+ else flags[name] = true;
106
+ }
107
+
108
+ return { command: positional[0] ?? '', target: positional[1] ?? '', rest: positional.slice(2), flags };
109
+ }
110
+
111
+ const flag = (args: Args, ...names: string[]): string | undefined => {
112
+ for (const name of names) if (typeof args.flags[name] === 'string') return args.flags[name] as string;
113
+ return undefined;
114
+ };
115
+ const has = (args: Args, ...names: string[]) => names.some((name) => args.flags[name] === true);
116
+
117
+ function splitTarget(target: string, fallbackPort: number) {
118
+ const stripped = target.replace(/^\w+:\/\//, '');
119
+ const [host, port] = stripped.split(':');
120
+ return { host, port: port ? Number(port) : fallbackPort };
121
+ }
122
+
123
+ class CliError extends Error {
124
+ constructor(message: string, readonly code: number) { super(message); }
125
+ }
126
+
127
+ const password = () => process.env.AI_REMOTE_PASSWORD ?? process.env.REMOTECTL_PASSWORD ?? '';
128
+
129
+ // --- session lookup ---------------------------------------------------------
130
+
131
+ interface Meta { name: string; host: string; port: number; pid: number; viewer: string | null; width: number; height: number; startedAt: string }
132
+
133
+ function liveSessions(): Meta[] {
134
+ ensureRunDir();
135
+ return readdirSync(RUN_DIR)
136
+ .filter((file) => file.endsWith('.json'))
137
+ .flatMap((file) => {
138
+ try {
139
+ const meta = JSON.parse(readFileSync(`${RUN_DIR}/${file}`, 'utf8')) as Meta;
140
+ // A metadata file outliving its process means the daemon was killed.
141
+ try { process.kill(meta.pid, 0); } catch { cleanup(meta.name); return []; }
142
+ return [meta];
143
+ } catch { return []; }
144
+ });
145
+ }
146
+
147
+ function cleanup(name: string): void {
148
+ for (const file of [socketPath(name), metaPath(name), logPath(name)]) {
149
+ try { if (existsSync(file)) unlinkSync(file); } catch { /* already gone */ }
150
+ }
151
+ }
152
+
153
+ /** Ask a running session to do something, or report that there is none. */
154
+ async function ask(name: string, op: string, args: Record<string, unknown> = {}): Promise<any> {
155
+ const response = await request(socketPath(name), op, args).catch((error: any) => {
156
+ if (error?.notRunning) return null;
157
+ throw error;
158
+ });
159
+ if (!response) return null;
160
+ if (!response.ok) throw new CliError(response.error ?? `The session refused "${op}".`, response.code ?? 1);
161
+ return response.result;
162
+ }
163
+
164
+ /**
165
+ * Start a session in the background and wait for it to answer.
166
+ *
167
+ * The daemon is this same executable: one file, so there is nothing to find on
168
+ * disk and nothing to keep in step with it.
169
+ */
170
+ async function startSession(args: Args, name: string, host: string, port: number): Promise<any> {
171
+ // Rebuilt rather than forwarded. This process's own argv carries the command
172
+ // that triggered the start -- `shot`, `click` -- and passing it through would
173
+ // make the session try to run it as well as host it.
174
+ const daemonArgs = [
175
+ process.argv[1], '__session', `${host}:${port}`,
176
+ '--name', name,
177
+ '-u', flag(args, 'u', 'user') ?? '',
178
+ '-d', flag(args, 'd', 'domain') ?? '',
179
+ '-s', flag(args, 's', 'security') ?? 'auto',
180
+ '-W', flag(args, 'W', 'width') ?? '1280',
181
+ '-H', flag(args, 'H', 'height') ?? '800',
182
+ '--ssh-user', flag(args, 'ssh-user') ?? flag(args, 'u', 'user') ?? '',
183
+ '--ssh-port', flag(args, 'ssh-port') ?? '22',
184
+ '--view-port', flag(args, 'view-port') ?? '7373',
185
+ '--idle', flag(args, 'idle') ?? '30',
186
+ ];
187
+ if (has(args, 'no-view', 'headless')) daemonArgs.push('--no-view');
188
+ if (has(args, 'no-open')) daemonArgs.push('--no-open');
189
+
190
+ // Its output goes to a file rather than nowhere: a session that fails to
191
+ // start is the one moment its log is worth having.
192
+ ensureRunDir();
193
+ const log = openSync(logPath(name), 'a');
194
+
195
+ const child = spawn(process.execPath, daemonArgs, {
196
+ detached: true,
197
+ stdio: ['ignore', log, log],
198
+ env: { ...process.env, AI_REMOTE_PASSWORD: password() },
199
+ });
200
+ child.unref();
201
+
202
+ const deadline = Date.now() + 45_000;
203
+ while (Date.now() < deadline) {
204
+ await new Promise((resolve) => setTimeout(resolve, 250));
205
+ const status = await ask(name, 'status').catch(() => null);
206
+ if (status) return status;
207
+ }
208
+ const detail = readLog(name);
209
+ throw new CliError(
210
+ `The session did not come up.${detail ? `\n${detail}` : ''}`,
211
+ /password|credential|logon|CredSSP|auth/i.test(detail) ? 11 : 13
212
+ );
213
+ }
214
+
215
+ // --- entry ------------------------------------------------------------------
216
+
217
+ async function main(): Promise<void> {
218
+ const args = parse(process.argv.slice(2));
219
+ if (!args.command || has(args, 'h', 'help')) { process.stdout.write(USAGE); return; }
220
+
221
+ const json = has(args, 'json');
222
+
223
+ if (args.command === 'list' || (args.command === 'status' && !args.target)) {
224
+ const sessions = liveSessions();
225
+ if (json) { process.stdout.write(`${JSON.stringify({ sessions })}\n`); return; }
226
+ if (!sessions.length) { process.stdout.write('No sessions are open.\n'); return; }
227
+ for (const meta of sessions) {
228
+ process.stdout.write(`${meta.name} ${meta.host}:${meta.port} ${meta.width}x${meta.height} pid ${meta.pid}`
229
+ + `${meta.viewer ? ` ${meta.viewer}` : ''}\n`);
230
+ }
231
+ return;
232
+ }
233
+
234
+ if (!args.target && args.command !== 'close') throw new CliError('Name the machine to connect to.', 12);
235
+
236
+ const { host, port } = splitTarget(args.target || '', 3389);
237
+ const name = sessionName(host, port, flag(args, 'name'));
238
+
239
+ if (args.command === 'close') {
240
+ const targets = args.target ? [name] : liveSessions().map((meta) => meta.name);
241
+ for (const target of targets) {
242
+ await ask(target, 'close').catch(() => null);
243
+ cleanup(target);
244
+ process.stdout.write(`Closed ${target}.\n`);
245
+ }
246
+ if (!targets.length) process.stdout.write('No sessions are open.\n');
247
+ return;
248
+ }
249
+
250
+ if (args.command === 'probe') {
251
+ const { reachable, ms, detail } = await probe(host, port);
252
+ if (json) process.stdout.write(`${JSON.stringify({ host, port, reachable, ms, detail })}\n`);
253
+ else process.stdout.write(`${host}:${port} ${reachable ? `reachable in ${ms}ms` : `unreachable — ${detail}`}\n`);
254
+ if (!reachable) throw new CliError(detail, 10);
255
+ return;
256
+ }
257
+
258
+ // The hidden command the background session runs as.
259
+ if (args.command === '__session') {
260
+ await runDaemon({
261
+ name,
262
+ host,
263
+ port,
264
+ username: flag(args, 'u', 'user') ?? '',
265
+ password: password(),
266
+ domain: flag(args, 'd', 'domain') ?? '',
267
+ width: Number(flag(args, 'W', 'width') ?? 1280),
268
+ height: Number(flag(args, 'H', 'height') ?? 800),
269
+ security: (flag(args, 's', 'security') ?? 'auto') as 'auto',
270
+ clientName: 'ai-remote',
271
+ view: !has(args, 'no-view', 'headless'),
272
+ launchBrowser: !has(args, 'no-open'),
273
+ viewPort: Number(flag(args, 'view-port') ?? 7373),
274
+ sshUsername: flag(args, 'ssh-user') ?? flag(args, 'u', 'user') ?? '',
275
+ sshPort: Number(flag(args, 'ssh-port') ?? 22),
276
+ // Zero by default: a session stays open until it is closed. An agent
277
+ // that pauses to think is not an agent that has finished.
278
+ idleMs: Math.max(0, Number(flag(args, 'idle') ?? 0)) * 60_000,
279
+ });
280
+ return;
281
+ }
282
+
283
+ if (args.command === 'shell') { await interactiveShell(args, host); return; }
284
+
285
+ // Everything else runs against a session: the one already open, or a new one.
286
+ let status = has(args, 'no-reuse') ? null : await ask(name, 'status');
287
+ const reused = Boolean(status);
288
+ if (!status) status = await startSession(args, name, host, port);
289
+
290
+ const report: Record<string, unknown> = { host, port, name, reused, width: status.width, height: status.height };
291
+
292
+ switch (args.command) {
293
+ case 'open':
294
+ // A session that was started headless can be given a window later, and
295
+ // asking to open one that is already open just re-pops it.
296
+ if (!has(args, 'no-view', 'headless') && !status.viewer) {
297
+ Object.assign(status, await ask(name, 'view-open', { launch: !has(args, 'no-open') }));
298
+ }
299
+ Object.assign(report, { viewer: status.viewer });
300
+ break;
301
+
302
+ case 'view': {
303
+ if (has(args, 'close')) {
304
+ const { closed } = await ask(name, 'view-close');
305
+ Object.assign(report, { viewer: null, closed });
306
+ break;
307
+ }
308
+ Object.assign(report, await ask(name, 'view-open', { launch: !has(args, 'no-open') }));
309
+ break;
310
+ }
311
+
312
+ case 'shot': {
313
+ const file = resolvePath(flag(args, 'o', 'out') ?? 'screen.png');
314
+ const maxEdge = Number(flag(args, 'max-edge') ?? 0);
315
+ Object.assign(report, await ask(name, 'shot', { file, maxEdge }));
316
+ break;
317
+ }
318
+
319
+ case 'click': {
320
+ const spec = flag(args, 'at') ?? args.rest[0];
321
+ if (!spec) throw new CliError('click needs a position, e.g. `click 192.168.1.5 640,400`', 12);
322
+ const [x, y] = spec.split(',').map(Number);
323
+ const button = flag(args, 'button') ?? 'left';
324
+ const step = `click ${x},${y}${button === 'left' ? '' : `,${button}`}`;
325
+ await ask(name, 'script', { script: has(args, 'double') ? `${step}; ${step}` : step });
326
+ Object.assign(report, { clicked: { x, y, button } });
327
+ break;
328
+ }
329
+
330
+ case 'type': {
331
+ const text = args.rest.join(' ');
332
+ if (!text) throw new CliError('type needs some text', 12);
333
+ await ask(name, 'script', { script: `type ${text}` });
334
+ Object.assign(report, { typed: text.length });
335
+ break;
336
+ }
337
+
338
+ case 'key': {
339
+ if (!args.rest.length) throw new CliError('key needs at least one code, e.g. MetaLeft', 12);
340
+ await ask(name, 'script', { script: args.rest.map((code) => `key ${code}`).join('; ') });
341
+ Object.assign(report, { keys: args.rest });
342
+ break;
343
+ }
344
+
345
+ case 'do': {
346
+ const script = args.rest.join(' ');
347
+ if (!script) throw new CliError('do needs a script, e.g. "key MetaLeft; wait 800; shot s.png"', 12);
348
+ Object.assign(report, await ask(name, 'script', { script }));
349
+ break;
350
+ }
351
+
352
+ case 'exec': {
353
+ const command = args.rest.join(' ');
354
+ if (!command) throw new CliError('exec needs a command to run', 12);
355
+ const result = await ask(name, 'exec', { command });
356
+ Object.assign(report, result);
357
+ if (!json) {
358
+ process.stdout.write(result.output.endsWith('\n') ? result.output : `${result.output}\n`);
359
+ if (result.exitStatus) process.stderr.write(`exit ${result.exitStatus}\n`);
360
+ }
361
+ if (json) process.stdout.write(`${JSON.stringify({ ok: result.exitStatus === 0, ...report })}\n`);
362
+ process.exit(result.exitStatus === 0 ? 0 : 1);
363
+ return;
364
+ }
365
+
366
+ default:
367
+ throw new CliError(`Unknown command "${args.command}".`, 12);
368
+ }
369
+
370
+ if (json) process.stdout.write(`${JSON.stringify({ ok: true, ...report })}\n`);
371
+ else process.stdout.write(`${describe(args.command, report)}\n`);
372
+ }
373
+
374
+ /** A terminal on the host, attached to this process's own stdin and stdout. */
375
+ async function interactiveShell(args: Args, host: string): Promise<void> {
376
+ const shell = new Shell({
377
+ host,
378
+ port: Number(flag(args, 'ssh-port') ?? 22),
379
+ username: flag(args, 'ssh-user') ?? flag(args, 'u', 'user') ?? '',
380
+ password: password(),
381
+ columns: process.stdout.columns ?? 120,
382
+ rows: process.stdout.rows ?? 30,
383
+ });
384
+
385
+ await shell.connect().catch((error: Error) => {
386
+ throw new CliError(error.message, /password|auth|denied/i.test(error.message) ? 11 : 10);
387
+ });
388
+
389
+ shell.onData((bytes) => process.stdout.write(bytes));
390
+
391
+ if (process.stdin.isTTY) process.stdin.setRawMode(true);
392
+ process.stdin.resume();
393
+ process.stdin.on('data', (chunk) => {
394
+ // Ctrl-] leaves, the way telnet has always done it, so Ctrl-C stays
395
+ // available to the program running on the far side.
396
+ if (chunk.length === 1 && chunk[0] === 0x1d) { shell.disconnect(); process.exit(0); }
397
+ shell.write(chunk.toString('utf8'));
398
+ });
399
+
400
+ process.stdout.on('resize', () => shell.resize(process.stdout.columns ?? 120, process.stdout.rows ?? 30));
401
+ process.stderr.write('[connected — Ctrl-] to leave]\r\n');
402
+ await new Promise(() => {});
403
+ }
404
+
405
+ async function probe(host: string, port: number) {
406
+ const net = await import('node:net');
407
+ const started = Date.now();
408
+ return new Promise<{ reachable: boolean; ms: number; detail: string }>((resolve) => {
409
+ const socket = net.connect({ host, port });
410
+ const done = (reachable: boolean, detail: string) => {
411
+ socket.destroy();
412
+ resolve({ reachable, ms: Date.now() - started, detail });
413
+ };
414
+ socket.setTimeout(5000);
415
+ socket.on('connect', () => done(true, 'the port accepted a connection'));
416
+ socket.on('timeout', () => done(false, 'the connection attempt timed out'));
417
+ socket.on('error', (error: Error) => done(false, error.message));
418
+ });
419
+ }
420
+
421
+ function describe(command: string, report: Record<string, unknown>): string {
422
+ const size = `${report.width}x${report.height}`;
423
+ const where = report.reused ? 'the open session' : 'a new session';
424
+
425
+ if (command === 'open') {
426
+ return `${report.reused ? 'Already connected' : 'Connected'} to ${report.host}:${report.port} at ${size}.`
427
+ + `${report.viewer ? `\nWindow: ${report.viewer}` : ''}`
428
+ + `\nRun more commands against it, then \`${NAME} close ${report.host}\` when you are done.`;
429
+ }
430
+ if (command === 'view') {
431
+ if (report.viewer) return `Window: ${report.viewer}`;
432
+ return report.closed
433
+ ? 'Closed the window. The session is still open.'
434
+ : 'There was no window open.';
435
+ }
436
+ if (command === 'shot') return `Wrote ${report.file} (${report.width}x${report.height}) from ${where}.`;
437
+ if (command === 'click') {
438
+ const { x, y } = report.clicked as { x: number; y: number };
439
+ return `Clicked (${x}, ${y}) on a ${size} desktop.`;
440
+ }
441
+ if (command === 'type') return `Typed ${report.typed} characters.`;
442
+ if (command === 'key') return `Pressed ${(report.keys as string[]).join(', ')}.`;
443
+ if (command === 'do') return `Ran ${(report.steps as string[]).length} steps: ${(report.steps as string[]).join(' → ')}`;
444
+ return 'Done.';
445
+ }
446
+
447
+ main().catch((error: unknown) => {
448
+ const code = error instanceof CliError ? error.code : 1;
449
+ process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
450
+ process.exit(code);
451
+ });