@simonfestl/husky-cli 1.0.0 → 1.2.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
@@ -174,6 +174,32 @@ husky vm-config update <config-id> --machine-type e2-standard-2
174
174
  husky vm-config delete <config-id>
175
175
  ```
176
176
 
177
+ ### Chat / Messaging
178
+
179
+ ```bash
180
+ # Check pending messages
181
+ husky chat pending
182
+ husky chat pending --json
183
+
184
+ # View inbox (GitHub + Google Chat)
185
+ husky chat inbox
186
+ husky chat inbox --unread
187
+
188
+ # Reply to any message (auto-detects platform)
189
+ husky chat reply-to <messageId> "Your response"
190
+
191
+ # Reply in Google Chat thread
192
+ husky chat reply-chat "Message" --thread <threadName>
193
+
194
+ # Mark message as read
195
+ husky chat mark-read <messageId>
196
+
197
+ # Watch for messages (inject into tmux)
198
+ husky chat watch-inject --tmux-session supervisor
199
+ ```
200
+
201
+ The `reply-to` command automatically detects whether the message is from GitHub or Google Chat and uses the appropriate API to send the reply.
202
+
177
203
  ### Settings
178
204
 
179
205
  ```bash
@@ -270,6 +296,17 @@ husky --version
270
296
 
271
297
  ## Changelog
272
298
 
299
+ ### v1.1.0 (2026-01-09) - Unified Reply System
300
+
301
+ **New Features:**
302
+ - `husky chat reply-to` now supports both GitHub and Google Chat
303
+ - Auto-detects platform from message metadata
304
+ - GitHub replies use GitHub App (no PAT required on VM)
305
+
306
+ **Improvements:**
307
+ - Require 8+ character prefix for messageId matching (prevents misdirected replies)
308
+ - Better error messages for short messageId prefixes
309
+
273
310
  ### v1.0.0 (2026-01-08) - Supervisor Architecture
274
311
 
275
312
  **BREAKING CHANGES:**
@@ -1,5 +1,13 @@
1
1
  import { Command } from "commander";
2
2
  import { getConfig } from "./config.js";
3
+ import { exec } from "child_process";
4
+ import { promisify } from "util";
5
+ const execAsync = promisify(exec);
6
+ // Helper to get the Husky API URL (for Google Chat integration)
7
+ function getHuskyApiUrl() {
8
+ const config = getConfig();
9
+ return config.apiUrl || null;
10
+ }
3
11
  export const chatCommand = new Command("chat")
4
12
  .description("Communicate with the dashboard chat");
5
13
  chatCommand
@@ -160,3 +168,502 @@ chatCommand
160
168
  process.exit(1);
161
169
  }
162
170
  });
