kroki 1.0.4 → 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 +1381 -116
  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 {}
50
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 {}
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];
61
252
  }
253
+ if (allDevices.length > 0) {
254
+ return allDevices[0];
255
+ }
256
+ return null;
257
+ }
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;
62
271
  }
63
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,39 +565,441 @@ 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
 
585
+ // ── UI Helpers ──────────────────────────────────────────────────────────────
586
+
587
+ function createProgressTracker({ verbose = false, isJson = false, initialText = '' }) {
588
+ const steps = [];
589
+ let currentStatus = initialText;
590
+ let timer = null;
591
+ const isTTY = Boolean(process.stdout.isTTY) && !isJson && !verbose;
592
+ const frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
593
+ let frameIdx = 0;
594
+
595
+ if (isTTY) {
596
+ timer = setInterval(() => {
597
+ const frame = frames[frameIdx++ % frames.length];
598
+ const text = currentStatus ? ` \x1b[90m${currentStatus.slice(0, 65)}\x1b[0m` : '';
599
+ process.stdout.write(`\r\x1b[36m${frame}\x1b[0m${text}\x1b[K`);
600
+ }, 80);
601
+ } else if (!isJson && !verbose) {
602
+ if (initialText) console.log(initialText);
603
+ } else if (verbose && initialText) {
604
+ console.log(initialText);
605
+ }
606
+
607
+ return {
608
+ onStep(stepText) {
609
+ if (!stepText || typeof stepText !== 'string') return;
610
+ const firstLine = stepText.split('\n')[0].trim();
611
+ if (!firstLine) return;
612
+ steps.push(firstLine);
613
+ if (verbose) {
614
+ console.log(` \x1b[90m→ ${firstLine.slice(0, 100)}\x1b[0m`);
615
+ } else {
616
+ currentStatus = firstLine;
617
+ }
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
+ },
629
+ stop() {
630
+ if (timer) {
631
+ clearInterval(timer);
632
+ timer = null;
633
+ }
634
+ if (isTTY) {
635
+ process.stdout.write('\r\x1b[K');
636
+ }
637
+ },
638
+ getSteps() {
639
+ return steps;
640
+ },
641
+ };
642
+ }
643
+
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.');
767
+ process.exit(1);
768
+ }
769
+
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
+
216
993
  async function pairCommand(code) {
217
994
  if (!code) {
218
995
  console.log(`
219
- \x1b[1mKroki Pairing\x1b[0m
220
- Requires an active \x1b[33mUltra\x1b[0m plan.
996
+ Pair Kroki CLI with a live browser profile.
221
997
 
222
998
  1. Log in at \x1b[36mhttps://kroki.ai/#/devices\x1b[0m
223
999
  2. Find your active browser profile
224
1000
  3. Click \x1b[32m"Скопировать код для AI"\x1b[0m
225
1001
  4. Run:
226
- \x1b[33mnpx kroki pair <YOUR_CODE>\x1b[0m
1002
+ \x1b[33mkroki pair <YOUR_CODE>\x1b[0m
227
1003
  `);
228
1004
  process.exit(0);
229
1005
  }
@@ -257,73 +1033,259 @@ Requires an active \x1b[33mUltra\x1b[0m plan.
257
1033
  process.exit(1);
258
1034
  }
259
1035
 
260
- saveActiveSession({
261
- ...payload,
262
- pairedAt: Date.now(),
263
- });
264
-
1036
+ saveFromCode(code);
265
1037
  conn.close();
266
1038
 
267
1039
  console.log(`\x1b[32m✔ Successfully paired with "${name}".\x1b[0m`);
268
1040
  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');
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');
271
1043
  }
272
1044
 
273
- async function taskCommand(taskText) {
274
- const session = getActiveSession();
275
- if (!session) {
276
- console.error('\x1b[31mError: Not paired.\x1b[0m');
277
- console.error('Pair first with code from https://kroki.ai/#/devices (Ultra plan required):');
278
- console.error(' \x1b[33mnpx kroki pair <YOUR_CODE>\x1b[0m\n');
279
- process.exit(1);
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: [] };
280
1058
  }
281
1059
 
282
- const conn = new RealtimeConnection(session);
283
- await conn.connect();
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
+ }
284
1082
 
285
1083
  const command = {
286
1084
  v: 1,
287
- id: `task-${Date.now()}`,
1085
+ id: `task-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
288
1086
  kind: 'chat_task',
289
- targetDeviceId: session.deviceId,
290
- taskId: `task-${Date.now()}`,
1087
+ targetDeviceId: device.deviceId,
1088
+ taskId: `task-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
291
1089
  issuedAt: Date.now(),
292
1090
  payload: {
293
1091
  task: taskText,
294
1092
  },
295
1093
  };
296
1094
 
