@simonfestl/husky-cli 1.6.5 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -111,6 +111,34 @@ husky vm update <session-id> --status approved
111
111
  husky vm delete <session-id>
112
112
  ```
113
113
 
114
+ ### E2E Testing (E2E Agent)
115
+
116
+ ```bash
117
+ # Run E2E tests for a task
118
+ husky e2e run <task-id>
119
+ husky e2e run <task-id> --secret ADMIN_PASSWORD --retries 2
120
+ husky e2e run <task-id> --env TEST_USER=admin --headed
121
+
122
+ # E2E Inbox (pending test requests)
123
+ husky e2e inbox # List all inbox messages
124
+ husky e2e inbox --status pending # Filter by status
125
+ husky e2e inbox --task <task-id> # Filter by task
126
+
127
+ # Watch for new E2E requests (auto-processing)
128
+ husky e2e watch --interval 30
129
+ husky e2e watch --once # Process once and exit
130
+
131
+ # Browser automation utilities
132
+ husky e2e screenshot <url> # Take screenshot
133
+ husky e2e screenshot <url> --upload # Upload to GCS
134
+ husky e2e record <url> # Record browser session
135
+
136
+ # Artifact management
137
+ husky e2e upload <file> --task <id> # Upload to GCS
138
+ husky e2e list --task <id> # List artifacts
139
+ husky e2e clean --older-than 7 # Clean old artifacts
140
+ ```
141
+
114
142
  ### Business Strategy
115
143
 
116
144
  ```bash
@@ -296,6 +324,20 @@ husky --version
296
324
 
297
325
  ## Changelog
298
326
 
327
+ ### v1.7.0 (2026-01-11) - E2E Agent Production Ready
328
+
329
+ **New Features:**
330
+ - `husky e2e inbox` - List E2E test requests from API
331
+ - `husky e2e watch` - Watch for and auto-process E2E requests
332
+ - `husky e2e run --secret` - Inject secrets from GCP Secret Manager
333
+ - `husky e2e run --env` - Set environment variables for tests
334
+ - `husky e2e run --retries` - Retry failed tests automatically
335
+
336
+ **Improvements:**
337
+ - All artifact URLs now use HTTPS
338
+ - Better error handling in E2E commands
339
+ - Updated permissions for e2e_agent role
340
+
299
341
  ### v1.1.0 (2026-01-09) - Unified Reply System
300
342
 
301
343
  **New Features:**
@@ -376,6 +376,49 @@ agentCommand
376
376
  process.exit(1);
377
377
  }
378
378
  });
379
+ // husky agent qa-review
380
+ agentCommand
381
+ .command("qa-review <taskId>")
382
+ .description("Trigger QA review for a task")
383
+ .option("--pr <url>", "PR URL to review")
384
+ .option("--json", "Output as JSON")
385
+ .action(async (taskId, options) => {
386
+ const config = getConfig();
387
+ if (!config.apiUrl) {
388
+ console.error("Error: API URL not configured. Run: husky config set api-url <url>");
389
+ process.exit(1);
390
+ }
391
+ try {
392
+ const res = await fetch(`${config.apiUrl}/api/tasks/${taskId}/assign-reviewer`, {
393
+ method: "POST",
394
+ headers: {
395
+ "Content-Type": "application/json",
396
+ ...(config.apiKey ? { "x-api-key": config.apiKey } : {}),
397
+ },
398
+ body: JSON.stringify({
399
+ prUrl: options.pr,
400
+ }),
401
+ });
402
+ if (!res.ok) {
403
+ const error = await res.text();
404
+ console.error(`Error assigning reviewer: ${res.status} - ${error}`);
405
+ process.exit(1);
406
+ }
407
+ const data = await res.json();
408
+ if (options.json) {
409
+ console.log(JSON.stringify(data, null, 2));
410
+ }
411
+ else {
412
+ console.log(`\n✓ Task ${taskId} assigned to reviewer`);
413
+ console.log(` Inbox ID: ${data.inboxId}`);
414
+ console.log(` Message: ${data.message}`);
415
+ }
416
+ }
417
+ catch (error) {
418
+ console.error("Error triggering QA review:", error);
419
+ process.exit(1);
420
+ }
421
+ });
379
422
  // husky agent register
380
423
  agentCommand
381
424
  .command("register")
@@ -446,7 +446,22 @@ chatCommand
446
446
  method: "POST",
447
447
  headers: config.apiKey ? { "x-api-key": config.apiKey } : {},
448
448
  });
