@simonfestl/husky-cli 1.33.0 → 1.34.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.
@@ -1,7 +1,13 @@
1
1
  /**
2
2
  * Husky Biz Tickets Command
3
3
  *
4
- * Manages support tickets via Zendesk API
4
+ * Manages support tickets via Zendesk API.
5
+ * Supports API proxy (server-side credentials) with fallback to direct API.
6
+ *
7
+ * Proxy Support:
8
+ * - list, get, create, reply, search commands try proxy first
9
+ * - Other commands use direct API (local credentials required)
10
+ * - Use --no-proxy flag to skip proxy
5
11
  */
6
12
  import { Command } from "commander";
7
13
  export declare const ticketsCommand: Command;
@@ -1,14 +1,34 @@
1
1
  /**
2
2
  * Husky Biz Tickets Command
3
3
  *
4
- * Manages support tickets via Zendesk API
4
+ * Manages support tickets via Zendesk API.
5
+ * Supports API proxy (server-side credentials) with fallback to direct API.
6
+ *
7
+ * Proxy Support:
8
+ * - list, get, create, reply, search commands try proxy first
9
+ * - Other commands use direct API (local credentials required)
10
+ * - Use --no-proxy flag to skip proxy
5
11
  */
6
12
  import { Command } from "commander";
7
- import { ZendeskClient } from "../../lib/biz/index.js";
13
+ import { ZendeskClient, tryZendeskProxy } from "../../lib/biz/index.js";
8
14
  import { AgentBrain } from "../../lib/biz/agent-brain.js";
9
15
  import * as fs from "fs";
10
16
  import * as path from "path";
11
17
  import { errorWithAutoHint } from "../../lib/error-hints.js";
18
+ // Helper to get client - tries proxy first for supported operations
19
+ async function getClient(options = {}) {
20
+ if (!options.noProxy) {
21
+ const proxy = await tryZendeskProxy();
22
+ if (proxy) {
23
+ return proxy;
24
+ }
25
+ }
26
+ return ZendeskClient.fromConfig();
27
+ }
28
+ // Type guard to check if client is proxy client
29
+ function isProxyClient(client) {
30
+ return 'isAvailable' in client;
31
+ }
12
32
  export const ticketsCommand = new Command("tickets")
13
33
  .description("Manage support tickets (Zendesk)");
14
34
  // husky biz tickets list
@@ -18,9 +38,10 @@ ticketsCommand
18
38
  .option("-s, --status <status>", "Filter by status (new, open, pending, solved, closed)")
19
39
  .option("-l, --limit <num>", "Number of tickets", "25")
20
40
  .option("--json", "Output as JSON")
