@simonfestl/husky-cli 0.7.2 → 0.8.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/config.d.ts +3 -1
- package/dist/commands/config.js +1 -1
- package/dist/commands/task.js +174 -7
- package/dist/commands/worker.d.ts +2 -0
- package/dist/commands/worker.js +210 -0
- package/dist/index.js +3 -3
- package/dist/lib/worker.d.ts +14 -0
- package/dist/lib/worker.js +113 -0
- package/package.json +1 -1
|
@@ -2,8 +2,10 @@ import { Command } from "commander";
|
|
|
2
2
|
interface Config {
|
|
3
3
|
apiUrl?: string;
|
|
4
4
|
apiKey?: string;
|
|
5
|
+
workerId?: string;
|
|
6
|
+
workerName?: string;
|
|
5
7
|
}
|
|
6
8
|
export declare function getConfig(): Config;
|
|
7
|
-
export declare function setConfig(key: "apiUrl" | "apiKey", value: string): void;
|
|
9
|
+
export declare function setConfig(key: "apiUrl" | "apiKey" | "workerId" | "workerName", value: string): void;
|
|
8
10
|
export declare const configCommand: Command;
|
|
9
11
|
export {};
|
package/dist/commands/config.js
CHANGED
|
@@ -46,7 +46,7 @@ function saveConfig(config) {
|
|
|
46
46
|
}
|
|
47
47
|
writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2));
|
|
48
48
|
}
|
|
49
|
-
// Helper to set a single config value (used by interactive mode)
|
|
49
|
+
// Helper to set a single config value (used by interactive mode and worker identity)
|
|
50
50
|
export function setConfig(key, value) {
|
|
51
51
|
const config = getConfig();
|
|
52
52
|
config[key] = value;
|
package/dist/commands/task.js
CHANGED
|
@@ -3,6 +3,9 @@ import { getConfig } from "./config.js";
|
|
|
3
3
|
import * as fs from "fs";
|
|
4
4
|
import * as readline from "readline";
|
|
5
5
|
import { paginateList, printPaginated } from "../lib/pagination.js";
|
|
6
|
+
import { ensureWorkerRegistered, generateSessionId, registerSession } from "../lib/worker.js";
|
|
7
|
+
import { WorktreeManager } from "../lib/worktree.js";
|
|
8
|
+
import { execSync } from "child_process";
|
|
6
9
|
export const taskCommand = new Command("task")
|
|
7
10
|
.description("Manage tasks");
|
|
8
11
|
// Helper: Get task ID from --id flag or HUSKY_TASK_ID env var
|
|
@@ -36,11 +39,86 @@ async function confirm(message) {
|
|
|
36
39
|
});
|
|
37
40
|
});
|
|
38
41
|
}
|
|
42
|
+
// Helper: Check if current directory is a git repository
|
|
43
|
+
function isGitRepo() {
|
|
44
|
+
try {
|
|
45
|
+
execSync("git rev-parse --is-inside-work-tree", { stdio: "ignore" });
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
// Helper: Get current Git remote URL (e.g., "simon-sfxecom/huskyv0")
|
|
53
|
+
function getGitRepoIdentifier() {
|
|
54
|
+
if (!isGitRepo())
|
|
55
|
+
return null;
|
|
56
|
+
try {
|
|
57
|
+
const remoteUrl = execSync("git remote get-url origin", { encoding: "utf-8" }).trim();
|
|
58
|
+
// Extract owner/repo from various URL formats:
|
|
59
|
+
// https://github.com/owner/repo.git
|
|
60
|
+
// git@github.com:owner/repo.git
|
|
61
|
+
const match = remoteUrl.match(/[/:]([\w-]+\/[\w-]+?)(\.git)?$/);
|
|
62
|
+
return match ? match[1] : null;
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
// Helper: Create worktree for task when starting (auto-isolation)
|
|
69
|
+
function createWorktreeForTask(taskId) {
|
|
70
|
+
if (!isGitRepo()) {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
try {
|
|
74
|
+
const sessionName = `task-${taskId.slice(0, 8)}`;
|
|
75
|
+
const manager = new WorktreeManager(process.cwd());
|
|
76
|
+
const info = manager.createWorktree(sessionName);
|
|
77
|
+
return { path: info.path, branch: info.branch };
|
|
78
|
+
}
|
|
79
|
+
catch (error) {
|
|
80
|
+
// Worktree creation failed, but don't block task update
|
|
81
|
+
console.warn("⚠ Could not create worktree:", error instanceof Error ? error.message : error);
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
// Helper: Find project ID by GitHub repo identifier
|
|
86
|
+
async function findProjectByRepo(apiUrl, apiKey, repoIdentifier) {
|
|
87
|
+
try {
|
|
88
|
+
const res = await fetch(`${apiUrl}/api/projects`, {
|
|
89
|
+
headers: apiKey ? { "x-api-key": apiKey } : {},
|
|
90
|
+
});
|
|
91
|
+
if (!res.ok)
|
|
92
|
+
return null;
|
|
93
|
+
const projects = await res.json();
|
|
94
|
+
// Extract repo name (e.g., "huskyv0" from "simon-sfxecom/huskyv0")
|
|
95
|
+
const repoName = repoIdentifier.split("/").pop()?.toLowerCase() || "";
|
|
96
|
+
// Try to match by:
|
|
97
|
+
// 1. githubRepo field (exact or partial match)
|
|
98
|
+
// 2. Project name similarity to repo name
|
|
99
|
+
const project = projects.find((p) => {
|
|
100
|
+
// Check githubRepo field first
|
|
101
|
+
if (p.githubRepo?.toLowerCase().includes(repoIdentifier.toLowerCase())) {
|
|
102
|
+
return true;
|
|
103
|
+
}
|
|
104
|
+
// Fallback: Check if project name matches repo name (case-insensitive)
|
|
105
|
+
const projectNameLower = p.name.toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
106
|
+
const repoNameClean = repoName.replace(/[^a-z0-9]/g, "");
|
|
107
|
+
return projectNameLower === repoNameClean || projectNameLower.includes(repoNameClean) || repoNameClean.includes(projectNameLower);
|
|
108
|
+
});
|
|
109
|
+
return project ? { id: project.id, name: project.name } : null;
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
39
115
|
// husky task list
|
|
40
116
|
taskCommand
|
|
41
117
|
.command("list")
|
|
42
118
|
.description("List all tasks")
|
|
43
119
|
.option("-s, --status <status>", "Filter by status")
|
|
120
|
+
.option("-a, --all", "Show all tasks (ignore current repo filter)")
|
|
121
|
+
.option("--project <id>", "Filter by project ID")
|
|
44
122
|
.option("-p, --page <num>", "Page number (starts at 1)", "1")
|
|
45
123
|
.option("-n, --per-page <num>", "Items per page (default: all)")
|
|
46
124
|
.option("-i, --interactive", "Interactive pagination with arrow keys")
|
|
@@ -56,13 +134,39 @@ taskCommand
|
|
|
56
134
|
if (options.status) {
|
|
57
135
|
url.searchParams.set("status", options.status);
|
|
58
136
|
}
|
|
137
|
+
// Auto-detect project from current repo (unless --all or --project specified)
|
|
138
|
+
let autoDetectedProject = null;
|
|
139
|
+
let filterProjectId = null;
|
|
140
|
+
if (!options.all && !options.project) {
|
|
141
|
+
const repoIdentifier = getGitRepoIdentifier();
|
|
142
|
+
if (repoIdentifier) {
|
|
143
|
+
autoDetectedProject = await findProjectByRepo(config.apiUrl, config.apiKey, repoIdentifier);
|
|
144
|
+
if (autoDetectedProject) {
|
|
145
|
+
filterProjectId = autoDetectedProject.id;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
else if (options.project) {
|
|
150
|
+
filterProjectId = options.project;
|
|
151
|
+
}
|
|
152
|
+
// Note: We don't pass projectId to API to avoid Firestore index requirement
|
|
153
|
+
// Instead, we filter client-side which is fine for reasonable task counts
|
|
59
154
|
const res = await fetch(url.toString(), {
|
|
60
155
|
headers: config.apiKey ? { "x-api-key": config.apiKey } : {},
|
|
61
156
|
});
|
|
62
157
|
if (!res.ok) {
|
|
63
158
|
throw new Error(`API error: ${res.status}`);
|
|
64
159
|
}
|
|
65
|
-
|
|
160
|
+
let tasks = await res.json();
|
|
161
|
+
// Client-side filtering by projectId (avoids Firestore composite index)
|
|
162
|
+
if (filterProjectId) {
|
|
163
|
+
tasks = tasks.filter(t => t.projectId === filterProjectId);
|
|
164
|
+
}
|
|
165
|
+
// Show filter info if auto-detected project
|
|
166
|
+
if (autoDetectedProject && !options.json) {
|
|
167
|
+
console.log(`\n 📁 Filtering by project: ${autoDetectedProject.name}`);
|
|
168
|
+
console.log(` Use --all to see all tasks\n`);
|
|
169
|
+
}
|
|
66
170
|
// JSON output
|
|
67
171
|
if (options.json) {
|
|
68
172
|
console.log(JSON.stringify(tasks, null, 2));
|
|
@@ -73,7 +177,7 @@ taskCommand
|
|
|
73
177
|
await paginateList({
|
|
74
178
|
items: tasks,
|
|
75
179
|
pageSize: 10,
|
|
76
|
-
title: "Tasks",
|
|
180
|
+
title: autoDetectedProject ? `Tasks (${autoDetectedProject.name})` : "Tasks",
|
|
77
181
|
emptyMessage: "No tasks found.",
|
|
78
182
|
renderItem: (task) => {
|
|
79
183
|
const statusIcon = task.status === "done" ? "✓" : task.status === "in_progress" ? "▶" : "○";
|
|
@@ -124,19 +228,29 @@ taskCommand
|
|
|
124
228
|
process.exit(1);
|
|
125
229
|
}
|
|
126
230
|
try {
|
|
231
|
+
// Ensure worker is registered and create a session
|
|
232
|
+
const workerId = await ensureWorkerRegistered(config.apiUrl, config.apiKey || "");
|
|
233
|
+
const sessionId = generateSessionId();
|
|
234
|
+
await registerSession(config.apiUrl, config.apiKey || "", workerId, sessionId);
|
|
127
235
|
const res = await fetch(`${config.apiUrl}/api/tasks/${id}/start`, {
|
|
128
236
|
method: "POST",
|
|
129
237
|
headers: {
|
|
130
238
|
"Content-Type": "application/json",
|
|
131
239
|
...(config.apiKey ? { "x-api-key": config.apiKey } : {}),
|
|
132
240
|
},
|
|
133
|
-
body: JSON.stringify({
|
|
241
|
+
body: JSON.stringify({
|
|
242
|
+
agent: "claude-code",
|
|
243
|
+
workerId,
|
|
244
|
+
sessionId,
|
|
245
|
+
}),
|
|
134
246
|
});
|
|
135
247
|
if (!res.ok) {
|
|
136
248
|
throw new Error(`API error: ${res.status}`);
|
|
137
249
|
}
|
|
138
250
|
const task = await res.json();
|
|
139
251
|
console.log(`✓ Started: ${task.title}`);
|
|
252
|
+
console.log(` Worker: ${workerId}`);
|
|
253
|
+
console.log(` Session: ${sessionId}`);
|
|
140
254
|
}
|
|
141
255
|
catch (error) {
|
|
142
256
|
console.error("Error starting task:", error);
|
|
@@ -224,6 +338,7 @@ taskCommand
|
|
|
224
338
|
.option("--priority <priority>", "New priority (low, medium, high, urgent)")
|
|
225
339
|
.option("--assignee <assignee>", "New assignee (human, llm, unassigned)")
|
|
226
340
|
.option("--project <projectId>", "Link to project")
|
|
341
|
+
.option("--no-worktree", "Skip automatic worktree creation when starting task")
|
|
227
342
|
.option("--json", "Output as JSON")
|
|
228
343
|
.action(async (id, options) => {
|
|
229
344
|
const config = ensureConfig();
|
|
@@ -245,6 +360,15 @@ taskCommand
|
|
|
245
360
|
console.error("Error: No update options provided. Use --help for available options.");
|
|
246
361
|
process.exit(1);
|
|
247
362
|
}
|
|
363
|
+
// Auto-create worktree when starting a task (unless --no-worktree)
|
|
364
|
+
let worktreeInfo = null;
|
|
365
|
+
if (options.status === "in_progress" && options.worktree !== false) {
|
|
366
|
+
worktreeInfo = createWorktreeForTask(id);
|
|
367
|
+
if (worktreeInfo) {
|
|
368
|
+
updates.worktreePath = worktreeInfo.path;
|
|
369
|
+
updates.worktreeBranch = worktreeInfo.branch;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
248
372
|
try {
|
|
249
373
|
const res = await fetch(`${config.apiUrl}/api/tasks/${id}`, {
|
|
250
374
|
method: "PATCH",
|
|
@@ -269,8 +393,13 @@ taskCommand
|
|
|
269
393
|
}
|
|
270
394
|
else {
|
|
271
395
|
console.log(`✓ Updated: ${task.title}`);
|
|
272
|
-
const changedFields = Object.keys(updates).join(", ");
|
|
396
|
+
const changedFields = Object.keys(updates).filter(k => k !== 'worktreePath' && k !== 'worktreeBranch').join(", ");
|
|
273
397
|
console.log(` Changed: ${changedFields}`);
|
|
398
|
+
// Show worktree info if created
|
|
399
|
+
if (worktreeInfo) {
|
|
400
|
+
console.log(` ⎇ Worktree: ${worktreeInfo.branch}`);
|
|
401
|
+
console.log(` cd ${worktreeInfo.path}`);
|
|
402
|
+
}
|
|
274
403
|
}
|
|
275
404
|
}
|
|
276
405
|
catch (error) {
|
|
@@ -416,6 +545,41 @@ taskCommand
|
|
|
416
545
|
process.exit(1);
|
|
417
546
|
}
|
|
418
547
|
});
|
|
548
|
+
// husky task message [--id <id>] -m <text>
|
|
549
|
+
taskCommand
|
|
550
|
+
.command("message")
|
|
551
|
+
.description("Post a status message to a task")
|
|
552
|
+
.option("--id <id>", "Task ID (or use HUSKY_TASK_ID env var)")
|
|
553
|
+
.option("-m, --message <text>", "Status message", "")
|
|
554
|
+
.action(async (options) => {
|
|
555
|
+
const config = ensureConfig();
|
|
556
|
+
const taskId = getTaskId(options);
|
|
557
|
+
if (!options.message) {
|
|
558
|
+
console.error("Error: Message required. Use -m or --message");
|
|
559
|
+
process.exit(1);
|
|
560
|
+
}
|
|
561
|
+
try {
|
|
562
|
+
const res = await fetch(`${config.apiUrl}/api/tasks/${taskId}/status`, {
|
|
563
|
+
method: "POST",
|
|
564
|
+
headers: {
|
|
565
|
+
"Content-Type": "application/json",
|
|
566
|
+
...(config.apiKey ? { "x-api-key": config.apiKey } : {}),
|
|
567
|
+
},
|
|
568
|
+
body: JSON.stringify({
|
|
569
|
+
message: options.message,
|
|
570
|
+
timestamp: new Date().toISOString(),
|
|
571
|
+
}),
|
|
572
|
+
});
|
|
573
|
+
if (!res.ok) {
|
|
574
|
+
throw new Error(`API error: ${res.status}`);
|
|
575
|
+
}
|
|
576
|
+
console.log("✓ Status message posted");
|
|
577
|
+
}
|
|
578
|
+
catch (error) {
|
|
579
|
+
console.error("Error posting message:", error);
|
|
580
|
+
process.exit(1);
|
|
581
|
+
}
|
|
582
|
+
});
|
|
419
583
|
// husky task plan [--summary <text>] [--file <path>] [--stdin] [--id <id>]
|
|
420
584
|
taskCommand
|
|
421
585
|
.command("plan")
|
|
@@ -915,12 +1079,15 @@ function printTasks(tasks) {
|
|
|
915
1079
|
if (statusTasks.length === 0)
|
|
916
1080
|
continue;
|
|
917
1081
|
console.log(`\n ${label}`);
|
|
918
|
-
console.log(" " + "─".repeat(
|
|
1082
|
+
console.log(" " + "─".repeat(85));
|
|
1083
|
+
console.log(` ${"ID".padEnd(10)} ${"Title".padEnd(30)} ${"Project".padEnd(15)} ${"Project ID".padEnd(12)} ${"Priority".padEnd(6)}`);
|
|
1084
|
+
console.log(" " + "─".repeat(85));
|
|
919
1085
|
for (const task of statusTasks) {
|
|
920
1086
|
const agentStr = task.agent ? ` (${task.agent})` : "";
|
|
921
1087
|
const doneStr = status === "done" ? " ✓" : "";
|
|
922
|
-
const
|
|
923
|
-
|
|
1088
|
+
const projectName = task.projectName?.slice(0, 15).padEnd(15) || "—".padEnd(15);
|
|
1089
|
+
const projectId = task.projectId?.slice(0, 12).padEnd(12) || "—".padEnd(12);
|
|
1090
|
+
console.log(` ${task.id.slice(0, 10).padEnd(10)} ${task.title.slice(0, 30).padEnd(30)} ${projectName} ${projectId} ${task.priority.padEnd(6)}${agentStr}${doneStr}`);
|
|
924
1091
|
}
|
|
925
1092
|
}
|
|
926
1093
|
console.log("");
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import { getConfig } from "./config.js";
|
|
3
|
+
import { getWorkerIdentity, ensureWorkerRegistered } from "../lib/worker.js";
|
|
4
|
+
export const workerCommand = new Command("worker")
|
|
5
|
+
.description("Manage workers and sessions");
|
|
6
|
+
// husky worker whoami - Show current worker identity
|
|
7
|
+
workerCommand
|
|
8
|
+
.command("whoami")
|
|
9
|
+
.description("Show current worker identity")
|
|
10
|
+
.option("--json", "Output as JSON")
|
|
11
|
+
.action(async (options) => {
|
|
12
|
+
const identity = getWorkerIdentity();
|
|
13
|
+
if (options.json) {
|
|
14
|
+
console.log(JSON.stringify(identity, null, 2));
|
|
15
|
+
}
|
|
16
|
+
else {
|
|
17
|
+
console.log("\n Worker Identity");
|
|
18
|
+
console.log(" " + "─".repeat(40));
|
|
19
|
+
console.log(` ID: ${identity.workerId}`);
|
|
20
|
+
console.log(` Name: ${identity.workerName}`);
|
|
21
|
+
console.log(` Host: ${identity.hostname}`);
|
|
22
|
+
console.log(` User: ${identity.username}`);
|
|
23
|
+
console.log(` Platform: ${identity.platform}`);
|
|
24
|
+
console.log(` Version: ${identity.agentVersion}`);
|
|
25
|
+
console.log("");
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
// husky worker register - Register worker with API
|
|
29
|
+
workerCommand
|
|
30
|
+
.command("register")
|
|
31
|
+
.description("Register this worker with the API")
|
|
32
|
+
.action(async () => {
|
|
33
|
+
const config = getConfig();
|
|
34
|
+
if (!config.apiUrl) {
|
|
35
|
+
console.error("Error: API URL not configured. Run: husky config set api-url <url>");
|
|
36
|
+
process.exit(1);
|
|
37
|
+
}
|
|
38
|
+
try {
|
|
39
|
+
const workerId = await ensureWorkerRegistered(config.apiUrl, config.apiKey || "");
|
|
40
|
+
const identity = getWorkerIdentity();
|
|
41
|
+
console.log(`✓ Worker registered: ${workerId}`);
|
|
42
|
+
console.log(` Name: ${identity.workerName}`);
|
|
43
|
+
}
|
|
44
|
+
catch (error) {
|
|
45
|
+
console.error("Error registering worker:", error);
|
|
46
|
+
process.exit(1);
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
// husky worker list - List all registered workers
|
|
50
|
+
workerCommand
|
|
51
|
+
.command("list")
|
|
52
|
+
.description("List all registered workers")
|
|
53
|
+
.option("--json", "Output as JSON")
|
|
54
|
+
.action(async (options) => {
|
|
55
|
+
const config = getConfig();
|
|
56
|
+
if (!config.apiUrl) {
|
|
57
|
+
console.error("Error: API URL not configured.");
|
|
58
|
+
process.exit(1);
|
|
59
|
+
}
|
|
60
|
+
try {
|
|
61
|
+
const res = await fetch(`${config.apiUrl}/api/workers`, {
|
|
62
|
+
headers: config.apiKey ? { "x-api-key": config.apiKey } : {},
|
|
63
|
+
});
|
|
64
|
+
if (!res.ok) {
|
|
65
|
+
throw new Error(`API error: ${res.status}`);
|
|
66
|
+
}
|
|
67
|
+
const workers = await res.json();
|
|
68
|
+
if (options.json) {
|
|
69
|
+
console.log(JSON.stringify(workers, null, 2));
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
if (workers.length === 0) {
|
|
73
|
+
console.log("No workers registered.");
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
console.log("\n Registered Workers");
|
|
77
|
+
console.log(" " + "─".repeat(60));
|
|
78
|
+
for (const worker of workers) {
|
|
79
|
+
const statusIcon = worker.lastSeen && isRecent(worker.lastSeen) ? "🟢" : "⚪";
|
|
80
|
+
const typeIcon = worker.type === "claude-code" ? "🤖" : "👤";
|
|
81
|
+
console.log(` ${statusIcon} ${typeIcon} ${worker.id.slice(0, 12).padEnd(12)} │ ${worker.name?.slice(0, 30).padEnd(30)} │ ${worker.hostname || "—"}`);
|
|
82
|
+
}
|
|
83
|
+
console.log("");
|
|
84
|
+
}
|
|
85
|
+
catch (error) {
|
|
86
|
+
console.error("Error fetching workers:", error);
|
|
87
|
+
process.exit(1);
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
// husky worker sessions - List active sessions
|
|
91
|
+
workerCommand
|
|
92
|
+
.command("sessions")
|
|
93
|
+
.description("List active worker sessions")
|
|
94
|
+
.option("--all", "Show all sessions (not just active)")
|
|
95
|
+
.option("--json", "Output as JSON")
|
|
96
|
+
.action(async (options) => {
|
|
97
|
+
const config = getConfig();
|
|
98
|
+
if (!config.apiUrl) {
|
|
99
|
+
console.error("Error: API URL not configured.");
|
|
100
|
+
process.exit(1);
|
|
101
|
+
}
|
|
102
|
+
try {
|
|
103
|
+
const endpoint = options.all
|
|
104
|
+
? `${config.apiUrl}/api/workers/sessions`
|
|
105
|
+
: `${config.apiUrl}/api/workers/sessions/active`;
|
|
106
|
+
const res = await fetch(endpoint, {
|
|
107
|
+
headers: config.apiKey ? { "x-api-key": config.apiKey } : {},
|
|
108
|
+
});
|
|
109
|
+
if (!res.ok) {
|
|
110
|
+
throw new Error(`API error: ${res.status}`);
|
|
111
|
+
}
|
|
112
|
+
const sessions = await res.json();
|
|
113
|
+
if (options.json) {
|
|
114
|
+
console.log(JSON.stringify(sessions, null, 2));
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
if (sessions.length === 0) {
|
|
118
|
+
console.log(options.all ? "No sessions found." : "No active sessions.");
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
console.log(`\n ${options.all ? "All" : "Active"} Sessions`);
|
|
122
|
+
console.log(" " + "─".repeat(70));
|
|
123
|
+
for (const session of sessions) {
|
|
124
|
+
const statusIcon = session.status === "active" ? "🟢" : "⚪";
|
|
125
|
+
const taskInfo = session.currentTaskId ? `Task: ${session.currentTaskId.slice(0, 8)}` : "No task";
|
|
126
|
+
const timeAgo = session.lastHeartbeat ? formatTimeAgo(session.lastHeartbeat) : "—";
|
|
127
|
+
console.log(` ${statusIcon} ${session.id.slice(0, 12).padEnd(12)} │ ${session.workerId.slice(0, 12).padEnd(12)} │ ${taskInfo.padEnd(20)} │ ${timeAgo}`);
|
|
128
|
+
}
|
|
129
|
+
console.log("");
|
|
130
|
+
}
|
|
131
|
+
catch (error) {
|
|
132
|
+
console.error("Error fetching sessions:", error);
|
|
133
|
+
process.exit(1);
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
// husky worker activity - Show who is working on what
|
|
137
|
+
workerCommand
|
|
138
|
+
.command("activity")
|
|
139
|
+
.description("Show current activity (who is working on what)")
|
|
140
|
+
.option("--json", "Output as JSON")
|
|
141
|
+
.action(async (options) => {
|
|
142
|
+
const config = getConfig();
|
|
143
|
+
if (!config.apiUrl) {
|
|
144
|
+
console.error("Error: API URL not configured.");
|
|
145
|
+
process.exit(1);
|
|
146
|
+
}
|
|
147
|
+
try {
|
|
148
|
+
// Fetch active sessions and in-progress tasks in parallel
|
|
149
|
+
const [sessionsRes, tasksRes] = await Promise.all([
|
|
150
|
+
fetch(`${config.apiUrl}/api/workers/sessions/active`, {
|
|
151
|
+
headers: config.apiKey ? { "x-api-key": config.apiKey } : {},
|
|
152
|
+
}),
|
|
153
|
+
fetch(`${config.apiUrl}/api/tasks?status=in_progress`, {
|
|
154
|
+
headers: config.apiKey ? { "x-api-key": config.apiKey } : {},
|
|
155
|
+
}),
|
|
156
|
+
]);
|
|
157
|
+
if (!sessionsRes.ok || !tasksRes.ok) {
|
|
158
|
+
throw new Error("API error fetching activity");
|
|
159
|
+
}
|
|
160
|
+
const sessions = await sessionsRes.json();
|
|
161
|
+
const tasks = await tasksRes.json();
|
|
162
|
+
if (options.json) {
|
|
163
|
+
console.log(JSON.stringify({ sessions, tasks }, null, 2));
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
console.log("\n Current Activity");
|
|
167
|
+
console.log(" " + "═".repeat(70));
|
|
168
|
+
if (tasks.length === 0) {
|
|
169
|
+
console.log(" No tasks currently in progress.");
|
|
170
|
+
}
|
|
171
|
+
else {
|
|
172
|
+
for (const task of tasks) {
|
|
173
|
+
const agentIcon = task.agent?.includes("claude") ? "🤖" : task.agent ? "👤" : "❓";
|
|
174
|
+
const workerInfo = task.workerId ? `Worker: ${task.workerId.slice(0, 8)}` : "No worker";
|
|
175
|
+
console.log(`\n ${agentIcon} ${task.title.slice(0, 50)}`);
|
|
176
|
+
console.log(` ID: ${task.id.slice(0, 12)} │ ${task.agent || "unassigned"} │ ${workerInfo}`);
|
|
177
|
+
if (task.projectName) {
|
|
178
|
+
console.log(` Project: ${task.projectName}`);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
console.log("\n " + "─".repeat(70));
|
|
183
|
+
console.log(` Active Sessions: ${sessions.length}`);
|
|
184
|
+
console.log(` Tasks In Progress: ${tasks.length}`);
|
|
185
|
+
console.log("");
|
|
186
|
+
}
|
|
187
|
+
catch (error) {
|
|
188
|
+
console.error("Error fetching activity:", error);
|
|
189
|
+
process.exit(1);
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
// Helper: Check if timestamp is recent (within 5 minutes)
|
|
193
|
+
function isRecent(timestamp) {
|
|
194
|
+
const diff = Date.now() - new Date(timestamp).getTime();
|
|
195
|
+
return diff < 5 * 60 * 1000; // 5 minutes
|
|
196
|
+
}
|
|
197
|
+
// Helper: Format time ago
|
|
198
|
+
function formatTimeAgo(timestamp) {
|
|
199
|
+
const diff = Date.now() - new Date(timestamp).getTime();
|
|
200
|
+
const minutes = Math.floor(diff / 60000);
|
|
201
|
+
const hours = Math.floor(diff / 3600000);
|
|
202
|
+
const days = Math.floor(diff / 86400000);
|
|
203
|
+
if (minutes < 1)
|
|
204
|
+
return "just now";
|
|
205
|
+
if (minutes < 60)
|
|
206
|
+
return `${minutes}m ago`;
|
|
207
|
+
if (hours < 24)
|
|
208
|
+
return `${hours}h ago`;
|
|
209
|
+
return `${days}d ago`;
|
|
210
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -13,17 +13,17 @@ import { workflowCommand } from "./commands/workflow.js";
|
|
|
13
13
|
import { julesCommand } from "./commands/jules.js";
|
|
14
14
|
import { vmCommand } from "./commands/vm.js";
|
|
15
15
|
import { vmConfigCommand } from "./commands/vm-config.js";
|
|
16
|
-
import { processCommand } from "./commands/process.js";
|
|
17
16
|
import { settingsCommand } from "./commands/settings.js";
|
|
18
17
|
import { strategyCommand } from "./commands/strategy.js";
|
|
19
18
|
import { completionCommand } from "./commands/completion.js";
|
|
20
19
|
import { worktreeCommand } from "./commands/worktree.js";
|
|
20
|
+
import { workerCommand } from "./commands/worker.js";
|
|
21
21
|
import { runInteractiveMode } from "./commands/interactive.js";
|
|
22
22
|
const program = new Command();
|
|
23
23
|
program
|
|
24
24
|
.name("husky")
|
|
25
25
|
.description("CLI for Huskyv0 Task Orchestration with Claude Agent")
|
|
26
|
-
.version("0.
|
|
26
|
+
.version("0.8.1");
|
|
27
27
|
program.addCommand(taskCommand);
|
|
28
28
|
program.addCommand(configCommand);
|
|
29
29
|
program.addCommand(agentCommand);
|
|
@@ -37,11 +37,11 @@ program.addCommand(workflowCommand);
|
|
|
37
37
|
program.addCommand(julesCommand);
|
|
38
38
|
program.addCommand(vmCommand);
|
|
39
39
|
program.addCommand(vmConfigCommand);
|
|
40
|
-
program.addCommand(processCommand);
|
|
41
40
|
program.addCommand(settingsCommand);
|
|
42
41
|
program.addCommand(strategyCommand);
|
|
43
42
|
program.addCommand(completionCommand);
|
|
44
43
|
program.addCommand(worktreeCommand);
|
|
44
|
+
program.addCommand(workerCommand);
|
|
45
45
|
// Check if no command was provided - run interactive mode
|
|
46
46
|
if (process.argv.length <= 2) {
|
|
47
47
|
runInteractiveMode();
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
interface WorkerIdentity {
|
|
2
|
+
workerId: string;
|
|
3
|
+
workerName: string;
|
|
4
|
+
hostname: string;
|
|
5
|
+
username: string;
|
|
6
|
+
platform: string;
|
|
7
|
+
agentVersion: string;
|
|
8
|
+
}
|
|
9
|
+
export declare function getWorkerIdentity(): WorkerIdentity;
|
|
10
|
+
export declare function generateSessionId(): string;
|
|
11
|
+
export declare function ensureWorkerRegistered(apiUrl: string, apiKey: string): Promise<string>;
|
|
12
|
+
export declare function registerSession(apiUrl: string, apiKey: string, workerId: string, sessionId: string): Promise<void>;
|
|
13
|
+
export declare function sessionHeartbeat(apiUrl: string, apiKey: string, sessionId: string, currentTaskId?: string | null): Promise<void>;
|
|
14
|
+
export {};
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { getConfig, setConfig } from "../commands/config.js";
|
|
2
|
+
import { hostname, userInfo, platform } from "os";
|
|
3
|
+
import { randomUUID } from "crypto";
|
|
4
|
+
import { readFileSync } from "fs";
|
|
5
|
+
import { join, dirname } from "path";
|
|
6
|
+
import { fileURLToPath } from "url";
|
|
7
|
+
// Get or generate persistent worker identity (stored in ~/.husky/config.json)
|
|
8
|
+
export function getWorkerIdentity() {
|
|
9
|
+
const config = getConfig();
|
|
10
|
+
// Generate worker ID if not exists
|
|
11
|
+
if (!config.workerId) {
|
|
12
|
+
const newId = `cli-${randomUUID().slice(0, 8)}`;
|
|
13
|
+
setConfig("workerId", newId);
|
|
14
|
+
config.workerId = newId;
|
|
15
|
+
}
|
|
16
|
+
// Generate worker name if not exists
|
|
17
|
+
if (!config.workerName) {
|
|
18
|
+
const name = `Claude Code @ ${hostname()}`;
|
|
19
|
+
setConfig("workerName", name);
|
|
20
|
+
config.workerName = name;
|
|
21
|
+
}
|
|
22
|
+
// Get agent version from package.json
|
|
23
|
+
let agentVersion = "unknown";
|
|
24
|
+
try {
|
|
25
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
26
|
+
const pkg = JSON.parse(readFileSync(join(__dirname, "../../package.json"), "utf-8"));
|
|
27
|
+
agentVersion = pkg.version;
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
// Ignore errors reading package.json
|
|
31
|
+
}
|
|
32
|
+
return {
|
|
33
|
+
workerId: config.workerId,
|
|
34
|
+
workerName: config.workerName,
|
|
35
|
+
hostname: hostname(),
|
|
36
|
+
username: userInfo().username,
|
|
37
|
+
platform: platform(),
|
|
38
|
+
agentVersion,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
// Generate a unique session ID for this CLI instance
|
|
42
|
+
export function generateSessionId() {
|
|
43
|
+
return `sess-${randomUUID().slice(0, 8)}`;
|
|
44
|
+
}
|
|
45
|
+
// Register or update worker with API, return workerId
|
|
46
|
+
export async function ensureWorkerRegistered(apiUrl, apiKey) {
|
|
47
|
+
const identity = getWorkerIdentity();
|
|
48
|
+
// Try to get existing worker
|
|
49
|
+
const getRes = await fetch(`${apiUrl}/api/workers/${identity.workerId}`, {
|
|
50
|
+
headers: { "x-api-key": apiKey },
|
|
51
|
+
});
|
|
52
|
+
if (getRes.ok) {
|
|
53
|
+
// Worker exists, just return the ID
|
|
54
|
+
return identity.workerId;
|
|
55
|
+
}
|
|
56
|
+
if (getRes.status === 404) {
|
|
57
|
+
// Register new worker
|
|
58
|
+
const registerRes = await fetch(`${apiUrl}/api/workers`, {
|
|
59
|
+
method: "POST",
|
|
60
|
+
headers: { "x-api-key": apiKey, "Content-Type": "application/json" },
|
|
61
|
+
body: JSON.stringify({
|
|
62
|
+
name: identity.workerName,
|
|
63
|
+
type: "claude-code",
|
|
64
|
+
hostname: identity.hostname,
|
|
65
|
+
username: identity.username,
|
|
66
|
+
platform: identity.platform,
|
|
67
|
+
agentVersion: identity.agentVersion,
|
|
68
|
+
}),
|
|
69
|
+
});
|
|
70
|
+
if (!registerRes.ok) {
|
|
71
|
+
console.error(`Warning: Failed to register worker: ${registerRes.status}`);
|
|
72
|
+
return identity.workerId;
|
|
73
|
+
}
|
|
74
|
+
const worker = await registerRes.json();
|
|
75
|
+
// Update local config with server-assigned ID if different
|
|
76
|
+
if (worker.id !== identity.workerId) {
|
|
77
|
+
setConfig("workerId", worker.id);
|
|
78
|
+
return worker.id;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return identity.workerId;
|
|
82
|
+
}
|
|
83
|
+
// Register a new session with API
|
|
84
|
+
export async function registerSession(apiUrl, apiKey, workerId, sessionId) {
|
|
85
|
+
try {
|
|
86
|
+
await fetch(`${apiUrl}/api/workers/sessions`, {
|
|
87
|
+
method: "POST",
|
|
88
|
+
headers: { "x-api-key": apiKey, "Content-Type": "application/json" },
|
|
89
|
+
body: JSON.stringify({
|
|
90
|
+
id: sessionId,
|
|
91
|
+
workerId,
|
|
92
|
+
pid: process.pid,
|
|
93
|
+
workingDirectory: process.cwd(),
|
|
94
|
+
}),
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
// Silently fail - session registration is optional
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
// Send session heartbeat
|
|
102
|
+
export async function sessionHeartbeat(apiUrl, apiKey, sessionId, currentTaskId) {
|
|
103
|
+
try {
|
|
104
|
+
await fetch(`${apiUrl}/api/workers/sessions/${sessionId}/heartbeat`, {
|
|
105
|
+
method: "POST",
|
|
106
|
+
headers: { "x-api-key": apiKey, "Content-Type": "application/json" },
|
|
107
|
+
body: JSON.stringify({ currentTaskId }),
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
// Silently fail
|
|
112
|
+
}
|
|
113
|
+
}
|