171
+ chatCommand
172
+ .command("review <question>")
173
+ .description("Request human review via Google Chat")
174
+ .option("--task-id <id>", "Link to a specific task")
175
+ .option("--context <text>", "Additional context for the reviewer")
176
+ .option("--priority <level>", "Priority: low, normal, urgent", "normal")
177
+ .option("--wait", "Wait for human response (polling)")
178
+ .option("--timeout <seconds>", "Timeout for waiting (default: 300)", "300")
179
+ .option("--json", "Output as JSON")
180
+ .action(async (question, options) => {
181
+ const config = getConfig();
182
+ const huskyApiUrl = getHuskyApiUrl();
183
+ if (!huskyApiUrl) {
184
+ console.error("Error: API URL not configured. Set husky-api-url or api-url.");
185
+ process.exit(1);
186
+ }
187
+ const workerId = process.env.HUSKY_WORKER_ID || `agent-${process.pid}`;
188
+ try {
189
+ const res = await fetch(`${huskyApiUrl}/api/google-chat/request-review`, {
190
+ method: "POST",
191
+ headers: {
192
+ "Content-Type": "application/json",
193
+ ...(config.apiKey ? { "x-api-key": config.apiKey } : {}),
194
+ },
195
+ body: JSON.stringify({
196
+ agentId: workerId,
197
+ taskId: options.taskId,
198
+ question,
199
+ context: options.context,
200
+ priority: options.priority,
201
+ }),
202
+ });
203
+ if (!res.ok) {
204
+ const error = await res.text();
205
+ throw new Error(`API error: ${res.status} - ${error}`);
206
+ }
207
+ const data = await res.json();
208
+ if (!options.wait) {
209
+ if (options.json) {
210
+ console.log(JSON.stringify(data, null, 2));
211
+ }
212
+ else {
213
+ console.log(`Review requested (ID: ${data.id})`);
214
+ console.log(`Status: ${data.status}`);
215
+ console.log(`\nTo check status: husky chat review-status ${data.id}`);
216
+ console.log(`To wait for response: husky chat review-wait ${data.id}`);
217
+ }
218
+ return;
219
+ }
220
+ console.log(`Review requested (ID: ${data.id}). Waiting for human response...`);
221
+ const timeoutMs = parseInt(options.timeout, 10) * 1000;
222
+ const startTime = Date.now();
223
+ const pollInterval = 5000;
224
+ while (Date.now() - startTime < timeoutMs) {
225
+ await new Promise((resolve) => setTimeout(resolve, pollInterval));
226
+ const pollRes = await fetch(`${huskyApiUrl}/api/google-chat/review/${data.id}/poll`, {
227
+ headers: config.apiKey ? { "x-api-key": config.apiKey } : {},
228
+ });
229
+ if (!pollRes.ok)
230
+ continue;
231
+ const pollData = await pollRes.json();
232
+ if (pollData.status === "answered" && pollData.response) {
233
+ if (options.json) {
234
+ console.log(JSON.stringify(pollData, null, 2));
235
+ }
236
+ else {
237
+ console.log(`\nHuman response received from ${pollData.respondedBy || "unknown"}:`);
238
+ console.log(`\n${pollData.response}`);
239
+ }
240
+ return;
241
+ }
242
+ process.stdout.write(".");
243
+ }
244
+ console.error("\nTimeout waiting for human response.");
245
+ process.exit(1);
246
+ }
247
+ catch (error) {
248
+ console.error("Error requesting review:", error);
249
+ process.exit(1);
250
+ }
251
+ });
252
+ chatCommand
253
+ .command("review-status <reviewId>")
254
+ .description("Check status of a human review request")
255
+ .option("--json", "Output as JSON")
256
+ .action(async (reviewId, options) => {
257
+ const config = getConfig();
258
+ const huskyApiUrl = getHuskyApiUrl();
259
+ if (!huskyApiUrl) {
260
+ console.error("Error: API URL not configured. Set husky-api-url or api-url.");
261
+ process.exit(1);
262
+ }
263
+ try {
264
+ const res = await fetch(`${huskyApiUrl}/api/google-chat/review/${reviewId}`, {
265
+ headers: config.apiKey ? { "x-api-key": config.apiKey } : {},
266
+ });
267
+ if (!res.ok) {
268
+ if (res.status === 404) {
269
+ console.error("Review not found.");
270
+ process.exit(1);
271
+ }
272
+ throw new Error(`API error: ${res.status}`);
273
+ }
274
+ const data = await res.json();
275
+ if (options.json) {
276
+ console.log(JSON.stringify(data, null, 2));
277
+ return;
278
+ }
279
+ console.log(`\nReview: ${data.id}`);
280
+ console.log(`Status: ${data.status}`);
281
+ console.log(`Question: ${data.question}`);
282
+ if (data.response) {
283
+ console.log(`\nResponse from ${data.respondedBy || "unknown"}:`);
284
+ console.log(data.response);
285
+ }
286
+ }
287
+ catch (error) {
288
+ console.error("Error checking review status:", error);
289
+ process.exit(1);
290
+ }
291
+ });
292
+ // ============================================
293
+ // SUPERVISOR INBOX COMMANDS (Google Chat <-> Supervisor)
294
+ // ============================================
295
+ chatCommand
296
+ .command("inbox")
297
+ .description("Get messages from Google Chat (supervisor inbox)")
298
+ .option("--unread", "Only show unread messages")
299
+ .option("--limit <n>", "Number of messages", "10")
300
+ .option("--json", "Output as JSON")
301
+ .action(async (options) => {
302
+ const config = getConfig();
303
+ const huskyApiUrl = getHuskyApiUrl();
304
+ if (!huskyApiUrl) {
305
+ console.error("Error: API URL not configured. Set husky-api-url or api-url.");
306
+ process.exit(1);
307
+ }
308
+ try {
309
+ const params = new URLSearchParams();
310
+ if (options.unread)
311
+ params.set("unread", "true");
312
+ if (options.limit)
313
+ params.set("limit", options.limit);
314
+ const res = await fetch(`${huskyApiUrl}/api/google-chat/inbox?${params}`, {
315
+ headers: config.apiKey ? { "x-api-key": config.apiKey } : {},
316
+ });
317
+ if (!res.ok) {
318
+ throw new Error(`API error: ${res.status}`);
319
+ }
320
+ const data = await res.json();
321
+ if (options.json) {
322
+ console.log(JSON.stringify(data, null, 2));
323
+ return;
324
+ }
325
+ if (!data.messages || data.messages.length === 0) {
326
+ console.log(options.unread ? "📭 No unread messages." : "📭 No messages in inbox.");
327
+ return;
328
+ }
329
+ console.log("\n 📬 Supervisor Inbox");
330
+ console.log(" " + "─".repeat(60));
331
+ for (const msg of data.messages) {
332
+ const time = new Date(msg.createdAt).toLocaleString();
333
+ const readIcon = msg.read ? "✓" : "●";
334
+ console.log(` ${readIcon} [${msg.id.slice(0, 8)}] ${msg.senderName} (${time})`);
335
+ console.log(` "${msg.text}"`);
336
+ console.log("");
337
+ }
338
+ }
339
+ catch (error) {
340
+ console.error("Error fetching inbox:", error);
341
+ process.exit(1);
342
+ }
343
+ });
344
+ chatCommand
345
+ .command("reply-chat <message>")
346
+ .description("Send a message to Google Chat (supervisor -> human)")
347
+ .option("--space <name>", "Target space (e.g., spaces/ABC123)")
348
+ .option("--thread <name>", "Reply in thread (e.g., spaces/ABC123/threads/XYZ)")
349
+ .action(async (message, options) => {
350
+ const config = getConfig();
351
+ const huskyApiUrl = getHuskyApiUrl();
352
+ if (!huskyApiUrl) {
353
+ console.error("Error: API URL not configured. Set husky-api-url or api-url.");
354
+ process.exit(1);
355
+ }
356
+ try {
357
+ const res = await fetch(`${huskyApiUrl}/api/google-chat/send`, {
358
+ method: "POST",
359
+ headers: {
360
+ "Content-Type": "application/json",
361
+ ...(config.apiKey ? { "x-api-key": config.apiKey } : {}),
362
+ },
363
+ body: JSON.stringify({
364
+ text: message,
365
+ spaceName: options.space,
366
+ threadName: options.thread,
367
+ }),
368
+ });
369
+ if (!res.ok) {
370
+ const error = await res.text();
371
+ throw new Error(`API error: ${res.status} - ${error}`);
372
+ }
373
+ console.log("✅ Message sent to Google Chat.");
374
+ }
375
+ catch (error) {
376
+ console.error("Error sending message:", error);
377
+ process.exit(1);
378
+ }
379
+ });
380
+ chatCommand
381
+ .command("reply-to <messageId> <response>")
382
+ .description("Reply to a specific inbox message in its thread (supports both GitHub and Google Chat)")
383
+ .action(async (messageId, response) => {
384
+ const config = getConfig();
385
+ const huskyApiUrl = getHuskyApiUrl();
386
+ if (!huskyApiUrl) {
387
+ console.error("Error: API URL not configured. Set husky-api-url or api-url.");
388
+ process.exit(1);
389
+ }
390
+ try {
391
+ // Fetch inbox to find the message
392
+ const inboxRes = await fetch(`${huskyApiUrl}/api/google-chat/inbox?limit=50`, {
393
+ headers: config.apiKey ? { "x-api-key": config.apiKey } : {},
394
+ });
395
+ if (!inboxRes.ok) {
396
+ throw new Error(`Failed to fetch inbox: ${inboxRes.status}`);
397
+ }
398
+ const data = await inboxRes.json();
399
+ // Require exact match or at least 8 characters for prefix matching to avoid misdirected replies
400
+ const msg = data.messages.find(m => m.id === messageId || (messageId.length >= 8 && m.id.startsWith(messageId)));
401
+ if (!msg) {
402
+ console.error(`Message ${messageId} not found in inbox.`);
403
+ if (messageId.length < 8) {
404
+ console.error("Hint: Provide at least 8 characters of the message ID for prefix matching.");
405
+ }
406
+ process.exit(1);
407
+ }
408
+ // Check if it's a GitHub message
409
+ const isGitHub = msg.spaceName?.startsWith("github:");
410
+ if (isGitHub) {
411
+ // Use GitHub reply endpoint
412
+ const sendRes = await fetch(`${huskyApiUrl}/api/github/inbox/${msg.id}/reply`, {
413
+ method: "POST",
414
+ headers: {
415
+ "Content-Type": "application/json",
416
+ ...(config.apiKey ? { "x-api-key": config.apiKey } : {}),
417
+ },
418
+ body: JSON.stringify({ text: response }),
419
+ });
420
+ if (!sendRes.ok) {
421
+ const error = await sendRes.text();
422
+ throw new Error(`API error: ${sendRes.status} - ${error}`);
423
+ }
424
+ console.log("✅ Reply posted to GitHub issue.");
425
+ }
426
+ else {
427
+ // Use Google Chat reply
428
+ const sendRes = await fetch(`${huskyApiUrl}/api/google-chat/send`, {
429
+ method: "POST",
430
+ headers: {
431
+ "Content-Type": "application/json",
432
+ ...(config.apiKey ? { "x-api-key": config.apiKey } : {}),
433
+ },
434
+ body: JSON.stringify({
435
+ text: response,
436
+ spaceName: msg.spaceName,
437
+ threadName: msg.threadName,
438
+ }),
439
+ });
440
+ if (!sendRes.ok) {
441
+ const error = await sendRes.text();
442
+ throw new Error(`API error: ${sendRes.status} - ${error}`);
443
+ }
444
+ // Mark as read
445
+ await fetch(`${huskyApiUrl}/api/google-chat/inbox/${msg.id}/read`, {
446
+ method: "POST",
447
+ headers: config.apiKey ? { "x-api-key": config.apiKey } : {},
448
+ });
449
+ console.log("✅ Reply sent to Google Chat and message marked as read.");
450
+ }
451
+ }
452
+ catch (error) {
453
+ console.error("Error replying:", error);
454
+ process.exit(1);
455
+ }
456
+ });
457
+ chatCommand
458
+ .command("mark-read <messageId>")
459
+ .description("Mark a message as read")
460
+ .action(async (messageId) => {
461
+ const config = getConfig();
462
+ const huskyApiUrl = getHuskyApiUrl();
463
+ if (!huskyApiUrl) {
464
+ console.error("Error: API URL not configured. Set husky-api-url or api-url.");
465
+ process.exit(1);
466
+ }
467
+ try {
468
+ const res = await fetch(`${huskyApiUrl}/api/google-chat/inbox/${messageId}/read`, {
469
+ method: "POST",
470
+ headers: config.apiKey ? { "x-api-key": config.apiKey } : {},
471
+ });
472
+ if (!res.ok) {
473
+ throw new Error(`API error: ${res.status}`);
474
+ }
475
+ console.log("✅ Message marked as read.");
476
+ }
477
+ catch (error) {
478
+ console.error("Error marking message as read:", error);
479
+ process.exit(1);
480
+ }
481
+ });
482
+ chatCommand
483
+ .command("watch")
484
+ .description("Watch for new messages (blocking, for supervisor agent)")
485
+ .option("--poll-interval <seconds>", "Poll interval in seconds", "10")
486
+ .action(async (options) => {
487
+ const config = getConfig();
488
+ const huskyApiUrl = getHuskyApiUrl();
489
+ if (!huskyApiUrl) {
490
+ console.error("Error: API URL not configured. Set husky-api-url or api-url.");
491
+ process.exit(1);
492
+ }
493
+ console.log("👀 Watching for new messages... (Ctrl+C to stop)");
494
+ const pollInterval = parseInt(options.pollInterval, 10) * 1000;
495
+ let lastSeenId = "";
496
+ const poll = async () => {
497
+ try {
498
+ const res = await fetch(`${huskyApiUrl}/api/google-chat/inbox?unread=true&limit=5`, {
499
+ headers: config.apiKey ? { "x-api-key": config.apiKey } : {},
500
+ });
501
+ if (!res.ok)
502
+ return;
503
+ const data = await res.json();
504
+ for (const msg of data.messages || []) {
505
+ if (msg.id !== lastSeenId) {
506
+ lastSeenId = msg.id;
507
+ const time = new Date(msg.createdAt).toLocaleTimeString();
508
+ console.log(`\n📨 [${time}] ${msg.senderName}: ${msg.text}`);
509
+ }
510
+ }
511
+ }
512
+ catch { }
513
+ };
514
+ await poll();
515
+ setInterval(poll, pollInterval);
516
+ process.on("SIGINT", () => {
517
+ console.log("\n👋 Stopped watching.");
518
+ process.exit(0);
519
+ });
520
+ await new Promise(() => { });
521
+ });
522
+ chatCommand
523
+ .command("watch-inject")
524
+ .description("Watch for messages and inject them into a tmux session")
525
+ .option("--poll-interval <seconds>", "Poll interval in seconds", "2")
526
+ .option("--tmux-session <name>", "Target tmux session name", "supervisor")
527
+ .option("--tmux-window <name>", "Target tmux window name or index (default: 0)", "0")
528
+ .option("--hint", "Show reply hint after messages (default: true)", true)
529
+ .option("--no-hint", "Hide reply hint")
530
+ .action(async (options) => {
531
+ const config = getConfig();
532
+ const huskyApiUrl = getHuskyApiUrl();
533
+ if (!huskyApiUrl) {
534
+ console.error("Error: API URL not configured. Set husky-api-url or api-url.");
535
+ process.exit(1);
536
+ }
537
+ const tmuxSession = options.tmuxSession;
538
+ const tmuxWindow = options.tmuxWindow;
539
+ const tmuxTarget = `${tmuxSession}:${tmuxWindow}`;
540
+ const pollInterval = parseInt(options.pollInterval, 10) * 1000;
541
+ const processedIds = new Set();
542
+ console.log(`📡 Watching for messages (Google Chat & GitHub)...`);
543
+ console.log(` Target: ${tmuxTarget}`);
544
+ console.log(` Poll interval: ${options.pollInterval}s`);
545
+ console.log(` Press Ctrl+C to stop\n`);
546
+ const injectToTmux = async (text, senderName, spaceName) => {
547
+ // Detect platform from spaceName
548
+ const isGitHub = spaceName?.startsWith("github:");
549
+ const platform = isGitHub ? "GitHub" : "Google Chat";
550
+ let formattedMessage = `[${platform}] ${senderName}: ${text}`;
551
+ if (options.hint) {
552
+ if (isGitHub) {
553
+ // Extract repo info from spaceName (format: github:owner/repo)
554
+ const repoInfo = spaceName?.replace("github:", "") || "";
555
+ formattedMessage += `\n💡 Reply on GitHub: ${repoInfo}`;
556
+ }
557
+ else {
558
+ formattedMessage += `\n💡 Tip: Use \`husky chat reply-chat "your response"\` to reply`;
559
+ }
560
+ }
561
+ const escapedMessage = formattedMessage
562
+ .replace(/\\/g, "\\\\")
563
+ .replace(/"/g, '\\"')
564
+ .replace(/\$/g, "\\$")
565
+ .replace(/`/g, "\\`")
566
+ .replace(/'/g, "'\\''");
567
+ try {
568
+ await execAsync(`tmux send-keys -t "${tmuxTarget}" "${escapedMessage}" Enter`, { timeout: 5000 });
569
+ return true;
570
+ }
571
+ catch (error) {
572
+ const err = error;
573
+ console.error(` ❌ Failed to inject: ${err.message}`);
574
+ return false;
575
+ }
576
+ };
577
+ const markAsRead = async (messageId) => {
578
+ try {
579
+ await fetch(`${huskyApiUrl}/api/google-chat/inbox/${messageId}/read`, {
580
+ method: "POST",
581
+ headers: config.apiKey ? { "x-api-key": config.apiKey } : {},
582
+ });
583
+ }
584
+ catch { }
585
+ };
586
+ const poll = async () => {
587
+ try {
588
+ const res = await fetch(`${huskyApiUrl}/api/google-chat/inbox?unread=true&limit=10`, {
589
+ headers: config.apiKey ? { "x-api-key": config.apiKey } : {},
590
+ });
591
+ if (!res.ok)
592
+ return;
593
+ const data = await res.json();
594
+ const messages = (data.messages || []).reverse();
595
+ for (const msg of messages) {
596
+ if (processedIds.has(msg.id))
597
+ continue;
598
+ processedIds.add(msg.id);
599
+ const time = new Date(msg.createdAt).toLocaleTimeString();
600
+ const platform = msg.spaceName?.startsWith("github:") ? "GitHub" : "Google Chat";
601
+ console.log(`📨 [${time}] Injecting ${platform} message from ${msg.senderName}`);
602
+ const success = await injectToTmux(msg.text, msg.senderName, msg.spaceName);
603
+ if (success) {
604
+ await markAsRead(msg.id);
605
+ console.log(` ✓ Injected and marked as read`);
606
+ }
607
+ }
608
+ }
609
+ catch (error) {
610
+ const err = error;
611
+ console.error(`Poll error: ${err.message}`);
612
+ }
613
+ };
614
+ await poll();
615
+ setInterval(poll, pollInterval);
616
+ process.on("SIGINT", () => {
617
+ console.log("\n👋 Stopped watching.");
618
+ process.exit(0);
619
+ });
620
+ await new Promise(() => { });
621
+ });
622
+ // ============================================
623
+ // REVIEW COMMANDS (kept for backwards compatibility)
624
+ // ============================================
625
+ chatCommand
626
+ .command("review-wait <reviewId>")
627
+ .description("Wait for a human review response")
628
+ .option("--timeout <seconds>", "Timeout in seconds (default: 300)", "300")
629
+ .option("--json", "Output as JSON")
630
+ .action(async (reviewId, options) => {
631
+ const config = getConfig();
632
+ const huskyApiUrl = getHuskyApiUrl();
633
+ if (!huskyApiUrl) {
634
+ console.error("Error: API URL not configured. Set husky-api-url or api-url.");
635
+ process.exit(1);
636
+ }
637
+ console.log(`Waiting for response to review ${reviewId}...`);
638
+ const timeoutMs = parseInt(options.timeout, 10) * 1000;
639
+ const startTime = Date.now();
640
+ const pollInterval = 5000;
641
+ try {
642
+ while (Date.now() - startTime < timeoutMs) {
643
+ const res = await fetch(`${huskyApiUrl}/api/google-chat/review/${reviewId}/poll`, {
644
+ headers: config.apiKey ? { "x-api-key": config.apiKey } : {},
645
+ });
646
+ if (res.ok) {
647
+ const data = await res.json();
648
+ if (data.status === "answered" && data.response) {
649
+ if (options.json) {
650
+ console.log(JSON.stringify(data, null, 2));
651
+ }
652
+ else {
653
+ console.log(`\nHuman response received from ${data.respondedBy || "unknown"}:`);
654
+ console.log(`\n${data.response}`);
655
+ }
656
+ return;
657
+ }
658
+ }
659
+ process.stdout.write(".");
660
+ await new Promise((resolve) => setTimeout(resolve, pollInterval));
661
+ }
662
+ console.error("\nTimeout waiting for human response.");
663
+ process.exit(1);
664
+ }
665
+ catch (error) {
666
+ console.error("Error waiting for review:", error);
667
+ process.exit(1);
668
+ }
669
+ });
@@ -0,0 +1,2 @@
1
+ import { Command } from "commander";
2
+ export declare const initCommand: Command;
@@ -0,0 +1,91 @@
1
+ import { Command } from "commander";
2
+ import { existsSync, writeFileSync } from "fs";
3
+ import { join } from "path";
4
+ import { createRequire } from "module";
5
+ import { generateLLMContext } from "./llm-context.js";
6
+ const require = createRequire(import.meta.url);
7
+ const packageJson = require("../../package.json");
8
+ const HUSKY_MD_FILENAME = "HUSKY.md";
9
+ function generateHuskyMdContent() {
10
+ const timestamp = new Date().toISOString();
11
+ const cliRef = generateLLMContext();
12
+ return `<!--
13
+ Auto-generated by husky init (v${packageJson.version})
14
+ Generated: ${timestamp}
15
+
16
+ This file instructs AI coding agents to use the Husky CLI.
17
+ Update with: husky init --force
18
+ -->
19
+
20
+ ${cliRef}
21
+
22
+ ---
23
+
24
+ ## Standard Workflow for AI Agents
25
+
26
+ ### On Session Start
27
+ \`\`\`bash
28
+ husky config test # Verify API connection
29
+ husky worker whoami # Confirm worker identity
30
+ \`\`\`
31
+
32
+ ### When Working on a Task
33
+ \`\`\`bash
34
+ # 1. Get task details
35
+ husky task get <id>
36
+
37
+ # 2. Start the task (creates isolated worktree)
38
+ husky task start <id>
39
+
40
+ # 3. CD into the worktree directory (MANDATORY)
41
+ cd <worktree-path> # Path shown in task start output
42
+
43
+ # 4. Report progress as you work
44
+ husky task message <id> "Analyzing codebase..."
45
+ husky task message <id> "Implementing feature X..."
46
+
47
+ # 5. When done, create PR and complete
48
+ husky worktree pr <worktree-name> -t "feat: description"
49
+ husky task done <id> --pr <pr-url>
50
+ \`\`\`
51
+
52
+ **IMPORTANT:** After \`husky task start\`, you MUST \`cd\` into the worktree directory before making any code changes.
53
+
54
+ ### When Handling Customer Support
55
+ \`\`\`bash
56
+ # 1. Get full customer context
57
+ husky biz customers 360 <email>
58
+
59
+ # 2. Check relevant tickets
60
+ husky biz tickets search "<customer-email>"
61
+
62
+ # 3. Check order history if needed
63
+ husky biz orders search "<order-id-or-email>"
64
+
65
+ # 4. Reply with context
66
+ husky biz tickets reply <ticket-id> "Your response..."
67
+ \`\`\`
68
+ `;
69
+ }
70
+ export const initCommand = new Command("init")
71
+ .description("Initialize Husky in the current directory (creates HUSKY.md)")
72
+ .option("-f, --force", "Overwrite existing HUSKY.md")
73
+ .option("-q, --quiet", "Suppress output")
74
+ .action((options) => {
75
+ const targetPath = join(process.cwd(), HUSKY_MD_FILENAME);
76
+ if (existsSync(targetPath) && !options.force) {
77
+ console.error(`Error: ${HUSKY_MD_FILENAME} already exists.`);
78
+ console.error("Use --force to overwrite.");
79
+ process.exit(1);
80
+ }
81
+ const content = generateHuskyMdContent();
82
+ writeFileSync(targetPath, content);
83
+ if (!options.quiet) {
84
+ console.log(`✓ Created ${HUSKY_MD_FILENAME}`);
85
+ console.log("");
86
+ console.log(" This file instructs AI agents to use the Husky CLI.");
87
+ console.log(" Commit it to your repository so all agents see it.");
88
+ console.log("");
89
+ console.log(" Update with: husky init --force");
90
+ }
91
+ });
@@ -0,0 +1,2 @@
1
+ import { Command } from "commander";
2
+ export declare const previewCommand: Command;
@@ -0,0 +1,161 @@
1
+ import { Command } from "commander";
2
+ import { getConfig } from "./config.js";
3
+ const PREVIEW_DEPLOY_TRIGGER_ID = "80b3ba55-ae74-41cd-b7d0-a477ecc357b1";
4
+ const PREVIEW_CLEANUP_TRIGGER_ID = "965c3e86-677f-4063-b391-43019f621ea2";
5
+ const GCP_PROJECT = "tigerv0";
6
+ async function apiRequest(method, path, body) {
7
+ const config = getConfig();
8
+ const url = `${config.apiUrl}${path}`;
9
+ const response = await fetch(url, {
10
+ method,
11
+ headers: {
12
+ "Content-Type": "application/json",
13
+ "x-api-key": config.apiKey || "",
14
+ },
15
+ body: body ? JSON.stringify(body) : undefined,
16
+ });
17
+ return response;
18
+ }
19
+ async function triggerCloudBuild(triggerId, substitutions) {
20
+ const { execSync } = await import("child_process");
21
+ const subsArgs = Object.entries(substitutions)
22
+ .map(([k, v]) => `${k}=${v}`)
23
+ .join(",");
24
+ try {
25
+ const cmd = `gcloud builds triggers run ${triggerId} --project=${GCP_PROJECT} --branch=main --substitutions=${subsArgs} --format="value(metadata.build.id)" 2>&1`;
26
+ const output = execSync(cmd, { encoding: "utf-8" }).trim();
27
+ const buildId = output.split("\n").pop() || "";
28
+ return { success: true, buildId };
29
+ }
30
+ catch (error) {
31
+ const message = error instanceof Error ? error.message : String(error);
32
+ return { success: false, error: message };
33
+ }
34
+ }
35
+ export const previewCommand = new Command("preview")
36
+ .description("Manage PR preview deployments");
37
+ previewCommand
38
+ .command("list")
39
+ .description("List active preview deployments")
40
+ .option("--json", "Output as JSON")
41
+ .action(async (options) => {
42
+ try {
43
+ const response = await apiRequest("GET", "/api/previews");
44
+ if (!response.ok) {
45
+ console.error(`Error: ${response.status} ${response.statusText}`);
46
+ process.exit(1);
47
+ }
48
+ const previews = await response.json();
49
+ if (options.json) {
50
+ console.log(JSON.stringify(previews, null, 2));
51
+ return;
52
+ }
53
+ if (!previews.length) {
54
+ console.log("No active previews");
55
+ return;
56
+ }
57
+ console.log("\nActive Previews:\n");
58
+ for (const p of previews) {
59
+ console.log(` PR #${p.prNumber}${p.prTitle ? `: ${p.prTitle}` : ""}`);
60
+ console.log(` Status: ${p.status}`);
61
+ console.log(` Dashboard: ${p.dashboardUrl}`);
62
+ console.log(` Terminal: ${p.terminalUrl}`);
63
+ console.log(` Commit: ${p.commitSha.slice(0, 7)}`);
64
+ console.log("");
65
+ }
66
+ }
67
+ catch (error) {
68
+ console.error("Failed to fetch previews:", error);
69
+ process.exit(1);
70
+ }
71
+ });
72
+ previewCommand
73
+ .command("deploy <pr-number>")
74
+ .description("Deploy a preview for a PR")
75
+ .option("--branch <branch>", "Branch to deploy (default: from PR)")
76
+ .action(async (prNumber, options) => {
77
+ const prNum = parseInt(prNumber, 10);
78
+ if (isNaN(prNum) || prNum <= 0) {
79
+ console.error("Error: PR number must be a positive integer");
80
+ process.exit(1);
81
+ }
82
+ console.log(`Triggering preview deployment for PR #${prNum}...`);
83
+ const result = await triggerCloudBuild(PREVIEW_DEPLOY_TRIGGER_ID, {
84
+ _PR_NUMBER: String(prNum),
85
+ });
86
+ if (result.success) {
87
+ console.log(`Build started: ${result.buildId}`);
88
+ console.log(`\nMonitor at: https://console.cloud.google.com/cloud-build/builds/${result.buildId}?project=${GCP_PROJECT}`);
89
+ console.log("\nPreview URLs will be available once build completes (~5-10 min)");
90
+ }
91
+ else {
92
+ console.error(`Failed to trigger build: ${result.error}`);
93
+ process.exit(1);
94
+ }
95
+ });
96
+ previewCommand
97
+ .command("cleanup [pr-number]")
98
+ .description("Cleanup preview deployments (specific PR or all merged)")
99
+ .action(async (prNumber) => {
100
+ const substitutions = {};
101
+ if (prNumber) {
102
+ const prNum = parseInt(prNumber, 10);
103
+ if (isNaN(prNum) || prNum <= 0) {
104
+ console.error("Error: PR number must be a positive integer");
105
+ process.exit(1);
106
+ }
107
+ substitutions._PR_NUMBER = String(prNum);
108
+ console.log(`Cleaning up preview for PR #${prNum}...`);
109
+ }
110
+ else {
111
+ console.log("Cleaning up all merged/closed PR previews...");
112
+ }
113
+ const result = await triggerCloudBuild(PREVIEW_CLEANUP_TRIGGER_ID, substitutions);
114
+ if (result.success) {
115
+ console.log(`Cleanup started: ${result.buildId}`);
116
+ console.log(`\nMonitor at: https://console.cloud.google.com/cloud-build/builds/${result.buildId}?project=${GCP_PROJECT}`);
117
+ }
118
+ else {
119
+ console.error(`Failed to trigger cleanup: ${result.error}`);
120
+ process.exit(1);
121
+ }
122
+ });
123
+ previewCommand
124
+ .command("status <pr-number>")
125
+ .description("Get status of a specific preview")
126
+ .option("--json", "Output as JSON")
127
+ .action(async (prNumber, options) => {
128
+ const prNum = parseInt(prNumber, 10);
129
+ if (isNaN(prNum) || prNum <= 0) {
130
+ console.error("Error: PR number must be a positive integer");
131
+ process.exit(1);
132
+ }
133
+ try {
134
+ const response = await apiRequest("GET", `/api/previews/${prNum}`);
135
+ if (response.status === 404) {
136
+ console.log(`No preview found for PR #${prNum}`);
137
+ process.exit(0);
138
+ }
139
+ if (!response.ok) {
140
+ console.error(`Error: ${response.status} ${response.statusText}`);
141
+ process.exit(1);
142
+ }
143
+ const preview = await response.json();
144
+ if (options.json) {
145
+ console.log(JSON.stringify(preview, null, 2));
146
+ return;
147
+ }
148
+ console.log(`\nPreview for PR #${preview.prNumber}`);
149
+ if (preview.prTitle)
150
+ console.log(` Title: ${preview.prTitle}`);
151
+ console.log(` Status: ${preview.status}`);
152
+ console.log(` Dashboard: ${preview.dashboardUrl}`);
153
+ console.log(` Terminal: ${preview.terminalUrl}`);
154
+ console.log(` Commit: ${preview.commitSha}`);
155
+ console.log(` Created: ${new Date(preview.createdAt).toLocaleString()}`);
156
+ }
157
+ catch (error) {
158
+ console.error("Failed to fetch preview:", error);
159
+ process.exit(1);
160
+ }
161
+ });
package/dist/index.js CHANGED
@@ -24,6 +24,8 @@ import { printLLMContext, llmCommand } from "./commands/llm-context.js";
24
24
  import { runInteractiveMode } from "./commands/interactive.js";
25
25
  import { serviceAccountCommand } from "./commands/service-account.js";
26
26
  import { chatCommand } from "./commands/chat.js";
27
+ import { previewCommand } from "./commands/preview.js";
28
+ import { initCommand } from "./commands/init.js";
27
29
  // Read version from package.json
28
30
  const require = createRequire(import.meta.url);
29
31
  const packageJson = require("../package.json");
@@ -54,7 +56,9 @@ program.addCommand(workerCommand);
54
56
  program.addCommand(bizCommand);
55
57
  program.addCommand(serviceAccountCommand);
56
58
  program.addCommand(chatCommand);
59
+ program.addCommand(previewCommand);
57
60
  program.addCommand(llmCommand);
61
+ program.addCommand(initCommand);
58
62
  // Handle --llm flag specially
59
63
  if (process.argv.includes("--llm")) {
60
64
  printLLMContext();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@simonfestl/husky-cli",
3
- "version": "1.0.0",
3
+ "version": "1.2.0",
4
4
  "description": "CLI for Huskyv0 Task Orchestration with Claude Agent SDK",
5
5
  "type": "module",
6
6
  "bin": {