boringpm 0.1.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/index.js ADDED
@@ -0,0 +1,786 @@
1
+ #!/usr/bin/env node
2
+ import { cancel, confirm, intro, isCancel, note, outro, spinner, text } from "@clack/prompts";
3
+ import { Command, InvalidArgumentError } from "commander";
4
+ import { clearStoredAuthSession, loadStoredAuthToken, saveStoredAuthSession, } from "./auth-store.js";
5
+ import { ApiClient } from "./api.js";
6
+ import { PLAN_SPEC_STATUSES, TASK_STATUSES, TEST_SCENARIO_STATUSES, TEST_SCENARIO_TYPES, } from "./types.js";
7
+ function parseStatus(value) {
8
+ if (!TASK_STATUSES.includes(value)) {
9
+ throw new InvalidArgumentError(`Invalid status \"${value}\". Must be one of: ${TASK_STATUSES.join(", ")}`);
10
+ }
11
+ return value;
12
+ }
13
+ function parsePlanSpecStatus(value) {
14
+ if (!PLAN_SPEC_STATUSES.includes(value)) {
15
+ throw new InvalidArgumentError(`Invalid spec status \"${value}\". Must be one of: ${PLAN_SPEC_STATUSES.join(", ")}`);
16
+ }
17
+ return value;
18
+ }
19
+ function parseTestScenarioStatus(value) {
20
+ if (!TEST_SCENARIO_STATUSES.includes(value)) {
21
+ throw new InvalidArgumentError(`Invalid test scenario status \"${value}\". Must be one of: ${TEST_SCENARIO_STATUSES.join(", ")}`);
22
+ }
23
+ return value;
24
+ }
25
+ function parseTestScenarioType(value) {
26
+ if (!TEST_SCENARIO_TYPES.includes(value)) {
27
+ throw new InvalidArgumentError(`Invalid test scenario type \"${value}\". Must be one of: ${TEST_SCENARIO_TYPES.join(", ")}`);
28
+ }
29
+ return value;
30
+ }
31
+ function parseCsvList(value) {
32
+ return value
33
+ .split(",")
34
+ .map((item) => item.trim())
35
+ .filter(Boolean);
36
+ }
37
+ async function resolveProjectId(client, projectReference) {
38
+ const input = projectReference.trim();
39
+ if (!input) {
40
+ throw new Error("Project reference is required");
41
+ }
42
+ const projects = await client.listProjects();
43
+ const byId = projects.find((project) => project.id === input);
44
+ if (byId) {
45
+ return byId.id;
46
+ }
47
+ const byName = projects.filter((project) => project.name.toLowerCase() === input.toLowerCase());
48
+ if (byName.length === 1) {
49
+ return byName[0].id;
50
+ }
51
+ if (byName.length > 1) {
52
+ throw new Error(`Multiple projects found with name \"${projectReference}\". Use project ID instead. Matching IDs: ${byName
53
+ .map((project) => project.id)
54
+ .join(", ")}`);
55
+ }
56
+ throw new Error(`Project \"${projectReference}\" not found. Run \"boringpm project list\" to see available projects.`);
57
+ }
58
+ async function resolveCurrentUserId(client) {
59
+ const me = await client.me();
60
+ return me.id;
61
+ }
62
+ async function resolveAssigneeReference(client, assigneeReference) {
63
+ if (assigneeReference === undefined) {
64
+ return undefined;
65
+ }
66
+ const value = assigneeReference.trim();
67
+ if (!value) {
68
+ throw new Error("Assignee reference cannot be empty");
69
+ }
70
+ const normalized = value.toLowerCase();
71
+ if (normalized === "none" || normalized === "null" || normalized === "unassigned") {
72
+ return null;
73
+ }
74
+ if (normalized === "me") {
75
+ return resolveCurrentUserId(client);
76
+ }
77
+ return value;
78
+ }
79
+ async function assertTaskInProject(client, taskId, projectId) {
80
+ const task = await client.getTask(taskId);
81
+ if (task.project !== projectId) {
82
+ throw new Error(`Task ${taskId} does not belong to project ${projectId}`);
83
+ }
84
+ }
85
+ function printTaskComments(comments) {
86
+ if (comments.length === 0) {
87
+ console.log("No comments yet.");
88
+ return;
89
+ }
90
+ for (const comment of comments) {
91
+ console.log(`[${comment.created_at}] ${comment.author}: ${comment.content}`);
92
+ }
93
+ }
94
+ function printTestScenarios(scenarios) {
95
+ if (scenarios.length === 0) {
96
+ console.log("No test scenarios yet.");
97
+ return;
98
+ }
99
+ for (const scenario of scenarios) {
100
+ console.log(`- ${scenario.id} [${scenario.status}] (${scenario.type}) ${scenario.title}`);
101
+ if (scenario.ran_at || scenario.ran_by) {
102
+ console.log(` ran: ${scenario.ran_at ?? "n/a"} by ${scenario.ran_by ?? "n/a"}`);
103
+ }
104
+ if (scenario.evidence) {
105
+ console.log(` evidence: ${scenario.evidence}`);
106
+ }
107
+ }
108
+ }
109
+ function resolveAuthToken(options) {
110
+ if (options.token?.trim()) {
111
+ return options.token.trim();
112
+ }
113
+ if (process.env.PM_AUTH_TOKEN?.trim()) {
114
+ return process.env.PM_AUTH_TOKEN.trim();
115
+ }
116
+ return loadStoredAuthToken();
117
+ }
118
+ function resolveClient(program) {
119
+ const options = program.opts();
120
+ return new ApiClient({
121
+ baseUrl: options.apiUrl,
122
+ authToken: resolveAuthToken(options),
123
+ actorName: options.actorName || process.env.PM_ACTOR_NAME || undefined,
124
+ });
125
+ }
126
+ function printJson(payload) {
127
+ console.log(JSON.stringify(payload, null, 2));
128
+ }
129
+ function unwrapPromptValue(value, cancelledMessage) {
130
+ if (isCancel(value)) {
131
+ cancel(cancelledMessage);
132
+ throw new Error(cancelledMessage);
133
+ }
134
+ return String(value).trim();
135
+ }
136
+ async function confirmDeleteAction(entityLabel, force) {
137
+ if (force) {
138
+ return;
139
+ }
140
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
141
+ throw new Error(`Refusing to delete ${entityLabel} without confirmation in non-interactive mode. Use --force to proceed.`);
142
+ }
143
+ const confirmed = await confirm({
144
+ message: `Delete ${entityLabel}? This cannot be undone.`,
145
+ initialValue: false,
146
+ });
147
+ if (isCancel(confirmed) || !confirmed) {
148
+ const message = "Delete cancelled";
149
+ cancel(message);
150
+ throw new Error(message);
151
+ }
152
+ }
153
+ const program = new Command();
154
+ program
155
+ .name("boringpm")
156
+ .description("Project manager CLI")
157
+ .version("0.1.2")
158
+ .option("--api-url <url>", "Project manager API base URL", process.env.PM_API_URL ?? "http://localhost:4000")
159
+ .option("--token <token>", "Auth token (overrides PM_AUTH_TOKEN and stored token)")
160
+ .option("--actor-name <name>", "Display name for notifications (overrides PM_ACTOR_NAME)");
161
+ program
162
+ .command("ping")
163
+ .description("Ping the API server")
164
+ .action(async () => {
165
+ const client = resolveClient(program);
166
+ const payload = await client.health();
167
+ printJson(payload);
168
+ });
169
+ program
170
+ .command("login")
171
+ .description("Interactive login with magic link and verification code")
172
+ .action(async () => {
173
+ const client = resolveClient(program);
174
+ intro("Project Manager Login");
175
+ const emailInput = await text({
176
+ message: "Email",
177
+ placeholder: "you@company.com",
178
+ validate: (value) => {
179
+ const email = value.trim();
180
+ if (!email) {
181
+ return "Email is required";
182
+ }
183
+ if (!email.includes("@")) {
184
+ return "Enter a valid email";
185
+ }
186
+ },
187
+ });
188
+ const email = unwrapPromptValue(emailInput, "Login cancelled");
189
+ const sendSpinner = spinner();
190
+ sendSpinner.start("Sending magic link/code email...");
191
+ await client.sendMagicLink(email);
192
+ sendSpinner.stop("Magic link/code sent");
193
+ note("Check your email and paste the verification code below.", "Email sent");
194
+ const codeInput = await text({
195
+ message: "Verification code",
196
+ placeholder: "123456",
197
+ validate: (value) => {
198
+ if (!value.trim()) {
199
+ return "Verification code is required";
200
+ }
201
+ },
202
+ });
203
+ const code = unwrapPromptValue(codeInput, "Login cancelled");
204
+ const verifySpinner = spinner();
205
+ verifySpinner.start("Verifying code...");
206
+ const authPayload = await client.verifyMagicCode(email, code);
207
+ await saveStoredAuthSession({
208
+ token: authPayload.token,
209
+ user: authPayload.user,
210
+ savedAt: new Date().toISOString(),
211
+ });
212
+ verifySpinner.stop("Logged in");
213
+ note(`Signed in as ${authPayload.user.email ?? authPayload.user.id}. Token saved to local auth store.`, "Success");
214
+ outro("Login complete");
215
+ });
216
+ program
217
+ .command("logout")
218
+ .description("Remove stored auth token")
219
+ .action(async () => {
220
+ await clearStoredAuthSession();
221
+ console.log("Logged out. Stored auth token removed.");
222
+ });
223
+ program
224
+ .command("whoami")
225
+ .description("Show currently authenticated user")
226
+ .action(async () => {
227
+ const client = resolveClient(program);
228
+ const payload = await client.me();
229
+ printJson(payload);
230
+ });
231
+ const auth = program.command("auth").description("Authentication commands");
232
+ auth
233
+ .command("send-link")
234
+ .description("Send magic link/code email")
235
+ .argument("<email>", "Email address")
236
+ .action(async (email) => {
237
+ const client = resolveClient(program);
238
+ const payload = await client.sendMagicLink(email);
239
+ printJson(payload);
240
+ });
241
+ auth
242
+ .command("verify")
243
+ .description("Verify email with magic code and return token")
244
+ .argument("<email>", "Email address")
245
+ .argument("<code>", "Verification code")
246
+ .action(async (email, code) => {
247
+ const client = resolveClient(program);
248
+ const payload = await client.verifyMagicCode(email, code);
249
+ printJson(payload);
250
+ });
251
+ auth
252
+ .command("me")
253
+ .description("Show currently authenticated user")
254
+ .action(async () => {
255
+ const client = resolveClient(program);
256
+ const payload = await client.me();
257
+ printJson(payload);
258
+ });
259
+ const team = program.command("team").description("Team operations");
260
+ team
261
+ .command("list")
262
+ .description("List teams you belong to")
263
+ .action(async () => {
264
+ const client = resolveClient(program);
265
+ const teams = await client.listTeams();
266
+ printJson(teams);
267
+ });
268
+ team
269
+ .command("create")
270
+ .description("Create a new team")
271
+ .argument("<name>", "Team name")
272
+ .action(async (name) => {
273
+ const client = resolveClient(program);
274
+ const created = await client.createTeam({ name });
275
+ printJson(created);
276
+ });
277
+ team
278
+ .command("view")
279
+ .description("View a team")
280
+ .argument("<teamId>", "Team ID")
281
+ .action(async (teamId) => {
282
+ const client = resolveClient(program);
283
+ const teamItem = await client.getTeam(teamId);
284
+ printJson(teamItem);
285
+ });
286
+ team
287
+ .command("update")
288
+ .description("Update a team (owner only)")
289
+ .argument("<teamId>", "Team ID")
290
+ .option("--name <name>", "New team name")
291
+ .action(async (teamId, options) => {
292
+ if (!options.name) {
293
+ throw new Error("Provide --name to update");
294
+ }
295
+ const client = resolveClient(program);
296
+ const updated = await client.updateTeam({ teamId, name: options.name });
297
+ printJson(updated);
298
+ });
299
+ team
300
+ .command("delete")
301
+ .description("Delete a team (owner only, blocked if projects linked)")
302
+ .argument("<teamId>", "Team ID")
303
+ .option("--force", "Skip confirmation prompt")
304
+ .action(async (teamId, options) => {
305
+ await confirmDeleteAction(`team ${teamId}`, options.force);
306
+ const client = resolveClient(program);
307
+ const result = await client.deleteTeam(teamId);
308
+ printJson(result);
309
+ });
310
+ team
311
+ .command("add-member")
312
+ .description("Add a member to a team (owner only)")
313
+ .argument("<teamId>", "Team ID")
314
+ .argument("<userId>", "User ID to add")
315
+ .action(async (teamId, userId) => {
316
+ const client = resolveClient(program);
317
+ const updated = await client.addTeamMember(teamId, userId);
318
+ printJson(updated);
319
+ });
320
+ team
321
+ .command("remove-member")
322
+ .description("Remove a member from a team (owner only)")
323
+ .argument("<teamId>", "Team ID")
324
+ .argument("<userId>", "User ID to remove")
325
+ .action(async (teamId, userId) => {
326
+ const client = resolveClient(program);
327
+ const updated = await client.removeTeamMember(teamId, userId);
328
+ printJson(updated);
329
+ });
330
+ team
331
+ .command("projects")
332
+ .description("List projects in a team")
333
+ .argument("<teamId>", "Team ID")
334
+ .action(async (teamId) => {
335
+ const client = resolveClient(program);
336
+ const projects = await client.listTeamProjects(teamId);
337
+ printJson(projects);
338
+ });
339
+ const project = program.command("project").description("Project operations");
340
+ project
341
+ .command("list")
342
+ .description("List visible projects")
343
+ .action(async () => {
344
+ const client = resolveClient(program);
345
+ const projects = await client.listProjects();
346
+ printJson(projects);
347
+ });
348
+ project
349
+ .command("view")
350
+ .description("View a single project")
351
+ .argument("<project>", "Project ID or project name")
352
+ .action(async (projectReference) => {
353
+ const client = resolveClient(program);
354
+ const projectId = await resolveProjectId(client, projectReference);
355
+ const projectItem = await client.getProject(projectId);
356
+ printJson(projectItem);
357
+ });
358
+ project
359
+ .command("create")
360
+ .description("Create a project")
361
+ .argument("<name>", "Project name")
362
+ .option("--participants <users>", "Comma-separated participant user IDs")
363
+ .option("--team <teamId>", "Team ID to link this project to")
364
+ .action(async (name, options) => {
365
+ const client = resolveClient(program);
366
+ const created = await client.createProject({
367
+ name,
368
+ participants: options.participants ? parseCsvList(options.participants) : undefined,
369
+ team: options.team,
370
+ });
371
+ printJson(created);
372
+ });
373
+ project
374
+ .command("add-user")
375
+ .description("Add a participant to a project")
376
+ .argument("<project>", "Project ID or project name")
377
+ .argument("<userId>", "User ID to add")
378
+ .action(async (projectReference, userId) => {
379
+ const client = resolveClient(program);
380
+ const projectId = await resolveProjectId(client, projectReference);
381
+ const updated = await client.addProjectParticipant(projectId, userId);
382
+ printJson(updated);
383
+ });
384
+ project
385
+ .command("remove-user")
386
+ .description("Remove a participant from a project")
387
+ .argument("<project>", "Project ID or project name")
388
+ .argument("<userId>", "User ID to remove")
389
+ .action(async (projectReference, userId) => {
390
+ const client = resolveClient(program);
391
+ const projectId = await resolveProjectId(client, projectReference);
392
+ const updated = await client.removeProjectParticipant(projectId, userId);
393
+ printJson(updated);
394
+ });
395
+ const plan = program.command("plan").description("Plan operations");
396
+ plan
397
+ .command("list")
398
+ .description("List plans in a project")
399
+ .argument("<project>", "Project ID or project name")
400
+ .action(async (projectReference) => {
401
+ const client = resolveClient(program);
402
+ const projectId = await resolveProjectId(client, projectReference);
403
+ const plans = await client.listPlans(projectId);
404
+ printJson(plans);
405
+ });
406
+ plan
407
+ .command("view")
408
+ .description("View a single plan")
409
+ .argument("<planId>", "Plan ID")
410
+ .action(async (planId) => {
411
+ const client = resolveClient(program);
412
+ const planItem = await client.getPlan(planId);
413
+ printJson(planItem);
414
+ });
415
+ plan
416
+ .command("create")
417
+ .description("Create a plan in a project")
418
+ .argument("<project>", "Project ID or project name")
419
+ .argument("<title>", "Plan title")
420
+ .option("--content <content>", "Plan content", "")
421
+ .option("--spec-status <status>", "Spec status", parsePlanSpecStatus, "draft")
422
+ .action(async (projectReference, title, options) => {
423
+ const client = resolveClient(program);
424
+ const projectId = await resolveProjectId(client, projectReference);
425
+ const created = await client.createPlan({
426
+ projectId,
427
+ title,
428
+ content: options.content,
429
+ spec_status: options.specStatus,
430
+ });
431
+ printJson(created);
432
+ });
433
+ plan
434
+ .command("update")
435
+ .description("Update a plan")
436
+ .argument("<planId>", "Plan ID")
437
+ .option("--title <title>", "New title")
438
+ .option("--content <content>", "New content")
439
+ .option("--spec-status <status>", "Spec status", parsePlanSpecStatus)
440
+ .action(async (planId, options) => {
441
+ if (options.title === undefined &&
442
+ options.content === undefined &&
443
+ options.specStatus === undefined) {
444
+ throw new Error("Provide --title, --content, --spec-status, or a combination");
445
+ }
446
+ const client = resolveClient(program);
447
+ const updated = await client.updatePlan({
448
+ planId,
449
+ title: options.title,
450
+ content: options.content,
451
+ spec_status: options.specStatus,
452
+ });
453
+ printJson(updated);
454
+ });
455
+ plan
456
+ .command("approve")
457
+ .description("Mark a spec plan as approved")
458
+ .argument("<planId>", "Plan ID")
459
+ .action(async (planId) => {
460
+ const client = resolveClient(program);
461
+ const updated = await client.updatePlan({
462
+ planId,
463
+ spec_status: "approved",
464
+ });
465
+ printJson(updated);
466
+ });
467
+ plan
468
+ .command("delete")
469
+ .description("Delete a plan")
470
+ .argument("<planId>", "Plan ID")
471
+ .option("-f, --force", "Skip confirmation prompt")
472
+ .action(async (planId, options) => {
473
+ await confirmDeleteAction(`plan ${planId}`, options.force);
474
+ const client = resolveClient(program);
475
+ const deleted = await client.deletePlan(planId);
476
+ printJson(deleted);
477
+ });
478
+ const task = program.command("task").description("Task operations");
479
+ task
480
+ .command("list")
481
+ .description("List tasks in a project")
482
+ .argument("<project>", "Project ID or project name")
483
+ .option("--status <status>", "Filter by status", parseStatus)
484
+ .option("--assignee <userId|me|none>", "Filter by assignee")
485
+ .option("--agent <name|none>", "Filter by agent tag")
486
+ .option("--mine", "Shortcut for --assignee me")
487
+ .action(async (projectReference, options) => {
488
+ if (options.mine && options.assignee !== undefined) {
489
+ throw new Error("Use either --mine or --assignee, not both");
490
+ }
491
+ const client = resolveClient(program);
492
+ const projectId = await resolveProjectId(client, projectReference);
493
+ const assignee = options.mine
494
+ ? await resolveCurrentUserId(client)
495
+ : await resolveAssigneeReference(client, options.assignee);
496
+ const agentFilter = options.agent === "none" ? null : options.agent;
497
+ const tasks = await client.listTasks(projectId, {
498
+ status: options.status,
499
+ assignee,
500
+ agent: agentFilter,
501
+ });
502
+ printJson(tasks);
503
+ });
504
+ task
505
+ .command("mine")
506
+ .description("List tasks assigned to you across all visible projects")
507
+ .option("--status <status>", "Filter by status", parseStatus)
508
+ .option("--agent <name>", "Filter by agent tag")
509
+ .action(async (options) => {
510
+ const client = resolveClient(program);
511
+ const myUserId = await resolveCurrentUserId(client);
512
+ const projects = await client.listProjects();
513
+ const agentFilter = options.agent === "none" ? null : options.agent;
514
+ const mineByProject = await Promise.all(projects.map(async (projectItem) => {
515
+ const tasks = await client.listTasks(projectItem.id, {
516
+ status: options.status,
517
+ assignee: myUserId,
518
+ agent: agentFilter,
519
+ });
520
+ return tasks.map((taskItem) => ({
521
+ projectId: projectItem.id,
522
+ projectName: projectItem.name,
523
+ ...taskItem,
524
+ }));
525
+ }));
526
+ const tasks = mineByProject
527
+ .flat()
528
+ .sort((a, b) => b.updated_at.localeCompare(a.updated_at));
529
+ printJson(tasks);
530
+ });
531
+ task
532
+ .command("view")
533
+ .description("View a single task")
534
+ .argument("<taskId>", "Task ID")
535
+ .action(async (taskId) => {
536
+ const client = resolveClient(program);
537
+ const taskItem = await client.getTask(taskId);
538
+ printJson(taskItem);
539
+ });
540
+ task
541
+ .command("create")
542
+ .description("Create a task")
543
+ .argument("<project>", "Project ID or project name")
544
+ .argument("[title]", "Task title")
545
+ .option("--title <title>", "Task title (alternative to positional)")
546
+ .option("--description <description>", "Task description", "")
547
+ .option("--plan <planId>", "Required approved spec plan ID")
548
+ .option("--agent <name>", "Agent tag (e.g. claudinho, anton, linus)")
549
+ .action(async (projectReference, positionalTitle, options) => {
550
+ const title = positionalTitle?.trim() || options.title?.trim() || "";
551
+ if (!title) {
552
+ throw new Error("Task title is required. Provide [title] or --title <title>");
553
+ }
554
+ if (!options.plan?.trim()) {
555
+ throw new Error("Task requires an approved spec link. Provide --plan <planId>");
556
+ }
557
+ const client = resolveClient(program);
558
+ const projectId = await resolveProjectId(client, projectReference);
559
+ const agent = options.agent?.trim();
560
+ const created = await client.createTask({
561
+ projectId,
562
+ title,
563
+ description: options.description,
564
+ status: "pending",
565
+ plan: options.plan.trim(),
566
+ agent: agent ? agent : null,
567
+ });
568
+ printJson(created);
569
+ });
570
+ task
571
+ .command("update")
572
+ .description("Update a task")
573
+ .argument("<taskId>", "Task ID")
574
+ .option("--title <title>", "New title")
575
+ .option("--description <description>", "New description")
576
+ .option("--status <status>", "New status", parseStatus)
577
+ .option("--plan <planId>", "New approved spec plan ID")
578
+ .option("--assignee <userId|me|none>", "Set assignee (self-assignment only enforced by API)")
579
+ .option("--agent <name|none>", "Set agent tag or 'none' to clear")
580
+ .action(async (taskId, options) => {
581
+ if (options.title === undefined &&
582
+ options.description === undefined &&
583
+ options.status === undefined &&
584
+ options.plan === undefined &&
585
+ options.assignee === undefined &&
586
+ options.agent === undefined) {
587
+ throw new Error("Provide at least one option to update");
588
+ }
589
+ const client = resolveClient(program);
590
+ if (options.plan !== undefined && !options.plan.trim()) {
591
+ throw new Error("Plan ID cannot be empty");
592
+ }
593
+ const plan = options.plan !== undefined ? options.plan.trim() : undefined;
594
+ const assignee = await resolveAssigneeReference(client, options.assignee);
595
+ const agent = options.agent === "none" ? null : options.agent;
596
+ const updated = await client.updateTask({
597
+ taskId,
598
+ title: options.title,
599
+ description: options.description,
600
+ status: options.status,
601
+ plan,
602
+ assignee,
603
+ agent,
604
+ });
605
+ printJson(updated);
606
+ });
607
+ task
608
+ .command("claim")
609
+ .description("Claim a task for yourself and move it to in_progress")
610
+ .argument("<taskId>", "Task ID")
611
+ .action(async (taskId) => {
612
+ const client = resolveClient(program);
613
+ const claimed = await client.claimTask(taskId);
614
+ printJson(claimed);
615
+ });
616
+ task
617
+ .command("release")
618
+ .description("Release a task assignment")
619
+ .argument("<taskId>", "Task ID")
620
+ .option("--status <status>", "Status after release", parseStatus)
621
+ .action(async (taskId, options) => {
622
+ const client = resolveClient(program);
623
+ const released = await client.releaseTask({
624
+ taskId,
625
+ status: options.status,
626
+ });
627
+ printJson(released);
628
+ });
629
+ task
630
+ .command("next")
631
+ .description("Claim the next available pending task in a project")
632
+ .argument("<project>", "Project ID or project name")
633
+ .action(async (projectReference) => {
634
+ const client = resolveClient(program);
635
+ const projectId = await resolveProjectId(client, projectReference);
636
+ const claimed = await client.claimNextTask(projectId);
637
+ printJson(claimed);
638
+ });
639
+ task
640
+ .command("done")
641
+ .description("Mark a task as completed")
642
+ .argument("<taskId>", "Task ID")
643
+ .option("--note <note>", "Completion note", "")
644
+ .option("--force", "Force completion with waiver fields")
645
+ .option("--waiver-reason <reason>", "Reason for forced completion")
646
+ .option("--approved-by <userId>", "Approver id for forced completion")
647
+ .action(async (taskId, options) => {
648
+ if (options.force && (!options.waiverReason?.trim() || !options.approvedBy?.trim())) {
649
+ throw new Error("--force requires both --waiver-reason and --approved-by");
650
+ }
651
+ const client = resolveClient(program);
652
+ const completed = await client.completeTask({
653
+ taskId,
654
+ note: options.note,
655
+ force: options.force,
656
+ waiver_reason: options.waiverReason,
657
+ approved_by: options.approvedBy,
658
+ });
659
+ printJson(completed);
660
+ });
661
+ task
662
+ .command("tests")
663
+ .description("List test scenarios for a task")
664
+ .argument("<project>", "Project ID or project name")
665
+ .argument("<taskId>", "Task ID")
666
+ .action(async (projectReference, taskId) => {
667
+ const client = resolveClient(program);
668
+ const projectId = await resolveProjectId(client, projectReference);
669
+ await assertTaskInProject(client, taskId, projectId);
670
+ const scenarios = await client.listTestScenarios(taskId);
671
+ printTestScenarios(scenarios);
672
+ });
673
+ task
674
+ .command("test-add")
675
+ .description("Add a test scenario to a task")
676
+ .argument("<project>", "Project ID or project name")
677
+ .argument("<taskId>", "Task ID")
678
+ .argument("<title>", "Scenario title")
679
+ .requiredOption("--steps <steps>", "Scenario steps")
680
+ .requiredOption("--expected <result>", "Expected result")
681
+ .option("--type <type>", "Scenario type", parseTestScenarioType, "manual")
682
+ .action(async (projectReference, taskId, title, options) => {
683
+ const client = resolveClient(program);
684
+ const projectId = await resolveProjectId(client, projectReference);
685
+ await assertTaskInProject(client, taskId, projectId);
686
+ const scenario = await client.createTestScenario({
687
+ taskId,
688
+ title,
689
+ type: options.type,
690
+ steps: options.steps,
691
+ expected_result: options.expected,
692
+ });
693
+ printJson(scenario);
694
+ });
695
+ task
696
+ .command("test-result")
697
+ .description("Record a test scenario result")
698
+ .argument("<project>", "Project ID or project name")
699
+ .argument("<taskId>", "Task ID")
700
+ .argument("<scenarioId>", "Scenario ID")
701
+ .argument("<status>", "Result status", parseTestScenarioStatus)
702
+ .option("--evidence <text>", "Evidence details (logs, links, notes)")
703
+ .action(async (projectReference, taskId, scenarioId, status, options) => {
704
+ const client = resolveClient(program);
705
+ const projectId = await resolveProjectId(client, projectReference);
706
+ await assertTaskInProject(client, taskId, projectId);
707
+ const updated = await client.updateTestScenarioResult({
708
+ taskId,
709
+ scenarioId,
710
+ status,
711
+ evidence: options.evidence,
712
+ });
713
+ printJson(updated);
714
+ });
715
+ task
716
+ .command("comments")
717
+ .description("List comments for a task")
718
+ .argument("<project>", "Project ID or project name")
719
+ .argument("<taskId>", "Task ID")
720
+ .action(async (projectReference, taskId) => {
721
+ const client = resolveClient(program);
722
+ const projectId = await resolveProjectId(client, projectReference);
723
+ await assertTaskInProject(client, taskId, projectId);
724
+ const comments = await client.listTaskComments(taskId);
725
+ printTaskComments(comments);
726
+ });
727
+ task
728
+ .command("comment")
729
+ .description("Add a comment to a task")
730
+ .argument("<project>", "Project ID or project name")
731
+ .argument("<taskId>", "Task ID")
732
+ .argument("<message>", "Comment message")
733
+ .action(async (projectReference, taskId, message) => {
734
+ const client = resolveClient(program);
735
+ const projectId = await resolveProjectId(client, projectReference);
736
+ await assertTaskInProject(client, taskId, projectId);
737
+ const comment = await client.createTaskComment(taskId, message);
738
+ printJson(comment);
739
+ });
740
+ task
741
+ .command("unclaim")
742
+ .description("Clear assignee from a task")
743
+ .argument("<taskId>", "Task ID")
744
+ .action(async (taskId) => {
745
+ const client = resolveClient(program);
746
+ const updated = await client.updateTask({
747
+ taskId,
748
+ assignee: null,
749
+ });
750
+ printJson(updated);
751
+ });
752
+ task
753
+ .command("delete")
754
+ .description("Delete a task")
755
+ .argument("<taskId>", "Task ID")
756
+ .option("-f, --force", "Skip confirmation prompt")
757
+ .action(async (taskId, options) => {
758
+ await confirmDeleteAction(`task ${taskId}`, options.force);
759
+ const client = resolveClient(program);
760
+ const deleted = await client.deleteTask(taskId);
761
+ printJson(deleted);
762
+ });
763
+ task
764
+ .command("move")
765
+ .description("Move a task to a new status")
766
+ .argument("<taskId>", "Task ID")
767
+ .argument("<status>", "New task status", parseStatus)
768
+ .action(async (taskId, status) => {
769
+ const client = resolveClient(program);
770
+ const updated = await client.updateTask({ taskId, status });
771
+ printJson(updated);
772
+ });
773
+ program
774
+ .command("board")
775
+ .description("Fetch board data for a project")
776
+ .argument("<project>", "Project ID or project name")
777
+ .action(async (projectReference) => {
778
+ const client = resolveClient(program);
779
+ const projectId = await resolveProjectId(client, projectReference);
780
+ const board = await client.board(projectId);
781
+ printJson(board);
782
+ });
783
+ program.parseAsync(process.argv).catch((error) => {
784
+ console.error(error instanceof Error ? error.message : String(error));
785
+ process.exit(1);
786
+ });