@kortix/agent-tunnel 0.1.0 → 0.1.2

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,716 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Agent Tunnel CLI — interact with the user's local machine via Agent Tunnel.
4
+ *
5
+ * Usage: bun run cli.ts <command> [args as JSON]
6
+ *
7
+ * Commands:
8
+ * status — list all tunnel connections
9
+ * fs_read '{"path":"/Users/me/file.txt"}' — read a file
10
+ * fs_write '{"path":"/tmp/out.txt","content":"hello"}' — write a file
11
+ * fs_list '{"path":"/Users/me"}' — list directory
12
+ * shell '{"command":"git","args":["status"]}' — run a command
13
+ * screenshot — take a screenshot
14
+ * click '{"x":100,"y":200}' — click at coordinates
15
+ * mouse_move '{"x":100,"y":200}' — move mouse
16
+ * mouse_drag '{"fromX":0,"fromY":0,"toX":100,"toY":100}' — drag
17
+ * mouse_scroll '{"x":500,"y":500,"deltaY":3}' — scroll
18
+ * type '{"text":"hello world"}' — type text
19
+ * key '{"keys":["cmd","s"]}' — press key combo
20
+ * window_list — list windows
21
+ * window_focus '{"windowId":123}' — focus a window
22
+ * app_launch '{"app":"Safari"}' — launch app
23
+ * app_quit '{"app":"Safari"}' — quit app
24
+ * clipboard_read — read clipboard
25
+ * clipboard_write '{"text":"copied"}' — write clipboard
26
+ * screen_info — get screen resolution
27
+ * cursor_image — screenshot around cursor
28
+ * ax_tree '{"pid":1234}' — accessibility tree
29
+ * ax_action '{"elementId":"0.3.1","action":"AXPress"}' — perform AX action
30
+ * ax_set_value '{"elementId":"0.3.1","value":"hello"}' — set element value
31
+ * ax_focus '{"elementId":"0.3.1"}' — focus element
32
+ * ax_search '{"query":"Submit"}' — search AX tree
33
+ */
34
+
35
+ import { writeFileSync, mkdirSync, readFileSync } from "fs";
36
+ import { join } from "path";
37
+ import { tmpdir } from "os";
38
+ import { randomBytes } from "crypto";
39
+ import { TunnelClient, TunnelClientError } from "./tunnel-client";
40
+ import type { AXElement } from "./tunnel-client";
41
+
42
+ // ── Env resolution (s6 → process.env) ─────────────────────────────────────
43
+
44
+ const S6_ENV_DIR = process.env.S6_ENV_DIR || "/run/s6/container_environment";
45
+
46
+ function getEnv(key: string): string | undefined {
47
+ try {
48
+ const val = readFileSync(`${S6_ENV_DIR}/${key}`, "utf-8").trim();
49
+ if (val) return val;
50
+ } catch {}
51
+ return process.env[key];
52
+ }
53
+
54
+ // ── Client setup ──────────────────────────────────────────────────────────
55
+
56
+ const FALLBACK_API_URL = "http://localhost:8008";
57
+
58
+ function getApiBase(): string {
59
+ const raw = getEnv("TUNNEL_API_URL") || FALLBACK_API_URL;
60
+ const url = raw.startsWith("http") ? raw : FALLBACK_API_URL;
61
+ return url.replace(/\/+$/, "");
62
+ }
63
+
64
+ const client = new TunnelClient({
65
+ apiUrl: `${getApiBase()}/v1/tunnel`,
66
+ token: getEnv("TUNNEL_TOKEN") || "",
67
+ tunnelId: getEnv("TUNNEL_ID"),
68
+ });
69
+
70
+ // ── Helpers ───────────────────────────────────────────────────────────────
71
+
72
+ function out(data: unknown): void {
73
+ console.log(JSON.stringify(data, null, 2));
74
+ }
75
+
76
+ function saveImage(base64: string, format: string): string {
77
+ const ext = format === "jpeg" || format === "jpg" ? "jpg" : "png";
78
+ const dir = join(tmpdir(), "tunnel-screenshots");
79
+ mkdirSync(dir, { recursive: true });
80
+ const path = join(
81
+ dir,
82
+ `screenshot-${randomBytes(4).toString("hex")}.${ext}`
83
+ );
84
+ writeFileSync(path, Buffer.from(base64, "base64"));
85
+ return path;
86
+ }
87
+
88
+ function formatAXTree(el: AXElement, indent = 0): string {
89
+ const pad = " ".repeat(indent);
90
+ const parts: string[] = [];
91
+
92
+ const label = el.title || el.value || el.description || "(unnamed)";
93
+ const flags: string[] = [];
94
+ if (!el.enabled) flags.push("disabled");
95
+ if (el.focused) flags.push("focused");
96
+ if (el.actions.length > 0) flags.push(`actions: ${el.actions.join(",")}`);
97
+ const flagStr = flags.length > 0 ? ` [${flags.join(", ")}]` : "";
98
+
99
+ parts.push(`${pad}[${el.role}] ${label} (id: ${el.id})${flagStr}`);
100
+
101
+ for (const child of el.children) {
102
+ parts.push(formatAXTree(child, indent + 1));
103
+ }
104
+
105
+ return parts.join("\n");
106
+ }
107
+
108
+ async function rpcSafe(
109
+ method: string,
110
+ params: Record<string, unknown> = {}
111
+ ): Promise<{ result: unknown; permissionRequired: false } | { result: null; permissionRequired: true; requestId: string; message: string }> {
112
+ try {
113
+ const result = await client.rpc(method, params);
114
+ return { result, permissionRequired: false };
115
+ } catch (err) {
116
+ if (err instanceof TunnelClientError && err.isPermissionRequest) {
117
+ return {
118
+ result: null,
119
+ permissionRequired: true,
120
+ requestId: err.requestId || "unknown",
121
+ message: `Permission required. A permission request (${err.requestId}) has been sent to the user for approval. The user needs to approve this request before you can access their local machine. Please inform the user and try again after they approve.`,
122
+ };
123
+ }
124
+ throw err;
125
+ }
126
+ }
127
+
128
+ // Wrapper: call rpcSafe, handle permission, return result or null
129
+ async function call(
130
+ method: string,
131
+ params: Record<string, unknown> = {}
132
+ ): Promise<unknown | null> {
133
+ const r = await rpcSafe(method, params);
134
+ if (r.permissionRequired) {
135
+ out({
136
+ success: false,
137
+ permissionRequired: true,
138
+ requestId: r.requestId,
139
+ message: r.message,
140
+ });
141
+ return null;
142
+ }
143
+ return r.result;
144
+ }
145
+
146
+ // ── Commands ──────────────────────────────────────────────────────────────
147
+
148
+ async function status() {
149
+ const connections = (await client.getConnections()) as Array<
150
+ Record<string, unknown>
151
+ >;
152
+
153
+ if (connections.length === 0) {
154
+ return out({
155
+ success: true,
156
+ connections: [],
157
+ message:
158
+ "No tunnel connections found. The user needs to set up Agent Tunnel first.",
159
+ });
160
+ }
161
+
162
+ let hasOnline = false;
163
+ const mapped = connections.map((data) => {
164
+ if (data.isLive) hasOnline = true;
165
+ return {
166
+ name: data.name || "Unnamed",
167
+ tunnelId: data.tunnelId,
168
+ status: data.isLive ? "ONLINE" : "OFFLINE",
169
+ capabilities: (data.capabilities as string[]) || [],
170
+ machineInfo: data.machineInfo || {},
171
+ };
172
+ });
173
+
174
+ out({
175
+ success: true,
176
+ connections: mapped,
177
+ hasOnline,
178
+ message: hasOnline
179
+ ? undefined
180
+ : "No tunnel is currently online. Ask the user to run `npx @kortix/agent-tunnel connect` on their local machine.",
181
+ });
182
+ }
183
+
184
+ async function fsRead(args: Record<string, unknown>) {
185
+ const result = await call("fs.read", {
186
+ path: args.path,
187
+ encoding: (args.encoding as string) || "utf-8",
188
+ });
189
+ if (result === null) return;
190
+ const data = result as Record<string, unknown>;
191
+ out({
192
+ success: true,
193
+ path: data.path || args.path,
194
+ size: data.size,
195
+ content: data.content,
196
+ });
197
+ }
198
+
199
+ async function fsWrite(args: Record<string, unknown>) {
200
+ const result = await call("fs.write", {
201
+ path: args.path,
202
+ content: args.content,
203
+ encoding: (args.encoding as string) || "utf-8",
204
+ });
205
+ if (result === null) return;
206
+ const data = result as Record<string, unknown>;
207
+ out({ success: true, path: data.path, size: data.size });
208
+ }
209
+
210
+ async function fsList(args: Record<string, unknown>) {
211
+ const result = await call("fs.list", {
212
+ path: args.path,
213
+ recursive: args.recursive || false,
214
+ });
215
+ if (result === null) return;
216
+ const data = result as {
217
+ entries: Array<{
218
+ name: string;
219
+ path: string;
220
+ isDirectory: boolean;
221
+ isFile: boolean;
222
+ }>;
223
+ count: number;
224
+ };
225
+ out({
226
+ success: true,
227
+ path: args.path,
228
+ count: data.count,
229
+ entries: data.entries,
230
+ });
231
+ }
232
+
233
+ async function shell(args: Record<string, unknown>) {
234
+ const result = await call("shell.exec", {
235
+ command: args.command,
236
+ args: (args.args as string[]) || [],
237
+ cwd: args.cwd,
238
+ timeout: args.timeout,
239
+ });
240
+ if (result === null) return;
241
+ const data = result as {
242
+ exitCode: number | null;
243
+ signal: string | null;
244
+ stdout: string;
245
+ stderr: string;
246
+ stdoutTruncated: boolean;
247
+ stderrTruncated: boolean;
248
+ };
249
+ out({
250
+ success: data.exitCode === 0,
251
+ exitCode: data.exitCode,
252
+ signal: data.signal,
253
+ stdout: data.stdout,
254
+ stderr: data.stderr,
255
+ stdoutTruncated: data.stdoutTruncated,
256
+ stderrTruncated: data.stderrTruncated,
257
+ });
258
+ }
259
+
260
+ async function screenshot(args: Record<string, unknown>) {
261
+ const params: Record<string, unknown> = {};
262
+ if (
263
+ args.x !== undefined &&
264
+ args.y !== undefined &&
265
+ args.width !== undefined &&
266
+ args.height !== undefined
267
+ ) {
268
+ params.region = {
269
+ x: args.x,
270
+ y: args.y,
271
+ width: args.width,
272
+ height: args.height,
273
+ };
274
+ }
275
+ if (args.windowId !== undefined) params.windowId = args.windowId;
276
+
277
+ const result = await call("desktop.screenshot", params);
278
+ if (result === null) return;
279
+ const data = result as {
280
+ image: string;
281
+ width: number;
282
+ height: number;
283
+ format?: string;
284
+ };
285
+ const format = data.format || "png";
286
+ const sizeKB = Math.round((data.image.length * 0.75) / 1024);
287
+ const path = saveImage(data.image, format);
288
+ out({
289
+ success: true,
290
+ path,
291
+ width: data.width,
292
+ height: data.height,
293
+ format,
294
+ sizeKB,
295
+ message: `Screenshot saved: ${path} (${data.width}x${data.height} ${format.toUpperCase()}, ${sizeKB}KB). Use the Read tool to view.`,
296
+ });
297
+ }
298
+
299
+ async function click(args: Record<string, unknown>) {
300
+ const result = await call("desktop.mouse.click", {
301
+ x: args.x,
302
+ y: args.y,
303
+ button: args.button,
304
+ clicks: args.clicks,
305
+ modifiers: args.modifiers,
306
+ });
307
+ if (result === null) return;
308
+ out({
309
+ success: true,
310
+ x: args.x,
311
+ y: args.y,
312
+ button: args.button || "left",
313
+ clicks: args.clicks || 1,
314
+ });
315
+ }
316
+
317
+ async function mouseMove(args: Record<string, unknown>) {
318
+ const result = await call("desktop.mouse.move", { x: args.x, y: args.y });
319
+ if (result === null) return;
320
+ out({ success: true, x: args.x, y: args.y });
321
+ }
322
+
323
+ async function mouseDrag(args: Record<string, unknown>) {
324
+ const result = await call("desktop.mouse.drag", {
325
+ fromX: args.fromX,
326
+ fromY: args.fromY,
327
+ toX: args.toX,
328
+ toY: args.toY,
329
+ button: args.button,
330
+ });
331
+ if (result === null) return;
332
+ out({
333
+ success: true,
334
+ fromX: args.fromX,
335
+ fromY: args.fromY,
336
+ toX: args.toX,
337
+ toY: args.toY,
338
+ });
339
+ }
340
+
341
+ async function mouseScroll(args: Record<string, unknown>) {
342
+ const result = await call("desktop.mouse.scroll", {
343
+ x: args.x,
344
+ y: args.y,
345
+ deltaX: args.deltaX,
346
+ deltaY: args.deltaY,
347
+ });
348
+ if (result === null) return;
349
+ out({
350
+ success: true,
351
+ x: args.x,
352
+ y: args.y,
353
+ deltaX: args.deltaX || 0,
354
+ deltaY: args.deltaY || 0,
355
+ });
356
+ }
357
+
358
+ async function typeText(args: Record<string, unknown>) {
359
+ const result = await call("desktop.keyboard.type", {
360
+ text: args.text,
361
+ delay: args.delay,
362
+ });
363
+ if (result === null) return;
364
+ out({ success: true, chars: (args.text as string).length });
365
+ }
366
+
367
+ async function pressKey(args: Record<string, unknown>) {
368
+ const result = await call("desktop.keyboard.key", { keys: args.keys });
369
+ if (result === null) return;
370
+ out({ success: true, keys: args.keys });
371
+ }
372
+
373
+ async function windowList() {
374
+ const result = await call("desktop.window.list", {});
375
+ if (result === null) return;
376
+ const data = result as {
377
+ windows: Array<{
378
+ id: number;
379
+ app: string;
380
+ title: string;
381
+ bounds: { x: number; y: number; width: number; height: number };
382
+ minimized: boolean;
383
+ }>;
384
+ };
385
+ out({ success: true, windows: data.windows });
386
+ }
387
+
388
+ async function windowFocus(args: Record<string, unknown>) {
389
+ const result = await call("desktop.window.focus", {
390
+ windowId: args.windowId,
391
+ });
392
+ if (result === null) return;
393
+ out({ success: true, windowId: args.windowId });
394
+ }
395
+
396
+ async function appLaunch(args: Record<string, unknown>) {
397
+ const result = await call("desktop.app.launch", { app: args.app });
398
+ if (result === null) return;
399
+ out({ success: true, app: args.app });
400
+ }
401
+
402
+ async function appQuit(args: Record<string, unknown>) {
403
+ const result = await call("desktop.app.quit", { app: args.app });
404
+ if (result === null) return;
405
+ out({ success: true, app: args.app });
406
+ }
407
+
408
+ async function clipboardRead() {
409
+ const result = await call("desktop.clipboard.read", {});
410
+ if (result === null) return;
411
+ const data = result as { text: string };
412
+ out({ success: true, text: data.text || "" });
413
+ }
414
+
415
+ async function clipboardWrite(args: Record<string, unknown>) {
416
+ const result = await call("desktop.clipboard.write", { text: args.text });
417
+ if (result === null) return;
418
+ out({ success: true, chars: (args.text as string).length });
419
+ }
420
+
421
+ async function screenInfo() {
422
+ const result = await call("desktop.screen.info", {});
423
+ if (result === null) return;
424
+ const data = result as {
425
+ width: number;
426
+ height: number;
427
+ scaleFactor: number;
428
+ };
429
+ out({ success: true, ...data });
430
+ }
431
+
432
+ async function cursorImage(args: Record<string, unknown>) {
433
+ const result = await call("desktop.cursor.image", { radius: args.radius });
434
+ if (result === null) return;
435
+ const data = result as {
436
+ image: string;
437
+ width: number;
438
+ height: number;
439
+ format?: string;
440
+ };
441
+ const format = data.format || "png";
442
+ const sizeKB = Math.round((data.image.length * 0.75) / 1024);
443
+ const path = saveImage(data.image, format);
444
+ out({
445
+ success: true,
446
+ path,
447
+ width: data.width,
448
+ height: data.height,
449
+ format,
450
+ sizeKB,
451
+ message: `Cursor area saved: ${path} (${data.width}x${data.height}). Use the Read tool to view.`,
452
+ });
453
+ }
454
+
455
+ async function axTree(args: Record<string, unknown>) {
456
+ const params: Record<string, unknown> = {};
457
+ if (args.pid !== undefined) params.pid = args.pid;
458
+ if (args.maxDepth !== undefined) params.maxDepth = args.maxDepth;
459
+ if (args.roles !== undefined) params.roles = args.roles;
460
+
461
+ const result = await call("desktop.ax.tree", params);
462
+ if (result === null) return;
463
+ const data = result as { root: AXElement; elementCount: number };
464
+ if (!data.root)
465
+ return out({
466
+ success: true,
467
+ tree: null,
468
+ message: "No accessibility tree available",
469
+ });
470
+
471
+ out({
472
+ success: true,
473
+ elementCount: data.elementCount,
474
+ tree: formatAXTree(data.root),
475
+ });
476
+ }
477
+
478
+ async function axAction(args: Record<string, unknown>) {
479
+ const result = await call("desktop.ax.action", {
480
+ elementId: args.elementId,
481
+ action: args.action,
482
+ pid: args.pid,
483
+ });
484
+ if (result === null) return;
485
+ const data = result as {
486
+ ok: boolean;
487
+ action: string;
488
+ elementId: string;
489
+ before: { focused: boolean; value: string };
490
+ after: { focused: boolean; value: string };
491
+ stateChanged: boolean;
492
+ role: string;
493
+ title: string;
494
+ };
495
+ out({
496
+ success: data.ok,
497
+ action: data.action,
498
+ elementId: data.elementId,
499
+ role: data.role,
500
+ title: data.title,
501
+ stateChanged: data.stateChanged,
502
+ before: data.before,
503
+ after: data.after,
504
+ });
505
+ }
506
+
507
+ async function axSetValue(args: Record<string, unknown>) {
508
+ const result = await call("desktop.ax.set_value", {
509
+ elementId: args.elementId,
510
+ value: args.value,
511
+ pid: args.pid,
512
+ });
513
+ if (result === null) return;
514
+ const data = result as {
515
+ ok: boolean;
516
+ elementId: string;
517
+ requestedValue: string;
518
+ actualValue: string;
519
+ error?: string;
520
+ };
521
+ out({
522
+ success: data.ok,
523
+ elementId: data.elementId,
524
+ requestedValue: data.requestedValue,
525
+ actualValue: data.actualValue,
526
+ error: data.error,
527
+ });
528
+ }
529
+
530
+ async function axFocus(args: Record<string, unknown>) {
531
+ const result = await call("desktop.ax.focus", {
532
+ elementId: args.elementId,
533
+ pid: args.pid,
534
+ });
535
+ if (result === null) return;
536
+ const data = result as {
537
+ ok: boolean;
538
+ elementId: string;
539
+ role: string;
540
+ title: string;
541
+ before: { focused: boolean };
542
+ after: { focused: boolean };
543
+ error?: string;
544
+ };
545
+ out({
546
+ success: data.ok,
547
+ elementId: data.elementId,
548
+ role: data.role,
549
+ title: data.title,
550
+ before: data.before,
551
+ after: data.after,
552
+ error: data.error,
553
+ });
554
+ }
555
+
556
+ async function axSearch(args: Record<string, unknown>) {
557
+ const params: Record<string, unknown> = { query: args.query };
558
+ if (args.role !== undefined) params.role = args.role;
559
+ if (args.pid !== undefined) params.pid = args.pid;
560
+ if (args.maxResults !== undefined) params.maxResults = args.maxResults;
561
+
562
+ const result = await call("desktop.ax.search", params);
563
+ if (result === null) return;
564
+ const data = result as { elements: AXElement[] };
565
+
566
+ if (!data.elements || data.elements.length === 0) {
567
+ return out({
568
+ success: true,
569
+ query: args.query,
570
+ elements: [],
571
+ message: `No elements found matching "${args.query}"`,
572
+ });
573
+ }
574
+
575
+ const elements = data.elements.map((el) => ({
576
+ id: el.id,
577
+ role: el.role,
578
+ title: el.title || el.value || el.description || "(unnamed)",
579
+ bounds: el.bounds,
580
+ enabled: el.enabled,
581
+ focused: el.focused,
582
+ actions: el.actions,
583
+ }));
584
+
585
+ out({ success: true, query: args.query, count: elements.length, elements });
586
+ }
587
+
588
+ // ── Dispatch ──────────────────────────────────────────────────────────────
589
+
590
+ const ALL_COMMANDS = [
591
+ "status",
592
+ "fs_read",
593
+ "fs_write",
594
+ "fs_list",
595
+ "shell",
596
+ "screenshot",
597
+ "click",
598
+ "mouse_move",
599
+ "mouse_drag",
600
+ "mouse_scroll",
601
+ "type",
602
+ "key",
603
+ "window_list",
604
+ "window_focus",
605
+ "app_launch",
606
+ "app_quit",
607
+ "clipboard_read",
608
+ "clipboard_write",
609
+ "screen_info",
610
+ "cursor_image",
611
+ "ax_tree",
612
+ "ax_action",
613
+ "ax_set_value",
614
+ "ax_focus",
615
+ "ax_search",
616
+ ];
617
+
618
+ const [cmd, rawArgs] = process.argv.slice(2);
619
+
620
+ if (!cmd) {
621
+ console.error(
622
+ `Usage: bun run cli.ts <command> [args as JSON]\n\nAvailable: ${ALL_COMMANDS.join(" | ")}`
623
+ );
624
+ process.exit(1);
625
+ }
626
+
627
+ const args = rawArgs ? JSON.parse(rawArgs) : {};
628
+
629
+ try {
630
+ switch (cmd) {
631
+ case "status":
632
+ await status();
633
+ break;
634
+ case "fs_read":
635
+ await fsRead(args);
636
+ break;
637
+ case "fs_write":
638
+ await fsWrite(args);
639
+ break;
640
+ case "fs_list":
641
+ await fsList(args);
642
+ break;
643
+ case "shell":
644
+ await shell(args);
645
+ break;
646
+ case "screenshot":
647
+ await screenshot(args);
648
+ break;
649
+ case "click":
650
+ await click(args);
651
+ break;
652
+ case "mouse_move":
653
+ await mouseMove(args);
654
+ break;
655
+ case "mouse_drag":
656
+ await mouseDrag(args);
657
+ break;
658
+ case "mouse_scroll":
659
+ await mouseScroll(args);
660
+ break;
661
+ case "type":
662
+ await typeText(args);
663
+ break;
664
+ case "key":
665
+ await pressKey(args);
666
+ break;
667
+ case "window_list":
668
+ await windowList();
669
+ break;
670
+ case "window_focus":
671
+ await windowFocus(args);
672
+ break;
673
+ case "app_launch":
674
+ await appLaunch(args);
675
+ break;
676
+ case "app_quit":
677
+ await appQuit(args);
678
+ break;
679
+ case "clipboard_read":
680
+ await clipboardRead();
681
+ break;
682
+ case "clipboard_write":
683
+ await clipboardWrite(args);
684
+ break;
685
+ case "screen_info":
686
+ await screenInfo();
687
+ break;
688
+ case "cursor_image":
689
+ await cursorImage(args);
690
+ break;
691
+ case "ax_tree":
692
+ await axTree(args);
693
+ break;
694
+ case "ax_action":
695
+ await axAction(args);
696
+ break;
697
+ case "ax_set_value":
698
+ await axSetValue(args);
699
+ break;
700
+ case "ax_focus":
701
+ await axFocus(args);
702
+ break;
703
+ case "ax_search":
704
+ await axSearch(args);
705
+ break;
706
+ default:
707
+ console.error(
708
+ `Unknown command: ${cmd}\n\nAvailable: ${ALL_COMMANDS.join(" | ")}`
709
+ );
710
+ process.exit(1);
711
+ }
712
+ } catch (err) {
713
+ const message = err instanceof Error ? err.message : String(err);
714
+ out({ success: false, error: message });
715
+ process.exit(1);
716
+ }
@@ -2,3 +2,4 @@ export { TunnelClient, TunnelClientError } from './tunnel-client';
2
2
  export type { TunnelClientConfig, AXElement } from './tunnel-client';
3
3
  export { createTunnelTools } from './tools';
4
4
  export type { TunnelToolDefinition, TunnelToolParameter } from './tools';
5
+ // CLI entrypoint: src/client/cli.ts (run via `bun run cli.ts <command> [json]`)