41
+ .option("--no-proxy", "Skip API proxy, use direct API")
21
42
  .action(async (options) => {
22
43
  try {
23
- const client = ZendeskClient.fromConfig();
44
+ const client = await getClient(options);
24
45
  const tickets = await client.listTickets({
25
46
  per_page: parseInt(options.limit, 10),
26
47
  status: options.status,
@@ -29,7 +50,8 @@ ticketsCommand
29
50
  console.log(JSON.stringify(tickets, null, 2));
30
51
  return;
31
52
  }
32
- console.log(`\n šŸŽ« Tickets (${tickets.length})\n`);
53
+ const proxyLabel = isProxyClient(client) ? " (via proxy)" : "";
54
+ console.log(`\n šŸŽ« Tickets (${tickets.length})${proxyLabel}\n`);
33
55
  if (tickets.length === 0) {
34
56
  console.log(" No tickets found.");
35
57
  return;
@@ -51,14 +73,24 @@ ticketsCommand
51
73
  .command("get <id>")
52
74
  .description("Get ticket with conversation history")
53
75
  .option("--json", "Output as JSON")
76
+ .option("--no-proxy", "Skip API proxy, use direct API")
54
77
  .action(async (id, options) => {
55
78
  try {
56
- const client = ZendeskClient.fromConfig();
79
+ const client = await getClient(options);
57
80
  const ticketId = parseInt(id, 10);
58
- const [ticket, comments] = await Promise.all([
59
- client.getTicket(ticketId),
60
- client.getTicketComments(ticketId),
61
- ]);
81
+ let ticket;
82
+ let comments;
83
+ if (isProxyClient(client)) {
84
+ const result = await client.getTicket(ticketId);
85
+ ticket = result.ticket;
86
+ comments = result.comments;
87
+ }
88
+ else {
89
+ [ticket, comments] = await Promise.all([
90
+ client.getTicket(ticketId),
91
+ client.getTicketComments(ticketId),
92
+ ]);
93
+ }
62
94
  if (options.json) {
63
95
  console.log(JSON.stringify({ ticket, comments }, null, 2));
64
96
  return;
@@ -95,11 +127,18 @@ ticketsCommand
95
127
  .command("reply <id>")
96
128
  .description("Reply to a ticket (public)")
97
129
  .requiredOption("-m, --message <text>", "Reply message")
130
+ .option("--no-proxy", "Skip API proxy, use direct API")
98
131
  .action(async (id, options) => {
99
132
  try {
100
- const client = ZendeskClient.fromConfig();
101
- const ticket = await client.addComment(parseInt(id, 10), options.message, true);
102
- console.log(`āœ“ Public reply added to ticket #${ticket.id}`);
133
+ const client = await getClient(options);
134
+ const ticketId = parseInt(id, 10);
135
+ if (isProxyClient(client)) {
136
+ await client.replyToTicket(ticketId, options.message, true);
137
+ }
138
+ else {
139
+ await client.addComment(ticketId, options.message, true);
140
+ }
141
+ console.log(`āœ“ Public reply added to ticket #${id}`);
103
142
  }
104
143
  catch (error) {
105
144
  console.error("Error:", error.message);
@@ -296,9 +335,10 @@ ticketsCommand
296
335
  .option("-e, --email <email>", "Requester email")
297
336
  .option("-p, --priority <priority>", "Priority (low, normal, high, urgent)")
298
337
  .option("--json", "Output as JSON")
338
+ .option("--no-proxy", "Skip API proxy, use direct API")
299
339
  .action(async (options) => {
300
340
  try {
301
- const client = ZendeskClient.fromConfig();
341
+ const client = await getClient(options);
302
342
  const ticketData = {
303
343
  subject: options.subject,
304
344
  comment: { body: options.message },
@@ -309,7 +349,13 @@ ticketsCommand
309
349
  if (options.priority) {
310
350
  ticketData.priority = options.priority;
311
351
  }
312
- const ticket = await client.createTicket(ticketData);
352
+ let ticket;
353
+ if (isProxyClient(client)) {
354
+ ticket = await client.createTicket(ticketData);
355
+ }
356
+ else {
357
+ ticket = await client.createTicket(ticketData);
358
+ }
313
359
  if (options.json) {
314
360
  console.log(JSON.stringify(ticket, null, 2));
315
361
  return;
@@ -328,9 +374,10 @@ ticketsCommand
328
374
  .command("search <query>")
329
375
  .description("Search tickets (Zendesk search syntax)")
330
376
  .option("--json", "Output as JSON")
377
+ .option("--no-proxy", "Skip API proxy, use direct API")
331
378
  .action(async (query, options) => {
332
379
  try {
333
- const client = ZendeskClient.fromConfig();
380
+ const client = await getClient(options);
334
381
  const tickets = await client.searchTickets(query);
335
382
  if (options.json) {
336
383
  console.log(JSON.stringify(tickets, null, 2));
@@ -0,0 +1,2 @@
1
+ import { Command } from "commander";
2
+ export declare const supervisorCommand: Command;
@@ -0,0 +1,528 @@
1
+ import { Command } from "commander";
2
+ import { getConfig } from "./config.js";
3
+ import { spawn, execSync } from "child_process";
4
+ import { promisify } from "util";
5
+ import { exec } from "child_process";
6
+ const execAsync = promisify(exec);
7
+ export const supervisorCommand = new Command("supervisor")
8
+ .description("Manage Husky Supervisor Agent");
9
+ // supervisor start
10
+ supervisorCommand
11
+ .command("start")
12
+ .description("Start the supervisor agent")
13
+ .option("--session <name>", "Tmux session name", "supervisor")
14
+ .option("--id <id>", "Agent ID", "supervisor")
15
+ .option("--name <name>", "Agent display name", "Husky Supervisor")
16
+ .option("--vm <name>", "VM name (auto-detected if not provided)")
17
+ .action(async (options) => {
18
+ const config = getConfig();
19
+ if (!config.apiUrl) {
20
+ console.error("Error: API URL not configured. Run: husky config set api-url <url>");
21
+ process.exit(1);
22
+ }
23
+ console.log("šŸ• Starting Husky Supervisor Agent...");
24
+ // Set environment variables
25
+ const env = {
26
+ ...process.env,
27
+ HUSKY_API_URL: config.apiUrl,
28
+ HUSKY_API_KEY: config.apiKey,
29
+ HUSKY_AGENT_ID: options.id,
30
+ HUSKY_SUPERVISOR_SESSION: options.session,
31
+ };
32
+ // Get the supervisor script path
33
+ const scriptPath = new URL("../scripts/supervisor-start.sh", import.meta.url).pathname;
34
+ try {
35
+ // Run the supervisor script
36
+ const child = spawn(scriptPath, [], {
37
+ env,
38
+ stdio: "inherit",
39
+ });
40
+ child.on("close", (code) => {
41
+ if (code !== 0) {
42
+ console.error(`Supervisor script exited with code ${code}`);
43
+ process.exit(code);
44
+ }
45
+ });
46
+ }
47
+ catch (error) {
48
+ console.error("Error starting supervisor:", error);
49
+ process.exit(1);
50
+ }
51
+ });
52
+ // supervisor status
53
+ supervisorCommand
54
+ .command("status")
55
+ .description("Check supervisor status")
56
+ .option("--session <name>", "Tmux session name", "supervisor")
57
+ .action(async (options) => {
58
+ try {
59
+ const { stdout } = await execAsync(`tmux has-session -t "${options.session}"`);
60
+ console.log("āœ… Supervisor is running");
61
+ console.log(`Session: ${options.session}`);
62
+ // List windows
63
+ const { stdout: windows } = await execAsync(`tmux list-windows -t "${options.session}"`);
64
+ console.log("\nActive windows:");
65
+ console.log(windows);
66
+ }
67
+ catch (error) {
68
+ console.log("āŒ Supervisor is not running");
69
+ process.exit(1);
70
+ }
71
+ });
72
+ // supervisor stop
73
+ supervisorCommand
74
+ .command("stop")
75
+ .description("Stop the supervisor agent")
76
+ .option("--session <name>", "Tmux session name", "supervisor")
77
+ .action(async (options) => {
78
+ try {
79
+ await execAsync(`tmux kill-session -t "${options.session}"`);
80
+ console.log("āœ… Supervisor stopped successfully");
81
+ }
82
+ catch (error) {
83
+ console.log("āŒ Supervisor is not running or failed to stop");
84
+ process.exit(1);
85
+ }
86
+ });
87
+ // supervisor restart
88
+ supervisorCommand
89
+ .command("restart")
90
+ .description("Restart the supervisor agent")
91
+ .option("--session <name>", "Tmux session name", "supervisor")
92
+ .action(async (options) => {
93
+ try {
94
+ console.log("šŸ”„ Restarting supervisor...");
95
+ await execAsync(`tmux kill-session -t "${options.session}"`);
96
+ console.log("āœ… Old supervisor stopped");
97
+ // Start new supervisor
98
+ const scriptPath = new URL("../scripts/supervisor-start.sh", import.meta.url).pathname;
99
+ const config = getConfig();
100
+ const child = spawn(scriptPath, [], {
101
+ env: {
102
+ ...process.env,
103
+ HUSKY_API_URL: config.apiUrl,
104
+ HUSKY_API_KEY: config.apiKey,
105
+ },
106
+ stdio: "inherit",
107
+ });
108
+ child.on("close", (code) => {
109
+ if (code !== 0) {
110
+ console.error(`Supervisor restart failed with code ${code}`);
111
+ process.exit(code);
112
+ }
113
+ });
114
+ }
115
+ catch (error) {
116
+ console.error("Error restarting supervisor:", error);
117
+ process.exit(1);
118
+ }
119
+ });
120
+ // supervisor attach
121
+ supervisorCommand
122
+ .command("attach")
123
+ .description("Attach to the supervisor tmux session")
124
+ .option("--session <name>", "Tmux session name", "supervisor")
125
+ .option("--window <name>", "Window name or number (default: supervisor)", "supervisor")
126
+ .action(async (options) => {
127
+ try {
128
+ const { stdout } = await execAsync(`tmux has-session -t "${options.session}"`);
129
+ // Attach to specific window
130
+ spawn("tmux", ["attach", "-t", `${options.session}:${options.window}`], {
131
+ stdio: "inherit",
132
+ });
133
+ }
134
+ catch (error) {
135
+ console.error("āŒ Supervisor is not running");
136
+ console.log("Start it with: husky supervisor start");
137
+ process.exit(1);
138
+ }
139
+ });
140
+ // supervisor logs
141
+ supervisorCommand
142
+ .command("logs")
143
+ .description("Show logs from supervisor windows")
144
+ .option("--session <name>", "Tmux session name", "supervisor")
145
+ .option("--window <name>", "Window name (supervisor, messages, tasks, vms, heartbeat)")
146
+ .option("--lines <n>", "Number of lines to show", "50")
147
+ .action(async (options) => {
148
+ try {
149
+ await execAsync(`tmux has-session -t "${options.session}"`);
150
+ const window = options.window || "supervisor";
151
+ const lines = options.lines;
152
+ console.log(`šŸ“‹ Showing last ${lines} lines from '${window}' window:\n`);
153
+ // Capture output from tmux pane
154
+ const { stdout } = await execAsync(`tmux capture-pane -t "${options.session}:${window}" -p | tail -n ${lines}`);
155
+ console.log(stdout);
156
+ }
157
+ catch (error) {
158
+ console.error("āŒ Failed to get logs");
159
+ if (error instanceof Error && error.message.includes("session")) {
160
+ console.error("Supervisor is not running");
161
+ }
162
+ process.exit(1);
163
+ }
164
+ });
165
+ // supervisor message
166
+ supervisorCommand
167
+ .command("message")
168
+ .description("Send a message to the supervisor interface")
169
+ .argument("<message>", "Message to send")
170
+ .option("--session <name>", "Tmux session name", "supervisor")
171
+ .action(async (message, options) => {
172
+ try {
173
+ await execAsync(`tmux has-session -t "${options.session}"`);
174
+ // Send message to supervisor window
175
+ const escapedMessage = message
176
+ .replace(/\\/g, "\\\\")
177
+ .replace(/"/g, '\\"')
178
+ .replace(/\$/g, "\\$");
179
+ await execAsync(`tmux send-keys -t "${options.session}:supervisor" "${escapedMessage}" Enter`);
180
+ console.log("āœ… Message sent to supervisor");
181
+ }
182
+ catch (error) {
183
+ console.error("āŒ Failed to send message");
184
+ if (error instanceof Error && error.message.includes("session")) {
185
+ console.error("Supervisor is not running");
186
+ }
187
+ process.exit(1);
188
+ }
189
+ });
190
+ // supervisor execute
191
+ supervisorCommand
192
+ .command("execute")
193
+ .description("Execute a command in the supervisor interface")
194
+ .argument("<command>", "Command to execute")
195
+ .option("--session <name>", "Tmux session name", "supervisor")
196
+ .option("--window <name>", "Window to execute in", "supervisor")
197
+ .action(async (command, options) => {
198
+ try {
199
+ await execAsync(`tmux has-session -t "${options.session}"`);
200
+ // Send command to specified window
201
+ const escapedCommand = command
202
+ .replace(/\\/g, "\\\\")
203
+ .replace(/"/g, '\\"')
204
+ .replace(/\$/g, "\\$");
205
+ await execAsync(`tmux send-keys -t "${options.session}:${options.window}" "${escapedCommand}" Enter`);
206
+ console.log(`āœ… Command sent to ${options.window} window`);
207
+ }
208
+ catch (error) {
209
+ console.error("āŒ Failed to execute command");
210
+ if (error instanceof Error && error.message.includes("session")) {
211
+ console.error("Supervisor is not running");
212
+ }
213
+ process.exit(1);
214
+ }
215
+ });
216
+ // supervisor workflow
217
+ supervisorCommand
218
+ .command("workflow")
219
+ .description("Show supervisor workflow status and next actions")
220
+ .option("--json", "Output as JSON")
221
+ .action(async (options) => {
222
+ const config = getConfig();
223
+ if (!config.apiUrl) {
224
+ console.error("Error: API URL not configured. Run: husky config set api-url <url>");
225
+ process.exit(1);
226
+ }
227
+ try {
228
+ // Get task backlog
229
+ const tasksRes = await fetch(`${config.apiUrl}/api/tasks?status=backlog&assignee=llm&limit=10`, {
230
+ headers: config.apiKey ? { "x-api-key": config.apiKey } : {},
231
+ });
232
+ // Get VM status
233
+ const vmCommand = "gcloud compute instances list --filter='name~husky-' --format='json(name,status,zone,machineType)'";
234
+ const { stdout: vmOutput } = await execAsync(vmCommand);
235
+ const vms = JSON.parse(vmOutput || "[]");
236
+ // Get registered agents
237
+ const agentsRes = await fetch(`${config.apiUrl}/api/agents`, {
238
+ headers: config.apiKey ? { "x-api-key": config.apiKey } : {},
239
+ });
240
+ const tasks = tasksRes.ok ? await tasksRes.json() : { tasks: [] };
241
+ const agents = agentsRes.ok ? await agentsRes.json() : { agents: [] };
242
+ if (options.json) {
243
+ console.log(JSON.stringify({
244
+ tasks: tasks.tasks || [],
245
+ vms,
246
+ agents: agents.agents || [],
247
+ timestamp: new Date().toISOString(),
248
+ }, null, 2));
249
+ return;
250
+ }
251
+ console.log("\nšŸ• Husky Supervisor Workflow Status");
252
+ console.log("=".repeat(50));
253
+ // Task status
254
+ console.log("\nšŸ“‹ Task Backlog:");
255
+ const backlogTasks = tasks.tasks || [];
256
+ if (backlogTasks.length === 0) {
257
+ console.log(" No tasks in backlog");
258
+ }
259
+ else {
260
+ backlogTasks.forEach((task, i) => {
261
+ console.log(` ${i + 1}. ${task.title} (${task.id})`);
262
+ console.log(` Priority: ${task.priority} | Assignee: ${task.assignee}`);
263
+ });
264
+ }
265
+ // VM status
266
+ console.log("\nšŸ–„ļø VM Fleet Status:");
267
+ const runningWorkers = vms.filter((vm) => vm.name.includes("worker") && vm.status === "RUNNING");
268
+ const suspendedWorkers = vms.filter((vm) => vm.name.includes("worker") && vm.status === "SUSPENDED");
269
+ const terminatedWorkers = vms.filter((vm) => vm.name.includes("worker") && vm.status === "TERMINATED");
270
+ console.log(` Running workers: ${runningWorkers.length}`);
271
+ console.log(` Suspended workers: ${suspendedWorkers.length}`);
272
+ console.log(` Terminated workers: ${terminatedWorkers.length}`);
273
+ if (vms.find((vm) => vm.name.includes("supervisor"))) {
274
+ const supervisorVm = vms.find((vm) => vm.name.includes("supervisor"));
275
+ console.log(` Supervisor VM: ${supervisorVm.status}`);
276
+ }
277
+ // Agent status
278
+ console.log("\nšŸ¤– Registered Agents:");
279
+ const agentList = agents.agents || [];
280
+ const onlineAgents = agentList.filter((agent) => agent.status === "online");
281
+ const busyAgents = agentList.filter((agent) => agent.status === "busy");
282
+ const offlineAgents = agentList.filter((agent) => agent.status === "offline");
283
+ console.log(` Online: ${onlineAgents.length}, Busy: ${busyAgents.length}, Offline: ${offlineAgents.length}`);
284
+ // Next actions
285
+ console.log("\nšŸŽÆ Suggested Actions:");
286
+ if (backlogTasks.length > 0 && runningWorkers.length === 0) {
287
+ console.log(" • Start worker VM(s) to handle task backlog");
288
+ }
289
+ if (terminatedWorkers.length > 0 && backlogTasks.length > 0) {
290
+ console.log(" • Resume terminated worker VMs");
291
+ }
292
+ if (backlogTasks.length > 0) {
293
+ console.log(" • Assign tasks to available workers");
294
+ }
295
+ if (offlineAgents.length > 0) {
296
+ console.log(" • Check on offline agents");
297
+ }
298
+ if (backlogTasks.length === 0 && runningWorkers.length > 0) {
299
+ console.log(" • Suspend idle worker VMs to save costs");
300
+ }
301
+ console.log("\nQuick commands:");
302
+ console.log(" husky supervisor start - Start supervisor");
303
+ console.log(" husky supervisor attach - Attach to supervisor");
304
+ console.log(" husk supervisor status - Check status");
305
+ console.log("");
306
+ }
307
+ catch (error) {
308
+ console.error("Error getting workflow status:", error);
309
+ process.exit(1);
310
+ }
311
+ });
312
+ // supervisor cycle
313
+ supervisorCommand
314
+ .command("cycle")
315
+ .description("Run the main supervisor loop (for automation)")
316
+ .option("--interval <seconds>", "Loop interval in seconds", "300") // 5 minutes default
317
+ .option("--once", "Run once and exit")
318
+ .action(async (options) => {
319
+ const config = getConfig();
320
+ if (!config.apiUrl) {
321
+ console.error("Error: API URL not configured. Run: husky config set api-url <url>");
322
+ process.exit(1);
323
+ }
324
+ const interval = parseInt(options.interval) * 1000;
325
+ console.log(`šŸ• Starting supervisor cycle${options.once ? ' (once)' : ` (every ${options.interval}s)`}...`);
326
+ const runCycle = async () => {
327
+ try {
328
+ console.log(`\n[${new Date().toLocaleTimeString()}] Running supervisor cycle...`);
329
+ // Step 1: Check for new tasks
330
+ const tasksRes = await fetch(`${config.apiUrl}/api/tasks?status=backlog&assignee=llm&limit=5`, {
331
+ headers: config.apiKey ? { "x-api-key": config.apiKey } : {},
332
+ });
333
+ if (tasksRes.ok) {
334
+ const tasksData = await tasksRes.json();
335
+ const tasks = tasksData.tasks || [];
336
+ if (tasks.length > 0) {
337
+ console.log(` Found ${tasks.length} tasks in backlog`);
338
+ // Step 2: Check available worker VMs
339
+ const { stdout: vmOutput } = await execAsync("gcloud compute instances list --filter='name~husky-worker' --format='json(name,status,zone)'");
340
+ const vms = JSON.parse(vmOutput || "[]");
341
+ const runningWorkers = vms.filter((vm) => vm.name.includes("worker") && vm.status === "RUNNING");
342
+ if (runningWorkers.length === 0) {
343
+ console.log(" No worker VMs running, starting one...");
344
+ // This would need to be implemented based on your GCP setup
345
+ console.log(" TODO: Implement VM startup logic");
346
+ }
347
+ // Step 3: Assign tasks to workers
348
+ tasks.forEach((task) => {
349
+ console.log(` Task ${task.id}: ${task.title}`);
350
+ });
351
+ }
352
+ else {
353
+ console.log(" No tasks in backlog");
354
+ }
355
+ }
356
+ console.log(`[${new Date().toLocaleTimeString()}] Cycle completed`);
357
+ }
358
+ catch (error) {
359
+ console.error(`[${new Date().toLocaleTimeString()}] Cycle error:`, error);
360
+ }
361
+ };
362
+ if (options.once) {
363
+ await runCycle();
364
+ }
365
+ else {
366
+ await runCycle();
367
+ setInterval(runCycle, interval);
368
+ console.log(`\nSupervisor cycle running. Press Ctrl+C to stop.`);
369
+ // Keep process alive
370
+ process.on("SIGINT", () => {
371
+ console.log("\nšŸ‘‹ Stopping supervisor cycle...");
372
+ process.exit(0);
373
+ });
374
+ await new Promise(() => { });
375
+ }
376
+ });
377
+ function checkGitHubPR(branch) {
378
+ try {
379
+ const result = execSync(`gh pr view "${branch}" --json url,mergedAt,state -q '{"url":.url,"mergedAt":.mergedAt,"state":.state}'`, { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] });
380
+ const data = JSON.parse(result.trim());
381
+ return {
382
+ exists: true,
383
+ merged: data.mergedAt !== null && data.mergedAt !== "",
384
+ url: data.url,
385
+ state: data.state,
386
+ };
387
+ }
388
+ catch {
389
+ return { exists: false, merged: false };
390
+ }
391
+ }
392
+ supervisorCommand
393
+ .command("auto-track")
394
+ .description("Automatic task status tracking based on GitHub PR state")
395
+ .option("--interval <minutes>", "Check interval in minutes", "5")
396
+ .option("--dry-run", "Show what would be updated without making changes")
397
+ .option("--once", "Run once and exit")
398
+ .option("--stale-hours <hours>", "Hours before task is considered stale", "24")
399
+ .option("--alert", "Send alerts for stale tasks to Google Chat")
400
+ .action(async (options) => {
401
+ const config = getConfig();
402
+ if (!config.apiUrl) {
403
+ console.error("Error: API URL not configured. Run: husky config set api-url <url>");
404
+ process.exit(1);
405
+ }
406
+ const intervalMs = parseInt(options.interval) * 60 * 1000;
407
+ const staleMs = parseInt(options.staleHours) * 60 * 60 * 1000;
408
+ const headers = {};
409
+ if (config.apiKey)
410
+ headers["x-api-key"] = config.apiKey;
411
+ console.log(`šŸ”„ Starting auto-tracking loop (interval: ${options.interval}m, stale: ${options.staleHours}h)`);
412
+ if (options.dryRun)
413
+ console.log(" DRY RUN MODE - no changes will be made\n");
414
+ async function checkAndUpdate() {
415
+ const timestamp = new Date().toLocaleTimeString();
416
+ console.log(`\n[${timestamp}] Checking task statuses...`);
417
+ try {
418
+ const res = await fetch(`${config.apiUrl}/api/tasks?status=in_progress,review&limit=50`, { headers });
419
+ if (!res.ok) {
420
+ console.error(` āŒ Failed to fetch tasks: ${res.status}`);
421
+ return;
422
+ }
423
+ const data = await res.json();
424
+ const tasks = data.tasks || [];
425
+ if (tasks.length === 0) {
426
+ console.log(" No in_progress or review tasks found");
427
+ return;
428
+ }
429
+ console.log(` Found ${tasks.length} active tasks`);
430
+ const staleTasks = [];
431
+ const now = Date.now();
432
+ for (const task of tasks) {
433
+ const taskAge = now - new Date(task.updatedAt).getTime();
434
+ const isStale = taskAge > staleMs;
435
+ if (isStale) {
436
+ staleTasks.push(task);
437
+ }
438
+ if (!task.worktreeBranch) {
439
+ console.log(` šŸ“Œ ${task.id}: No branch linked (${task.status})${isStale ? " āš ļø STALE" : ""}`);
440
+ continue;
441
+ }
442
+ const prStatus = checkGitHubPR(task.worktreeBranch);
443
+ if (prStatus.merged && task.status !== "done") {
444
+ console.log(` āœ… ${task.id}: PR merged → marking done`);
445
+ if (!options.dryRun) {
446
+ const updateRes = await fetch(`${config.apiUrl}/api/tasks/${task.id}`, {
447
+ method: "PATCH",
448
+ headers: { ...headers, "Content-Type": "application/json" },
449
+ body: JSON.stringify({
450
+ status: "done",
451
+ statusMessage: "PR merged - auto-tracked by supervisor",
452
+ }),
453
+ });
454
+ if (!updateRes.ok) {
455
+ console.error(` āŒ Failed to update task: ${updateRes.status}`);
456
+ }
457
+ }
458
+ }
459
+ else if (prStatus.exists && task.status === "in_progress") {
460
+ console.log(` šŸ“ ${task.id}: PR exists (${prStatus.state}) → marking review`);
461
+ if (!options.dryRun) {
462
+ const updateRes = await fetch(`${config.apiUrl}/api/tasks/${task.id}`, {
463
+ method: "PATCH",
464
+ headers: { ...headers, "Content-Type": "application/json" },
465
+ body: JSON.stringify({
466
+ status: "review",
467
+ prUrl: prStatus.url,
468
+ statusMessage: "PR opened - auto-tracked by supervisor",
469
+ }),
470
+ });
471
+ if (!updateRes.ok) {
472
+ console.error(` āŒ Failed to update task: ${updateRes.status}`);
473
+ }
474
+ }
475
+ }
476
+ else if (!prStatus.exists && task.status === "in_progress") {
477
+ console.log(` šŸ”Ø ${task.id}: In progress, no PR yet${isStale ? " āš ļø STALE" : ""}`);
478
+ }
479
+ else {
480
+ console.log(` ā„¹ļø ${task.id}: ${task.status} (PR: ${prStatus.exists ? prStatus.state : "none"})${isStale ? " āš ļø STALE" : ""}`);
481
+ }
482
+ }
483
+ if (staleTasks.length > 0) {
484
+ console.log(`\n āš ļø Found ${staleTasks.length} stale tasks (>${options.staleHours}h inactive):`);
485
+ staleTasks.forEach((t) => {
486
+ const hours = Math.round((now - new Date(t.updatedAt).getTime()) / (1000 * 60 * 60));
487
+ console.log(` - ${t.title} (${t.id}) - ${hours}h inactive`);
488
+ });
489
+ if (options.alert && !options.dryRun) {
490
+ const alertMessage = `āš ļø **Stale Task Alert**\n\n${staleTasks.length} tasks inactive for >${options.staleHours}h:\n\n${staleTasks.map((t) => `- ${t.title} (${t.id})`).join("\n")}\n\nPlease review and update status.`;
491
+ try {
492
+ const chatRes = await fetch(`${config.apiUrl}/api/google-chat/send`, {
493
+ method: "POST",
494
+ headers: { ...headers, "Content-Type": "application/json" },
495
+ body: JSON.stringify({
496
+ text: alertMessage,
497
+ spaceName: process.env.GOOGLE_CHAT_DM_SPACE,
498
+ }),
499
+ });
500
+ if (chatRes.ok) {
501
+ console.log(" šŸ“£ Stale task alert sent to Google Chat");
502
+ }
503
+ }
504
+ catch (error) {
505
+ console.error(" āŒ Failed to send alert:", error);
506
+ }
507
+ }
508
+ }
509
+ console.log(`[${new Date().toLocaleTimeString()}] Check completed`);
510
+ }
511
+ catch (error) {
512
+ console.error(`[${timestamp}] Error:`, error);
513
+ }
514
+ }
515
+ if (options.once) {
516
+ await checkAndUpdate();
517
+ }
518
+ else {
519
+ await checkAndUpdate();
520
+ setInterval(checkAndUpdate, intervalMs);
521
+ console.log(`\nAuto-tracking running. Press Ctrl+C to stop.`);
522
+ process.on("SIGINT", () => {
523
+ console.log("\nšŸ‘‹ Stopping auto-tracking...");
524
+ process.exit(0);
525
+ });
526
+ await new Promise(() => { });
527
+ }
528
+ });
@@ -154,7 +154,8 @@ taskCommand
154
154
  // Note: We don't pass projectId to API to avoid Firestore index requirement
155
155
  // Instead, we filter client-side which is fine for reasonable task counts
156
156
  const api = getApiClient();
157
- let tasks = await api.get(url.pathname + url.search);
157
+ const response = await api.get(url.pathname + url.search);
158
+ let tasks = response.tasks;
158
159
  // Client-side filtering by projectId (avoids Firestore composite index)
159
160
  if (filterProjectId) {
160
161
  tasks = tasks.filter(t => t.projectId === filterProjectId);
@@ -2,24 +2,47 @@ import { Command } from "commander";
2
2
  import { GoogleGenerativeAI } from "@google/generative-ai";
3
3
  import { getConfig } from "./config.js";
4
4
  import { z } from "zod";
5
+ import { apiRequest } from "../lib/api-client.js";
5
6
  // Input validation schemas
6
7
  const YouTubeUrlSchema = z.string().min(1, "YouTube URL cannot be empty");
7
8
  const YouTubeOptionsSchema = z.object({
8
9
  json: z.boolean().optional(),
9
- prompt: z.string().max(10000, "Custom prompt too long (max 10000 characters)").optional()
10
+ prompt: z.string().max(10000, "Custom prompt too long (max 10000 characters)").optional(),
11
+ language: z.enum(["en", "de"]).optional(),
12
+ useProxy: z.boolean().optional(),
10
13
  });
11
14
  export const youtubeCommand = new Command("youtube")
12
15
  .description("YouTube video summarization using Gemini AI")
13
16
  .argument("<url>", "YouTube video URL")
14
17
  .option("--json", "Output as JSON")
15
18
  .option("--prompt <prompt>", "Custom summarization prompt")
19
+ .option("--language <lang>", "Summary language (en, de)", "de")
20
+ .option("--no-proxy", "Skip API proxy, use direct Gemini API")
16
21
  .action(async (url, options) => {
17
22
  try {
18
23
  // Validate URL
19
24
  const validatedUrl = YouTubeUrlSchema.parse(url);
20
25
  // Validate options
21
26
  const validatedOptions = YouTubeOptionsSchema.parse(options);
22
- await summarizeVideo(validatedUrl, validatedOptions);
27
+ // Try proxy first (if not disabled)
28
+ if (validatedOptions.useProxy !== false) {
29
+ try {
30
+ await summarizeViaProxy(validatedUrl, validatedOptions);
31
+ return;
32
+ }
33
+ catch (error) {
34
+ const err = error;
35
+ // Only fall back on auth/access errors, not on video errors
36
+ if (!err.message.includes("403") && !err.message.includes("401") && !err.message.includes("Forbidden")) {
37
+ throw error;
38
+ }
39
+ if (!validatedOptions.json) {
40
+ console.log("āš ļø Proxy unavailable, falling back to direct API...\n");
41
+ }
42
+ }
43
+ }
44
+ // Fall back to direct API
45
+ await summarizeVideoDirect(validatedUrl, validatedOptions);
23
46
  }
24
47
  catch (error) {
25
48
  if (error instanceof z.ZodError) {
@@ -58,13 +81,50 @@ function extractVideoId(url) {
58
81
  throw new Error(`Invalid YouTube URL: ${url}`);
59
82
  }
60
83
  /**
61
- * Summarize YouTube video using Gemini (directly with URL)
84
+ * Summarize via API proxy (uses server-side credentials)
62
85
  */
63
- async function summarizeVideo(url, options) {
86
+ async function summarizeViaProxy(url, options) {
64
87
  const videoId = extractVideoId(url);
65
88
  const fullUrl = `https://www.youtube.com/watch?v=${videoId}`;
66
- console.log(`šŸ“¹ Video: ${videoId}`);
67
- console.log(`šŸ”— URL: ${fullUrl}\n`);
89
+ if (!options.json) {
90
+ console.log(`šŸ“¹ Video: ${videoId}`);
91
+ console.log(`šŸ”— URL: ${fullUrl}`);
92
+ console.log(`🌐 Using API proxy...\n`);
93
+ }
94
+ const result = await apiRequest("/api/proxy/youtube/summarize", {
95
+ method: "POST",
96
+ body: {
97
+ url: fullUrl,
98
+ prompt: options.prompt,
99
+ language: options.language || "de",
100
+ },
101
+ });
102
+ if (options.json) {
103
+ console.log(JSON.stringify({
104
+ videoId: result.videoId,
105
+ url: result.url,
106
+ summary: result.summary,
107
+ language: result.language,
108
+ }, null, 2));
109
+ }
110
+ else {
111
+ console.log('šŸ“ Zusammenfassung:');
112
+ console.log('='.repeat(70));
113
+ console.log(result.summary);
114
+ console.log('='.repeat(70));
115
+ console.log(`\nāœ“ URL: ${result.url}`);
116
+ }
117
+ }
118
+ /**
119
+ * Summarize YouTube video using direct Gemini API (fallback)
120
+ */
121
+ async function summarizeVideoDirect(url, options) {
122
+ const videoId = extractVideoId(url);
123
+ const fullUrl = `https://www.youtube.com/watch?v=${videoId}`;
124
+ if (!options.json) {
125
+ console.log(`šŸ“¹ Video: ${videoId}`);
126
+ console.log(`šŸ”— URL: ${fullUrl}\n`);
127
+ }
68
128
  // Get Gemini API key
69
129
  // Priority: Standard Gemini API (has 3.0) > Vertex AI (has 2.5 Pro) > Config
70
130
  const config = getConfig();
@@ -92,12 +152,29 @@ async function summarizeVideo(url, options) {
92
152
  // Use Gemini 3.0 for standard API, 2.5 Pro for Vertex AI
93
153
  const modelName = isVertex ? "gemini-2.5-pro" : "gemini-3.0-flash";
94
154
  const modelLabel = isVertex ? "Gemini 2.5 Pro (Vertex AI)" : "Gemini 3.0 Flash";
95
- console.log(`šŸ¤– Analyzing with ${modelLabel}...\n`);
155
+ if (!options.json) {
156
+ console.log(`šŸ¤– Analyzing with ${modelLabel}...\n`);
157
+ }
96
158
  // Initialize Gemini
97
159
  const genAI = new GoogleGenerativeAI(geminiApiKey);
98
160
  const model = genAI.getGenerativeModel({ model: modelName });
99
161
  // Custom prompt or default
100
- const prompt = options.prompt || `
162
+ const prompt = options.prompt || (options.language === "en" ? `
163
+ Create a structured summary of this YouTube video.
164
+
165
+ Format:
166
+ ## Main Topics
167
+ [3-5 key points]
168
+
169
+ ## Key Insights
170
+ [Most important learnings]
171
+
172
+ ## Summary
173
+ [1-2 paragraph overview]
174
+
175
+ ## Keywords
176
+ [Relevant keywords, comma-separated]
177
+ `.trim() : `
101
178
  Erstelle eine strukturierte Zusammenfassung dieses YouTube Videos.
102
179
 
103
180
  Format:
@@ -112,7 +189,7 @@ Format:
112
189
 
113
190
  ## Keywords
114
191
  [Relevante Keywords kommagetrennt]
115
- `.trim();
192
+ `.trim());
116
193
  // Gemini can directly analyze YouTube URLs
117
194
  let summary;
118
195
  try {
package/dist/index.js CHANGED
@@ -38,6 +38,8 @@ import { authCommand } from "./commands/auth.js";
38
38
  import { businessCommand } from "./commands/business.js";
39
39
  import { planCommand } from "./commands/plan.js";
40
40
  import { diagramsCommand } from "./commands/diagrams.js";
41
+ import { supervisorCommand } from "./commands/supervisor.js";
42
+ import { checkVersion } from "./lib/version-check.js";
41
43
  // Read version from package.json
42
44
  const require = createRequire(import.meta.url);
43
45
  const packageJson = require("../package.json");
@@ -83,16 +85,23 @@ program.addCommand(authCommand);
83
85
  program.addCommand(businessCommand);
84
86
  program.addCommand(planCommand);
85
87
  program.addCommand(diagramsCommand);
88
+ program.addCommand(supervisorCommand);
86
89
  // Handle --llm flag specially
87
90
  if (process.argv.includes("--llm")) {
88
91
  printLLMContext();
89
92
  process.exit(0);
90
93
  }
91
- // Check if no command was provided - run interactive mode
92
- if (process.argv.length <= 2) {
93
- runInteractiveMode();
94
- }
95
- else {
96
- program.parse();
94
+ const skipVersionCheck = ["--version", "-V", "--help", "-h", "completion"].some((flag) => process.argv.includes(flag));
95
+ async function main() {
96
+ if (!skipVersionCheck) {
97
+ await checkVersion({ silent: process.env.HUSKY_SKIP_VERSION_CHECK === "1" });
98
+ }
99
+ if (process.argv.length <= 2) {
100
+ runInteractiveMode();
101
+ }
102
+ else {
103
+ program.parse();
104
+ }
97
105
  }
106
+ main();
98
107
  // trigger CI
@@ -3,6 +3,7 @@
3
3
  */
4
4
  export { BillbeeClient } from './billbee.js';
5
5
  export { ZendeskClient } from './zendesk.js';
6
+ export { ZendeskProxyClient, getZendeskProxyClient, tryZendeskProxy } from './zendesk-proxy.js';
6
7
  export { SeaTableClient } from './seatable.js';
7
8
  export { QdrantClient } from './qdrant.js';
8
9
  export { EmbeddingService, EMBEDDING_MODELS } from './embeddings.js';
@@ -3,6 +3,7 @@
3
3
  */
4
4
  export { BillbeeClient } from './billbee.js';
5
5
  export { ZendeskClient } from './zendesk.js';
6
+ export { ZendeskProxyClient, getZendeskProxyClient, tryZendeskProxy } from './zendesk-proxy.js';
6
7
  export { SeaTableClient } from './seatable.js';
7
8
  export { QdrantClient } from './qdrant.js';
8
9
  export { EmbeddingService, EMBEDDING_MODELS } from './embeddings.js';
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Zendesk Proxy Client
3
+ *
4
+ * Wraps API proxy calls for Zendesk operations.
5
+ * This client uses server-side credentials via the Husky API proxy.
6
+ */
7
+ import type { ZendeskTicket, ZendeskUser, TicketComment, ZendeskMacro } from './zendesk-types.js';
8
+ export declare class ZendeskProxyClient {
9
+ private available;
10
+ /**
11
+ * Check if proxy is available (has permission)
12
+ */
13
+ isAvailable(): Promise<boolean>;
14
+ listTickets(params?: {
15
+ status?: string;
16
+ per_page?: number;
17
+ page?: number;
18
+ }): Promise<ZendeskTicket[]>;
19
+ getTicket(ticketId: number): Promise<{
20
+ ticket: ZendeskTicket;
21
+ comments: TicketComment[];
22
+ }>;
23
+ createTicket(data: {
24
+ subject: string;
25
+ comment: {
26
+ body: string;
27
+ };
28
+ requester?: {
29
+ email: string;
30
+ };
31
+ priority?: string;
32
+ tags?: string[];
33
+ }): Promise<ZendeskTicket>;
34
+ updateTicket(ticketId: number, data: {
35
+ status?: string;
36
+ priority?: string;
37
+ assignee_id?: number;
38
+ tags?: string[];
39
+ custom_fields?: Array<{
40
+ id: number;
41
+ value: unknown;
42
+ }>;
43
+ }): Promise<ZendeskTicket>;
44
+ replyToTicket(ticketId: number, body: string, isPublic?: boolean): Promise<ZendeskTicket>;
45
+ searchTickets(query: string): Promise<ZendeskTicket[]>;
46
+ listUsers(): Promise<ZendeskUser[]>;
47
+ listMacros(): Promise<ZendeskMacro[]>;
48
+ }
49
+ export declare function getZendeskProxyClient(): ZendeskProxyClient;
50
+ /**
51
+ * Try to use proxy, return null if unavailable.
52
+ * Useful for checking if proxy should be used.
53
+ */
54
+ export declare function tryZendeskProxy(): Promise<ZendeskProxyClient | null>;
55
+ export default ZendeskProxyClient;
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Zendesk Proxy Client
3
+ *
4
+ * Wraps API proxy calls for Zendesk operations.
5
+ * This client uses server-side credentials via the Husky API proxy.
6
+ */
7
+ import { apiRequest } from '../api-client.js';
8
+ export class ZendeskProxyClient {
9
+ available = null;
10
+ /**
11
+ * Check if proxy is available (has permission)
12
+ */
13
+ async isAvailable() {
14
+ if (this.available !== null) {
15
+ return this.available;
16
+ }
17
+ try {
18
+ await apiRequest('/api/proxy/zendesk/users', {
19
+ method: 'GET',
20
+ });
21
+ this.available = true;
22
+ return true;
23
+ }
24
+ catch (error) {
25
+ const err = error;
26
+ if (err.message.includes('403') || err.message.includes('401') || err.message.includes('Forbidden')) {
27
+ this.available = false;
28
+ return false;
29
+ }
30
+ // Network error - treat as unavailable
31
+ this.available = false;
32
+ return false;
33
+ }
34
+ }
35
+ // =========================================================================
36
+ // TICKETS
37
+ // =========================================================================
38
+ async listTickets(params) {
39
+ const query = new URLSearchParams();
40
+ if (params?.status)
41
+ query.set('status', params.status);
42
+ if (params?.per_page)
43
+ query.set('per_page', String(params.per_page));
44
+ if (params?.page)
45
+ query.set('page', String(params.page));
46
+ const queryString = query.toString();
47
+ const endpoint = `/api/proxy/zendesk/tickets${queryString ? `?${queryString}` : ''}`;
48
+ const response = await apiRequest(endpoint);
49
+ return response.tickets;
50
+ }
51
+ async getTicket(ticketId) {
52
+ const response = await apiRequest(`/api/proxy/zendesk/tickets/${ticketId}`);
53
+ return response;
54
+ }
55
+ async createTicket(data) {
56
+ const response = await apiRequest('/api/proxy/zendesk/tickets', {
57
+ method: 'POST',
58
+ body: data,
59
+ });
60
+ return response.ticket;
61
+ }
62
+ async updateTicket(ticketId, data) {
63
+ const response = await apiRequest(`/api/proxy/zendesk/tickets/${ticketId}`, {
64
+ method: 'PATCH',
65
+ body: data,
66
+ });
67
+ return response.ticket;
68
+ }
69
+ async replyToTicket(ticketId, body, isPublic = true) {
70
+ const response = await apiRequest(`/api/proxy/zendesk/tickets/${ticketId}/reply`, {
71
+ method: 'POST',
72
+ body: { body, public: isPublic },
73
+ });
74
+ return response.ticket;
75
+ }
76
+ async searchTickets(query) {
77
+ const params = new URLSearchParams({ query });
78
+ const response = await apiRequest(`/api/proxy/zendesk/search?${params}`);
79
+ return response.tickets;
80
+ }
81
+ // =========================================================================
82
+ // USERS
83
+ // =========================================================================
84
+ async listUsers() {
85
+ const response = await apiRequest('/api/proxy/zendesk/users');
86
+ return response.users;
87
+ }
88
+ // =========================================================================
89
+ // MACROS
90
+ // =========================================================================
91
+ async listMacros() {
92
+ const response = await apiRequest('/api/proxy/zendesk/macros');
93
+ return response.macros;
94
+ }
95
+ }
96
+ // Singleton instance
97
+ let proxyClient = null;
98
+ export function getZendeskProxyClient() {
99
+ if (!proxyClient) {
100
+ proxyClient = new ZendeskProxyClient();
101
+ }
102
+ return proxyClient;
103
+ }
104
+ /**
105
+ * Try to use proxy, return null if unavailable.
106
+ * Useful for checking if proxy should be used.
107
+ */
108
+ export async function tryZendeskProxy() {
109
+ const client = getZendeskProxyClient();
110
+ const available = await client.isAvailable();
111
+ return available ? client : null;
112
+ }
113
+ export default ZendeskProxyClient;
@@ -0,0 +1,4 @@
1
+ export declare function checkVersion(options?: {
2
+ silent?: boolean;
3
+ }): Promise<void>;
4
+ export declare function getCurrentVersion(): string;
@@ -0,0 +1,112 @@
1
+ import { createRequire } from "module";
2
+ import { getConfig } from "../commands/config.js";
3
+ import * as fs from "fs";
4
+ import * as path from "path";
5
+ import * as os from "os";
6
+ const require = createRequire(import.meta.url);
7
+ const packageJson = require("../../package.json");
8
+ const CACHE_FILE = path.join(os.homedir(), ".husky", "version-cache.json");
9
+ const CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour
10
+ function readCache() {
11
+ try {
12
+ if (!fs.existsSync(CACHE_FILE))
13
+ return null;
14
+ const data = JSON.parse(fs.readFileSync(CACHE_FILE, "utf-8"));
15
+ const checkedAt = new Date(data.checkedAt).getTime();
16
+ if (Date.now() - checkedAt > CACHE_TTL_MS)
17
+ return null;
18
+ return data;
19
+ }
20
+ catch {
21
+ return null;
22
+ }
23
+ }
24
+ function writeCache(cache) {
25
+ try {
26
+ const dir = path.dirname(CACHE_FILE);
27
+ if (!fs.existsSync(dir))
28
+ fs.mkdirSync(dir, { recursive: true });
29
+ fs.writeFileSync(CACHE_FILE, JSON.stringify(cache, null, 2));
30
+ }
31
+ catch {
32
+ // Ignore cache write errors
33
+ }
34
+ }
35
+ async function fetchLatestVersion() {
36
+ try {
37
+ const controller = new AbortController();
38
+ const timeout = setTimeout(() => controller.abort(), 3000);
39
+ const res = await fetch("https://registry.npmjs.org/@simonfestl/husky-cli/latest", { signal: controller.signal });
40
+ clearTimeout(timeout);
41
+ if (!res.ok)
42
+ return null;
43
+ const data = await res.json();
44
+ return data.version || null;
45
+ }
46
+ catch {
47
+ return null;
48
+ }
49
+ }
50
+ async function fetchMinApiVersion() {
51
+ try {
52
+ const config = getConfig();
53
+ if (!config.apiUrl)
54
+ return null;
55
+ const controller = new AbortController();
56
+ const timeout = setTimeout(() => controller.abort(), 3000);
57
+ const res = await fetch(`${config.apiUrl}/api/health`, {
58
+ signal: controller.signal,
59
+ });
60
+ clearTimeout(timeout);
61
+ const minVersion = res.headers.get("X-Min-CLI-Version");
62
+ return minVersion || null;
63
+ }
64
+ catch {
65
+ return null;
66
+ }
67
+ }
68
+ function compareVersions(a, b) {
69
+ const partsA = a.split(".").map(Number);
70
+ const partsB = b.split(".").map(Number);
71
+ for (let i = 0; i < Math.max(partsA.length, partsB.length); i++) {
72
+ const numA = partsA[i] || 0;
73
+ const numB = partsB[i] || 0;
74
+ if (numA < numB)
75
+ return -1;
76
+ if (numA > numB)
77
+ return 1;
78
+ }
79
+ return 0;
80
+ }
81
+ export async function checkVersion(options = {}) {
82
+ const currentVersion = packageJson.version;
83
+ let cache = readCache();
84
+ if (!cache) {
85
+ const [latestVersion, minApiVersion] = await Promise.all([
86
+ fetchLatestVersion(),
87
+ fetchMinApiVersion(),
88
+ ]);
89
+ if (latestVersion) {
90
+ cache = {
91
+ latestVersion,
92
+ checkedAt: new Date().toISOString(),
93
+ minApiVersion: minApiVersion || undefined,
94
+ };
95
+ writeCache(cache);
96
+ }
97
+ }
98
+ if (!cache)
99
+ return;
100
+ if (cache.minApiVersion && compareVersions(currentVersion, cache.minApiVersion) < 0) {
101
+ console.error(`\nā›” CLI version ${currentVersion} is below minimum required (${cache.minApiVersion})`);
102
+ console.error(` Run: sudo npm install -g @simonfestl/husky-cli@latest\n`);
103
+ process.exit(1);
104
+ }
105
+ if (!options.silent && compareVersions(currentVersion, cache.latestVersion) < 0) {
106
+ console.warn(`\nāš ļø CLI outdated: ${currentVersion} → ${cache.latestVersion}`);
107
+ console.warn(` Run: sudo npm install -g @simonfestl/husky-cli@latest\n`);
108
+ }
109
+ }
110
+ export function getCurrentVersion() {
111
+ return packageJson.version;
112
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@simonfestl/husky-cli",
3
- "version": "1.33.0",
3
+ "version": "1.34.0",
4
4
  "description": "CLI for Huskyv0 Task Orchestration with Claude Agent SDK",
5
5
  "type": "module",
6
6
  "bin": {