449
- console.log("✅ Reply sent to Google Chat and message marked as read.");
449
+ // Add reaction to original message if messageName is available
450
+ if (msg.messageName) {
451
+ try {
452
+ await fetch(`${huskyApiUrl}/api/google-chat/messages/${encodeURIComponent(msg.messageName)}/react`, {
453
+ method: "POST",
454
+ headers: {
455
+ "Content-Type": "application/json",
456
+ ...(config.apiKey ? { "x-api-key": config.apiKey } : {}),
457
+ },
458
+ body: JSON.stringify({ emoji: "✅" }),
459
+ });
460
+ }
461
+ catch {
462
+ // Reaction is optional - don't fail if it doesn't work
463
+ }
464
+ }
450
465
  }
451
466
  }
452
467
  catch (error) {
@@ -60,6 +60,21 @@ function uploadToGCS(localPath, gcsPath) {
60
60
  console.error("GCS upload failed:", result.stderr);
61
61
  return { success: false, url: "" };
62
62
  }
63
+ // Helper: Fetch secret from GCP Secret Manager
64
+ async function fetchSecret(secretName) {
65
+ const result = runCommand("gcloud", [
66
+ "secrets", "versions", "access", "latest",
67
+ "--secret", secretName,
68
+ ]);
69
+ if (!result.success) {
70
+ throw new Error(`Failed to fetch secret ${secretName}: ${result.stderr}`);
71
+ }
72
+ return result.stdout.trim();
73
+ }
74
+ // Helper: Sleep function
75
+ function sleep(ms) {
76
+ return new Promise((resolve) => setTimeout(resolve, ms));
77
+ }
63
78
  // husky e2e run <task-id>
64
79
  e2eCommand
65
80
  .command("run <taskId>")
@@ -69,16 +84,48 @@ e2eCommand
69
84
  .option("--browser <browser>", "Browser to use (chromium, firefox, webkit)", "chromium")
70
85
  .option("--timeout <ms>", "Test timeout in milliseconds", "60000")
71
86
  .option("--output <dir>", "Output directory for results")
87
+ .option("--retries <count>", "Number of retries for failed tests", "0")
88
+ .option("--secret <names...>", "Secrets to fetch from GCP Secret Manager")
89
+ .option("--env <values...>", "Environment variables (KEY=value)")
72
90
  .option("--json", "Output as JSON")
73
91
  .action(async (taskId, options) => {
74
- requireAnyPermission(["task:e2e", "deploy:sandbox", "e2e:*"]);
92
+ requireAnyPermission(["task:e2e_pass", "deploy:sandbox", "deploy:*"]);
75
93
  const config = ensureConfig();
76
94
  const timestamp = generateTimestamp();
77
95
  const outputDir = options.output || `/tmp/e2e-${taskId}-${timestamp}`;
96
+ const maxRetries = parseInt(options.retries, 10);
97
+ // Inject secrets from GCP Secret Manager
98
+ if (options.secret && options.secret.length > 0) {
99
+ console.log(`\n Loading secrets from GCP...`);
100
+ for (const secretName of options.secret) {
101
+ try {
102
+ const value = await fetchSecret(secretName);
103
+ process.env[secretName] = value;
104
+ console.log(` ✓ ${secretName} loaded`);
105
+ }
106
+ catch (error) {
107
+ console.error(` ✗ Failed to load ${secretName}:`, error.message);
108
+ process.exit(1);
109
+ }
110
+ }
111
+ }
112
+ // Inject environment variables
113
+ if (options.env && options.env.length > 0) {
114
+ console.log(`\n Setting environment variables...`);
115
+ for (const envPair of options.env) {
116
+ const [key, ...valueParts] = envPair.split("=");
117
+ const value = valueParts.join("=");
118
+ process.env[key] = value;
119
+ console.log(` ✓ ${key} set`);
120
+ }
121
+ }
78
122
  console.log(`\n E2E Tests for Task: ${taskId}\n`);
79
123
  console.log(` Output directory: ${outputDir}`);
80
124
  console.log(` Browser: ${options.browser}`);
81
125
  console.log(` Headless: ${!options.headed}`);
126
+ if (maxRetries > 0) {
127
+ console.log(` Retries: ${maxRetries}`);
128
+ }
82
129
  console.log("");
83
130
  // Create output directory
84
131
  if (!fs.existsSync(outputDir)) {
@@ -129,9 +176,28 @@ e2eCommand
129
176
  if (options.browser) {
130
177
  playwrightArgs.push(`--project=${options.browser}`);
131
178
  }
132
- const result = runCommand("npx", ["playwright", ...playwrightArgs], {
133
- timeout: parseInt(options.timeout, 10) * 2, // Allow 2x timeout for test suite
134
- });
179
+ // Run tests with retry logic
180
+ let result = { stdout: "", stderr: "", success: false };
181
+ let attempts = 0;
182
+ while (attempts <= maxRetries) {
183
+ attempts++;
184
+ if (attempts > 1) {
185
+ console.log(`\n Retry ${attempts - 1}/${maxRetries}...\n`);
186
+ await sleep(5000); // Wait 5 seconds before retry
187
+ }
188
+ result = runCommand("npx", ["playwright", ...playwrightArgs], {
189
+ timeout: parseInt(options.timeout, 10) * 2, // Allow 2x timeout for test suite
190
+ });
191
+ if (result.success) {
192
+ break;
193
+ }
194
+ // Capture failure screenshot on last attempt
195
+ if (attempts > maxRetries) {
196
+ console.log(" Capturing failure screenshot...");
197
+ const failScreenshot = path.join(outputDir, `failure-${timestamp}.png`);
198
+ // Note: Playwright already captures screenshots on failure with --screenshot=on
199
+ }
200
+ }
135
201
  const testResult = {
136
202
  taskId,
137
203
  taskName,
@@ -183,7 +249,7 @@ e2eCommand
183
249
  .option("--video", "Record video of the session")
184
250
  .option("--json", "Output as JSON")
185
251
  .action(async (url, options) => {
186
- requireAnyPermission(["task:e2e", "deploy:sandbox", "e2e:*"]);
252
+ requireAnyPermission(["task:e2e_pass", "deploy:sandbox", "deploy:*"]);
187
253
  const timestamp = generateTimestamp();
188
254
  const outputFile = options.output || `/tmp/e2e-recording-${timestamp}.ts`;
189
255
  const videoDir = `/tmp/e2e-video-${timestamp}`;
@@ -260,7 +326,7 @@ e2eCommand
260
326
  .option("--type <type>", "File type (screenshot, video, recording, report)", "file")
261
327
  .option("--json", "Output as JSON")
262
328
  .action(async (file, options) => {
263
- requireAnyPermission(["task:e2e", "deploy:sandbox", "e2e:*"]);
329
+ requireAnyPermission(["task:e2e_pass", "deploy:sandbox", "deploy:*"]);
264
330
  if (!fs.existsSync(file)) {
265
331
  console.error(`Error: File not found: ${file}`);
266
332
  process.exit(1);
@@ -312,7 +378,7 @@ e2eCommand
312
378
  .option("--upload", "Upload screenshot to GCS")
313
379
  .option("--json", "Output as JSON")
314
380
  .action(async (url, options) => {
315
- requireAnyPermission(["task:e2e", "deploy:sandbox", "e2e:*"]);
381
+ requireAnyPermission(["task:e2e_pass", "deploy:sandbox", "deploy:*"]);
316
382
  const timestamp = generateTimestamp();
317
383
  const defaultFilename = `screenshot-${timestamp}.png`;
318
384
  const outputFile = options.output || `/tmp/${defaultFilename}`;
@@ -415,7 +481,7 @@ e2eCommand
415
481
  .option("--limit <n>", "Limit number of results", "20")
416
482
  .option("--json", "Output as JSON")
417
483
  .action(async (options) => {
418
- requireAnyPermission(["task:e2e", "deploy:sandbox", "e2e:*"]);
484
+ requireAnyPermission(["task:e2e_pass", "deploy:sandbox", "deploy:*"]);
419
485
  let gcsPath = `gs://${GCS_BUCKET}/e2e/`;
420
486
  if (options.task) {
421
487
  gcsPath += `${options.task}/`;
@@ -483,7 +549,7 @@ e2eCommand
483
549
  .option("--older-than <days>", "Remove artifacts older than N days", "7")
484
550
  .option("--dry-run", "Show what would be deleted without deleting")
485
551
  .action(async (options) => {
486
- requireAnyPermission(["task:e2e", "deploy:sandbox", "e2e:*"]);
552
+ requireAnyPermission(["task:e2e_pass", "deploy:sandbox", "deploy:*"]);
487
553
  console.log(`\n Cleaning E2E Artifacts\n`);
488
554
  const tmpDir = "/tmp";
489
555
  const e2ePatterns = ["e2e-", "screenshot-", "recording-"];
@@ -526,3 +592,182 @@ e2eCommand
526
592
  console.log(` ${options.dryRun ? "Would clean" : "Cleaned"}: ${cleaned} items (${(totalSize / 1024 / 1024).toFixed(2)} MB)`);
527
593
  console.log("");
528
594
  });
595
+ // husky e2e inbox - List E2E inbox messages
596
+ e2eCommand
597
+ .command("inbox")
598
+ .description("List E2E inbox messages from API")
599
+ .option("--status <status>", "Filter by status (pending, running, completed, failed)")
600
+ .option("--task <taskId>", "Filter by task ID")
601
+ .option("--limit <n>", "Limit number of results", "50")
602
+ .option("--json", "Output as JSON")
603
+ .action(async (options) => {
604
+ requireAnyPermission(["task:e2e_pass", "deploy:sandbox", "deploy:*"]);
605
+ const config = ensureConfig();
606
+ const params = new URLSearchParams();
607
+ if (options.status)
608
+ params.set("status", options.status);
609
+ if (options.task)
610
+ params.set("taskId", options.task);
611
+ if (options.limit)
612
+ params.set("limit", options.limit);
613
+ const url = `${config.apiUrl}/api/e2e/inbox?${params.toString()}`;
614
+ try {
615
+ const res = await fetch(url, {
616
+ headers: config.apiKey ? { "x-api-key": config.apiKey } : {},
617
+ });
618
+ if (!res.ok) {
619
+ console.error(`Error: ${res.status} ${res.statusText}`);
620
+ process.exit(1);
621
+ }
622
+ const inbox = await res.json();
623
+ if (options.json) {
624
+ console.log(JSON.stringify(inbox, null, 2));
625
+ return;
626
+ }
627
+ console.log(`\n E2E Inbox (${inbox.length} items)\n`);
628
+ if (inbox.length === 0) {
629
+ console.log(" No E2E inbox messages found.");
630
+ console.log("");
631
+ return;
632
+ }
633
+ console.log(" " + "-".repeat(80));
634
+ console.log(` ${"STATUS".padEnd(12)} ${"TASK ID".padEnd(24)} TITLE`);
635
+ console.log(" " + "-".repeat(80));
636
+ for (const item of inbox) {
637
+ const statusIcons = {
638
+ pending: "⏳",
639
+ running: "🔄",
640
+ completed: "✅",
641
+ failed: "❌",
642
+ };
643
+ const statusIcon = statusIcons[item.status] || "❓";
644
+ console.log(` ${statusIcon} ${item.status.padEnd(10)} ${item.taskId.padEnd(24)} ${item.taskTitle?.slice(0, 40) || "N/A"}`);
645
+ }
646
+ console.log("");
647
+ }
648
+ catch (error) {
649
+ console.error("Error fetching E2E inbox:", error.message);
650
+ process.exit(1);
651
+ }
652
+ });
653
+ // husky e2e watch - Watch E2E inbox for new test requests
654
+ e2eCommand
655
+ .command("watch")
656
+ .description("Watch E2E inbox for new test requests")
657
+ .option("--interval <seconds>", "Poll interval in seconds", "30")
658
+ .option("--once", "Process inbox once and exit")
659
+ .action(async (options) => {
660
+ requireAnyPermission(["task:e2e_pass", "deploy:sandbox", "deploy:*"]);
661
+ const config = ensureConfig();
662
+ const interval = parseInt(options.interval, 10) * 1000;
663
+ console.log(`\n E2E Inbox Watcher\n`);
664
+ console.log(` API: ${config.apiUrl}`);
665
+ console.log(` Poll interval: ${options.interval}s`);
666
+ console.log("");
667
+ const processInbox = async () => {
668
+ try {
669
+ // Fetch E2E inbox from API
670
+ const res = await fetch(`${config.apiUrl}/api/e2e/inbox`, {
671
+ headers: config.apiKey ? { "x-api-key": config.apiKey } : {},
672
+ });
673
+ if (!res.ok) {
674
+ console.error(` Error fetching inbox: ${res.status}`);
675
+ return;
676
+ }
677
+ const inbox = await res.json();
678
+ const pending = inbox.filter((item) => item.status === "pending");
679
+ if (pending.length === 0) {
680
+ console.log(` [${new Date().toISOString()}] No pending E2E requests`);
681
+ return;
682
+ }
683
+ console.log(` [${new Date().toISOString()}] Found ${pending.length} pending E2E request(s)`);
684
+ for (const item of pending) {
685
+ console.log(`\n Processing: ${item.taskId} - ${item.taskTitle}`);
686
+ // Update status to running
687
+ await fetch(`${config.apiUrl}/api/e2e/inbox/${item.id}`, {
688
+ method: "PATCH",
689
+ headers: {
690
+ "Content-Type": "application/json",
691
+ ...(config.apiKey ? { "x-api-key": config.apiKey } : {}),
692
+ },
693
+ body: JSON.stringify({ status: "running" }),
694
+ });
695
+ // Run E2E tests for this task
696
+ const timestamp = generateTimestamp();
697
+ const outputDir = `/tmp/e2e-${item.taskId}-${timestamp}`;
698
+ console.log(` Running tests in ${outputDir}...`);
699
+ const playwrightArgs = [
700
+ "test",
701
+ "--reporter=list",
702
+ `--output=${outputDir}`,
703
+ "--timeout=60000",
704
+ ];
705
+ const result = runCommand("npx", ["playwright", ...playwrightArgs], {
706
+ timeout: 300000, // 5 min
707
+ });
708
+ // Upload results
709
+ const resultsPath = path.join(outputDir, "results.json");
710
+ const testResult = {
711
+ taskId: item.taskId,
712
+ success: result.success,
713
+ timestamp,
714
+ stdout: result.stdout,
715
+ stderr: result.stderr,
716
+ };
717
+ if (!fs.existsSync(outputDir)) {
718
+ fs.mkdirSync(outputDir, { recursive: true });
719
+ }
720
+ fs.writeFileSync(resultsPath, JSON.stringify(testResult, null, 2));
721
+ const gcsPath = `e2e/${item.taskId}/${timestamp}/results.json`;
722
+ const uploadResult = uploadToGCS(resultsPath, gcsPath);
723
+ // Update inbox status
724
+ await fetch(`${config.apiUrl}/api/e2e/inbox/${item.id}`, {
725
+ method: "PATCH",
726
+ headers: {
727
+ "Content-Type": "application/json",
728
+ ...(config.apiKey ? { "x-api-key": config.apiKey } : {}),
729
+ },
730
+ body: JSON.stringify({
731
+ status: result.success ? "completed" : "failed",
732
+ completedAt: new Date().toISOString(),
733
+ result: {
734
+ passed: result.success,
735
+ gcsUrl: uploadResult.url,
736
+ },
737
+ }),
738
+ });
739
+ // Update task status via API
740
+ const taskEndpoint = result.success
741
+ ? `${config.apiUrl}/api/tasks/${item.taskId}/e2e/pass`
742
+ : `${config.apiUrl}/api/tasks/${item.taskId}/e2e/fail`;
743
+ await fetch(taskEndpoint, {
744
+ method: "POST",
745
+ headers: {
746
+ "Content-Type": "application/json",
747
+ ...(config.apiKey ? { "x-api-key": config.apiKey } : {}),
748
+ },
749
+ body: JSON.stringify({
750
+ notes: result.success ? "E2E tests passed" : "E2E tests failed",
751
+ testResults: testResult,
752
+ screenshots: uploadResult.url ? [uploadResult.url] : [],
753
+ }),
754
+ });
755
+ console.log(` ${result.success ? "✓" : "✗"} Task ${item.taskId}: ${result.success ? "PASSED" : "FAILED"}`);
756
+ }
757
+ }
758
+ catch (error) {
759
+ console.error(` Error processing inbox:`, error.message);
760
+ }
761
+ };
762
+ // Process once or continuously
763
+ if (options.once) {
764
+ await processInbox();
765
+ }
766
+ else {
767
+ console.log(" Watching for E2E requests... (Ctrl+C to stop)\n");
768
+ while (true) {
769
+ await processInbox();
770
+ await sleep(interval);
771
+ }
772
+ }
773
+ });
@@ -928,37 +928,60 @@ taskCommand
928
928
  process.exit(1);
929
929
  }
930
930
  });
931
- // husky task qa-reject [--id <id>] [--notes <text>]
931
+ // husky task qa-reject [--id <id>] [--notes <text>] [--issues <json>]
932
932
  taskCommand
933
933
  .command("qa-reject")
934
- .description("Manually reject QA for a task")
934
+ .description("Reject QA and request fixes")
935
935
  .option("--id <id>", "Task ID (or set HUSKY_TASK_ID)")
936
936
  .option("--notes <text>", "Rejection notes")
937
+ .option("--issues <json>", "Issues as JSON array")
937
938
  .action(async (options) => {
938
939
  const config = ensureConfig();
939
940
  const taskId = getTaskId(options);
941
+ let issues = [];
942
+ if (options.issues) {
943
+ try {
944
+ issues = JSON.parse(options.issues);
945
+ }
946
+ catch {
947
+ console.error("Error: --issues must be valid JSON");
948
+ process.exit(1);
949
+ }
950
+ }
940
951
  try {
941
- const res = await fetch(`${config.apiUrl}/api/tasks/${taskId}/qa/approve`, {
952
+ const res = await fetch(`${config.apiUrl}/api/tasks/${taskId}/qa/reject`, {
942
953
  method: "POST",
943
954
  headers: {
944
955
  "Content-Type": "application/json",
945
956
  ...(config.apiKey ? { "x-api-key": config.apiKey } : {}),
946
957
  },
947
958
  body: JSON.stringify({
948
- approved: false,
949
959
  notes: options.notes,
960
+ issues,
950
961
  }),
951
962
  });
952
963
  if (!res.ok) {
953
964
  throw new Error(`API error: ${res.status}`);
954
965
  }
955
- console.log(`✗ QA manually rejected for task ${taskId}`);
966
+ console.log(`✗ QA rejected for task ${taskId}`);
967
+ console.log(` Task returned to worker for fixes`);
956
968
  }
957
969
  catch (error) {
958
970
  console.error("Error rejecting QA:", error);
959
971
  process.exit(1);
960
972
  }
961
973
  });
974
+ // husky task qa-request-fix (alias for qa-reject with specific focus)
975
+ taskCommand
976
+ .command("qa-request-fix")
977
+ .description("Request fixes from the worker")
978
+ .option("--id <id>", "Task ID (or set HUSKY_TASK_ID)")
979
+ .requiredOption("--notes <text>", "Fix instructions")
980
+ .option("--issues <json>", "Issues as JSON array")
981
+ .action(async (options) => {
982
+ // Re-use qa-reject logic
983
+ await taskCommand.parseAsync(["node", "husky", "task", "qa-reject", "--id", getTaskId(options), "--notes", options.notes, ...(options.issues ? ["--issues", options.issues] : [])], { from: "user" });
984
+ });
962
985
  // husky task qa-iteration [--id <id>] --iteration <n> --status <status> [--issues <json>] [--duration <seconds>]
963
986
  taskCommand
964
987
  .command("qa-iteration")
@@ -1183,3 +1183,275 @@ function printLog(log) {
1183
1183
  }
1184
1184
  console.log(`${prefix}${timestamp} ${level} ${source} ${log.message}`);
1185
1185
  }
1186
+ // ============================================================================
1187
+ // Anthropic OAuth Commands (for subscription auth)
1188
+ // ============================================================================
1189
+ // husky vm auth-status <sessionId>
1190
+ vmCommand
1191
+ .command("auth-status <sessionId>")
1192
+ .description("Check Anthropic OAuth status for a VM session")
1193
+ .option("--wait", "Wait for authentication to complete (polling)")
1194
+ .option("--timeout <seconds>", "Timeout for waiting (default: 300)", "300")
1195
+ .option("--json", "Output as JSON")
1196
+ .action(async (sessionId, options) => {
1197
+ const config = ensureConfig();
1198
+ const checkStatus = async () => {
1199
+ const res = await fetch(`${config.apiUrl}/api/vm-sessions/${sessionId}/auth-status`, {
1200
+ headers: config.apiKey ? { "x-api-key": config.apiKey } : {},
1201
+ });
1202
+ if (!res.ok) {
1203
+ const error = await res.text();
1204
+ throw new Error(`API error: ${res.status} - ${error}`);
1205
+ }
1206
+ return await res.json();
1207
+ };
1208
+ try {
1209
+ let data = await checkStatus();
1210
+ if (options.wait && ["none", "awaiting_url", "awaiting_code"].includes(data.authStatus)) {
1211
+ console.log("Waiting for Anthropic authentication...");
1212
+ const timeoutMs = parseInt(options.timeout, 10) * 1000;
1213
+ const startTime = Date.now();
1214
+ const pollInterval = 3000;
1215
+ while (Date.now() - startTime < timeoutMs) {
1216
+ await new Promise((resolve) => setTimeout(resolve, pollInterval));
1217
+ data = await checkStatus();
1218
+ if (!["none", "awaiting_url", "awaiting_code"].includes(data.authStatus)) {
1219
+ break;
1220
+ }
1221
+ process.stdout.write(".");
1222
+ }
1223
+ console.log("");
1224
+ }
1225
+ if (options.json) {
1226
+ console.log(JSON.stringify(data, null, 2));
1227
+ return;
1228
+ }
1229
+ const statusIcon = {
1230
+ none: "⚪",
1231
+ awaiting_url: "🔗",
1232
+ awaiting_code: "⏳",
1233
+ code_submitted: "📤",
1234
+ authenticated: "✅",
1235
+ failed: "❌",
1236
+ };
1237
+ const icon = statusIcon[data.authStatus] || "❓";
1238
+ console.log(`\n ${icon} Auth Status: ${data.authStatus.toUpperCase()}`);
1239
+ console.log(" " + "-".repeat(40));
1240
+ console.log(` VM Status: ${data.vmStatus}`);
1241
+ if (data.authUrl) {
1242
+ console.log(` Auth URL: ${data.authUrl}`);
1243
+ }
1244
+ if (data.authRequestedAt) {
1245
+ console.log(` Requested: ${new Date(data.authRequestedAt).toLocaleString()}`);
1246
+ }
1247
+ if (data.authCodeSubmittedAt) {
1248
+ console.log(` Code sent: ${new Date(data.authCodeSubmittedAt).toLocaleString()}`);
1249
+ }
1250
+ console.log("");
1251
+ // Exit with error code if not authenticated
1252
+ if (data.authStatus === "failed") {
1253
+ process.exit(1);
1254
+ }
1255
+ }
1256
+ catch (error) {
1257
+ console.error("Error checking auth status:", error);
1258
+ process.exit(1);
1259
+ }
1260
+ });
1261
+ // husky vm submit-auth-code <sessionId> <code>
1262
+ vmCommand
1263
+ .command("submit-auth-code <sessionId> <code>")
1264
+ .description("Submit Anthropic auth code to a VM session (fallback if GChat routing fails)")
1265
+ .option("--submitted-by <name>", "Name of person submitting the code")
1266
+ .option("--json", "Output as JSON")
1267
+ .action(async (sessionId, code, options) => {
1268
+ const config = ensureConfig();
1269
+ // Validate code format (same as server-side)
1270
+ const sanitizedCode = code.replace(/[^A-Za-z0-9-_]/g, "");
1271
+ if (sanitizedCode !== code || code.length < 4) {
1272
+ console.error("Error: Invalid auth code format.");
1273
+ console.error(" Code must be at least 4 characters and contain only:");
1274
+ console.error(" - Letters (A-Z, a-z)");
1275
+ console.error(" - Numbers (0-9)");
1276
+ console.error(" - Dashes (-) and underscores (_)");
1277
+ process.exit(1);
1278
+ }
1279
+ try {
1280
+ const res = await fetch(`${config.apiUrl}/api/vm-sessions/${sessionId}/submit-auth-code`, {
1281
+ method: "POST",
1282
+ headers: {
1283
+ "Content-Type": "application/json",
1284
+ ...(config.apiKey ? { "x-api-key": config.apiKey } : {}),
1285
+ },
1286
+ body: JSON.stringify({
1287
+ code: code,
1288
+ submittedBy: options.submittedBy || "CLI",
1289
+ }),
1290
+ });
1291
+ if (!res.ok) {
1292
+ const error = await res.text();
1293
+ throw new Error(`API error: ${res.status} - ${error}`);
1294
+ }
1295
+ const data = await res.json();
1296
+ if (options.json) {
1297
+ console.log(JSON.stringify(data, null, 2));
1298
+ return;
1299
+ }
1300
+ console.log(`\n ✅ ${data.message}`);
1301
+ console.log("");
1302
+ }
1303
+ catch (error) {
1304
+ console.error("Error submitting auth code:", error);
1305
+ process.exit(1);
1306
+ }
1307
+ });
1308
+ // husky vm claude-code <sessionId>
1309
+ vmCommand
1310
+ .command("claude-code <sessionId>")
1311
+ .description("Connect to Claude Code on a VM via SSH (for debugging/interaction)")
1312
+ .option("--zone <zone>", "GCP zone", "europe-west1-b")
1313
+ .option("--attach", "Attach to existing tmux session instead of new shell")
1314
+ .action(async (sessionId, options) => {
1315
+ const config = ensureConfig();
1316
+ try {
1317
+ // Get session details
1318
+ const res = await fetch(`${config.apiUrl}/api/vm-sessions/${sessionId}`, {
1319
+ headers: config.apiKey ? { "x-api-key": config.apiKey } : {},
1320
+ });
1321
+ if (!res.ok) {
1322
+ const error = await res.text();
1323
+ throw new Error(`API error: ${res.status} - ${error}`);
1324
+ }
1325
+ const session = await res.json();
1326
+ if (session.vmStatus !== "running" && session.vmStatus !== "awaiting_auth") {
1327
+ console.error(`Error: VM is not running (status: ${session.vmStatus})`);
1328
+ process.exit(1);
1329
+ }
1330
+ const zone = options.zone || session.vmZone || "europe-west1-b";
1331
+ const vmName = session.vmName;
1332
+ console.log(`\n 🔗 Connecting to Claude Code on ${vmName}...`);
1333
+ console.log(" " + "-".repeat(40));
1334
+ console.log(` VM: ${vmName}`);
1335
+ console.log(` Zone: ${zone}`);
1336
+ console.log(` Status: ${session.vmStatus}`);
1337
+ if (session.authStatus) {
1338
+ console.log(` Auth: ${session.authStatus}`);
1339
+ }
1340
+ console.log("");
1341
+ // Build SSH command
1342
+ const sshArgs = [
1343
+ "compute", "ssh", vmName,
1344
+ `--zone=${zone}`,
1345
+ ];
1346
+ if (options.attach) {
1347
+ // Attach to existing tmux session
1348
+ sshArgs.push("--command=tmux attach-session -t main || tmux new-session -s main");
1349
+ console.log(" Attaching to tmux session 'main'...\n");
1350
+ }
1351
+ else {
1352
+ // Just SSH in
1353
+ console.log(" Opening SSH shell...\n");
1354
+ console.log(" Tip: Run 'tmux attach -t main' to see Claude Code output");
1355
+ console.log("");
1356
+ }
1357
+ // Execute SSH
1358
+ const result = spawnSync("gcloud", sshArgs, {
1359
+ stdio: "inherit",
1360
+ });
1361
+ if (result.status !== 0) {
1362
+ console.error("\nSSH connection failed");
1363
+ process.exit(1);
1364
+ }
1365
+ }
1366
+ catch (error) {
1367
+ console.error("Error connecting to VM:", error);
1368
+ process.exit(1);
1369
+ }
1370
+ });
1371
+ // husky vm send-message <sessionId> <message>
1372
+ vmCommand
1373
+ .command("send-message <sessionId> <message>")
1374
+ .description("Send a message directly to a VM agent")
1375
+ .option("--sender <name>", "Sender name", "CLI User")
1376
+ .option("--email <email>", "Sender email")
1377
+ .option("--thread <name>", "Thread name for context")
1378
+ .option("--json", "Output as JSON")
1379
+ .action(async (sessionId, message, options) => {
1380
+ const config = ensureConfig();
1381
+ try {
1382
+ const res = await fetch(`${config.apiUrl}/api/vm-sessions/${sessionId}/send-message`, {
1383
+ method: "POST",
1384
+ headers: {
1385
+ "Content-Type": "application/json",
1386
+ ...(config.apiKey ? { "x-api-key": config.apiKey } : {}),
1387
+ },
1388
+ body: JSON.stringify({
1389
+ content: message,
1390
+ senderName: options.sender,
1391
+ senderEmail: options.email,
1392
+ threadName: options.thread,
1393
+ }),
1394
+ });
1395
+ if (!res.ok) {
1396
+ const error = await res.text();
1397
+ throw new Error(`API error: ${res.status} - ${error}`);
1398
+ }
1399
+ const data = await res.json();
1400
+ if (options.json) {
1401
+ console.log(JSON.stringify(data, null, 2));
1402
+ return;
1403
+ }
1404
+ console.log(`✅ ${data.message}`);
1405
+ console.log(` Message ID: ${data.messageId}`);
1406
+ }
1407
+ catch (error) {
1408
+ console.error("Error sending message:", error);
1409
+ process.exit(1);
1410
+ }
1411
+ });
1412
+ // husky vm messages <sessionId>
1413
+ vmCommand
1414
+ .command("messages <sessionId>")
1415
+ .description("List messages sent to a VM session")
1416
+ .option("--limit <n>", "Number of messages", "20")
1417
+ .option("--status <status>", "Filter by status (pending, delivered, failed)")
1418
+ .option("--json", "Output as JSON")
1419
+ .action(async (sessionId, options) => {
1420
+ const config = ensureConfig();
1421
+ try {
1422
+ const params = new URLSearchParams();
1423
+ if (options.limit)
1424
+ params.set("limit", options.limit);
1425
+ if (options.status)
1426
+ params.set("status", options.status);
1427
+ const res = await fetch(`${config.apiUrl}/api/vm-sessions/${sessionId}/messages?${params}`, {
1428
+ headers: config.apiKey ? { "x-api-key": config.apiKey } : {},
1429
+ });
1430
+ if (!res.ok) {
1431
+ const error = await res.text();
1432
+ throw new Error(`API error: ${res.status} - ${error}`);
1433
+ }
1434
+ const data = await res.json();
1435
+ if (options.json) {
1436
+ console.log(JSON.stringify(data, null, 2));
1437
+ return;
1438
+ }
1439
+ if (!data.messages || data.messages.length === 0) {
1440
+ console.log("\n No messages found for this VM session.");
1441
+ return;
1442
+ }
1443
+ console.log("\n VM Session Messages");
1444
+ console.log(" " + "-".repeat(60));
1445
+ for (const msg of data.messages) {
1446
+ const time = new Date(msg.createdAt).toLocaleTimeString();
1447
+ const statusIcon = msg.status === "delivered" ? "✅" : msg.status === "failed" ? "❌" : "⏳";
1448
+ console.log(` ${statusIcon} [${time}] ${msg.senderName}`);
1449
+ console.log(` ${msg.content.substring(0, 60)}${msg.content.length > 60 ? "..." : ""}`);
1450
+ console.log("");
1451
+ }
1452
+ }
1453
+ catch (error) {
1454
+ console.error("Error fetching messages:", error);
1455
+ process.exit(1);
1456
+ }
1457
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@simonfestl/husky-cli",
3
- "version": "1.6.5",
3
+ "version": "1.7.0",
4
4
  "description": "CLI for Huskyv0 Task Orchestration with Claude Agent SDK",
5
5
  "type": "module",
6
6
  "bin": {