1095
+ const initialMsg = `\x1b[36m[Kroki: ${devLabel}]\x1b[0m Running: "${taskText}"...`;
1096
+ const tracker = createProgressTracker({ verbose, isJson: json, initialText: initialMsg });
1097
+
297
1098
  try {
298
- console.log(`\x1b[36m[Kroki: ${session.deviceName || 'Browser'}]\x1b[0m Running: "${taskText}"...`);
299
1099
  const result = await conn.sendCommand(command, ev => {
300
1100
  if (ev.kind === 'execution') {
301
1101
  const details = ev.payload?.data?.details || ev.payload?.title || ev.payload?.type;
302
- if (details && typeof details === 'string') {
303
- const firstLine = details.split('\n')[0].trim();
304
- if (firstLine) console.log(` \x1b[90m→ ${firstLine.slice(0, 80)}\x1b[0m`);
305
- }
1102
+ if (details) tracker.onStep(`[${devLabel}] ${details}`);
306
1103
  }
307
1104
  });
308
1105
 
309
- console.log('\x1b[32m✔ Task completed.\x1b[0m');
310
- if (result && typeof result === 'object') {
311
- if (result.message) {
312
- console.log(`\n\x1b[1m${result.message}\x1b[0m\n`);
313
- }
314
- console.log(JSON.stringify(result, null, 2));
315
- }
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() };
316
1111
  } finally {
317
1112
  conn.close();
318
1113
  }
319
1114
  }
320
1115
 
