kroki 1.0.5 → 1.0.7

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 (2) hide show
  1. package/bin/kroki.js +1311 -185
  2. package/package.json +1 -1
package/bin/kroki.js CHANGED
@@ -10,8 +10,12 @@
10
10
  import { existsSync, readFileSync, writeFileSync, unlinkSync, mkdirSync } from 'node:fs';
11
11
  import { join } from 'node:path';
12
12
  import { homedir } from 'node:os';
13
+ import { createServer } from 'node:http';
14
+ import readline, { createInterface } from 'node:readline';
15
+ import { exec } from 'node:child_process';
13
16
 
14
17
  const CONFIG_DIR = join(homedir(), '.kroki');
18
+ const CONFIG_FILE = join(CONFIG_DIR, 'config.json');
15
19
  const SESSION_FILE = join(CONFIG_DIR, 'session.json');
16
20
 
17
21
  const DEFAULT_SUPABASE_URL = 'https://vjkzlvtvnmorokocnbhp.supabase.co';
@@ -24,43 +28,336 @@ function ensureConfigDir() {
24
28
  }
25
29
  }
26
30
 
31
+ function openBrowser(url) {
32
+ const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
33
+ exec(`${cmd} "${url}"`, () => {});
34
+ }
35
+
27
36
  function decodePairingCode(code) {
28
- const trimmed = code.trim();
37
+ if (typeof code === 'object' && code !== null) {
38
+ if (code.token) {
39
+ if (!code.googleId) {
40
+ try {
41
+ const parts = code.token.split('.');
42
+ if (parts.length >= 2) {
43
+ const jwtPayload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8'));
44
+ code.googleId = jwtPayload.google_id || jwtPayload.sub;
45
+ }
46
+ } catch {}
47
+ }
48
+ return code;
49
+ }
50
+ throw new Error('Missing token in authorization payload');
51
+ }
52
+
53
+ const trimmed = String(code).trim();
29
54
  const raw = trimmed.startsWith('kroki_') ? trimmed.slice(6) : trimmed;
30
55
  try {
31
56
  const jsonStr = Buffer.from(raw, 'base64url').toString('utf8');
32
57
  const parsed = JSON.parse(jsonStr);
33
- if (!parsed.googleId || !parsed.token || !parsed.deviceId) {
34
- throw new Error('Missing required fields in pairing code');
58
+ if (!parsed.token) {
59
+ throw new Error('Missing token in code');
60
+ }
61
+ if (!parsed.googleId) {
62
+ try {
63
+ const parts = parsed.token.split('.');
64
+ if (parts.length >= 2) {
65
+ const jwtPayload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8'));
66
+ parsed.googleId = jwtPayload.google_id || jwtPayload.sub;
67
+ }
68
+ } catch {}
35
69
  }
36
70
  return parsed;
37
71
  } catch {
38
72
  throw new Error(
39
- 'Invalid Kroki pairing code.\nPlease get a valid code from your Kroki Devices panel: https://kroki.ai/#/devices',
73
+ 'Invalid Kroki code.\nPlease get a valid code from your Kroki Devices panel: https://kroki.ai/#/devices',
40
74
  );
41
75
  }
42
76
  }
43
77
 
44
- function getActiveSession() {
45
- if (!existsSync(SESSION_FILE)) return null;
46
- try {
47
- return JSON.parse(readFileSync(SESSION_FILE, 'utf8'));
48
- } catch {
49
- return null;
78
+ // ── Config & Session Management ─────────────────────────────────────────────
79
+
80
+ function getConfig() {
81
+ if (existsSync(CONFIG_FILE)) {
82
+ try {
83
+ return JSON.parse(readFileSync(CONFIG_FILE, 'utf8'));
84
+ } catch {}
85
+ }
86
+ // Fallback / migration from legacy session.json
87
+ if (existsSync(SESSION_FILE)) {
88
+ try {
89
+ const legacy = JSON.parse(readFileSync(SESSION_FILE, 'utf8'));
90
+ if (legacy && legacy.token) {
91
+ const config = {
92
+ account: {
93
+ googleId: legacy.googleId,
94
+ token: legacy.token,
95
+ supabaseUrl: legacy.supabaseUrl || DEFAULT_SUPABASE_URL,
96
+ anonKey: legacy.anonKey || DEFAULT_ANON_KEY,
97
+ },
98
+ selectedDeviceIds: legacy.deviceId ? [legacy.deviceId] : [],
99
+ defaultDeviceId: legacy.deviceId || null,
100
+ devices: {},
101
+ };
102
+ if (legacy.deviceId) {
103
+ config.devices[legacy.deviceId] = {
104
+ deviceId: legacy.deviceId,
105
+ deviceName: legacy.deviceName || legacy.deviceId,
106
+ platform: legacy.platform || 'mac',
107
+ lastSeenAt: legacy.pairedAt || Date.now(),
108
+ };
109
+ }
110
+ saveConfig(config);
111
+ return config;
112
+ }
113
+ } catch {}
50
114
  }
115
+ return null;
51
116
  }
52
117
 
53
- function saveActiveSession(session) {
118
+ function saveConfig(config) {
54
119
  ensureConfigDir();
55
- writeFileSync(SESSION_FILE, JSON.stringify(session, null, 2), { mode: 0o600 });
120
+ writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), { mode: 0o600 });
121
+ // Also keep session.json in sync for legacy compatibility
122
+ if (config.account && config.account.token) {
123
+ const activeDevice = (config.devices && config.defaultDeviceId && config.devices[config.defaultDeviceId]) || null;
124
+ const legacySession = {
125
+ v: 1,
126
+ token: config.account.token,
127
+ googleId: config.account.googleId,
128
+ deviceId: activeDevice?.deviceId || '',
129
+ deviceName: activeDevice?.deviceName || '',
130
+ platform: activeDevice?.platform || 'mac',
131
+ supabaseUrl: config.account.supabaseUrl || DEFAULT_SUPABASE_URL,
132
+ pairedAt: Date.now(),
133
+ };
134
+ writeFileSync(SESSION_FILE, JSON.stringify(legacySession, null, 2), { mode: 0o600 });
135
+ }
56
136
  }
57
137
 
58
- function clearActiveSession() {
59
- if (existsSync(SESSION_FILE)) {
60
- unlinkSync(SESSION_FILE);
138
+ function clearAllSessions() {
139
+ if (existsSync(CONFIG_FILE)) unlinkSync(CONFIG_FILE);
140
+ if (existsSync(SESSION_FILE)) unlinkSync(SESSION_FILE);
141
+ }
142
+
143
+ function saveFromCode(code) {
144
+ const payload = decodePairingCode(code);
145
+ const config = getConfig() || {
146
+ account: {},
147
+ selectedDeviceIds: [],
148
+ defaultDeviceId: null,
149
+ devices: {},
150
+ };
151
+
152
+ config.account = {
153
+ googleId: payload.googleId,
154
+ email: payload.email || config.account?.email,
155
+ token: payload.token,
156
+ supabaseUrl: payload.supabaseUrl || DEFAULT_SUPABASE_URL,
157
+ anonKey: payload.anonKey || DEFAULT_ANON_KEY,
158
+ };
159
+
160
+ config.devices = config.devices || {};
161
+ config.selectedDeviceIds = config.selectedDeviceIds || [];
162
+
163
+ if (Array.isArray(payload.accounts)) {
164
+ for (const acc of payload.accounts) {
165
+ if (Array.isArray(acc.devices)) {
166
+ for (const d of acc.devices) {
167
+ const resolvedEmail =
168
+ acc.email ||
169
+ (acc.google_id === payload.googleId ? payload.email : null) ||
170
+ config.account?.email ||
171
+ acc.label ||
172
+ acc.google_id;
173
+ config.devices[d.device_id] = {
174
+ deviceId: d.device_id,
175
+ deviceName: d.device_name || d.device_id,
176
+ platform: d.platform || 'mac',
177
+ account: resolvedEmail,
178
+ googleId: acc.google_id,
179
+ lastSeenAt: d.last_seen_at ? Date.parse(d.last_seen_at) : Date.now(),
180
+ };
181
+ if (!config.defaultDeviceId) {
182
+ config.defaultDeviceId = d.device_id;
183
+ }
184
+ if (!config.selectedDeviceIds.includes(d.device_id)) {
185
+ config.selectedDeviceIds.push(d.device_id);
186
+ }
187
+ }
188
+ }
189
+ }
190
+ }
191
+
192
+ if (payload.deviceId) {
193
+ config.devices[payload.deviceId] = {
194
+ deviceId: payload.deviceId,
195
+ deviceName: payload.deviceName || payload.deviceId,
196
+ platform: payload.platform || 'mac',
197
+ lastSeenAt: Date.now(),
198
+ };
199
+ if (!config.selectedDeviceIds.includes(payload.deviceId)) {
200
+ config.selectedDeviceIds.push(payload.deviceId);
201
+ }
202
+ config.defaultDeviceId = payload.deviceId;
203
+ }
204
+
205
+ saveConfig(config);
206
+ return config;
207
+ }
208
+
209
+ function resolveTargetDevice(config, targetQuery) {
210
+ const devices = config.devices || {};
211
+ const allDevices = Object.values(devices);
212
+
213
+ const query =
214
+ targetQuery !== null && targetQuery !== undefined && String(targetQuery).trim() !== ''
215
+ ? targetQuery
216
+ : process.env.KROKI_DEVICE && String(process.env.KROKI_DEVICE).trim() !== ''
217
+ ? process.env.KROKI_DEVICE
218
+ : null;
219
+
220
+ if (query !== null && query !== undefined && String(query).trim() !== '') {
221
+ const q = String(query).toLowerCase().trim();
222
+ // 1. Index number (1-based, e.g. "1", "2")
223
+ const num = parseInt(q, 10);
224
+ if (!isNaN(num) && String(num) === q && num >= 1 && num <= allDevices.length) {
225
+ return allDevices[num - 1];
226
+ }
227
+ // 2. Exact ID
228
+ let match = allDevices.find(d => d.deviceId.toLowerCase() === q);
229
+ // 3. Exact name
230
+ if (!match) match = allDevices.find(d => (d.deviceName || '').toLowerCase() === q);
231
+ // 4. Exact account / email
232
+ if (!match) match = allDevices.find(d => (d.account || '').toLowerCase() === q);
233
+ // 5. Substring name or account
234
+ if (!match) {
235
+ match = allDevices.find(
236
+ d => (d.deviceName || '').toLowerCase().includes(q) || (d.account || '').toLowerCase().includes(q),
237
+ );
238
+ }
239
+ if (!match) {
240
+ const names = allDevices
241
+ .map((d, i) => `[${i + 1}] "${d.deviceName || d.deviceId}"${d.account ? ` (${d.account})` : ''}`)
242
+ .join('\n ');
243
+ throw new Error(`Device "${targetQuery}" not found.\nAvailable browsers:\n ${names || 'none'}`);
244
+ }
245
+ return match;
246
+ }
247
+
248
+ // Default: defaultDeviceId or first device
249
+ const defId = config.defaultDeviceId;
250
+ if (defId && devices[defId]) {
251
+ return devices[defId];
252
+ }
253
+ if (allDevices.length > 0) {
254
+ return allDevices[0];
61
255
  }
256
+ return null;
62
257
  }
63
258
 
259
+ function isTokenExpired(token) {
260
+ if (!token) return true;
261
+ try {
262
+ const parts = token.split('.');
263
+ if (parts.length >= 2) {
264
+ const jwt = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8'));
265
+ if (jwt.exp) {
266
+ return Date.now() / 1000 >= jwt.exp;
267
+ }
268
+ }
269
+ } catch {}
270
+ return false;
271
+ }
272
+
273
+ async function fetchOnlineDeviceIds(config, timeoutMs = 1500) {
274
+ if (!config?.account?.token || !config?.account?.supabaseUrl) {
275
+ return { onlineIds: new Set(), expired: false };
276
+ }
277
+
278
+ if (isTokenExpired(config.account.token)) {
279
+ return { onlineIds: new Set(), expired: true };
280
+ }
281
+
282
+ const onlineIds = new Set();
283
+ const googleIds = new Set();
284
+ if (config.account.googleId) googleIds.add(config.account.googleId);
285
+ for (const d of Object.values(config.devices || {})) {
286
+ if (d.googleId) googleIds.add(d.googleId);
287
+ }
288
+
289
+ const anonKey = config.account.anonKey || DEFAULT_ANON_KEY;
290
+ const wsBase = (config.account.supabaseUrl || DEFAULT_SUPABASE_URL).replace(/^http/, 'ws');
291
+ const wsUrl = `${wsBase}/realtime/v1/websocket?apikey=${anonKey}&vsn=1.0.0`;
292
+
293
+ await Promise.all(
294
+ Array.from(googleIds).map(gId => {
295
+ return new Promise(resolve => {
296
+ let ws = null;
297
+ let settled = false;
298
+ const done = () => {
299
+ if (!settled) {
300
+ settled = true;
301
+ if (ws) {
302
+ try {
303
+ ws.close();
304
+ } catch {}
305
+ }
306
+ resolve();
307
+ }
308
+ };
309
+
310
+ const timer = setTimeout(done, timeoutMs);
311
+
312
+ try {
313
+ ws = new globalThis.WebSocket(wsUrl);
314
+ ws.onopen = () => {
315
+ const payload = {
316
+ topic: `realtime:control:${gId}`,
317
+ event: 'phx_join',
318
+ payload: {
319
+ config: { private: true, broadcast: { self: false }, presence: { key: '' } },
320
+ access_token: config.account.token,
321
+ },
322
+ ref: 'ping_' + gId,
323
+ };
324
+ ws.send(JSON.stringify(payload));
325
+ };
326
+
327
+ ws.onmessage = event => {
328
+ try {
329
+ const msg = JSON.parse(event.data);
330
+ if (msg.event === 'presence_state' && msg.payload) {
331
+ for (const [k, v] of Object.entries(msg.payload)) {
332
+ if (k && k.startsWith('device-')) onlineIds.add(k);
333
+ const metas = Array.isArray(v) ? v : Array.isArray(v?.metas) ? v.metas : [];
334
+ for (const m of metas) {
335
+ if (m && m.deviceId) onlineIds.add(m.deviceId);
336
+ }
337
+ }
338
+ clearTimeout(timer);
339
+ done();
340
+ }
341
+ } catch {}
342
+ };
343
+
344
+ ws.onerror = () => {
345
+ clearTimeout(timer);
346
+ done();
347
+ };
348
+ } catch {
349
+ clearTimeout(timer);
350
+ done();
351
+ }
352
+ });
353
+ }),
354
+ );
355
+
356
+ return { onlineIds, expired: false };
357
+ }
358
+
359
+ // ── Realtime WebSocket Connection ───────────────────────────────────────────
360
+
64
361
  class RealtimeConnection {
65
362
  constructor(config) {
66
363
  this.url = config.supabaseUrl || DEFAULT_SUPABASE_URL;
@@ -73,12 +370,17 @@ class RealtimeConnection {
73
370
  this.joinRef = null;
74
371
  this.ws = null;
75
372
  this.heartbeatTimer = null;
373
+ this.onlineDeviceIds = new Set();
76
374
  }
77
375
 
78
376
  nextRef() {
79
377
  return String(++this.ref);
80
378
  }
81
379
 
380
+ isDeviceOnline(deviceId) {
381
+ return this.onlineDeviceIds.has(deviceId);
382
+ }
383
+
82
384
  async connect(timeoutMs = 15000) {
83
385
  return new Promise((resolve, reject) => {
84
386
  const wsUrl = `${this.url.replace(/^http/, 'ws')}/realtime/v1/websocket?apikey=${this.anonKey}&vsn=1.0.0`;
@@ -100,7 +402,7 @@ class RealtimeConnection {
100
402
  );
101
403
  }
102
404
  }, 25000);
103
- resolve(this);
405
+ setTimeout(() => resolve(this), 250);
104
406
  })
105
407
  .catch(reject);
106
408
  };
@@ -119,12 +421,47 @@ class RealtimeConnection {
119
421
  topic: this.topic,
120
422
  event: 'phx_join',
121
423
  payload: {
122
- config: { private: true, broadcast: { self: false } },
424
+ config: { private: true, broadcast: { self: false }, presence: { key: '' } },
123
425
  access_token: this.token,
124
426
  },
125
427
  ref: this.joinRef,
126
428
  };
127
429
 
430
+ const presenceHandler = event => {
431
+ try {
432
+ const msg = JSON.parse(event.data);
433
+ if (msg.event === 'presence_state' && msg.payload) {
434
+ for (const [k, v] of Object.entries(msg.payload)) {
435
+ if (k && k.startsWith('device-')) this.onlineDeviceIds.add(k);
436
+ const metas = Array.isArray(v) ? v : Array.isArray(v?.metas) ? v.metas : [];
437
+ for (const m of metas) {
438
+ if (m && m.deviceId) this.onlineDeviceIds.add(m.deviceId);
439
+ }
440
+ }
441
+ } else if (msg.event === 'presence_diff' && msg.payload) {
442
+ if (msg.payload.joins) {
443
+ for (const [k, v] of Object.entries(msg.payload.joins)) {
444
+ if (k && k.startsWith('device-')) this.onlineDeviceIds.add(k);
445
+ const metas = Array.isArray(v) ? v : Array.isArray(v?.metas) ? v.metas : [];
446
+ for (const m of metas) {
447
+ if (m && m.deviceId) this.onlineDeviceIds.add(m.deviceId);
448
+ }
449
+ }
450
+ }
451
+ if (msg.payload.leaves) {
452
+ for (const [k, v] of Object.entries(msg.payload.leaves)) {
453
+ if (k && k.startsWith('device-')) this.onlineDeviceIds.delete(k);
454
+ const metas = Array.isArray(v) ? v : Array.isArray(v?.metas) ? v.metas : [];
455
+ for (const m of metas) {
456
+ if (m && m.deviceId) this.onlineDeviceIds.delete(m.deviceId);
457
+ }
458
+ }
459
+ }
460
+ }
461
+ } catch {}
462
+ };
463
+ this.ws.addEventListener('message', presenceHandler);
464
+
128
465
  const handler = event => {
129
466
  try {
130
467
  const msg = JSON.parse(event.data);
@@ -133,7 +470,12 @@ class RealtimeConnection {
133
470
  if (msg.payload?.status === 'ok') {
134
471
  resolve();
135
472
  } else {
136
- reject(new Error(`Channel join rejected: ${JSON.stringify(msg.payload || {})}`));
473
+ const reason = msg.payload?.response?.reason || JSON.stringify(msg.payload || {});
474
+ if (typeof reason === 'string' && reason.includes('Token has expired')) {
475
+ reject(new Error('Session token has expired. Run "kroki login" to reconnect.'));
476
+ } else {
477
+ reject(new Error(`Channel join rejected: ${reason}`));
478
+ }
137
479
  }
138
480
  }
139
481
  } catch {}
@@ -144,38 +486,70 @@ class RealtimeConnection {
144
486
  });
145
487
  }
146
488
 
147
- sendCommand(command, onEvent, timeoutMs = 240000) {
489
+ async sendCommand(command, onEvent, timeoutMs = 600000) {
148
490
  return new Promise((resolve, reject) => {
149
- const msgRef = this.nextRef();
150
- const timer = setTimeout(() => {
151
- reject(new Error('Command timed out while waiting for browser response (4 min limit).'));
491
+ let activityTimeout = null;
492
+ const resetActivityTimeout = () => {
493
+ if (activityTimeout) clearTimeout(activityTimeout);
494
+ // 2-minute silence limit between steps
495
+ activityTimeout = setTimeout(() => {
496
+ cleanup();
497
+ reject(new Error('Task timed out: no activity received for 2 minutes'));
498
+ }, 120000);
499
+ };
500
+ resetActivityTimeout();
501
+
502
+ const totalTimeout = setTimeout(() => {
503
+ cleanup();
504
+ reject(new Error(`Task reached maximum time limit (${Math.round(timeoutMs / 60000)}m)`));
152
505
  }, timeoutMs);
153
506
 
507
+ const cleanup = () => {
508
+ if (activityTimeout) clearTimeout(activityTimeout);
509
+ clearTimeout(totalTimeout);
510
+ this.ws.removeEventListener('message', handler);
511
+ };
512
+
154
513
  const handler = event => {
155
514
  try {
156
515
  const msg = JSON.parse(event.data);
157
- if (msg.topic === this.topic && msg.event === 'broadcast') {
158
- const envelope = msg.payload?.payload;
159
- if (!envelope || envelope.deviceId !== this.deviceId) return;
516
+ let envelope = null;
517
+ if (msg.event === 'broadcast' && msg.payload) {
518
+ envelope = msg.payload.payload || msg.payload;
519
+ } else if (msg.event === 'event' && msg.payload) {
520
+ envelope = msg.payload;
521
+ } else if (msg.payload && (msg.payload.kind || msg.payload.taskId)) {
522
+ envelope = msg.payload;
523
+ }
160
524
 
525
+ if (envelope && (envelope.taskId === command.taskId || !envelope.taskId)) {
526
+ resetActivityTimeout();
161
527
  if (onEvent) onEvent(envelope);
162
528
 
163
529
  if (
530
+ envelope.kind === 'task_complete' ||
164
531
  envelope.kind === 'chat_task_complete' ||
165
532
  envelope.kind === 'workflow_complete' ||
166
- envelope.kind === 'pair_approved'
533
+ envelope.payload?.state === 'task.ok' ||
534
+ envelope.payload?.state === 'task.cancel'
167
535
  ) {
168
- clearTimeout(timer);
169
- this.ws.removeEventListener('message', handler);
170
- resolve(envelope.payload);
171
- } else if (envelope.kind === 'pair_rejected') {
172
- clearTimeout(timer);
173
- this.ws.removeEventListener('message', handler);
174
- reject(new Error('Pairing request was rejected in Kroki browser.'));
175
- } else if (envelope.kind === 'error') {
176
- clearTimeout(timer);
177
- this.ws.removeEventListener('message', handler);
178
- reject(new Error(envelope.payload?.message || 'Remote error from Kroki browser'));
536
+ cleanup();
537
+ if (envelope.kind === 'workflow_complete' && envelope.payload?.success === false) {
538
+ const errMsg =
539
+ envelope.payload?.error ||
540
+ (envelope.payload?.cancelled ? 'Workflow was cancelled' : 'Workflow execution failed');
541
+ const err = new Error(errMsg);
542
+ err.payload = envelope.payload;
543
+ reject(err);
544
+ return;
545
+ }
546
+ resolve(envelope.payload || { success: true });
547
+ } else if (envelope.kind === 'error' || envelope.payload?.state === 'task.fail') {
548
+ cleanup();
549
+ const errMsg = envelope.payload?.message || envelope.payload?.error || 'Task failed';
550
+ const err = new Error(errMsg);
551
+ err.payload = envelope.payload;
552
+ reject(err);
179
553
  }
180
554
  }
181
555
  } catch {}
@@ -183,7 +557,7 @@ class RealtimeConnection {
183
557
 
184
558
  this.ws.addEventListener('message', handler);
185
559
 
186
- const packet = {
560
+ const broadcastMsg = {
187
561
  topic: this.topic,
188
562
  event: 'broadcast',
189
563
  payload: {
@@ -191,84 +565,24 @@ class RealtimeConnection {
191
565
  event: 'command',
192
566
  payload: command,
193
567
  },
194
- ref: msgRef,
195
- join_ref: this.joinRef,
568
+ ref: this.nextRef(),
196
569
  };
197
570
 
198
- this.ws.send(JSON.stringify(packet));
571
+ this.ws.send(JSON.stringify(broadcastMsg));
199
572
  });
200
573
  }
201
574
 
202
575
  close() {
203
- if (this.heartbeatTimer) {
204
- clearInterval(this.heartbeatTimer);
205
- this.heartbeatTimer = null;
206
- }
576
+ if (this.heartbeatTimer) clearInterval(this.heartbeatTimer);
207
577
  if (this.ws) {
208
578
  try {
209
579
  this.ws.close();
210
580
  } catch {}
211
- this.ws = null;
212
581
  }
213
582
  }
214
583
  }
215
584
 
216
- async function pairCommand(code) {
217
- if (!code) {
218
- console.log(`
219
- \x1b[1mKroki Pairing\x1b[0m
220
- Requires an active \x1b[33mUltra\x1b[0m plan.
221
-
222
- 1. Log in at \x1b[36mhttps://kroki.ai/#/devices\x1b[0m
223
- 2. Find your active browser profile
224
- 3. Click \x1b[32m"Скопировать код для AI"\x1b[0m
225
- 4. Run:
226
- \x1b[33mnpx kroki pair <YOUR_CODE>\x1b[0m
227
- `);
228
- process.exit(0);
229
- }
230
-
231
- const payload = decodePairingCode(code);
232
- const name = payload.deviceName || payload.deviceId;
233
- console.log(`\x1b[36mConnecting to profile [${name}]...\x1b[0m`);
234
-
235
- const conn = new RealtimeConnection(payload);
236
- await conn.connect();
237
-
238
- console.log(`\x1b[33mWaiting for confirmation in Kroki browser (click "Разрешить")...\x1b[0m`);
239
-
240
- const pairCmd = {
241
- v: 1,
242
- id: `pair-${Date.now()}`,
243
- kind: 'pair_request',
244
- targetDeviceId: payload.deviceId,
245
- taskId: `pair-${Date.now()}`,
246
- issuedAt: Date.now(),
247
- payload: {
248
- clientName: 'Antigravity CLI',
249
- },
250
- };
251
-
252
- try {
253
- await conn.sendCommand(pairCmd, undefined, 60000);
254
- } catch (err) {
255
- conn.close();
256
- console.error(`\x1b[31m✖ ${err.message}\x1b[0m`);
257
- process.exit(1);
258
- }
259
-
260
- saveActiveSession({
261
- ...payload,
262
- pairedAt: Date.now(),
263
- });
264
-
265
- conn.close();
266
-
267
- console.log(`\x1b[32m✔ Successfully paired with "${name}".\x1b[0m`);
268
- console.log('You can now run tasks without entering the code:');
269
- console.log(' \x1b[33mnpx kroki "open example.com and extract titles"\x1b[0m');
270
- console.log(' \x1b[33mnpx kroki run "My Workflow Name"\x1b[0m\n');
271
- }
585
+ // ── UI Helpers ──────────────────────────────────────────────────────────────
272
586
 
273
587
  function createProgressTracker({ verbose = false, isJson = false, initialText = '' }) {
274
588
  const steps = [];
@@ -302,6 +616,16 @@ function createProgressTracker({ verbose = false, isJson = false, initialText =
302
616
  currentStatus = firstLine;
303
617
  }
304
618
  },
619
+ onStatus(statusText) {
620
+ if (!statusText || typeof statusText !== 'string') return;
621
+ const firstLine = statusText.split('\n')[0].trim();
622
+ if (!firstLine) return;
623
+ if (verbose) {
624
+ console.log(` \x1b[90mℹ ${firstLine.slice(0, 100)}\x1b[0m`);
625
+ } else {
626
+ currentStatus = firstLine;
627
+ }
628
+ },
305
629
  stop() {
306
630
  if (timer) {
307
631
  clearInterval(timer);
@@ -317,81 +641,651 @@ function createProgressTracker({ verbose = false, isJson = false, initialText =
317
641
  };
318
642
  }
319
643
 
320
- async function taskCommand(taskText, { verbose = false, json = false } = {}) {
321
- const session = getActiveSession();
322
- if (!session) {
323
- console.error('\x1b[31mError: Not paired.\x1b[0m');
324
- console.error('Pair first with code from https://kroki.ai/#/devices (Ultra plan required):');
325
- console.error(' \x1b[33mnpx kroki pair <YOUR_CODE>\x1b[0m\n');
644
+ // ── Commands ────────────────────────────────────────────────────────────────
645
+
646
+ async function loginCommand(explicitCode) {
647
+ if (explicitCode) {
648
+ saveFromCode(explicitCode);
649
+ console.log('\x1b[32m✔ Successfully logged in!\x1b[0m\n');
650
+ await devicesCommand();
651
+ return;
652
+ }
653
+
654
+ const port = 54321;
655
+ const authUrl = `https://kroki.ai/#/devices?cli_port=${port}`;
656
+
657
+ console.log(`\x1b[36mLogging in to Kroki...\x1b[0m`);
658
+ console.log(`Opening browser at: \x1b[33m${authUrl}\x1b[0m\n`);
659
+
660
+ openBrowser(authUrl);
661
+
662
+ console.log('Waiting for browser authentication (or paste your code from https://kroki.ai/#/devices below):');
663
+
664
+ let resolved = false;
665
+ let server = null;
666
+ let rl = null;
667
+
668
+ const finish = async code => {
669
+ if (resolved) return;
670
+ resolved = true;
671
+ if (server) {
672
+ try {
673
+ server.close();
674
+ } catch {}
675
+ }
676
+ if (rl) {
677
+ try {
678
+ rl.close();
679
+ } catch {}
680
+ }
681
+ try {
682
+ saveFromCode(code);
683
+ console.log('\n\x1b[32m✔ Successfully logged in!\x1b[0m\n');
684
+ await devicesCommand();
685
+ } catch (e) {
686
+ console.error(`\x1b[31m✖ Login failed:\x1b[0m ${e.message}`);
687
+ process.exit(1);
688
+ }
689
+ };
690
+
691
+ try {
692
+ server = createServer((req, res) => {
693
+ const parsedUrl = new URL(req.url, `http://localhost:${port}`);
694
+ res.setHeader('Access-Control-Allow-Origin', '*');
695
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
696
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
697
+
698
+ if (req.method === 'OPTIONS') {
699
+ res.writeHead(204);
700
+ res.end();
701
+ return;
702
+ }
703
+
704
+ if (req.method === 'POST') {
705
+ let body = '';
706
+ req.on('data', chunk => {
707
+ body += chunk;
708
+ });
709
+ req.on('end', () => {
710
+ try {
711
+ const data = JSON.parse(body);
712
+ if (data.token || data.code) {
713
+ const cfg = saveFromCode(data.code ? data.code : data);
714
+ const activeIds =
715
+ cfg.selectedDeviceIds && cfg.selectedDeviceIds.length > 0
716
+ ? cfg.selectedDeviceIds
717
+ : Object.keys(cfg.devices || {});
718
+ res.writeHead(200, { 'Content-Type': 'application/json' });
719
+ res.end(JSON.stringify({ ok: true, selectedDeviceIds: activeIds }));
720
+ finish(data.code ? data.code : data);
721
+ return;
722
+ }
723
+ } catch {}
724
+ res.writeHead(400);
725
+ res.end(JSON.stringify({ error: 'invalid payload' }));
726
+ });
727
+ return;
728
+ }
729
+
730
+ const codeParam = parsedUrl.searchParams.get('code') || parsedUrl.searchParams.get('token');
731
+ if (codeParam) {
732
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
733
+ res.end(
734
+ `<html><body style="font-family:system-ui;text-align:center;padding:50px;background:#18181b;color:#f4f4f5"><h2>✔ Kroki CLI Authorized!</h2><p>You can return to your terminal.</p></body></html>`,
735
+ );
736
+ finish(codeParam);
737
+ return;
738
+ }
739
+
740
+ res.writeHead(404);
741
+ res.end();
742
+ });
743
+
744
+ server.listen(port, '127.0.0.1');
745
+ server.on('error', () => {
746
+ // Port in use, proceed with stdin prompt
747
+ });
748
+ } catch {}
749
+
750
+ rl = createInterface({
751
+ input: process.stdin,
752
+ output: process.stdout,
753
+ });
754
+
755
+ rl.question('> ', answer => {
756
+ if (answer && answer.trim()) {
757
+ finish(answer.trim());
758
+ }
759
+ });
760
+ }
761
+
762
+ async function devicesCommand({ json = false, all = false, target = null, args = [] } = {}) {
763
+ const config = getConfig();
764
+ if (!config || !config.account || !config.account.token) {
765
+ console.error('\x1b[31mError: Not logged in.\x1b[0m');
766
+ console.error('Run \x1b[33mkroki login\x1b[0m to authenticate first.');
326
767
  process.exit(1);
327
768
  }
328
769
 
329
- const conn = new RealtimeConnection(session);
770
+ const devices = { ...(config.devices || {}) };
771
+
772
+ // Fetch accounts & devices from backend using control token
773
+ try {
774
+ const res = await fetch(`${config.account.supabaseUrl || DEFAULT_SUPABASE_URL}/functions/v1/get-control-token`, {
775
+ method: 'POST',
776
+ headers: {
777
+ Origin: 'https://kroki.ai',
778
+ 'Content-Type': 'application/json',
779
+ apikey: config.account.anonKey || DEFAULT_ANON_KEY,
780
+ Authorization: `Bearer ${config.account.token}`,
781
+ },
782
+ body: JSON.stringify({ action: 'list_devices' }),
783
+ });
784
+ if (res.ok) {
785
+ const data = await res.json();
786
+ if (Array.isArray(data.accounts)) {
787
+ for (const acc of data.accounts) {
788
+ if (Array.isArray(acc.devices)) {
789
+ for (const d of acc.devices) {
790
+ const resolvedEmail =
791
+ acc.email ||
792
+ (acc.google_id === config.account.googleId ? config.account.email : null) ||
793
+ config.account.email ||
794
+ acc.label ||
795
+ acc.google_id;
796
+ devices[d.device_id] = {
797
+ deviceId: d.device_id,
798
+ deviceName: d.device_name || d.device_id,
799
+ platform: d.platform || 'mac',
800
+ account: resolvedEmail,
801
+ googleId: acc.google_id,
802
+ lastSeenAt: d.last_seen_at ? Date.parse(d.last_seen_at) : Date.now(),
803
+ };
804
+ }
805
+ }
806
+ }
807
+ config.devices = devices;
808
+ saveConfig(config);
809
+ }
810
+ }
811
+ } catch {}
812
+
813
+ const deviceList = Object.values(devices);
814
+
815
+ if (deviceList.length === 0) {
816
+ console.log('\n\x1b[33mNo connected browsers found in this account.\x1b[0m');
817
+ console.log('Connect Chrome from the Kroki extension or visit https://kroki.ai/#/devices\n');
818
+ return;
819
+ }
820
+
821
+ // Ensure a default device exists
822
+ if (!config.defaultDeviceId || !devices[config.defaultDeviceId]) {
823
+ config.defaultDeviceId = deviceList[0].deviceId;
824
+ saveConfig(config);
825
+ }
826
+
827
+ const rawInput = (args.length > 0 ? args.join(' ') : target || '').trim();
828
+
829
+ // Query live presence
830
+ const { onlineIds, expired } = await fetchOnlineDeviceIds(config);
831
+
832
+ // 1. If arguments provided to select browser(s): e.g. "1", "1, 2", "all"
833
+ if (rawInput) {
834
+ if (rawInput.toLowerCase() === 'all') {
835
+ config.activeDeviceIds = [];
836
+ saveConfig(config);
837
+ console.log('\x1b[32m✔ All browsers are now active.\x1b[0m\n');
838
+ return;
839
+ }
840
+
841
+ const parts = rawInput
842
+ .split(/[,\s]+/)
843
+ .map(s => s.trim())
844
+ .filter(Boolean);
845
+ const chosen = [];
846
+ for (const p of parts) {
847
+ try {
848
+ const dev = resolveTargetDevice(config, p);
849
+ if (dev && !chosen.some(c => c.deviceId === dev.deviceId)) {
850
+ chosen.push(dev);
851
+ }
852
+ } catch (err) {
853
+ console.error(`\x1b[31m✖ Error resolving "${p}":\x1b[0m ${err.message}`);
854
+ process.exit(1);
855
+ }
856
+ }
857
+
858
+ if (chosen.length > 0) {
859
+ config.activeDeviceIds = chosen.map(d => d.deviceId);
860
+ config.defaultDeviceId = chosen[0].deviceId;
861
+ saveConfig(config);
862
+ if (chosen.length === 1) {
863
+ const idx = deviceList.findIndex(d => d.deviceId === chosen[0].deviceId);
864
+ const isOnline = onlineIds.has(chosen[0].deviceId);
865
+ const statusTag = isOnline ? ' \x1b[32m● online\x1b[0m' : ' \x1b[90m○ offline\x1b[0m';
866
+ console.log(
867
+ `\x1b[32m✔ Active browser set to:\x1b[0m \x1b[36m[${idx + 1}] ${chosen[0].deviceName}\x1b[0m${statusTag} (${chosen[0].account || 'browser'})\n`,
868
+ );
869
+ if (!isOnline && !expired) {
870
+ console.log(
871
+ `\x1b[33m⚠ Note: This browser is currently offline. Open Chrome on this profile before running tasks.\x1b[0m\n`,
872
+ );
873
+ }
874
+ } else {
875
+ console.log(`\x1b[32m✔ Active browsers set (${chosen.length}):\x1b[0m`);
876
+ let hasOffline = false;
877
+ chosen.forEach(c => {
878
+ const idx = deviceList.findIndex(d => d.deviceId === c.deviceId);
879
+ const isOnline = onlineIds.has(c.deviceId);
880
+ if (!isOnline) hasOffline = true;
881
+ const statusTag = isOnline ? ' \x1b[32m● online\x1b[0m' : ' \x1b[90m○ offline\x1b[0m';
882
+ console.log(` \x1b[36m[${idx + 1}]\x1b[0m ${c.deviceName}${statusTag} (${c.account || 'browser'})`);
883
+ });
884
+ console.log();
885
+ if (hasOffline && !expired) {
886
+ console.log(
887
+ `\x1b[33m⚠ Note: Some chosen browsers are offline. Tasks will only execute on online browsers.\x1b[0m\n`,
888
+ );
889
+ }
890
+ }
891
+ return;
892
+ }
893
+ }
894
+
895
+ // 2. JSON output
896
+ if (json) {
897
+ const activeIds = config.activeDeviceIds && config.activeDeviceIds.length > 0 ? config.activeDeviceIds : null;
898
+ const list = activeIds && !all ? deviceList.filter(d => activeIds.includes(d.deviceId)) : deviceList;
899
+ const output = list.map(d => {
900
+ const idx = deviceList.findIndex(item => item.deviceId === d.deviceId);
901
+ const isOnline = onlineIds.has(d.deviceId);
902
+ return {
903
+ index: idx + 1,
904
+ deviceId: d.deviceId,
905
+ deviceName: d.deviceName,
906
+ account: d.account || null,
907
+ platform: d.platform || 'mac',
908
+ status: isOnline ? 'online' : 'offline',
909
+ isOnline,
910
+ isActive: !activeIds || activeIds.includes(d.deviceId),
911
+ isDefault: config.defaultDeviceId === d.deviceId,
912
+ lastSeenAt: d.lastSeenAt || null,
913
+ };
914
+ });
915
+ console.log(JSON.stringify(output, null, 2));
916
+ return;
917
+ }
918
+
919
+ // 3. Display list of browsers
920
+ const activeIds = config.activeDeviceIds && config.activeDeviceIds.length > 0 ? config.activeDeviceIds : null;
921
+ console.log(`\n\x1b[1mAvailable Kroki Browsers (${deviceList.length}):\x1b[0m`);
922
+ deviceList.forEach((d, idx) => {
923
+ const isAct = !activeIds || activeIds.includes(d.deviceId);
924
+ const isDef = config.defaultDeviceId === d.deviceId;
925
+ const isOnline = onlineIds.has(d.deviceId);
926
+ const statusTag = isOnline ? ' \x1b[32m● online\x1b[0m' : ' \x1b[90m○ offline\x1b[0m';
927
+ const mark = isAct ? '\x1b[32m✔\x1b[0m ' : ' ';
928
+ const defTag = isDef ? ' \x1b[33m[default]\x1b[0m' : '';
929
+ const accTag = d.account && !/^\d{15,}$/.test(d.account) ? ` \x1b[90m(${d.account})\x1b[0m` : '';
930
+ console.log(` ${mark}\x1b[36m[${idx + 1}]\x1b[0m ${d.deviceName || d.deviceId}${statusTag}${accTag}${defTag}`);
931
+ });
932
+ console.log();
933
+
934
+ if (activeIds && activeIds.length < deviceList.length) {
935
+ console.log(`\x1b[32mActive:\x1b[0m ${activeIds.length} of ${deviceList.length} browsers\n`);
936
+ }
937
+
938
+ if (expired) {
939
+ console.log(
940
+ `\x1b[33m⚠ Note: Session token has expired. Run "kroki login" to re-authenticate and refresh live presence.\x1b[0m\n`,
941
+ );
942
+ }
943
+
944
+ // Interactive selection if in terminal (TTY)
945
+ if (process.stdin.isTTY) {
946
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
947
+ rl.question('Select browser(s) [1..10, or "all"] (Enter to keep current): ', ans => {
948
+ rl.close();
949
+ const val = ans.trim();
950
+ if (!val) return;
951
+ if (val.toLowerCase() === 'all') {
952
+ config.activeDeviceIds = [];
953
+ saveConfig(config);
954
+ console.log('\n\x1b[32m✔ All browsers are now active.\x1b[0m\n');
955
+ return;
956
+ }
957
+
958
+ const parts = val
959
+ .split(/[,\s]+/)
960
+ .map(s => s.trim())
961
+ .filter(Boolean);
962
+ const chosen = [];
963
+ for (const p of parts) {
964
+ try {
965
+ const dev = resolveTargetDevice(config, p);
966
+ if (dev && !chosen.some(c => c.deviceId === dev.deviceId)) {
967
+ chosen.push(dev);
968
+ }
969
+ } catch (err) {
970
+ console.warn(`\x1b[33mWarning: ${err.message}\x1b[0m`);
971
+ }
972
+ }
973
+
974
+ if (chosen.length > 0) {
975
+ config.activeDeviceIds = chosen.map(d => d.deviceId);
976
+ config.defaultDeviceId = chosen[0].deviceId;
977
+ saveConfig(config);
978
+ console.log(`\n\x1b[32m✔ Active browser(s) set (${chosen.length}):\x1b[0m`);
979
+ chosen.forEach(c => {
980
+ const idx = deviceList.findIndex(d => d.deviceId === c.deviceId);
981
+ const isOnline = onlineIds.has(c.deviceId);
982
+ const statusTag = isOnline ? ' \x1b[32m● online\x1b[0m' : ' \x1b[90m○ offline\x1b[0m';
983
+ console.log(` \x1b[36m[${idx + 1}]\x1b[0m ${c.deviceName}${statusTag} (${c.account || 'browser'})`);
984
+ });
985
+ console.log();
986
+ }
987
+ });
988
+ } else {
989
+ console.log('\x1b[90mSelect browser: kroki devices <number> (e.g. kroki devices 1, kroki devices all)\x1b[0m\n');
990
+ }
991
+ }
992
+
993
+ async function pairCommand(code) {
994
+ if (!code) {
995
+ console.log(`
996
+ Pair Kroki CLI with a live browser profile.
997
+
998
+ 1. Log in at \x1b[36mhttps://kroki.ai/#/devices\x1b[0m
999
+ 2. Find your active browser profile
1000
+ 3. Click \x1b[32m"Скопировать код для AI"\x1b[0m
1001
+ 4. Run:
1002
+ \x1b[33mkroki pair <YOUR_CODE>\x1b[0m
1003
+ `);
1004
+ process.exit(0);
1005
+ }
1006
+
1007
+ const payload = decodePairingCode(code);
1008
+ const name = payload.deviceName || payload.deviceId;
1009
+ console.log(`\x1b[36mConnecting to profile [${name}]...\x1b[0m`);
1010
+
1011
+ const conn = new RealtimeConnection(payload);
330
1012
  await conn.connect();
331
1013
 
1014
+ console.log(`\x1b[33mWaiting for confirmation in Kroki browser (click "Разрешить")...\x1b[0m`);
1015
+
1016
+ const pairCmd = {
1017
+ v: 1,
1018
+ id: `pair-${Date.now()}`,
1019
+ kind: 'pair_request',
1020
+ targetDeviceId: payload.deviceId,
1021
+ taskId: `pair-${Date.now()}`,
1022
+ issuedAt: Date.now(),
1023
+ payload: {
1024
+ clientName: 'Antigravity CLI',
1025
+ },
1026
+ };
1027
+
1028
+ try {
1029
+ await conn.sendCommand(pairCmd, undefined, 60000);
1030
+ } catch (err) {
1031
+ conn.close();
1032
+ console.error(`\x1b[31m✖ ${err.message}\x1b[0m`);
1033
+ process.exit(1);
1034
+ }
1035
+
1036
+ saveFromCode(code);
1037
+ conn.close();
1038
+
1039
+ console.log(`\x1b[32m✔ Successfully paired with "${name}".\x1b[0m`);
1040
+ console.log('You can now run tasks without entering the code:');
1041
+ console.log(' \x1b[33mkroki "open example.com and extract titles"\x1b[0m');
1042
+ console.log(' \x1b[33mkroki run "My Workflow Name"\x1b[0m\n');
1043
+ }
1044
+
1045
+ async function runSingleDeviceTask(config, device, taskText, { verbose = false, json = false } = {}) {
1046
+ const devList = Object.values(config.devices || {});
1047
+ const devIdx = devList.findIndex(d => d.deviceId === device.deviceId) + 1;
1048
+ const idxStr = devIdx > 0 ? `[${devIdx}] ` : '';
1049
+ const devLabel = `${idxStr}${device.deviceName || device.deviceId}`;
1050
+
1051
+ if (isTokenExpired(config.account?.token)) {
1052
+ const errMsg = 'Session token has expired. Run "kroki login" to re-authenticate.';
1053
+ if (!json) {
1054
+ console.error('\x1b[31m✖ Error: Session token has expired.\x1b[0m');
1055
+ console.error('Run \x1b[33mkroki login\x1b[0m to re-authenticate.\n');
1056
+ }
1057
+ return { device, ok: false, error: errMsg, steps: [] };
1058
+ }
1059
+
1060
+ const targetGoogleId = device.googleId || config.account.googleId;
1061
+ const conn = new RealtimeConnection({
1062
+ ...config.account,
1063
+ googleId: targetGoogleId,
1064
+ deviceId: device.deviceId,
1065
+ });
1066
+
1067
+ try {
1068
+ await conn.connect();
1069
+ } catch (err) {
1070
+ return { device, ok: false, error: err.message, steps: [] };
1071
+ }
1072
+
1073
+ if (!conn.isDeviceOnline(device.deviceId)) {
1074
+ conn.close();
1075
+ const errMsg = `Browser ${devLabel} is offline. Make sure Google Chrome is open with the Kroki extension active on this profile.`;
1076
+ if (!json) {
1077
+ console.error(`\x1b[31m✖ Cannot run task: Browser ${devLabel} is offline.\x1b[0m`);
1078
+ console.error(` Make sure Google Chrome is open with the Kroki extension active on this profile.\n`);
1079
+ }
1080
+ return { device, ok: false, error: errMsg, steps: [] };
1081
+ }
1082
+
332
1083
  const command = {
333
1084
  v: 1,
334
- id: `task-${Date.now()}`,
1085
+ id: `task-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
335
1086
  kind: 'chat_task',
336
- targetDeviceId: session.deviceId,
337
- taskId: `task-${Date.now()}`,
1087
+ targetDeviceId: device.deviceId,
1088
+ taskId: `task-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
338
1089
  issuedAt: Date.now(),
339
1090
  payload: {
340
1091
  task: taskText,
341
1092
  },
342
1093
  };
343
1094
 
344
- const initialMsg = `\x1b[36m[Kroki: ${session.deviceName || 'Browser'}]\x1b[0m Running: "${taskText}"...`;
1095
+ const initialMsg = `\x1b[36m[Kroki: ${devLabel}]\x1b[0m Running: "${taskText}"...`;
345
1096
  const tracker = createProgressTracker({ verbose, isJson: json, initialText: initialMsg });
346
1097
 
347
1098
  try {
348
1099
  const result = await conn.sendCommand(command, ev => {
349
1100
  if (ev.kind === 'execution') {
350
1101
  const details = ev.payload?.data?.details || ev.payload?.title || ev.payload?.type;
351
- if (details) tracker.onStep(details);
1102
+ if (details) tracker.onStep(`[${devLabel}] ${details}`);
352
1103
  }
353
1104
  });
354
1105
 
355
1106
  tracker.stop();
1107
+ return { device, ok: true, result, steps: tracker.getSteps() };
1108
+ } catch (err) {
1109
+ tracker.stop();
1110
+ return { device, ok: false, error: err.message, steps: tracker.getSteps() };
1111
+ } finally {
1112
+ conn.close();
1113
+ }
1114
+ }
356
1115
 
357
- if (json) {
358
- console.log(JSON.stringify(result, null, 2));
359
- } else {
360
- if (verbose) {
361
- console.log('\x1b[32m✔ Task completed.\x1b[0m\n');
1116
+ async function taskCommand(taskText, { verbose = false, json = false, device: targetQuery = null, all = false } = {}) {
1117
+ const config = getConfig();
1118
+ if (!config || !config.account || !config.account.token) {
1119
+ console.error('\x1b[31mError: Not logged in or paired.\x1b[0m');
1120
+ console.error('Run \x1b[33mkroki login\x1b[0m or \x1b[33mkroki pair <CODE>\x1b[0m first.\n');
1121
+ process.exit(1);
1122
+ }
1123
+
1124
+ // Auto-detect natural language requests to run a workflow (e.g. 'запусти Order Ali', 'run workflow Ali')
1125
+ const wfMatch = taskText.match(
1126
+ /^(?:запусти(?:ть)?|стартуй|run|launch|execute)\s+(?:воркфлоу|workflow\s+)?["']?([^"']+)["']?$/i,
1127
+ );
1128
+ if (wfMatch) {
1129
+ const candidate = wfMatch[1].trim();
1130
+ const resolvedDev = resolveTargetDevice(config, targetQuery);
1131
+ const targetGoogleId = resolvedDev?.googleId || config.account.googleId;
1132
+ try {
1133
+ const res = await fetch(`${config.account.supabaseUrl || DEFAULT_SUPABASE_URL}/functions/v1/get-control-token`, {
1134
+ method: 'POST',
1135
+ headers: {
1136
+ Origin: 'https://kroki.ai',
1137
+ 'Content-Type': 'application/json',
1138
+ apikey: config.account.anonKey || DEFAULT_ANON_KEY,
1139
+ Authorization: `Bearer ${config.account.token}`,
1140
+ },
1141
+ body: JSON.stringify({ action: 'list_workflows', owner_google_id: targetGoogleId }),
1142
+ });
1143
+ if (res.ok) {
1144
+ const data = await res.json();
1145
+ if (Array.isArray(data.workflows)) {
1146
+ const q = candidate.toLowerCase();
1147
+ const match =
1148
+ data.workflows.find(w => w.id === candidate) ||
1149
+ data.workflows.find(w => (w.name || '').toLowerCase() === q) ||
1150
+ data.workflows.find(w => (w.name || '').toLowerCase().includes(q));
1151
+ if (match) {
1152
+ console.log(
1153
+ `\x1b[36m[Kroki]\x1b[0m Detected workflow "${match.name || match.id}". Launching workflow runner...\n`,
1154
+ );
1155
+ return workflowCommand(match.id, null, { verbose, json, device: targetQuery, taskText });
1156
+ }
1157
+ }
362
1158
  }
363
- if (result && typeof result === 'object' && result.message) {
364
- console.log(result.message);
365
- } else if (result && typeof result === 'object') {
366
- console.log(JSON.stringify(result, null, 2));
1159
+ } catch {}
1160
+ }
1161
+
1162
+ const isAll =
1163
+ all ||
1164
+ targetQuery === 'all' ||
1165
+ targetQuery === 'both' ||
1166
+ /обоих браузер|всех браузер|на всех|на обоих|все браузеры|оба браузера/i.test(taskText);
1167
+
1168
+ let targetDevices = [];
1169
+ if (isAll) {
1170
+ targetDevices = Object.values(config.devices || {});
1171
+ } else {
1172
+ const device = resolveTargetDevice(config, targetQuery);
1173
+ if (device) targetDevices = [device];
1174
+ }
1175
+
1176
+ if (targetDevices.length === 0) {
1177
+ console.error('\x1b[31mError: No target browser device found.\x1b[0m');
1178
+ console.error('Run \x1b[33mkroki devices\x1b[0m to select active browsers.\n');
1179
+ process.exit(1);
1180
+ }
1181
+
1182
+ if (targetDevices.length === 1) {
1183
+ const r = await runSingleDeviceTask(config, targetDevices[0], taskText, { verbose, json });
1184
+ if (!r.ok) {
1185
+ if (!verbose && r.steps && r.steps.length > 0) {
1186
+ console.error('\x1b[90mRecent steps before failure:\x1b[0m');
1187
+ for (const step of r.steps.slice(-10)) {
1188
+ console.error(` \x1b[90m→ ${step}\x1b[0m`);
1189
+ }
1190
+ console.error('');
367
1191
  }
368
- }
369
- } catch (err) {
370
- tracker.stop();
371
- if (!verbose && tracker.getSteps().length > 0) {
372
- console.error('\x1b[90mRecent steps before failure:\x1b[0m');
373
- for (const step of tracker.getSteps().slice(-10)) {
374
- console.error(` \x1b[90m→ ${step}\x1b[0m`);
1192
+ if (json) {
1193
+ console.error(JSON.stringify({ success: false, error: r.error, steps: r.steps }, null, 2));
1194
+ } else {
1195
+ console.error(`\x1b[31m✖ Error: ${r.error}\x1b[0m`);
375
1196
  }
376
- console.error('');
1197
+ process.exit(1);
377
1198
  }
1199
+
378
1200
  if (json) {
379
- console.error(JSON.stringify({ success: false, error: err.message, steps: tracker.getSteps() }, null, 2));
1201
+ console.log(JSON.stringify(r.result, null, 2));
380
1202
  } else {
381
- console.error(`\x1b[31m✖ Error: ${err.message}\x1b[0m`);
1203
+ if (verbose) console.log('\x1b[32m✔ Task completed.\x1b[0m\n');
1204
+ if (r.result && typeof r.result === 'object' && r.result.message) {
1205
+ console.log(r.result.message);
1206
+ } else if (r.result && typeof r.result === 'object') {
1207
+ console.log(JSON.stringify(r.result, null, 2));
1208
+ }
382
1209
  }
1210
+ return;
1211
+ }
1212
+
1213
+ // Multi-device parallel dispatch
1214
+ console.log(`\x1b[36mDispatching task to ${targetDevices.length} browsers in parallel...\x1b[0m\n`);
1215
+ const results = await Promise.all(
1216
+ targetDevices.map(d => runSingleDeviceTask(config, d, taskText, { verbose, json })),
1217
+ );
1218
+
1219
+ if (json) {
1220
+ console.log(JSON.stringify(results, null, 2));
1221
+ } else {
1222
+ console.log('\n\x1b[32m✔ Completed on all target browsers:\x1b[0m');
1223
+ for (const r of results) {
1224
+ const devName = r.device.deviceName || r.device.deviceId;
1225
+ if (r.ok) {
1226
+ const msg = r.result && typeof r.result === 'object' && r.result.message ? r.result.message : 'Success';
1227
+ console.log(` \x1b[32m✔\x1b[0m \x1b[1m${devName}\x1b[0m: ${msg}`);
1228
+ } else {
1229
+ console.log(` \x1b[31m✖\x1b[0m \x1b[1m${devName}\x1b[0m: ${r.error}`);
1230
+ }
1231
+ }
1232
+ }
1233
+ }
1234
+
1235
+ async function workflowsCommand({ json = false, device: targetQuery = null } = {}) {
1236
+ const config = getConfig();
1237
+ if (!config || !config.account || !config.account.token) {
1238
+ console.error('\x1b[31mError: Not logged in.\x1b[0m');
1239
+ console.error('Run \x1b[33mkroki login\x1b[0m first.\n');
383
1240
  process.exit(1);
384
- } finally {
385
- conn.close();
386
1241
  }
1242
+
1243
+ const device = resolveTargetDevice(config, targetQuery);
1244
+ const targetGoogleId = device?.googleId || config.account.googleId;
1245
+
1246
+ const res = await fetch(`${config.account.supabaseUrl || DEFAULT_SUPABASE_URL}/functions/v1/get-control-token`, {
1247
+ method: 'POST',
1248
+ headers: {
1249
+ Origin: 'https://kroki.ai',
1250
+ 'Content-Type': 'application/json',
1251
+ apikey: config.account.anonKey || DEFAULT_ANON_KEY,
1252
+ Authorization: `Bearer ${config.account.token}`,
1253
+ },
1254
+ body: JSON.stringify({ action: 'list_workflows', owner_google_id: targetGoogleId }),
1255
+ });
1256
+ if (!res.ok) {
1257
+ console.error('Failed to load workflows.');
1258
+ process.exit(1);
1259
+ }
1260
+ const data = await res.json();
1261
+ const list = data.workflows || [];
1262
+ if (json) {
1263
+ console.log(JSON.stringify(list, null, 2));
1264
+ return;
1265
+ }
1266
+ console.log(`\n\x1b[1mAvailable workflows for ${device?.deviceName || 'browser'}:\x1b[0m`);
1267
+ for (const wf of list) {
1268
+ console.log(` • \x1b[36m${wf.name}\x1b[0m \x1b[90m(${wf.id})\x1b[0m`);
1269
+ }
1270
+ console.log('\nRun workflow with:\n \x1b[33mkroki run "<name>"\x1b[0m\n');
387
1271
  }
388
1272
 
389
- async function workflowCommand(nameOrId, varsJson, { verbose = false, json = false } = {}) {
390
- const session = getActiveSession();
391
- if (!session) {
392
- console.error('\x1b[31mError: Not paired.\x1b[0m');
393
- console.error('Pair first with code from https://kroki.ai/#/devices (Ultra plan required):');
394
- console.error(' \x1b[33mnpx kroki pair <YOUR_CODE>\x1b[0m\n');
1273
+ async function workflowCommand(
1274
+ nameOrId,
1275
+ varsJson,
1276
+ { verbose = false, json = false, device: targetQuery = null, taskText = null } = {},
1277
+ ) {
1278
+ const config = getConfig();
1279
+ if (!config || !config.account || !config.account.token) {
1280
+ console.error('\x1b[31mError: Not logged in or paired.\x1b[0m');
1281
+ console.error('Run \x1b[33mkroki login\x1b[0m or \x1b[33mkroki pair <CODE>\x1b[0m first.\n');
1282
+ process.exit(1);
1283
+ }
1284
+
1285
+ const device = resolveTargetDevice(config, targetQuery);
1286
+ if (!device) {
1287
+ console.error('\x1b[31mError: No target browser device found.\x1b[0m');
1288
+ console.error('Run \x1b[33mkroki devices\x1b[0m to select active browsers.\n');
395
1289
  process.exit(1);
396
1290
  }
397
1291
 
@@ -404,56 +1298,219 @@ async function workflowCommand(nameOrId, varsJson, { verbose = false, json = fal
404
1298
  }
405
1299
  }
406
1300
 
407
- const conn = new RealtimeConnection(session);
408
- await conn.connect();
1301
+ const targetGoogleId = device.googleId || config.account.googleId;
1302
+
1303
+ // Resolve human workflow name to actual workflowId
1304
+ let targetWorkflowId = nameOrId;
1305
+ let resolvedName = nameOrId;
1306
+
1307
+ try {
1308
+ const res = await fetch(`${config.account.supabaseUrl || DEFAULT_SUPABASE_URL}/functions/v1/get-control-token`, {
1309
+ method: 'POST',
1310
+ headers: {
1311
+ Origin: 'https://kroki.ai',
1312
+ 'Content-Type': 'application/json',
1313
+ apikey: config.account.anonKey || DEFAULT_ANON_KEY,
1314
+ Authorization: `Bearer ${config.account.token}`,
1315
+ },
1316
+ body: JSON.stringify({ action: 'list_workflows', owner_google_id: targetGoogleId }),
1317
+ });
1318
+ if (res.ok) {
1319
+ const data = await res.json();
1320
+ if (Array.isArray(data.workflows)) {
1321
+ const q = nameOrId.toLowerCase().trim();
1322
+ const match =
1323
+ data.workflows.find(w => w.id === nameOrId) ||
1324
+ data.workflows.find(w => (w.name || '').toLowerCase() === q) ||
1325
+ data.workflows.find(w => (w.name || '').toLowerCase().includes(q));
1326
+ if (match) {
1327
+ targetWorkflowId = match.id;
1328
+ resolvedName = match.name || match.id;
1329
+ }
1330
+ }
1331
+ }
1332
+ } catch {}
1333
+
1334
+ if (isTokenExpired(config.account?.token)) {
1335
+ console.error('\x1b[31m✖ Error: Session token has expired.\x1b[0m');
1336
+ console.error('Run \x1b[33mkroki login\x1b[0m to re-authenticate.\n');
1337
+ process.exit(1);
1338
+ }
1339
+
1340
+ const conn = new RealtimeConnection({
1341
+ ...config.account,
1342
+ googleId: targetGoogleId,
1343
+ deviceId: device.deviceId,
1344
+ });
1345
+
1346
+ try {
1347
+ await conn.connect();
1348
+ } catch (err) {
1349
+ console.error(`\x1b[31m✖ Error: ${err.message}\x1b[0m\n`);
1350
+ process.exit(1);
1351
+ }
1352
+
1353
+ const devList = Object.values(config.devices || {});
1354
+ const devIdx = devList.findIndex(d => d.deviceId === device.deviceId) + 1;
1355
+ const idxStr = devIdx > 0 ? `[${devIdx}] ` : '';
1356
+ const devLabel = `${idxStr}${device.deviceName || device.deviceId}`;
1357
+
1358
+ if (!conn.isDeviceOnline(device.deviceId)) {
1359
+ conn.close();
1360
+ console.error(`\x1b[31m✖ Cannot run workflow: Browser ${devLabel} is offline.\x1b[0m`);
1361
+ console.error(` Make sure Google Chrome is open with the Kroki extension active on this profile.\n`);
1362
+ process.exit(1);
1363
+ }
409
1364
 
410
1365
  const command = {
411
1366
  v: 1,
412
1367
  id: `wf-${Date.now()}`,
413
1368
  kind: 'workflow_execute',
414
- targetDeviceId: session.deviceId,
1369
+ targetDeviceId: device.deviceId,
415
1370
  taskId: `wf-${Date.now()}`,
416
1371
  issuedAt: Date.now(),
417
1372
  payload: {
418
- workflowId: nameOrId,
1373
+ workflowId: targetWorkflowId,
1374
+ workflowName: resolvedName,
1375
+ task: taskText || `Run workflow "${resolvedName}"`,
419
1376
  variables,
420
1377
  },
421
1378
  };
422
1379
 
423
- const initialMsg = `\x1b[36m[Kroki: ${session.deviceName || 'Browser'}]\x1b[0m Executing workflow "${nameOrId}"...`;
424
- const tracker = createProgressTracker({ verbose, isJson: json, initialText: initialMsg });
1380
+ const devName = device.deviceName || device.deviceId || 'Browser';
1381
+ const initialMsg = `\x1b[36m[Kroki: ${devName}]\x1b[0m Started workflow "${resolvedName}" (ID: ${targetWorkflowId})`;
1382
+ if (!json) {
1383
+ console.log(initialMsg);
1384
+ }
1385
+
1386
+ const tracker = createProgressTracker({ verbose, isJson: json, initialText: 'Running workflow...' });
1387
+ const startTime = Date.now();
1388
+ let stepCount = 0;
1389
+ const stepHistory = [];
1390
+ let currentStepStartTime = Date.now();
425
1391
 
426
1392
  try {
427
1393
  const result = await conn.sendCommand(command, ev => {
428
- if (ev.kind === 'workflow_step_start') {
429
- const stepDesc = `[step] ${ev.payload?.stepType || 'action'}: ${ev.payload?.comment || ''}`;
430
- tracker.onStep(stepDesc);
1394
+ if (ev.kind === 'workflow_queued') {
1395
+ const queuePos = ev.payload?.position;
1396
+ tracker.onStatus(`Queued (position ${queuePos || 1}). Waiting for runner...`);
1397
+ } else if (ev.kind === 'workflow_step_start') {
1398
+ stepCount++;
1399
+ currentStepStartTime = Date.now();
1400
+ const type = ev.payload?.stepType || 'ACTION';
1401
+ const comment = ev.payload?.comment ? `: ${ev.payload.comment}` : '';
1402
+ const stepDesc = `[step ${stepCount}] ${type}${comment}`;
1403
+ stepHistory.push({
1404
+ index: stepCount,
1405
+ id: ev.payload?.stepId,
1406
+ type,
1407
+ comment: ev.payload?.comment || '',
1408
+ startedAt: currentStepStartTime,
1409
+ status: 'running',
1410
+ });
1411
+ if (verbose) {
1412
+ tracker.onStep(stepDesc);
1413
+ } else {
1414
+ tracker.onStatus(`Running "${resolvedName}" (step ${stepCount})...`);
1415
+ }
1416
+ } else if (ev.kind === 'workflow_step_result') {
1417
+ const lastStep = stepHistory[stepHistory.length - 1];
1418
+ const duration = ((Date.now() - currentStepStartTime) / 1000).toFixed(1);
1419
+ if (lastStep) {
1420
+ lastStep.duration = `${duration}s`;
1421
+ lastStep.status = ev.payload?.success ? 'success' : 'failed';
1422
+ lastStep.error = ev.payload?.error;
1423
+ }
1424
+ if (verbose) {
1425
+ const stepLabel = lastStep ? `[step ${lastStep.index}] ${lastStep.type}` : 'step';
1426
+ if (ev.payload?.success) {
1427
+ console.log(` \x1b[32m✔\x1b[0m ${stepLabel} completed in ${duration}s`);
1428
+ } else {
1429
+ console.log(` \x1b[31m✖\x1b[0m ${stepLabel} failed: ${ev.payload?.error || 'Unknown error'}`);
1430
+ }
1431
+ }
431
1432
  }
432
1433
  });
433
1434
 
434
1435
  tracker.stop();
1436
+ const elapsed = ((Date.now() - startTime) / 1000).toFixed(1) + 's';
435
1437
 
436
1438
  if (json) {
437
- console.log(JSON.stringify(result, null, 2));
1439
+ console.log(
1440
+ JSON.stringify(
1441
+ {
1442
+ success: true,
1443
+ workflowId: targetWorkflowId,
1444
+ workflowName: resolvedName,
1445
+ device: devName,
1446
+ elapsed,
1447
+ totalSteps: stepCount,
1448
+ steps: stepHistory,
1449
+ result,
1450
+ },
1451
+ null,
1452
+ 2,
1453
+ ),
1454
+ );
438
1455
  } else {
1456
+ console.log(
1457
+ `\x1b[32m✔ Workflow "${resolvedName}" completed successfully on ${devName} in ${elapsed} (${stepCount} steps executed).\x1b[0m`,
1458
+ );
439
1459
  if (verbose) {
440
- console.log('\x1b[32m✔ Workflow finished.\x1b[0m\n');
1460
+ if (stepHistory.length > 0) {
1461
+ console.log('\n\x1b[1mExecution Details:\x1b[0m');
1462
+ for (const s of stepHistory) {
1463
+ const icon = s.status === 'success' ? '\x1b[32m✔\x1b[0m' : '\x1b[31m✖\x1b[0m';
1464
+ const comment = s.comment ? ` - ${s.comment}` : '';
1465
+ const dur = s.duration ? ` (${s.duration})` : '';
1466
+ console.log(` ${icon} [step ${s.index}] \x1b[1m${s.type}\x1b[0m${comment}${dur}`);
1467
+ }
1468
+ }
1469
+ if (result && typeof result === 'object' && Object.keys(result).length > 0) {
1470
+ console.log('\n\x1b[1mResult Payload:\x1b[0m');
1471
+ console.log(JSON.stringify(result, null, 2));
1472
+ }
1473
+ console.log('');
441
1474
  }
442
- if (result) console.log(JSON.stringify(result, null, 2));
443
1475
  }
444
1476
  } catch (err) {
445
1477
  tracker.stop();
446
- if (!verbose && tracker.getSteps().length > 0) {
447
- console.error('\x1b[90mRecent steps before failure:\x1b[0m');
448
- for (const step of tracker.getSteps().slice(-10)) {
449
- console.error(` \x1b[90m→ ${step}\x1b[0m`);
450
- }
451
- console.error('');
452
- }
1478
+ const elapsed = ((Date.now() - startTime) / 1000).toFixed(1) + 's';
1479
+ const isCancelled = err.payload?.cancelled || /cancelled/i.test(err.message);
1480
+ const failStatus = isCancelled ? 'was cancelled' : 'failed';
1481
+
453
1482
  if (json) {
454
- console.error(JSON.stringify({ success: false, error: err.message, steps: tracker.getSteps() }, null, 2));
1483
+ console.error(
1484
+ JSON.stringify(
1485
+ {
1486
+ success: false,
1487
+ cancelled: isCancelled,
1488
+ workflowId: targetWorkflowId,
1489
+ workflowName: resolvedName,
1490
+ device: devName,
1491
+ elapsed,
1492
+ error: err.message,
1493
+ totalSteps: stepCount,
1494
+ steps: stepHistory,
1495
+ },
1496
+ null,
1497
+ 2,
1498
+ ),
1499
+ );
455
1500
  } else {
456
- console.error(`\x1b[31m✖ Error: ${err.message}\x1b[0m`);
1501
+ console.error(
1502
+ `\x1b[31m✖ Workflow "${resolvedName}" ${failStatus} on ${devName} in ${elapsed}: ${err.message}\x1b[0m`,
1503
+ );
1504
+ if (stepHistory.length > 0) {
1505
+ console.error('\n\x1b[90mRecent steps before failure:\x1b[0m');
1506
+ for (const s of stepHistory.slice(-5)) {
1507
+ const icon = s.status === 'failed' ? '\x1b[31m✖\x1b[0m' : '\x1b[90m→\x1b[0m';
1508
+ const comment = s.comment ? ` - ${s.comment}` : '';
1509
+ const dur = s.duration ? ` (${s.duration})` : '';
1510
+ console.error(` ${icon} [step ${s.index}] ${s.type}${comment}${dur}`);
1511
+ }
1512
+ console.error('');
1513
+ }
457
1514
  }
458
1515
  process.exit(1);
459
1516
  } finally {
@@ -462,26 +1519,36 @@ async function workflowCommand(nameOrId, varsJson, { verbose = false, json = fal
462
1519
  }
463
1520
 
464
1521
  function statusCommand({ json = false } = {}) {
465
- const session = getActiveSession();
466
- if (!session) {
1522
+ const config = getConfig();
1523
+ if (!config || !config.account || !config.account.token) {
467
1524
  if (json) {
468
- console.log(JSON.stringify({ paired: false }, null, 2));
1525
+ console.log(JSON.stringify({ authenticated: false, paired: false }, null, 2));
469
1526
  return;
470
1527
  }
471
- console.log('No active paired profile.');
472
- console.log('To pair, get a code from https://kroki.ai/#/devices and run:');
473
- console.log(' npx kroki pair <CODE>');
1528
+ console.log('Not logged in.');
1529
+ console.log('Run \x1b[33mkroki login\x1b[0m or \x1b[33mkroki pair <CODE>\x1b[0m to connect.');
474
1530
  return;
475
1531
  }
1532
+
1533
+ const defaultDevice = (config.devices && config.defaultDeviceId && config.devices[config.defaultDeviceId]) || null;
1534
+ const selectedCount = (config.selectedDeviceIds || []).length;
1535
+ const totalCount = Object.keys(config.devices || {}).length;
1536
+
476
1537
  if (json) {
477
1538
  console.log(
478
1539
  JSON.stringify(
479
1540
  {
480
- paired: true,
481
- deviceId: session.deviceId,
482
- deviceName: session.deviceName,
483
- platform: session.platform,
484
- pairedAt: session.pairedAt,
1541
+ authenticated: true,
1542
+ googleId: config.account.googleId,
1543
+ defaultDevice: defaultDevice
1544
+ ? {
1545
+ deviceId: defaultDevice.deviceId,
1546
+ deviceName: defaultDevice.deviceName,
1547
+ platform: defaultDevice.platform,
1548
+ }
1549
+ : null,
1550
+ selectedDevicesCount: selectedCount,
1551
+ totalDevicesCount: totalCount,
485
1552
  },
486
1553
  null,
487
1554
  2,
@@ -489,10 +1556,17 @@ function statusCommand({ json = false } = {}) {
489
1556
  );
490
1557
  return;
491
1558
  }
1559
+
1560
+ console.log(`\x1b[32m✔ Authenticated\x1b[0m (Account ID: ${config.account.googleId})`);
1561
+ if (defaultDevice) {
1562
+ console.log(
1563
+ ` Default browser: \x1b[36m${defaultDevice.deviceName || defaultDevice.deviceId}\x1b[0m (${defaultDevice.account || defaultDevice.platform || 'browser'})`,
1564
+ );
1565
+ console.log(` Device ID: ${defaultDevice.deviceId}`);
1566
+ }
492
1567
  console.log(
493
- `\x1b[32m✔ Paired\x1b[0m with "${session.deviceName || session.deviceId}" (${session.platform || 'browser'})`,
1568
+ ` All ${totalCount} browsers are available (use -d <number|name> to target, or 'kroki devices' to list)`,
494
1569
  );
495
- console.log(` Device ID: ${session.deviceId}`);
496
1570
  }
497
1571
 
498
1572
  function helpCommand() {
@@ -500,17 +1574,30 @@ function helpCommand() {
500
1574
  \x1b[1mKroki CLI\x1b[0m — Browser Automation & AI Agent Bridge for Google Chrome
501
1575
 
502
1576
  \x1b[1mUsage:\x1b[0m
503
- npx kroki "<task>" [options] Send an AI task to the live browser
504
- npx kroki run "<workflow>" [json] [opts] Execute a saved workflow
505
- npx kroki pair <CODE> Pair with a browser profile (from https://kroki.ai/#/devices)
506
- npx kroki status [options] View paired profile and status
507
- npx kroki clear Disconnect active pairing session
1577
+ kroki login [code] Log in to your Kroki account
1578
+ kroki devices [1..N | all] View or select working browser(s)
1579
+ kroki "<task>" [options] Send an AI task to the live browser
1580
+ kroki run "<workflow>" [json] [opts] Execute a saved workflow
1581
+ kroki list / workflows [options] List available workflows
1582
+ kroki status [options] View active account and browsers
1583
+ kroki clear / logout Clear local credentials and logout
508
1584
 
509
1585
  \x1b[1mOptions:\x1b[0m
1586
+ -d, --device <number|name|id> Target specific browser on-demand
510
1587
  -v, --verbose Show real-time execution steps and logs
511
1588
  -j, --json Output result as JSON
512
1589
  -h, --help Show help
513
1590
 
1591
+ \x1b[1mExamples:\x1b[0m
1592
+ kroki devices
1593
+ kroki devices 1
1594
+ kroki devices 1, 2
1595
+ kroki devices all
1596
+ kroki "open apple.com"
1597
+ kroki "check orders" -d 2
1598
+ kroki run "Order Ali"
1599
+ kroki "open google.com" --all
1600
+
514
1601
  \x1b[1mDashboard:\x1b[0m
515
1602
  https://kroki.ai/#/devices
516
1603
  `);
@@ -521,13 +1608,24 @@ function helpCommand() {
521
1608
  const rawArgs = process.argv.slice(2);
522
1609
  let verbose = false;
523
1610
  let json = false;
1611
+ let targetDevice = process.env.KROKI_DEVICE || null;
1612
+ let runOnAll = false;
524
1613
  const filteredArgs = [];
525
1614
 
526
- for (const arg of rawArgs) {
1615
+ for (let i = 0; i < rawArgs.length; i++) {
1616
+ const arg = rawArgs[i];
527
1617
  if (arg === '--verbose' || arg === '-v') {
528
1618
  verbose = true;
529
1619
  } else if (arg === '--json' || arg === '-j') {
530
1620
  json = true;
1621
+ } else if (arg === '--all' || arg === '-a') {
1622
+ runOnAll = true;
1623
+ } else if (arg === '-d' || arg === '--device') {
1624
+ targetDevice = rawArgs[++i] || null;
1625
+ } else if (arg.startsWith('--device=')) {
1626
+ targetDevice = arg.slice(9);
1627
+ } else if (arg.startsWith('-d=')) {
1628
+ targetDevice = arg.slice(3);
531
1629
  } else {
532
1630
  filteredArgs.push(arg);
533
1631
  }
@@ -536,6 +1634,25 @@ for (const arg of rawArgs) {
536
1634
  const first = filteredArgs[0] || 'help';
537
1635
 
538
1636
  switch (first) {
1637
+ case 'login':
1638
+ case 'auth':
1639
+ loginCommand(filteredArgs[1]).catch(e => {
1640
+ console.error(`\x1b[31mError:\x1b[0m ${e.message}`);
1641
+ process.exit(1);
1642
+ });
1643
+ break;
1644
+
1645
+ case 'device':
1646
+ case 'devices':
1647
+ case 'use':
1648
+ case 'select':
1649
+ case 'default':
1650
+ devicesCommand({ json, all: runOnAll, target: targetDevice, args: filteredArgs.slice(1) }).catch(e => {
1651
+ console.error(`\x1b[31mError:\x1b[0m ${e.message}`);
1652
+ process.exit(1);
1653
+ });
1654
+ break;
1655
+
539
1656
  case 'pair':
540
1657
  case 'connect':
541
1658
  pairCommand(filteredArgs[1]).catch(e => {
@@ -550,16 +1667,25 @@ switch (first) {
550
1667
 
551
1668
  case 'run':
552
1669
  case 'workflow':
553
- workflowCommand(filteredArgs[1], filteredArgs[2], { verbose, json }).catch(e => {
1670
+ workflowCommand(filteredArgs[1], filteredArgs[2], { verbose, json, device: targetDevice }).catch(e => {
1671
+ console.error(`\x1b[31mError:\x1b[0m ${e.message}`);
1672
+ process.exit(1);
1673
+ });
1674
+ break;
1675
+
1676
+ case 'list':
1677
+ case 'workflows':
1678
+ workflowsCommand({ json, device: targetDevice }).catch(e => {
554
1679
  console.error(`\x1b[31mError:\x1b[0m ${e.message}`);
555
1680
  process.exit(1);
556
1681
  });
557
1682
  break;
558
1683
 
559
1684
  case 'clear':
1685
+ case 'logout':
560
1686
  case 'disconnect':
561
- clearActiveSession();
562
- console.log('\x1b[32m✔ Session cleared.\x1b[0m');
1687
+ clearAllSessions();
1688
+ console.log('\x1b[32m✔ Logged out and sessions cleared.\x1b[0m');
563
1689
  break;
564
1690
 
565
1691
  case 'help':
@@ -574,7 +1700,7 @@ switch (first) {
574
1700
  helpCommand();
575
1701
  break;
576
1702
  }
577
- taskCommand(taskText, { verbose, json }).catch(e => {
1703
+ taskCommand(taskText, { verbose, json, device: targetDevice, all: runOnAll }).catch(e => {
578
1704
  console.error(`\x1b[31mError:\x1b[0m ${e.message}`);
579
1705
  process.exit(1);
580
1706
  });