@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.
- package/README.md +46 -0
- package/dist/commands/agent.js +43 -0
- package/dist/commands/biz/tickets.js +31 -1
- package/dist/commands/brain.js +279 -8
- package/dist/commands/chat.js +124 -1
- package/dist/commands/config.d.ts +4 -0
- package/dist/commands/config.js +9 -1
- package/dist/commands/e2e.js +361 -9
- package/dist/commands/image.d.ts +2 -0
- package/dist/commands/image.js +141 -0
- package/dist/commands/llm-context.js +69 -2
- package/dist/commands/task.js +109 -5
- package/dist/commands/vm.js +272 -0
- package/dist/commands/youtube.d.ts +2 -0
- package/dist/commands/youtube.js +178 -0
- package/dist/index.js +4 -0
- package/dist/lib/agent-identity.d.ts +25 -0
- package/dist/lib/agent-identity.js +73 -0
- package/dist/lib/biz/agent-brain.d.ts +63 -1
- package/dist/lib/biz/agent-brain.js +316 -4
- package/dist/lib/biz/learning-capture.d.ts +42 -0
- package/dist/lib/biz/learning-capture.js +107 -0
- package/dist/lib/biz/pii-filter.d.ts +34 -0
- package/dist/lib/biz/pii-filter.js +125 -0
- package/dist/lib/biz/qdrant.d.ts +5 -1
- package/dist/lib/biz/qdrant.js +20 -6
- package/dist/lib/biz/sop-generator.d.ts +39 -0
- package/dist/lib/biz/sop-generator.js +131 -0
- package/package.json +7 -2
package/dist/commands/e2e.js
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
133
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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,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
|
-
| \`
|
|
260
|
-
|
|
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
|
|