@simonfestl/husky-cli 0.7.2 → 0.8.1
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 +92 -2
- package/dist/lib/worker.d.ts +14 -0
- package/dist/lib/worker.js +113 -0
- package/package.json +2 -2
|
@@ -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,6 +39,33 @@ 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: Create worktree for task when starting (auto-isolation)
|
|
53
|
+
function createWorktreeForTask(taskId) {
|
|
54
|
+
if (!isGitRepo()) {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
try {
|
|
58
|
+
const sessionName = `task-${taskId.slice(0, 8)}`;
|
|
59
|
+
const manager = new WorktreeManager(process.cwd());
|
|
60
|
+
const info = manager.createWorktree(sessionName);
|
|
61
|
+
return { path: info.path, branch: info.branch };
|
|
62
|
+
}
|
|
63
|
+
catch (error) {
|
|
64
|
+
// Worktree creation failed, but don't block task update
|
|
65
|
+
console.warn("⚠ Could not create worktree:", error instanceof Error ? error.message : error);
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
39
69
|
// husky task list
|
|
40
70
|
taskCommand
|
|
41
71
|
.command("list")
|
|
@@ -124,19 +154,29 @@ taskCommand
|
|
|
124
154
|
process.exit(1);
|
|
125
155
|
}
|
|
126
156
|
try {
|
|
157
|
+
// Ensure worker is registered and create a session
|
|
158
|
+
const workerId = await ensureWorkerRegistered(config.apiUrl, config.apiKey || "");
|
|
159
|
+
const sessionId = generateSessionId();
|
|
160
|
+
await registerSession(config.apiUrl, config.apiKey || "", workerId, sessionId);
|
|
127
161
|
const res = await fetch(`${config.apiUrl}/api/tasks/${id}/start`, {
|
|
128
162
|
method: "POST",
|
|
129
163
|
headers: {
|
|
130
164
|
"Content-Type": "application/json",
|
|
131
165
|
...(config.apiKey ? { "x-api-key": config.apiKey } : {}),
|
|
132
166
|
},
|
|
133
|
-
body: JSON.stringify({
|
|
167
|
+
body: JSON.stringify({
|
|
168
|
+
agent: "claude-code",
|
|
169
|
+
workerId,
|
|
170
|
+
sessionId,
|
|
171
|
+
}),
|
|
134
172
|
});
|
|
135
173
|
if (!res.ok) {
|
|
136
174
|
throw new Error(`API error: ${res.status}`);
|
|
137
175
|
}
|
|
138
176
|
const task = await res.json();
|
|
139
177
|
console.log(`✓ Started: ${task.title}`);
|
|
178
|
+
console.log(` Worker: ${workerId}`);
|
|
179
|
+
console.log(` Session: ${sessionId}`);
|
|
140
180
|
}
|
|
141
181
|
catch (error) {
|
|
142
182
|
console.error("Error starting task:", error);
|
|
@@ -224,6 +264,7 @@ taskCommand
|
|
|
224
264
|
.option("--priority <priority>", "New priority (low, medium, high, urgent)")
|
|
225
265
|
.option("--assignee <assignee>", "New assignee (human, llm, unassigned)")
|
|
226
266
|
.option("--project <projectId>", "Link to project")
|
|
267
|
+
.option("--no-worktree", "Skip automatic worktree creation when starting task")
|
|
227
268
|
.option("--json", "Output as JSON")
|
|
228
269
|
.action(async (id, options) => {
|
|
229
270
|
const config = ensureConfig();
|
|
@@ -245,6 +286,15 @@ taskCommand
|
|
|
245
286
|
console.error("Error: No update options provided. Use --help for available options.");
|
|
246
287
|
process.exit(1);
|
|
247
288
|
}
|
|
289
|
+
// Auto-create worktree when starting a task (unless --no-worktree)
|
|
290
|
+
let worktreeInfo = null;
|
|
291
|
+
if (options.status === "in_progress" && options.worktree !== false) {
|
|
292
|
+
worktreeInfo = createWorktreeForTask(id);
|
|
293
|
+
if (worktreeInfo) {
|
|
294
|
+
updates.worktreePath = worktreeInfo.path;
|
|
295
|
+
updates.worktreeBranch = worktreeInfo.branch;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
248
298
|
try {
|
|
249
299
|
const res = await fetch(`${config.apiUrl}/api/tasks/${id}`, {
|
|
250
300
|
method: "PATCH",
|
|
@@ -269,8 +319,13 @@ taskCommand
|
|
|
269
319
|
}
|
|
270
320
|
else {
|
|
271
321
|
console.log(`✓ Updated: ${task.title}`);
|
|
272
|
-
const changedFields = Object.keys(updates).join(", ");
|
|
322
|
+
const changedFields = Object.keys(updates).filter(k => k !== 'worktreePath' && k !== 'worktreeBranch').join(", ");
|
|
273
323
|
console.log(` Changed: ${changedFields}`);
|
|
324
|
+
// Show worktree info if created
|
|
325
|
+
if (worktreeInfo) {
|
|
326
|
+
console.log(` ⎇ Worktree: ${worktreeInfo.branch}`);
|
|
327
|
+
console.log(` cd ${worktreeInfo.path}`);
|
|
328
|
+
}
|
|
274
329
|
}
|
|
275
330
|
}
|
|
276
331
|
catch (error) {
|
|
@@ -416,6 +471,41 @@ taskCommand
|
|
|
416
471
|
process.exit(1);
|
|
417
472
|
}
|
|
418
473
|
});
|
|
474
|
+
// husky task message [--id <id>] -m <text>
|
|
475
|
+
taskCommand
|
|
476
|
+
.command("message")
|
|
477
|
+
.description("Post a status message to a task")
|
|
478
|
+
.option("--id <id>", "Task ID (or use HUSKY_TASK_ID env var)")
|
|
479
|
+
.option("-m, --message <text>", "Status message", "")
|
|
480
|
+
.action(async (options) => {
|
|
481
|
+
const config = ensureConfig();
|
|
482
|
+
const taskId = getTaskId(options);
|
|
483
|
+
if (!options.message) {
|
|
484
|
+
console.error("Error: Message required. Use -m or --message");
|
|
485
|
+
process.exit(1);
|
|
486
|
+
}
|
|
487
|
+
try {
|
|
488
|
+
const res = await fetch(`${config.apiUrl}/api/tasks/${taskId}/status`, {
|
|
489
|
+
method: "POST",
|
|
490
|
+
headers: {
|
|
491
|
+
"Content-Type": "application/json",
|
|
492
|
+
...(config.apiKey ? { "x-api-key": config.apiKey } : {}),
|
|
493
|
+
},
|
|
494
|
+
body: JSON.stringify({
|
|
495
|
+
message: options.message,
|
|
496
|
+
timestamp: new Date().toISOString(),
|
|
497
|
+
}),
|
|
498
|
+
});
|
|
499
|
+
if (!res.ok) {
|
|
500
|
+
throw new Error(`API error: ${res.status}`);
|
|
501
|
+
}
|
|
502
|
+
console.log("✓ Status message posted");
|
|
503
|
+
}
|
|
504
|
+
catch (error) {
|
|
505
|
+
console.error("Error posting message:", error);
|
|
506
|
+
process.exit(1);
|
|
507
|
+
}
|
|
508
|
+
});
|
|
419
509
|
// husky task plan [--summary <text>] [--file <path>] [--stdin] [--id <id>]
|
|
420
510
|
taskCommand
|
|
421
511
|
.command("plan")
|
|
@@ -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
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@simonfestl/husky-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.1",
|
|
4
4
|
"description": "CLI for Huskyv0 Task Orchestration with Claude Agent SDK",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -42,4 +42,4 @@
|
|
|
42
42
|
"bugs": {
|
|
43
43
|
"url": "https://github.com/simon-sfxecom/huskyv0/issues"
|
|
44
44
|
}
|
|
45
|
-
}
|
|
45
|
+
}
|