@simonfestl/husky-cli 0.3.0 → 0.5.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.
@@ -0,0 +1,473 @@
1
+ import { Command } from "commander";
2
+ import { getConfig } from "./config.js";
3
+ export const projectCommand = new Command("project")
4
+ .description("Manage projects and knowledge");
5
+ // Helper: Ensure API is configured
6
+ function ensureConfig() {
7
+ const config = getConfig();
8
+ if (!config.apiUrl) {
9
+ console.error("Error: API URL not configured. Run: husky config set api-url <url>");
10
+ process.exit(1);
11
+ }
12
+ return config;
13
+ }
14
+ // Work status labels
15
+ const WORK_STATUS_LABELS = {
16
+ planning: "Planning",
17
+ in_progress: "In Progress",
18
+ review: "Review",
19
+ completed: "Completed",
20
+ on_hold: "On Hold",
21
+ };
22
+ // Knowledge category config
23
+ const KNOWLEDGE_CATEGORY_CONFIG = {
24
+ architecture: { label: "Architecture", icon: "A" },
25
+ patterns: { label: "Patterns", icon: "P" },
26
+ decisions: { label: "Decisions", icon: "D" },
27
+ learnings: { label: "Learnings", icon: "L" },
28
+ };
29
+ // ============================================
30
+ // PROJECT CRUD COMMANDS
31
+ // ============================================
32
+ // husky project list
33
+ projectCommand
34
+ .command("list")
35
+ .description("List all projects")
36
+ .option("--json", "Output as JSON")
37
+ .option("--status <status>", "Filter by status (active, archived)")
38
+ .option("--work-status <workStatus>", "Filter by work status (planning, in_progress, review, completed, on_hold)")
39
+ .option("--archived", "Include archived projects")
40
+ .action(async (options) => {
41
+ const config = ensureConfig();
42
+ try {
43
+ const url = new URL("/api/projects", config.apiUrl);
44
+ if (options.archived || options.status === "archived") {
45
+ url.searchParams.set("archived", "true");
46
+ }
47
+ const res = await fetch(url.toString(), {
48
+ headers: config.apiKey ? { "x-api-key": config.apiKey } : {},
49
+ });
50
+ if (!res.ok) {
51
+ throw new Error(`API error: ${res.status}`);
52
+ }
53
+ let projects = await res.json();
54
+ // Apply filters
55
+ if (options.status) {
56
+ projects = projects.filter((p) => p.status === options.status);
57
+ }
58
+ if (options.workStatus) {
59
+ projects = projects.filter((p) => p.workStatus === options.workStatus);
60
+ }
61
+ if (options.json) {
62
+ console.log(JSON.stringify(projects, null, 2));
63
+ }
64
+ else {
65
+ printProjects(projects);
66
+ }
67
+ }
68
+ catch (error) {
69
+ console.error("Error fetching projects:", error);
70
+ process.exit(1);
71
+ }
72
+ });
73
+ // husky project create <name>
74
+ projectCommand
75
+ .command("create <name>")
76
+ .description("Create a new project")
77
+ .option("-d, --description <description>", "Project description")
78
+ .option("--status <status>", "Project status (active, archived)", "active")
79
+ .option("--work-status <workStatus>", "Work status (planning, in_progress, review, completed, on_hold)", "planning")
80
+ .option("--tech-stack <techStack>", "Tech stack description")
81
+ .option("--github <githubRepo>", "GitHub repository URL")
82
+ .option("--color <color>", "Project color (hex)")
83
+ .option("--json", "Output as JSON")
84
+ .action(async (name, options) => {
85
+ const config = ensureConfig();
86
+ try {
87
+ const res = await fetch(`${config.apiUrl}/api/projects`, {
88
+ method: "POST",
89
+ headers: {
90
+ "Content-Type": "application/json",
91
+ ...(config.apiKey ? { "x-api-key": config.apiKey } : {}),
92
+ },
93
+ body: JSON.stringify({
94
+ name,
95
+ description: options.description,
96
+ status: options.status,
97
+ workStatus: options.workStatus,
98
+ techStack: options.techStack,
99
+ githubRepo: options.github,
100
+ color: options.color,
101
+ }),
102
+ });
103
+ if (!res.ok) {
104
+ const errorData = await res.json().catch(() => ({}));
105
+ throw new Error(errorData.error || `API error: ${res.status}`);
106
+ }
107
+ const project = await res.json();
108
+ if (options.json) {
109
+ console.log(JSON.stringify(project, null, 2));
110
+ }
111
+ else {
112
+ console.log(`Created project: ${project.name}`);
113
+ console.log(` ID: ${project.id}`);
114
+ console.log(` Status: ${project.status}`);
115
+ console.log(` Work Status: ${project.workStatus}`);
116
+ }
117
+ }
118
+ catch (error) {
119
+ console.error("Error creating project:", error);
120
+ process.exit(1);
121
+ }
122
+ });
123
+ // husky project get <id>
124
+ projectCommand
125
+ .command("get <id>")
126
+ .description("Get project details")
127
+ .option("--json", "Output as JSON")
128
+ .option("--tasks", "Include project tasks")
129
+ .action(async (id, options) => {
130
+ const config = ensureConfig();
131
+ try {
132
+ const url = new URL(`/api/projects/${id}`, config.apiUrl);
133
+ if (options.tasks) {
134
+ url.searchParams.set("tasks", "true");
135
+ }
136
+ const res = await fetch(url.toString(), {
137
+ headers: config.apiKey ? { "x-api-key": config.apiKey } : {},
138
+ });
139
+ if (!res.ok) {
140
+ if (res.status === 404) {
141
+ console.error(`Error: Project ${id} not found`);
142
+ }
143
+ else {
144
+ console.error(`Error: API returned ${res.status}`);
145
+ }
146
+ process.exit(1);
147
+ }
148
+ const project = await res.json();
149
+ if (options.json) {
150
+ console.log(JSON.stringify(project, null, 2));
151
+ }
152
+ else {
153
+ printProjectDetail(project);
154
+ }
155
+ }
156
+ catch (error) {
157
+ console.error("Error fetching project:", error);
158
+ process.exit(1);
159
+ }
160
+ });
161
+ // husky project update <id>
162
+ projectCommand
163
+ .command("update <id>")
164
+ .description("Update a project")
165
+ .option("-n, --name <name>", "New project name")
166
+ .option("-d, --description <description>", "New description")
167
+ .option("--status <status>", "New status (active, archived)")
168
+ .option("--work-status <workStatus>", "New work status (planning, in_progress, review, completed, on_hold)")
169
+ .option("--tech-stack <techStack>", "Tech stack description")
170
+ .option("--github <githubRepo>", "GitHub repository URL")
171
+ .option("--color <color>", "Project color (hex)")
172
+ .option("--json", "Output as JSON")
173
+ .action(async (id, options) => {
174
+ const config = ensureConfig();
175
+ // Build update payload
176
+ const updateData = {};
177
+ if (options.name)
178
+ updateData.name = options.name;
179
+ if (options.description)
180
+ updateData.description = options.description;
181
+ if (options.status)
182
+ updateData.status = options.status;
183
+ if (options.workStatus)
184
+ updateData.workStatus = options.workStatus;
185
+ if (options.techStack)
186
+ updateData.techStack = options.techStack;
187
+ if (options.github)
188
+ updateData.githubRepo = options.github;
189
+ if (options.color)
190
+ updateData.color = options.color;
191
+ if (Object.keys(updateData).length === 0) {
192
+ console.error("Error: No update options provided.");
193
+ console.log("Use -n/--name, -d/--description, --status, --work-status, --tech-stack, --github, or --color");
194
+ process.exit(1);
195
+ }
196
+ try {
197
+ const res = await fetch(`${config.apiUrl}/api/projects/${id}`, {
198
+ method: "PATCH",
199
+ headers: {
200
+ "Content-Type": "application/json",
201
+ ...(config.apiKey ? { "x-api-key": config.apiKey } : {}),
202
+ },
203
+ body: JSON.stringify(updateData),
204
+ });
205
+ if (!res.ok) {
206
+ if (res.status === 404) {
207
+ console.error(`Error: Project ${id} not found`);
208
+ }
209
+ else {
210
+ const errorData = await res.json().catch(() => ({}));
211
+ console.error(`Error: ${errorData.error || `API returned ${res.status}`}`);
212
+ }
213
+ process.exit(1);
214
+ }
215
+ const project = await res.json();
216
+ if (options.json) {
217
+ console.log(JSON.stringify(project, null, 2));
218
+ }
219
+ else {
220
+ console.log(`Project updated successfully`);
221
+ console.log(` Name: ${project.name}`);
222
+ console.log(` Status: ${project.status}`);
223
+ console.log(` Work Status: ${project.workStatus}`);
224
+ }
225
+ }
226
+ catch (error) {
227
+ console.error("Error updating project:", error);
228
+ process.exit(1);
229
+ }
230
+ });
231
+ // husky project delete <id>
232
+ projectCommand
233
+ .command("delete <id>")
234
+ .description("Delete a project")
235
+ .option("--force", "Skip confirmation")
236
+ .action(async (id, options) => {
237
+ const config = ensureConfig();
238
+ if (!options.force) {
239
+ console.log("Warning: This will permanently delete the project and all associated data.");
240
+ console.log("Use --force to confirm deletion.");
241
+ process.exit(1);
242
+ }
243
+ try {
244
+ const res = await fetch(`${config.apiUrl}/api/projects/${id}`, {
245
+ method: "DELETE",
246
+ headers: config.apiKey ? { "x-api-key": config.apiKey } : {},
247
+ });
248
+ if (!res.ok) {
249
+ if (res.status === 404) {
250
+ console.error(`Error: Project ${id} not found`);
251
+ }
252
+ else {
253
+ console.error(`Error: API returned ${res.status}`);
254
+ }
255
+ process.exit(1);
256
+ }
257
+ console.log(`Project deleted`);
258
+ }
259
+ catch (error) {
260
+ console.error("Error deleting project:", error);
261
+ process.exit(1);
262
+ }
263
+ });
264
+ // ============================================
265
+ // KNOWLEDGE MANAGEMENT COMMANDS
266
+ // ============================================
267
+ // husky project add-knowledge <projectId>
268
+ projectCommand
269
+ .command("add-knowledge <projectId>")
270
+ .description("Add a knowledge entry to a project")
271
+ .requiredOption("--title <title>", "Knowledge entry title")
272
+ .requiredOption("--content <content>", "Knowledge entry content (markdown)")
273
+ .option("--type <type>", "Category: architecture, patterns, decisions, learnings", "learnings")
274
+ .option("--json", "Output as JSON")
275
+ .action(async (projectId, options) => {
276
+ const config = ensureConfig();
277
+ const validCategories = ["architecture", "patterns", "decisions", "learnings"];
278
+ if (!validCategories.includes(options.type)) {
279
+ console.error(`Error: Invalid category "${options.type}". Must be one of: ${validCategories.join(", ")}`);
280
+ process.exit(1);
281
+ }
282
+ try {
283
+ const res = await fetch(`${config.apiUrl}/api/projects/${projectId}/knowledge`, {
284
+ method: "POST",
285
+ headers: {
286
+ "Content-Type": "application/json",
287
+ ...(config.apiKey ? { "x-api-key": config.apiKey } : {}),
288
+ },
289
+ body: JSON.stringify({
290
+ title: options.title,
291
+ content: options.content,
292
+ category: options.type,
293
+ }),
294
+ });
295
+ if (!res.ok) {
296
+ if (res.status === 404) {
297
+ console.error(`Error: Project ${projectId} not found`);
298
+ }
299
+ else {
300
+ const errorData = await res.json().catch(() => ({}));
301
+ console.error(`Error: ${errorData.error || `API returned ${res.status}`}`);
302
+ }
303
+ process.exit(1);
304
+ }
305
+ const knowledge = await res.json();
306
+ if (options.json) {
307
+ console.log(JSON.stringify(knowledge, null, 2));
308
+ }
309
+ else {
310
+ console.log(`Knowledge entry added`);
311
+ console.log(` ID: ${knowledge.id}`);
312
+ console.log(` Title: ${knowledge.title}`);
313
+ console.log(` Category: ${knowledge.category}`);
314
+ }
315
+ }
316
+ catch (error) {
317
+ console.error("Error adding knowledge entry:", error);
318
+ process.exit(1);
319
+ }
320
+ });
321
+ // husky project list-knowledge <projectId>
322
+ projectCommand
323
+ .command("list-knowledge <projectId>")
324
+ .description("List knowledge entries for a project")
325
+ .option("--json", "Output as JSON")
326
+ .option("--type <type>", "Filter by category: architecture, patterns, decisions, learnings")
327
+ .action(async (projectId, options) => {
328
+ const config = ensureConfig();
329
+ try {
330
+ const url = new URL(`/api/projects/${projectId}/knowledge`, config.apiUrl);
331
+ if (options.type) {
332
+ url.searchParams.set("category", options.type);
333
+ }
334
+ const res = await fetch(url.toString(), {
335
+ headers: config.apiKey ? { "x-api-key": config.apiKey } : {},
336
+ });
337
+ if (!res.ok) {
338
+ if (res.status === 404) {
339
+ console.error(`Error: Project ${projectId} not found`);
340
+ }
341
+ else {
342
+ console.error(`Error: API returned ${res.status}`);
343
+ }
344
+ process.exit(1);
345
+ }
346
+ const knowledge = await res.json();
347
+ if (options.json) {
348
+ console.log(JSON.stringify(knowledge, null, 2));
349
+ }
350
+ else {
351
+ printKnowledgeList(projectId, knowledge);
352
+ }
353
+ }
354
+ catch (error) {
355
+ console.error("Error fetching knowledge entries:", error);
356
+ process.exit(1);
357
+ }
358
+ });
359
+ // husky project delete-knowledge <projectId> <knowledgeId>
360
+ projectCommand
361
+ .command("delete-knowledge <projectId> <knowledgeId>")
362
+ .description("Delete a knowledge entry from a project")
363
+ .option("--force", "Skip confirmation")
364
+ .action(async (projectId, knowledgeId, options) => {
365
+ const config = ensureConfig();
366
+ if (!options.force) {
367
+ console.log("Warning: This will permanently delete the knowledge entry.");
368
+ console.log("Use --force to confirm deletion.");
369
+ process.exit(1);
370
+ }
371
+ try {
372
+ const res = await fetch(`${config.apiUrl}/api/projects/${projectId}/knowledge/${knowledgeId}`, {
373
+ method: "DELETE",
374
+ headers: config.apiKey ? { "x-api-key": config.apiKey } : {},
375
+ });
376
+ if (!res.ok) {
377
+ if (res.status === 404) {
378
+ console.error(`Error: Knowledge entry not found`);
379
+ }
380
+ else {
381
+ console.error(`Error: API returned ${res.status}`);
382
+ }
383
+ process.exit(1);
384
+ }
385
+ console.log(`Knowledge entry deleted`);
386
+ }
387
+ catch (error) {
388
+ console.error("Error deleting knowledge entry:", error);
389
+ process.exit(1);
390
+ }
391
+ });
392
+ // ============================================
393
+ // OUTPUT FORMATTERS
394
+ // ============================================
395
+ function printProjects(projects) {
396
+ if (projects.length === 0) {
397
+ console.log("\n No projects found.");
398
+ console.log(" Create one with: husky project create <name>\n");
399
+ return;
400
+ }
401
+ console.log("\n PROJECTS");
402
+ console.log(" " + "-".repeat(80));
403
+ console.log(` ${"ID".padEnd(24)} ${"NAME".padEnd(25)} ${"STATUS".padEnd(10)} ${"WORK STATUS".padEnd(14)}`);
404
+ console.log(" " + "-".repeat(80));
405
+ for (const project of projects) {
406
+ const workStatusLabel = WORK_STATUS_LABELS[project.workStatus] || project.workStatus;
407
+ const truncatedName = project.name.length > 23 ? project.name.substring(0, 20) + "..." : project.name;
408
+ const statusIcon = project.status === "active" ? "+" : "-";
409
+ console.log(` ${project.id.padEnd(24)} ${truncatedName.padEnd(25)} ${statusIcon} ${project.status.padEnd(8)} ${workStatusLabel}`);
410
+ }
411
+ console.log(" " + "-".repeat(80));
412
+ console.log(` Total: ${projects.length} project(s)\n`);
413
+ }
414
+ function printProjectDetail(project) {
415
+ const workStatusLabel = WORK_STATUS_LABELS[project.workStatus] || project.workStatus;
416
+ console.log(`\n Project: ${project.name}`);
417
+ console.log(" " + "=".repeat(60));
418
+ console.log(` ID: ${project.id}`);
419
+ console.log(` Status: ${project.status}`);
420
+ console.log(` Work Status: ${workStatusLabel}`);
421
+ if (project.description) {
422
+ console.log(` Description: ${project.description}`);
423
+ }
424
+ if (project.techStack) {
425
+ console.log(` Tech Stack: ${project.techStack}`);
426
+ }
427
+ if (project.githubRepo) {
428
+ console.log(` GitHub: ${project.githubRepo}`);
429
+ }
430
+ console.log(` Color: ${project.color}`);
431
+ console.log(` Created: ${new Date(project.createdAt).toLocaleString()}`);
432
+ if (project.tasks && project.tasks.length > 0) {
433
+ console.log(`\n Tasks (${project.tasks.length}):`);
434
+ console.log(" " + "-".repeat(50));
435
+ for (const task of project.tasks.slice(0, 10)) {
436
+ const statusIcon = task.status === "done" ? "[ok]" :
437
+ task.status === "in_progress" ? "[->]" :
438
+ "[ ]";
439
+ console.log(` ${statusIcon} ${task.title.slice(0, 40)}`);
440
+ }
441
+ if (project.tasks.length > 10) {
442
+ console.log(` ... and ${project.tasks.length - 10} more`);
443
+ }
444
+ }
445
+ console.log("");
446
+ }
447
+ function printKnowledgeList(projectId, knowledge) {
448
+ if (knowledge.length === 0) {
449
+ console.log(`\n No knowledge entries found for project ${projectId}.`);
450
+ console.log(` Add one with: husky project add-knowledge ${projectId} --title <title> --content <content>\n`);
451
+ return;
452
+ }
453
+ console.log(`\n KNOWLEDGE - Project: ${projectId}`);
454
+ console.log(" " + "-".repeat(80));
455
+ console.log(` ${"ID".padEnd(24)} ${"CATEGORY".padEnd(14)} ${"TITLE".padEnd(35)}`);
456
+ console.log(" " + "-".repeat(80));
457
+ for (const entry of knowledge) {
458
+ const categoryConfig = KNOWLEDGE_CATEGORY_CONFIG[entry.category] || { label: entry.category, icon: "?" };
459
+ const truncatedTitle = entry.title.length > 33 ? entry.title.substring(0, 30) + "..." : entry.title;
460
+ console.log(` ${entry.id.padEnd(24)} [${categoryConfig.icon}] ${categoryConfig.label.padEnd(10)} ${truncatedTitle}`);
461
+ }
462
+ // Summary by category
463
+ const byCategory = {};
464
+ for (const entry of knowledge) {
465
+ byCategory[entry.category] = (byCategory[entry.category] || 0) + 1;
466
+ }
467
+ console.log(" " + "-".repeat(80));
468
+ console.log(` Total: ${knowledge.length} entries`);
469
+ const categoryStr = Object.entries(byCategory)
470
+ .map(([cat, count]) => `${KNOWLEDGE_CATEGORY_CONFIG[cat]?.label || cat}: ${count}`)
471
+ .join(", ");
472
+ console.log(` By Category: ${categoryStr}\n`);
473
+ }