@simonfestl/husky-cli 0.7.1 → 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/README.md CHANGED
@@ -1,5 +1,8 @@
1
1
  # Husky CLI
2
2
 
3
+ [![CLI Tests](https://github.com/simon-sfxecom/huskyv0/actions/workflows/cli-tests.yml/badge.svg)](https://github.com/simon-sfxecom/huskyv0/actions/workflows/cli-tests.yml)
4
+ [![codecov](https://codecov.io/gh/simon-sfxecom/huskyv0/branch/main/graph/badge.svg?flag=cli)](https://codecov.io/gh/simon-sfxecom/huskyv0)
5
+
3
6
  CLI for Huskyv0 Task Orchestration with Claude Agent SDK integration.
4
7
 
5
8
  **Part of the [huskyv0 monorepo](https://github.com/simon-sfxecom/huskyv0)**
@@ -378,6 +381,47 @@ husky --version
378
381
  - Configuration management
379
382
  - API key authentication
380
383
 
384
+ ## Development
385
+
386
+ ### Testing
387
+
388
+ The CLI has comprehensive test coverage using Vitest:
389
+
390
+ ```bash
391
+ # Run all tests
392
+ npm test
393
+
394
+ # Run tests in watch mode
395
+ npm run test:watch
396
+
397
+ # Run with coverage
398
+ npm run test:coverage
399
+
400
+ # Run specific test file
401
+ npm test tests/unit/commands/config.test.ts
402
+ ```
403
+
404
+ **Test Structure:**
405
+ - `tests/setup.ts` - Global test configuration (MSW, memfs, mocks)
406
+ - `tests/unit/` - Unit tests for individual modules
407
+ - `tests/integration/` - Integration tests for workflows
408
+ - `tests/helpers/` - Reusable mocking utilities
409
+
410
+ **Current Coverage:**
411
+ - Config Command: ~29% lines, 60% functions
412
+ - Worktree Library: ~55% lines, 70% branches, 65% functions
413
+ - Total: 29 tests across 4 test files
414
+
415
+ ### Building
416
+
417
+ ```bash
418
+ # Build TypeScript
419
+ npm run build
420
+
421
+ # Watch mode for development
422
+ npm run dev
423
+ ```
424
+
381
425
  ## License
382
426
 
383
427
  MIT
@@ -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 {};
@@ -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;
@@ -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({ agent: "claude-code" }),
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")
@@ -919,7 +1009,8 @@ function printTasks(tasks) {
919
1009
  for (const task of statusTasks) {
920
1010
  const agentStr = task.agent ? ` (${task.agent})` : "";
921
1011
  const doneStr = status === "done" ? " ✓" : "";
922
- console.log(` ${task.id} ${task.title.slice(0, 30).padEnd(30)} ${task.priority}${agentStr}${doneStr}`);
1012
+ const projectStr = (task.projectName || task.projectId)?.slice(0, 15).padEnd(15) || "—".padEnd(15);
1013
+ console.log(` ${task.id.slice(0, 10).padEnd(10)} ${task.title.slice(0, 25).padEnd(25)} ${projectStr} ${task.priority.padEnd(6)}${agentStr}${doneStr}`);
923
1014
  }
924
1015
  }
925
1016
  console.log("");
@@ -168,10 +168,11 @@ worktreeCommand
168
168
  process.exit(1);
169
169
  }
170
170
  });
171
- // husky worktree remove <session-name>
171
+ // husky worktree remove <session-name> (alias: delete)
172
172
  worktreeCommand
173
173
  .command("remove <session-name>")
174
- .description("Remove a worktree")
174
+ .alias("delete")
175
+ .description("Remove a worktree (alias: delete)")
175
176
  .option("-p, --project <path>", "Project directory (default: current directory)")
176
177
  .option("--delete-branch", "Also delete the associated branch")
177
178
  .option("--force", "Force removal even with uncommitted changes")
@@ -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.7.1",
3
+ "version": "0.8.1",
4
4
  "description": "CLI for Huskyv0 Task Orchestration with Claude Agent SDK",
5
5
  "type": "module",
6
6
  "bin": {
@@ -10,7 +10,12 @@
10
10
  "scripts": {
11
11
  "build": "tsc",
12
12
  "dev": "tsc --watch",
13
- "start": "node dist/index.js"
13
+ "start": "node dist/index.js",
14
+ "test": "vitest run",
15
+ "test:watch": "vitest",
16
+ "test:coverage": "vitest run --coverage",
17
+ "test:unit": "vitest run tests/unit",
18
+ "test:integration": "vitest run tests/integration"
14
19
  },
15
20
  "dependencies": {
16
21
  "@anthropic-ai/claude-code": "^1.0.0",
@@ -19,7 +24,11 @@
19
24
  },
20
25
  "devDependencies": {
21
26
  "@types/node": "^22",
22
- "typescript": "^5"
27
+ "typescript": "^5",
28
+ "vitest": "^2.1.8",
29
+ "@vitest/coverage-v8": "^2.1.8",
30
+ "msw": "^2.6.8",
31
+ "memfs": "^4.14.0"
23
32
  },
24
33
  "files": [
25
34
  "dist"
@@ -33,4 +42,4 @@
33
42
  "bugs": {
34
43
  "url": "https://github.com/simon-sfxecom/huskyv0/issues"
35
44
  }
36
- }
45
+ }