@simonfestl/husky-cli 1.6.5 → 1.8.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,289 @@ 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
+ });
774
+ // husky e2e done <inboxId> - Complete an E2E test request
775
+ e2eCommand
776
+ .command("done <inboxId>")
777
+ .description("Complete an E2E test request from inbox")
778
+ .option("--passed", "Mark test as passed")
779
+ .option("--failed", "Mark test as failed")
780
+ .option("--notes <notes>", "Add notes about the test result")
781
+ .option("--screenshots <urls...>", "Screenshot URLs to attach")
782
+ .option("--video <url>", "Video URL to attach")
783
+ .option("--json", "Output as JSON")
784
+ .action(async (inboxId, options) => {
785
+ requireAnyPermission(["task:e2e_pass", "deploy:sandbox", "deploy:*"]);
786
+ const config = ensureConfig();
787
+ // Determine status
788
+ let passed;
789
+ if (options.passed) {
790
+ passed = true;
791
+ }
792
+ else if (options.failed) {
793
+ passed = false;
794
+ }
795
+ if (passed === undefined) {
796
+ console.error("Error: Must specify --passed or --failed");
797
+ process.exit(1);
798
+ }
799
+ const status = passed ? "completed" : "failed";
800
+ console.log(`\n Completing E2E Test Request\n`);
801
+ console.log(` Inbox ID: ${inboxId}`);
802
+ console.log(` Result: ${passed ? "PASSED" : "FAILED"}`);
803
+ if (options.notes) {
804
+ console.log(` Notes: ${options.notes}`);
805
+ }
806
+ console.log("");
807
+ try {
808
+ // First, get the inbox item to find the taskId
809
+ const getRes = await fetch(`${config.apiUrl}/api/e2e/inbox/${inboxId}`, {
810
+ headers: config.apiKey ? { "x-api-key": config.apiKey } : {},
811
+ });
812
+ if (!getRes.ok) {
813
+ console.error(`Error: Inbox item not found (${getRes.status})`);
814
+ process.exit(1);
815
+ }
816
+ const inboxItem = await getRes.json();
817
+ const taskId = inboxItem.taskId;
818
+ // Build result object
819
+ const result = {
820
+ passed,
821
+ notes: options.notes,
822
+ screenshots: options.screenshots || [],
823
+ video: options.video,
824
+ completedAt: new Date().toISOString(),
825
+ };
826
+ // Update inbox status
827
+ const updateRes = await fetch(`${config.apiUrl}/api/e2e/inbox/${inboxId}`, {
828
+ method: "PATCH",
829
+ headers: {
830
+ "Content-Type": "application/json",
831
+ ...(config.apiKey ? { "x-api-key": config.apiKey } : {}),
832
+ },
833
+ body: JSON.stringify({
834
+ status,
835
+ result,
836
+ }),
837
+ });
838
+ if (!updateRes.ok) {
839
+ console.error(`Error updating inbox: ${updateRes.status}`);
840
+ process.exit(1);
841
+ }
842
+ // Update task status
843
+ const taskEndpoint = passed
844
+ ? `${config.apiUrl}/api/tasks/${taskId}/e2e/pass`
845
+ : `${config.apiUrl}/api/tasks/${taskId}/e2e/fail`;
846
+ const taskRes = await fetch(taskEndpoint, {
847
+ method: "POST",
848
+ headers: {
849
+ "Content-Type": "application/json",
850
+ ...(config.apiKey ? { "x-api-key": config.apiKey } : {}),
851
+ },
852
+ body: JSON.stringify({
853
+ notes: options.notes || (passed ? "E2E tests passed" : "E2E tests failed"),
854
+ screenshots: options.screenshots || [],
855
+ video: options.video,
856
+ }),
857
+ });
858
+ if (!taskRes.ok) {
859
+ console.warn(`Warning: Could not update task status: ${taskRes.status}`);
860
+ }
861
+ if (options.json) {
862
+ console.log(JSON.stringify({
863
+ success: true,
864
+ inboxId,
865
+ taskId,
866
+ status,
867
+ result,
868
+ }, null, 2));
869
+ }
870
+ else {
871
+ console.log(` ${passed ? "✓" : "✗"} E2E test request completed`);
872
+ console.log(` Task ${taskId} status updated to: ${passed ? "pr_ready" : "e2e_testing"}`);
873
+ console.log("");
874
+ }
875
+ }
876
+ catch (error) {
877
+ console.error("Error completing E2E request:", error.message);
878
+ process.exit(1);
879
+ }
880
+ });
@@ -0,0 +1,2 @@
1
+ import { Command } from "commander";
2
+ export declare const imageCommand: Command;
@@ -0,0 +1,141 @@
1
+ import { Command } from "commander";
2
+ import { writeFileSync } from "fs";
3
+ import { exec } from "child_process";
4
+ import { promisify } from "util";
5
+ import { getConfig } from "./config.js";
6
+ import { z } from "zod";
7
+ const execAsync = promisify(exec);
8
+ // Input validation schema
9
+ const ImageOptionsSchema = z.object({
10
+ ar: z.enum(['1:1', '16:9', '9:16', '4:3', '3:4']).default('16:9'),
11
+ output: z.string().optional(),
12
+ json: z.boolean().optional()
13
+ });
14
+ export const imageCommand = new Command("image")
15
+ .description("Image generation with Google Imagen 3 🎨")
16
+ .argument("<prompt>", "Text description of the image to generate")
17
+ .option("-o, --output <path>", "Output file path (default: imagen-{timestamp}.png)")
18
+ .option("--ar <ratio>", "Aspect ratio: 1:1, 16:9, 9:16, 4:3, 3:4 (default: 16:9)", "16:9")
19
+ .option("--json", "Output metadata as JSON")
20
+ .action(async (prompt, options) => {
21
+ try {
22
+ // Validate prompt
23
+ if (!prompt || prompt.trim().length === 0) {
24
+ console.error("Error: Prompt cannot be empty");
25
+ process.exit(1);
26
+ }
27
+ if (prompt.length > 1000) {
28
+ console.error("Error: Prompt too long (max 1000 characters)");
29
+ process.exit(1);
30
+ }
31
+ // Validate options with Zod
32
+ const validatedOptions = ImageOptionsSchema.parse(options);
33
+ await generateImage(prompt.trim(), validatedOptions);
34
+ }
35
+ catch (error) {
36
+ if (error instanceof z.ZodError) {
37
+ console.error("Error: Invalid options");
38
+ error.issues.forEach((issue) => {
39
+ console.error(` - ${issue.path.join('.')}: ${issue.message}`);
40
+ });
41
+ process.exit(1);
42
+ }
43
+ const err = error;
44
+ console.error(`Error: ${err.message}`);
45
+ if (process.env.DEBUG) {
46
+ console.error(`Debug: ${err.stack}`);
47
+ }
48
+ process.exit(1);
49
+ }
50
+ });
51
+ /**
52
+ * Generate image from text prompt using Imagen 3 REST API
53
+ */
54
+ async function generateImage(prompt, options) {
55
+ console.log(`🎨 Generating image with Google Imagen 3...`);
56
+ console.log(`📝 Prompt: "${prompt}"\n`);
57
+ // Get project ID from config or environment
58
+ const config = getConfig();
59
+ const projectId = process.env.GCP_PROJECT_ID || config.gcpProjectId || 'tigerv0';
60
+ const location = process.env.GCP_LOCATION || config.gcpLocation || 'us-central1';
61
+ const aspectRatio = options.ar || '16:9';
62
+ const timestamp = Date.now();
63
+ const outputPath = options.output || `imagen-${timestamp}.png`;
64
+ console.log(`⚙️ Aspect Ratio: ${aspectRatio}`);
65
+ console.log(`⚙️ Project: ${projectId}\n`);
66
+ console.log(`🔄 Calling Vertex AI Imagen 3...\n`);
67
+ try {
68
+ // Get access token using gcloud
69
+ const { stdout: token } = await execAsync('gcloud auth print-access-token');
70
+ const accessToken = token.trim();
71
+ if (!accessToken) {
72
+ throw new Error('Failed to get access token. Make sure you are authenticated with gcloud.');
73
+ }
74
+ // Build Imagen 3 REST API URL
75
+ const apiUrl = `https://${location}-aiplatform.googleapis.com/v1/projects/${projectId}/locations/${location}/publishers/google/models/imagen-3.0-generate-001:predict`;
76
+ // Prepare request body (NO string interpolation for security)
77
+ const requestBody = {
78
+ instances: [{ prompt }],
79
+ parameters: {
80
+ sampleCount: 1,
81
+ aspectRatio,
82
+ safetyFilterLevel: "block_some",
83
+ personGeneration: "allow_adult"
84
+ }
85
+ };
86
+ // Make secure HTTP request using fetch (NO curl command injection)
87
+ const response = await fetch(apiUrl, {
88
+ method: 'POST',
89
+ headers: {
90
+ 'Authorization': `Bearer ${accessToken}`,
91
+ 'Content-Type': 'application/json'
92
+ },
93
+ body: JSON.stringify(requestBody)
94
+ });
95
+ if (!response.ok) {
96
+ const errorText = await response.text();
97
+ if (response.status === 401) {
98
+ throw new Error('Authentication failed. Run: gcloud auth login');
99
+ }
100
+ else if (response.status === 403) {
101
+ throw new Error('Permission denied. Check your GCP project permissions.');
102
+ }
103
+ else if (response.status === 429) {
104
+ throw new Error('API quota exceeded. Please try again later.');
105
+ }
106
+ else {
107
+ throw new Error(`API returned ${response.status}: ${errorText}`);
108
+ }
109
+ }
110
+ const data = await response.json();
111
+ if (!data.predictions || data.predictions.length === 0) {
112
+ throw new Error('No image was generated. Try a different prompt.');
113
+ }
114
+ // Extract base64 image data and save to file
115
+ const base64Data = data.predictions[0].bytesBase64Encoded;
116
+ const buffer = Buffer.from(base64Data, 'base64');
117
+ writeFileSync(outputPath, buffer);
118
+ if (options.json) {
119
+ console.log(JSON.stringify({
120
+ prompt,
121
+ file: outputPath,
122
+ aspectRatio,
123
+ model: 'imagen-3.0-generate-001',
124
+ project: projectId,
125
+ location
126
+ }, null, 2));
127
+ }
128
+ else {
129
+ console.log(`💾 Saved: ${outputPath}`);
130
+ console.log(`\n✅ Done! Generated image with Imagen 3 🎨`);
131
+ }
132
+ }
133
+ catch (error) {
134
+ const err = error;
135
+ // Don't expose sensitive error details in production
136
+ if (err.message.includes('gcloud')) {
137
+ throw err; // User-friendly message already set
138
+ }
139
+ throw new Error(`Failed to generate image. ${process.env.DEBUG ? err.message : 'Run with DEBUG=1 for details.'}`);
140
+ }
141
+ }
@@ -248,16 +248,83 @@ husky biz qdrant search <coll> "<q>" # Semantic search
248
248
 
