@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 +37 -0
- package/dist/commands/chat.js +507 -0
- package/dist/commands/init.d.ts +2 -0
- package/dist/commands/init.js +91 -0
- package/dist/commands/preview.d.ts +2 -0
- package/dist/commands/preview.js +161 -0
- package/dist/index.js +4 -0
- package/package.json +1 -1
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:**
|
package/dist/commands/chat.js
CHANGED
|
@@ -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,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,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();
|