@simonfestl/husky-cli 1.7.0 → 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 CHANGED
@@ -128,6 +128,10 @@ husky e2e inbox --task <task-id> # Filter by task
128
128
  husky e2e watch --interval 30
129
129
  husky e2e watch --once # Process once and exit
130
130
 
131
+ # Complete E2E test request (used by e2e-bridge)
132
+ husky e2e done <inbox-id> --passed # Mark as passed
133
+ husky e2e done <inbox-id> --failed --notes "reason"
134
+
131
135
  # Browser automation utilities
132
136
  husky e2e screenshot <url> # Take screenshot
133
137
  husky e2e screenshot <url> --upload # Upload to GCS
@@ -5,6 +5,7 @@
5
5
  */
6
6
  import { Command } from "commander";
7
7
  import { ZendeskClient } from "../../lib/biz/index.js";
8
+ import { AgentBrain } from "../../lib/biz/agent-brain.js";
8
9
  import * as fs from "fs";
9
10
  import * as path from "path";
10
11
  export const ticketsCommand = new Command("tickets")
@@ -181,9 +182,38 @@ ticketsCommand
181
182
  ticketsCommand
182
183
  .command("close <id>")
183
184
  .description("Close/solve a ticket")
184
- .action(async (id) => {
185
+ .option("--learning", "Capture learning from ticket before closing")
186
+ .option("-a, --agent <id>", "Agent ID for learning capture")
187
+ .action(async (id, options) => {
185
188
  try {
186
189
  const client = ZendeskClient.fromConfig();
190
+ // Capture learning if flag is set
191
+ if (options.learning) {
192
+ const ticketId = parseInt(id, 10);
193
+ const ticket = await client.getTicket(ticketId);
194
+ const comments = await client.getTicketComments(ticketId);
195
+ // Build learning content from ticket and resolution
196
+ const learningContent = [
197
+ `Ticket #${ticket.id}: ${ticket.subject}`,
198
+ `Status: ${ticket.status} → solved`,
199
+ `Priority: ${ticket.priority}`,
200
+ `Tags: ${ticket.tags?.join(', ') || 'none'}`,
201
+ '',
202
+ 'Resolution:',
203
+ comments.slice(-3).map((c) => `- ${c.body.substring(0, 200)}${c.body.length > 200 ? '...' : ''}`).join('\n')
204
+ ].join('\n');
205
+ // Capture to Brain
206
+ const agentId = options.agent || process.env.HUSKY_AGENT_ID || 'support-agent';
207
+ const brain = new AgentBrain(agentId, 'support');
208
+ await brain.remember(learningContent, ['ticket-resolution', 'support', ...(ticket.tags || [])], {
209
+ ticketId: ticket.id,
210
+ subject: ticket.subject,
211
+ priority: ticket.priority,
212
+ closedAt: new Date().toISOString()
213
+ }, 'private' // visibility
214
+ );
215
+ console.log(` 💡 Captured learning from ticket #${ticket.id}`);
216
+ }
187
217
  const ticket = await client.closeTicket(parseInt(id, 10));
188
218
  console.log(`✓ Ticket #${ticket.id} marked as solved`);
189
219
  }
@@ -1,5 +1,6 @@
1
1
  import { Command } from "commander";
2
2
  import { AgentBrain, AGENT_TYPES, isValidAgentType } from "../lib/biz/agent-brain.js";
3
+ import { generateSOP, formatSOPAsMarkdown } from "../lib/biz/sop-generator.js";
3
4
  const DEFAULT_AGENT = process.env.HUSKY_AGENT_ID || 'default';
4
5
  function createBrain(agentId, agentType) {
5
6
  const validAgentType = isValidAgentType(agentType) ? agentType : undefined;
@@ -13,14 +14,21 @@ brainCommand
13
14
  .option("-a, --agent <id>", "Agent ID", DEFAULT_AGENT)
14
15
  .option("-t, --tags <tags>", "Comma-separated tags")
15
16
  .option("--agent-type <type>", `Agent type for database selection (${AGENT_TYPES.join(", ")})`)
17
+ .option("--visibility <level>", "Visibility level (private, team, public)", "private")
18
+ .option("--allow-pii", "Skip PII filtering (use only for technical/internal content)")
16
19
  .option("--json", "Output as JSON")
17
20
  .action(async (content, options) => {
18
21
  try {
19
22
  const brain = createBrain(options.agent, options.agentType);
20
23
  const tags = options.tags ? options.tags.split(",").map((t) => t.trim()) : [];
24
+ const visibility = options.visibility;
21
25
  const dbInfo = brain.getDatabaseInfo();
26
+ if (!["private", "team", "public"].includes(visibility)) {
27
+ console.error("Error: Visibility must be 'private', 'team', or 'public'");
28
+ process.exit(1);
29
+ }
22
30
  console.log(` Storing memory for agent: ${options.agent} (db: ${dbInfo.databaseName})...`);
23
- const id = await brain.remember(content, tags);
31
+ const id = await brain.remember(content, tags, undefined, visibility, options.allowPii);
24
32
  if (options.json) {
25
33
  console.log(JSON.stringify({ success: true, id, agent: options.agent, database: dbInfo.databaseName }));
26
34
  }
@@ -40,25 +48,39 @@ brainCommand
40
48
  .option("-l, --limit <num>", "Max results", "5")
41
49
  .option("-m, --min-score <score>", "Minimum similarity score (0-1)", "0.5")
42
50
  .option("--agent-type <type>", `Agent type for database selection (${AGENT_TYPES.join(", ")})`)
51
+ .option("--shared", "Search shared memories from other agents")
52
+ .option("--public-only", "Search only public memories (requires --shared)")
43
53
  .option("--json", "Output as JSON")
44
54
  .action(async (query, options) => {
45
55
  try {
46
56
  const brain = createBrain(options.agent, options.agentType);
47
- const dbInfo = brain.getDatabaseInfo();
48
- console.log(` Searching memories for: "${query}" (db: ${dbInfo.databaseName})...`);
49
- const results = await brain.recall(query, parseInt(options.limit, 10), parseFloat(options.minScore));
57
+ let results;
58
+ if (options.shared) {
59
+ // Search shared memories
60
+ results = await brain.recallShared(query, parseInt(options.limit, 10), parseFloat(options.minScore), options.publicOnly);
61
+ }
62
+ else {
63
+ // Search personal memories
64
+ const dbInfo = brain.getDatabaseInfo();
65
+ console.log(` Searching memories for: "${query}" (db: ${dbInfo.databaseName})...`);
66
+ results = await brain.recall(query, parseInt(options.limit, 10), parseFloat(options.minScore));
67
+ }
50
68
  if (options.json) {
51
- console.log(JSON.stringify({ success: true, query, database: dbInfo.databaseName, results }));
69
+ const dbInfo = brain.getDatabaseInfo();
70
+ console.log(JSON.stringify({ success: true, query, database: dbInfo.databaseName, shared: options.shared || false, results }));
52
71
  return;
53
72
  }
54
- console.log(`\n 🧠 Memories for "${query}" (${results.length} found)\n`);
73
+ const icon = options.shared ? '🌐' : '🧠';
74
+ const label = options.shared ? 'Shared Memories' : 'Memories';
75
+ console.log(`\n ${icon} ${label} for "${query}" (${results.length} found)\n`);
55
76
  if (results.length === 0) {
56
- console.log(" No relevant memories found.");
77
+ console.log(` No relevant ${options.shared ? 'shared ' : ''}memories found.`);
57
78
  return;
58
79
  }
59
80
  for (const r of results) {
60
81
  const tags = r.memory.tags.length > 0 ? ` [${r.memory.tags.join(", ")}]` : "";
61
- console.log(` [${(r.score * 100).toFixed(1)}%] ${r.memory.content.slice(0, 80)}${tags}`);
82
+ const visibility = options.shared && r.memory.visibility ? ` [${r.memory.visibility}]` : "";
83
+ console.log(` [${(r.score * 100).toFixed(1)}%]${visibility} ${r.memory.content.slice(0, 80)}${tags}`);
62
84
  }
63
85
  console.log("");
64
86
  }
@@ -212,4 +234,253 @@ brainCommand
212
234
  process.exit(1);
213
235
  }
214
236
  });
237
+ // ============================================================================
238
+ // Phase 2: Cross-Agent Sharing
239
+ // ============================================================================
240
+ brainCommand
241
+ .command("publish <id>")
242
+ .description("Publish a memory for sharing")
243
+ .option("-a, --agent <id>", "Agent ID", DEFAULT_AGENT)
244
+ .option("--agent-type <type>", `Agent type for database selection (${AGENT_TYPES.join(", ")})`)
245
+ .option("--visibility <level>", "Visibility level (team, public)", "team")
246
+ .action(async (memoryId, options) => {
247
+ try {
248
+ const brain = createBrain(options.agent, options.agentType);
249
+ const visibility = options.visibility;
250
+ if (visibility !== "team" && visibility !== "public") {
251
+ console.error("Error: Visibility must be 'team' or 'public'");
252
+ process.exit(1);
253
+ }
254
+ await brain.publish(memoryId, visibility);
255
+ console.log(` ✓ Memory published as ${visibility}`);
256
+ }
257
+ catch (error) {
258
+ console.error("Error:", error.message);
259
+ process.exit(1);
260
+ }
261
+ });
262
+ brainCommand
263
+ .command("unpublish <id>")
264
+ .description("Unpublish a memory (set to private)")
265
+ .option("-a, --agent <id>", "Agent ID", DEFAULT_AGENT)
266
+ .option("--agent-type <type>", `Agent type for database selection (${AGENT_TYPES.join(", ")})`)
267
+ .action(async (memoryId, options) => {
268
+ try {
269
+ const brain = createBrain(options.agent, options.agentType);
270
+ await brain.unpublish(memoryId);
271
+ console.log(` ✓ Memory unpublished (set to private)`);
272
+ }
273
+ catch (error) {
274
+ console.error("Error:", error.message);
275
+ process.exit(1);
276
+ }
277
+ });
278
+ brainCommand
279
+ .command("shared")
280
+ .description("List shared memories")
281
+ .option("-a, --agent <id>", "Agent ID", DEFAULT_AGENT)
282
+ .option("-l, --limit <num>", "Max results", "20")
283
+ .option("--agent-type <type>", `Agent type for database selection (${AGENT_TYPES.join(", ")})`)
284
+ .option("--public-only", "Show only public memories")
285
+ .option("--json", "Output as JSON")
286
+ .action(async (options) => {
287
+ try {
288
+ const brain = createBrain(options.agent, options.agentType);
289
+ const memories = await brain.listShared(parseInt(options.limit, 10), options.publicOnly);
290
+ if (options.json) {
291
+ console.log(JSON.stringify({ success: true, memories }));
292
+ return;
293
+ }
294
+ console.log(`\n 🌐 Shared Memories (${memories.length})\n`);
295
+ if (memories.length === 0) {
296
+ console.log(" No shared memories found.");
297
+ return;
298
+ }
299
+ for (const m of memories) {
300
+ const visibility = m.visibility || 'private';
301
+ const endorsements = m.endorsements || 0;
302
+ console.log(` [${visibility}] ${m.content.slice(0, 70)}... (${endorsements} 👍)`);
303
+ }
304
+ console.log("");
305
+ }
306
+ catch (error) {
307
+ console.error("Error:", error.message);
308
+ process.exit(1);
309
+ }
310
+ });
311
+ // ============================================================================
312
+ // Phase 3: Quality & Decay
313
+ // ============================================================================
314
+ brainCommand
315
+ .command("boost <id>")
316
+ .description("Boost a memory (positive feedback)")
317
+ .option("-a, --agent <id>", "Agent ID", DEFAULT_AGENT)
318
+ .option("--agent-type <type>", `Agent type for database selection (${AGENT_TYPES.join(", ")})`)
319
+ .action(async (memoryId, options) => {
320
+ try {
321
+ const brain = createBrain(options.agent, options.agentType);
322
+ await brain.boost(memoryId);
323
+ console.log(` ✓ Memory boosted: ${memoryId}`);
324
+ }
325
+ catch (error) {
326
+ console.error("Error:", error.message);
327
+ process.exit(1);
328
+ }
329
+ });
330
+ brainCommand
331
+ .command("downvote <id>")
332
+ .description("Downvote a memory (negative feedback)")
333
+ .option("-a, --agent <id>", "Agent ID", DEFAULT_AGENT)
334
+ .option("--agent-type <type>", `Agent type for database selection (${AGENT_TYPES.join(", ")})`)
335
+ .action(async (memoryId, options) => {
336
+ try {
337
+ const brain = createBrain(options.agent, options.agentType);
338
+ await brain.downvote(memoryId);
339
+ console.log(` ✓ Memory downvoted: ${memoryId}`);
340
+ }
341
+ catch (error) {
342
+ console.error("Error:", error.message);
343
+ process.exit(1);
344
+ }
345
+ });
346
+ brainCommand
347
+ .command("quality <id>")
348
+ .description("Show quality metrics for a memory")
349
+ .option("-a, --agent <id>", "Agent ID", DEFAULT_AGENT)
350
+ .option("--agent-type <type>", `Agent type for database selection (${AGENT_TYPES.join(", ")})`)
351
+ .option("--json", "Output as JSON")
352
+ .action(async (memoryId, options) => {
353
+ try {
354
+ const brain = createBrain(options.agent, options.agentType);
355
+ const quality = await brain.getQuality(memoryId);
356
+ if (options.json) {
357
+ console.log(JSON.stringify({ success: true, ...quality }));
358
+ return;
359
+ }
360
+ console.log(`\n 📊 Quality Metrics: ${memoryId}`);
361
+ console.log(` ────────────────────────────────`);
362
+ console.log(` Recall Count: ${quality.recallCount}`);
363
+ console.log(` Boost Count: ${quality.boostCount}`);
364
+ console.log(` Downvote Count: ${quality.downvoteCount}`);
365
+ console.log(` Quality Score: ${quality.qualityScore.toFixed(2)}`);
366
+ console.log(` Status: ${quality.status}`);
367
+ console.log("");
368
+ }
369
+ catch (error) {
370
+ console.error("Error:", error.message);
371
+ process.exit(1);
372
+ }
373
+ });
374
+ brainCommand
375
+ .command("cleanup")
376
+ .description("Archive low-quality memories")
377
+ .option("-a, --agent <id>", "Agent ID", DEFAULT_AGENT)
378
+ .option("--agent-type <type>", `Agent type for database selection (${AGENT_TYPES.join(", ")})`)
379
+ .option("--dry-run", "Show what would be archived without doing it", true)
380
+ .option("--execute", "Actually perform the cleanup (removes dry-run)")
381
+ .option("--threshold <score>", "Quality threshold for archiving", "0.1")
382
+ .option("--min-age-days <days>", "Minimum age in days", "90")
383
+ .option("-t, --tag <tags...>", "Filter by tags (for system migrations)")
384
+ .option("--json", "Output as JSON")
385
+ .action(async (options) => {
386
+ try {
387
+ const brain = createBrain(options.agent, options.agentType);
388
+ const dryRun = !options.execute;
389
+ const tags = options.tag;
390
+ const toArchive = await brain.cleanup(dryRun, parseFloat(options.threshold), parseInt(options.minAgeDays, 10), tags);
391
+ if (options.json) {
392
+ console.log(JSON.stringify({ success: true, dryRun, count: toArchive.length, memories: toArchive }));
393
+ return;
394
+ }
395
+ console.log(`\n 🧹 Cleanup ${dryRun ? '(DRY RUN)' : ''}`);
396
+ console.log(` ────────────────────────────────`);
397
+ console.log(` Memories to archive: ${toArchive.length}`);
398
+ if (toArchive.length > 0) {
399
+ console.log(`\n Memories:`);
400
+ for (const m of toArchive.slice(0, 10)) {
401
+ const age = Math.floor((Date.now() - m.createdAt.getTime()) / (1000 * 60 * 60 * 24));
402
+ console.log(` ${m.id.slice(0, 8)} │ ${m.content.slice(0, 50)}... (${age}d old, Q: ${m.qualityScore?.toFixed(2)})`);
403
+ }
404
+ if (toArchive.length > 10) {
405
+ console.log(` ... and ${toArchive.length - 10} more`);
406
+ }
407
+ }
408
+ if (dryRun && toArchive.length > 0) {
409
+ console.log(`\n 💡 Use --execute to actually perform the cleanup`);
410
+ }
411
+ console.log("");
412
+ }
413
+ catch (error) {
414
+ console.error("Error:", error.message);
415
+ process.exit(1);
416
+ }
417
+ });
418
+ brainCommand
419
+ .command("purge")
420
+ .description("Permanently delete archived memories")
421
+ .option("-a, --agent <id>", "Agent ID", DEFAULT_AGENT)
422
+ .option("--agent-type <type>", `Agent type for database selection (${AGENT_TYPES.join(", ")})`)
423
+ .option("--retention-days <days>", "Retention period in days", "365")
424
+ .option("--json", "Output as JSON")
425
+ .action(async (options) => {
426
+ try {
427
+ const brain = createBrain(options.agent, options.agentType);
428
+ const count = await brain.purge(parseInt(options.retentionDays, 10));
429
+ if (options.json) {
430
+ console.log(JSON.stringify({ success: true, deleted: count }));
431
+ return;
432
+ }
433
+ console.log(` ✓ Purged ${count} archived memory(ies)`);
434
+ }
435
+ catch (error) {
436
+ console.error("Error:", error.message);
437
+ process.exit(1);
438
+ }
439
+ });
440
+ // ============================================================================
441
+ // Phase 5: SOP Generation
442
+ // ============================================================================
443
+ brainCommand
444
+ .command("generate-sop <topic>")
445
+ .description("Generate SOP from learnings")
446
+ .option("-a, --agent <id>", "Agent ID", DEFAULT_AGENT)
447
+ .option("--agent-type <type>", `Agent type for database selection (${AGENT_TYPES.join(", ")})`)
448
+ .option("--min-memories <num>", "Minimum learnings required", "5")
449
+ .option("--json", "Output as JSON")
450
+ .option("-o, --output <file>", "Save SOP to file")
451
+ .action(async (topic, options) => {
452
+ try {
453
+ console.log(` Generating SOP for topic: ${topic}...`);
454
+ const sop = await generateSOP(options.agent, {
455
+ topic,
456
+ agentType: isValidAgentType(options.agentType) ? options.agentType : undefined,
457
+ minMemories: parseInt(options.minMemories, 10),
458
+ });
459
+ if (options.json) {
460
+ const output = JSON.stringify(sop, null, 2);
461
+ if (options.output) {
462
+ const fs = await import("fs");
463
+ fs.writeFileSync(options.output, output);
464
+ console.log(` ✓ SOP saved to ${options.output}`);
465
+ }
466
+ else {
467
+ console.log(output);
468
+ }
469
+ return;
470
+ }
471
+ const markdown = formatSOPAsMarkdown(sop);
472
+ if (options.output) {
473
+ const fs = await import("fs");
474
+ fs.writeFileSync(options.output, markdown);
475
+ console.log(` ✓ SOP saved to ${options.output}`);
476
+ }
477
+ else {
478
+ console.log(markdown);
479
+ }
480
+ }
481
+ catch (error) {
482
+ console.error("Error:", error.message);
483
+ process.exit(1);
484
+ }
485
+ });
215
486
  export default brainCommand;
@@ -377,6 +377,114 @@ chatCommand
377
377
  process.exit(1);
378
378
  }
379
379
  });
380
+ chatCommand
381
+ .command("send-file <filePath>")
382
+ .description("Send a file attachment to Google Chat (images auto-compressed)")
383
+ .option("--space <name>", "Target space (e.g., spaces/ABC123)")
384
+ .option("--thread <name>", "Reply in thread")
385
+ .option("--text <message>", "Optional message text to accompany the file")
386
+ .option("--no-compress", "Skip image compression")
387
+ .action(async (filePath, options) => {
388
+ const config = getConfig();
389
+ const huskyApiUrl = getHuskyApiUrl();
390
+ if (!huskyApiUrl) {
391
+ console.error("Error: API URL not configured. Set husky-api-url or api-url.");
392
+ process.exit(1);
393
+ }
394
+ const fs = await import("fs");
395
+ const path = await import("path");
396
+ // Check if file exists
397
+ if (!fs.existsSync(filePath)) {
398
+ console.error(`Error: File not found: ${filePath}`);
399
+ process.exit(1);
400
+ }
401
+ const fileName = path.basename(filePath);
402
+ // Determine MIME type from extension
403
+ const ext = path.extname(filePath).toLowerCase().slice(1);
404
+ const mimeTypes = {
405
+ // Images
406
+ png: "image/png",
407
+ jpg: "image/jpeg",
408
+ jpeg: "image/jpeg",
409
+ gif: "image/gif",
410
+ webp: "image/webp",
411
+ svg: "image/svg+xml",
412
+ // Documents
413
+ pdf: "application/pdf",
414
+ txt: "text/plain",
415
+ md: "text/markdown",
416
+ // Data
417
+ json: "application/json",
418
+ xml: "application/xml",
419
+ csv: "text/csv",
420
+ yaml: "application/x-yaml",
421
+ yml: "application/x-yaml",
422
+ // Code
423
+ js: "text/javascript",
424
+ ts: "text/typescript",
425
+ py: "text/x-python",
426
+ html: "text/html",
427
+ css: "text/css",
428
+ sh: "text/x-sh",
429
+ sql: "text/x-sql",
430
+ };
431
+ const mimeType = mimeTypes[ext] || "application/octet-stream";
432
+ // Read file
433
+ let fileBuffer = fs.readFileSync(filePath);
434
+ const originalSize = fileBuffer.length;
435
+ console.log(`📤 Preparing ${fileName} (${(originalSize / 1024).toFixed(1)} KB, ${mimeType})...`);
436
+ // Compress images automatically (unless --no-compress flag is set)
437
+ const isImage = mimeType.startsWith("image/") && !mimeType.includes("svg");
438
+ if (isImage && options.compress !== false) {
439
+ try {
440
+ const sharp = await import("sharp");
441
+ console.log(`🔄 Compressing image...`);
442
+ const compressed = await sharp.default(fileBuffer)
443
+ .resize(1920, 1920, {
444
+ fit: "inside",
445
+ withoutEnlargement: true
446
+ })
447
+ .jpeg({ quality: 80 })
448
+ .toBuffer();
449
+ fileBuffer = Buffer.from(compressed);
450
+ const compressedSize = fileBuffer.length;
451
+ const savedPercent = ((1 - compressedSize / originalSize) * 100).toFixed(0);
452
+ console.log(`✓ Compressed: ${(originalSize / 1024).toFixed(1)} KB → ${(compressedSize / 1024).toFixed(1)} KB (saved ${savedPercent}%)`);
453
+ }
454
+ catch (error) {
455
+ console.warn(`⚠️ Compression failed, uploading original: ${error.message}`);
456
+ }
457
+ }
458
+ const fileBase64 = fileBuffer.toString("base64");
459
+ console.log(`📤 Uploading ${fileName} (${(fileBuffer.length / 1024).toFixed(1)} KB)...`);
460
+ try {
461
+ const res = await fetch(`${huskyApiUrl}/api/google-chat/send-file`, {
462
+ method: "POST",
463
+ headers: {
464
+ "Content-Type": "application/json",
465
+ ...(config.apiKey ? { "x-api-key": config.apiKey } : {}),
466
+ },
467
+ body: JSON.stringify({
468
+ fileBase64,
469
+ fileName,
470
+ mimeType,
471
+ text: options.text,
472
+ spaceName: options.space,
473
+ threadName: options.thread,
474
+ }),
475
+ });
476
+ if (!res.ok) {
477
+ const error = await res.text();
478
+ throw new Error(`API error: ${res.status} - ${error}`);
479
+ }
480
+ const data = await res.json();
481
+ console.log(`✅ File sent to Google Chat: ${data.fileName}`);
482
+ }
483
+ catch (error) {
484
+ console.error("Error sending file:", error);
485
+ process.exit(1);
486
+ }
487
+ });
380
488
  chatCommand
381
489
  .command("reply-to <messageId> <response>")
382
490
  .description("Reply to a specific inbox message in its thread (supports both GitHub and Google Chat)")
@@ -24,6 +24,10 @@ interface Config {
24
24
  gotessToken?: string;
25
25
  gotessBookId?: string;
26
26
  agentType?: string;
27
+ geminiApiKey?: string;
28
+ nocodbApiToken?: string;
29
+ nocodbBaseUrl?: string;
30
+ nocodbWorkspaceId?: string;
27
31
  }
28
32
  export declare function getConfig(): Config;
29
33
  /**
@@ -159,6 +159,12 @@ configCommand
159
159
  "gotess-token": "gotessToken",
160
160
  "gotess-book-id": "gotessBookId",
161
161
  "agent-type": "agentType",
162
+ // Gemini
163
+ "gemini-api-key": "geminiApiKey",
164
+ // NocoDB
165
+ "nocodb-api-token": "nocodbApiToken",
166
+ "nocodb-base-url": "nocodbBaseUrl",
167
+ "nocodb-workspace-id": "nocodbWorkspaceId",
162
168
  };
163
169
  const configKey = keyMappings[key];
164
170
  if (!configKey) {
@@ -171,6 +177,8 @@ configCommand
171
177
  console.log(" Qdrant: qdrant-url, qdrant-api-key");
172
178
  console.log(" GCP: gcp-project-id, gcp-location");
173
179
  console.log(" Gotess: gotess-token, gotess-book-id");
180
+ console.log(" Gemini: gemini-api-key");
181
+ console.log(" NocoDB: nocodb-api-token, nocodb-base-url, nocodb-workspace-id");
174
182
  console.log(" Brain: agent-type");
175
183
  process.exit(1);
176
184
  }
@@ -193,7 +201,7 @@ configCommand
193
201
  config[configKey] = value;
194
202
  saveConfig(config);
195
203
  // Mask sensitive values in output
196
- const sensitiveKeys = ["api-key", "billbee-api-key", "billbee-password", "zendesk-api-token", "seatable-api-token", "gotess-token"];
204
+ const sensitiveKeys = ["api-key", "billbee-api-key", "billbee-password", "zendesk-api-token", "seatable-api-token", "gotess-token", "gemini-api-key", "nocodb-api-token"];
197
205
  const displayValue = sensitiveKeys.includes(key) ? "***" : value;
198
206
  console.log(`✓ Set ${key} = ${displayValue}`);
199
207
  });
@@ -771,3 +771,110 @@ e2eCommand
771
771
  }
772
772
  }
773
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;