249
249
  ---
250
250
 
251
+ ## Agent Brain (Lernender Agent)
252
+
253
+ > [!IMPORTANT]
254
+ > **MANDATORY WORKFLOW für Daily Ops (Support, Worker, etc.)**
255
+ >
256
+ > 1. **VOR der Arbeit**: \`husky brain recall\` - Prüfe ob ähnliches Problem bereits gelöst wurde
257
+ > 2. **NACH der Arbeit**: \`husky brain remember\` - Speichere neue Erkenntnisse
258
+
259
+ ### Role-Based Access
260
+
261
+ Jeder Agent-Typ hat sein **eigenes Brain** und kann NUR auf eigene Memories zugreifen:
262
+
263
+ | Agent Type | Brain Collection | Zugriff |
264
+ |------------|------------------|---------|
265
+ | \`support\` | brain_support | Nur Support-Wissen |
266
+ | \`worker\` | brain_worker | Nur Worker-Wissen |
267
+ | \`supervisor\` | brain_supervisor | Nur Supervisor-Wissen |
268
+ | \`claude\` | brain_claude | Nur Claude-Wissen |
269
+
270
+ Der Agent-Typ wird automatisch aus \`HUSKY_AGENT_TYPE\` oder Config gelesen.
271
+
272
+ ### Commands
273
+
274
+ \`\`\`bash
275
+ # ZUERST: Bestehendes Wissen abrufen
276
+ husky brain recall "kunde kann nicht einloggen" # Semantic Search
277
+ husky brain recall "billing problem" --limit 10 # Mehr Ergebnisse
278
+
279
+ # DANACH: Neues Wissen speichern
280
+ husky brain remember "Login-Problem bei Safari gelöst durch Cache löschen" --tags "login,safari,cache"
281
+
282
+ # Weitere Commands
283
+ husky brain list # Alle eigenen Memories
284
+ husky brain tags "billing,refund" # Nach Tags suchen
285
+ husky brain stats # Statistiken
286
+ husky brain info # Aktuelle Konfiguration
287
+ husky brain forget <id> # Memory löschen
288
+ \`\`\`
289
+
290
+ ### Workflow Beispiel: Support Agent
291
+
292
+ \`\`\`bash
293
+ # 1. Neues Ticket kommt rein: "Kunde kann Rechnung nicht herunterladen"
294
+ husky brain recall "rechnung download problem"
295
+
296
+ # 2. Brain findet ähnliche gelöste Fälle:
297
+ # [92.3%] PDF-Download funktioniert nicht bei AdBlocker - Lösung: AdBlocker deaktivieren
298
+ # [87.1%] Rechnung lädt nicht - Browser-Cache war voll
299
+
300
+ # 3. Agent nutzt Wissen, löst Ticket
301
+
302
+ # 4. Nach Lösung: Neues Wissen speichern
303
+ husky brain remember "Rechnungs-Download fehlgeschlagen wegen Corporate Firewall - IT musste *.billbee.io freigeben" --tags "billing,download,firewall,corporate"
304
+ \`\`\`
305
+
306
+ ### Best Practices
307
+
308
+ - **Tags verwenden**: Immer relevante Tags hinzufügen für bessere Auffindbarkeit
309
+ - **Konkret sein**: "Safari Cache löschen löst Login" statt "Login Problem gelöst"
310
+ - **Kontext speichern**: Ursache UND Lösung dokumentieren
311
+ - **Regelmäßig recall**: Vor JEDER neuen Aufgabe prüfen ob Wissen existiert
312
+
313
+ ---
314
+
251
315
  ## Environment Variables
252
316
 
253
317
  | Variable | Description |
254
318
  |----------|-------------|
255
319
  | \`HUSKY_ENV\` | Environment prefix (PROD/SANDBOX) |
320
+ | \`HUSKY_AGENT_TYPE\` | Agent type for brain access (support/worker/supervisor/claude) |
321
+ | \`HUSKY_AGENT_ID\` | Unique agent identifier |
256
322
  | \`PROD_BILLBEE_API_KEY\` | Billbee API key |
257
323
  | \`PROD_ZENDESK_API_TOKEN\` | Zendesk token |
258
324
  | \`PROD_SEATABLE_API_TOKEN\` | SeaTable token |
259
- | \`PROD_QDRANT_URL\` | Qdrant URL |
260
- | \`PROD_QDRANT_API_KEY\` | Qdrant API key |
325
+ | \`HUSKY_QDRANT_URL\` | Qdrant URL (internal: http://10.132.0.46:6333) |
326
+
327
+ > **Note:** Qdrant runs on internal VM (VPC). No API key needed - access is secured by VPC isolation.
261
328
 
262
329
  ---
263
330