321
- async function workflowCommand(nameOrId, varsJson) {
322
- const session = getActiveSession();
323
- if (!session) {
324
- console.error('\x1b[31mError: Not paired.\x1b[0m');
325
- console.error('Pair first with code from https://kroki.ai/#/devices (Ultra plan required):');
326
- console.error(' \x1b[33mnpx kroki pair <YOUR_CODE>\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
+ }
1158
+ }
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('');
1191
+ }
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`);
1196
+ }
1197
+ process.exit(1);
1198
+ }
1199
+
1200
+ if (json) {
1201
+ console.log(JSON.stringify(r.result, null, 2));
1202
+ } else {
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
+ }
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');
1240
+ process.exit(1);
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');
1271
+ }
1272
+
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');
327
1289
  process.exit(1);
328
1290
  }
329
1291
 
@@ -336,49 +1298,275 @@ async function workflowCommand(nameOrId, varsJson) {
336
1298
  }
337
1299
  }
338
1300
 
339
- const conn = new RealtimeConnection(session);
340
- 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
+ }
341
1364
 
342
1365
  const command = {
343
1366
  v: 1,
344
1367
  id: `wf-${Date.now()}`,
345
1368
  kind: 'workflow_execute',
346
- targetDeviceId: session.deviceId,
1369
+ targetDeviceId: device.deviceId,
347
1370
  taskId: `wf-${Date.now()}`,
348
1371
  issuedAt: Date.now(),
349
1372
  payload: {
350
- workflowId: nameOrId,
1373
+ workflowId: targetWorkflowId,
1374
+ workflowName: resolvedName,
1375
+ task: taskText || `Run workflow "${resolvedName}"`,
351
1376
  variables,
352
1377
  },
353
1378
  };
354
1379
 
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();
1391
+
355
1392
  try {
356
- console.log(`\x1b[36m[Kroki: ${session.deviceName || 'Browser'}]\x1b[0m Executing workflow "${nameOrId}"...`);
357
1393
  const result = await conn.sendCommand(command, ev => {
358
- if (ev.kind === 'workflow_step_start') {
359
- console.log(` \x1b[90m[step] ${ev.payload?.stepType || 'action'}: ${ev.payload?.comment || ''}\x1b[0m`);
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
+ }
360
1432
  }
361
1433
  });
362
1434
 
363
- console.log('\x1b[32m✔ Workflow finished.\x1b[0m');
364
- if (result) console.log(JSON.stringify(result, null, 2));
1435
+ tracker.stop();
1436
+ const elapsed = ((Date.now() - startTime) / 1000).toFixed(1) + 's';
1437
+
1438
+ if (json) {
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
+ );
1455
+ } else {
1456
+ console.log(
1457
+ `\x1b[32m✔ Workflow "${resolvedName}" completed successfully on ${devName} in ${elapsed} (${stepCount} steps executed).\x1b[0m`,
1458
+ );
1459
+ if (verbose) {
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('');
1474
+ }
1475
+ }
1476
+ } catch (err) {
1477
+ tracker.stop();
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
+
1482
+ if (json) {
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
+ );
1500
+ } else {
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
+ }
1514
+ }
1515
+ process.exit(1);
365
1516
  } finally {
366
1517
  conn.close();
367
1518
  }
368
1519
  }
369
1520
 
370
- function statusCommand() {
371
- const session = getActiveSession();
372
- if (!session) {
373
- console.log('No active paired profile.');
374
- console.log('To pair, get a code from https://kroki.ai/#/devices and run:');
375
- console.log(' npx kroki pair <CODE>');
1521
+ function statusCommand({ json = false } = {}) {
1522
+ const config = getConfig();
1523
+ if (!config || !config.account || !config.account.token) {
1524
+ if (json) {
1525
+ console.log(JSON.stringify({ authenticated: false, paired: false }, null, 2));
1526
+ return;
1527
+ }
1528
+ console.log('Not logged in.');
1529
+ console.log('Run \x1b[33mkroki login\x1b[0m or \x1b[33mkroki pair <CODE>\x1b[0m to connect.');
1530
+ return;
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
+
1537
+ if (json) {
1538
+ console.log(
1539
+ JSON.stringify(
1540
+ {
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,
1552
+ },
1553
+ null,
1554
+ 2,
1555
+ ),
1556
+ );
376
1557
  return;
377
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
+ }
378
1567
  console.log(
379
- `\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)`,
380
1569
  );
381
- console.log(` Device ID: ${session.deviceId}`);
382
1570
  }
383
1571
 
384
1572
  function helpCommand() {
@@ -386,11 +1574,29 @@ function helpCommand() {
386
1574
  \x1b[1mKroki CLI\x1b[0m — Browser Automation & AI Agent Bridge for Google Chrome
387
1575
 
388
1576
  \x1b[1mUsage:\x1b[0m
389
- npx kroki pair <CODE> Pair with a browser profile (from https://kroki.ai/#/devices)
390
- npx kroki "<task>" Send an AI task to the live browser
391
- npx kroki run "<workflow>" [json] Execute a saved workflow
392
- npx kroki status View paired profile and status
393
- 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
1584
+
1585
+ \x1b[1mOptions:\x1b[0m
1586
+ -d, --device <number|name|id> Target specific browser on-demand
1587
+ -v, --verbose Show real-time execution steps and logs
1588
+ -j, --json Output result as JSON
1589
+ -h, --help Show help
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
394
1600
 
395
1601
  \x1b[1mDashboard:\x1b[0m
396
1602
  https://kroki.ai/#/devices
@@ -400,33 +1606,86 @@ function helpCommand() {
400
1606
  // ── Entry Point ─────────────────────────────────────────────────────────────
401
1607
 
402
1608
  const rawArgs = process.argv.slice(2);
403
- const first = rawArgs[0] || 'help';
1609
+ let verbose = false;
1610
+ let json = false;
1611
+ let targetDevice = process.env.KROKI_DEVICE || null;
1612
+ let runOnAll = false;
1613
+ const filteredArgs = [];
1614
+
1615
+ for (let i = 0; i < rawArgs.length; i++) {
1616
+ const arg = rawArgs[i];
1617
+ if (arg === '--verbose' || arg === '-v') {
1618
+ verbose = true;
1619
+ } else if (arg === '--json' || arg === '-j') {
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);
1629
+ } else {
1630
+ filteredArgs.push(arg);
1631
+ }
1632
+ }
1633
+
1634
+ const first = filteredArgs[0] || 'help';
404
1635
 
405
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
+
406
1656
  case 'pair':
407
1657
  case 'connect':
408
- pairCommand(rawArgs[1]).catch(e => {
1658
+ pairCommand(filteredArgs[1]).catch(e => {
409
1659
  console.error(`\x1b[31mError:\x1b[0m ${e.message}`);
410
1660
  process.exit(1);
411
1661
  });
412
1662
  break;
413
1663
 
414
1664
  case 'status':
415
- statusCommand();
1665
+ statusCommand({ json });
416
1666
  break;
417
1667
 
418
1668
  case 'run':
419
1669
  case 'workflow':
420
- workflowCommand(rawArgs[1], rawArgs[2]).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 => {
421
1679
  console.error(`\x1b[31mError:\x1b[0m ${e.message}`);
422
1680
  process.exit(1);
423
1681
  });
424
1682
  break;
425
1683
 
426
1684
  case 'clear':
1685
+ case 'logout':
427
1686
  case 'disconnect':
428
- clearActiveSession();
429
- console.log('\x1b[32m✔ Session cleared.\x1b[0m');
1687
+ clearAllSessions();
1688
+ console.log('\x1b[32m✔ Logged out and sessions cleared.\x1b[0m');
430
1689
  break;
431
1690
 
432
1691
  case 'help':
@@ -435,10 +1694,16 @@ switch (first) {
435
1694
  helpCommand();
436
1695
  break;
437
1696
 
438
- default:
439
- taskCommand(rawArgs.join(' ')).catch(e => {
1697
+ default: {
1698
+ const taskText = filteredArgs.join(' ').trim();
1699
+ if (!taskText) {
1700
+ helpCommand();
1701
+ break;
1702
+ }
1703
+ taskCommand(taskText, { verbose, json, device: targetDevice, all: runOnAll }).catch(e => {
440
1704
  console.error(`\x1b[31mError:\x1b[0m ${e.message}`);
441
1705
  process.exit(1);
442
1706
  });
443
1707
  break;
1708
+ }
444
1709
  }