tmux-team 2.2.0 → 3.0.0-alpha.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.
@@ -1,1011 +0,0 @@
1
- // ─────────────────────────────────────────────────────────────
2
- // PM Commands - project management CLI
3
- // ─────────────────────────────────────────────────────────────
4
-
5
- import type { Context } from '../types.js';
6
- import { ExitCodes } from '../exits.js';
7
- import { colors } from '../ui.js';
8
- import {
9
- checkPermission,
10
- buildPermissionPath,
11
- PermissionChecks,
12
- type PermissionCheck,
13
- } from './permissions.js';
14
- import {
15
- findCurrentTeamId,
16
- getStorageAdapter,
17
- generateTeamId,
18
- getTeamsDir,
19
- linkTeam,
20
- listTeams,
21
- createStorageAdapter,
22
- saveTeamConfig,
23
- } from './manager.js';
24
- import type { StorageAdapter } from './storage/adapter.js';
25
- import type { TaskStatus, MilestoneStatus, StorageBackend } from './types.js';
26
- import path from 'path';
27
- import fs from 'fs';
28
- import os from 'os';
29
-
30
- // ─────────────────────────────────────────────────────────────
31
- // Helpers
32
- // ─────────────────────────────────────────────────────────────
33
-
34
- async function requireTeam(ctx: Context): Promise<{ teamId: string; storage: StorageAdapter }> {
35
- const teamId = findCurrentTeamId(process.cwd(), ctx.paths.globalDir);
36
- if (!teamId) {
37
- ctx.ui.error("No team found. Run 'tmux-team pm init' first or navigate to a linked directory.");
38
- ctx.exit(ExitCodes.CONFIG_MISSING);
39
- }
40
- const storage = getStorageAdapter(teamId, ctx.paths.globalDir);
41
-
42
- // Validate team exists
43
- const team = await storage.getTeam();
44
- if (!team) {
45
- ctx.ui.error(`Team ${teamId} not found. The .tmux-team-id file may be stale.`);
46
- ctx.exit(ExitCodes.CONFIG_MISSING);
47
- }
48
-
49
- return { teamId, storage };
50
- }
51
-
52
- function formatStatus(status: TaskStatus | MilestoneStatus): string {
53
- switch (status) {
54
- case 'pending':
55
- return colors.yellow('pending');
56
- case 'in_progress':
57
- return colors.blue('in_progress');
58
- case 'done':
59
- return colors.green('done');
60
- default:
61
- return status;
62
- }
63
- }
64
-
65
- function parseStatus(s: string): TaskStatus {
66
- const normalized = s.toLowerCase().replace(/-/g, '_');
67
- if (normalized === 'pending' || normalized === 'in_progress' || normalized === 'done') {
68
- return normalized as TaskStatus;
69
- }
70
- throw new Error(`Invalid status: ${s}. Use: pending, in_progress, done`);
71
- }
72
-
73
- function requirePermission(ctx: Context, check: PermissionCheck): void {
74
- const result = checkPermission(ctx.config, check);
75
-
76
- // Display warning if there's an identity conflict
77
- if (result.warning) {
78
- ctx.ui.warn(result.warning);
79
- }
80
-
81
- if (!result.allowed) {
82
- const permPath = buildPermissionPath(check);
83
- ctx.ui.error(`Permission denied: ${result.actor} cannot perform ${permPath}`);
84
- ctx.exit(ExitCodes.ERROR);
85
- }
86
- }
87
-
88
- // ─────────────────────────────────────────────────────────────
89
- // Commands
90
- // ─────────────────────────────────────────────────────────────
91
-
92
- export async function cmdPmInit(ctx: Context, args: string[]): Promise<void> {
93
- requirePermission(ctx, PermissionChecks.teamCreate());
94
-
95
- const { ui, flags, paths } = ctx;
96
-
97
- // Parse flags: --name, --backend, --repo
98
- let name = 'Unnamed Project';
99
- let backend: StorageBackend = 'fs';
100
- let repo: string | undefined;
101
-
102
- for (let i = 0; i < args.length; i++) {
103
- if (args[i] === '--name' && args[i + 1]) {
104
- name = args[++i];
105
- } else if (args[i].startsWith('--name=')) {
106
- name = args[i].slice(7);
107
- } else if (args[i] === '--backend' && args[i + 1]) {
108
- const b = args[++i];
109
- if (b !== 'fs' && b !== 'github') {
110
- ui.error(`Invalid backend: ${b}. Use: fs, github`);
111
- ctx.exit(ExitCodes.ERROR);
112
- }
113
- backend = b;
114
- } else if (args[i].startsWith('--backend=')) {
115
- const b = args[i].slice(10);
116
- if (b !== 'fs' && b !== 'github') {
117
- ui.error(`Invalid backend: ${b}. Use: fs, github`);
118
- ctx.exit(ExitCodes.ERROR);
119
- }
120
- backend = b as StorageBackend;
121
- } else if (args[i] === '--repo' && args[i + 1]) {
122
- repo = args[++i];
123
- } else if (args[i].startsWith('--repo=')) {
124
- repo = args[i].slice(7);
125
- }
126
- }
127
-
128
- // Validate GitHub backend requires repo
129
- if (backend === 'github' && !repo) {
130
- ui.error('GitHub backend requires --repo flag (e.g., --repo owner/repo)');
131
- ctx.exit(ExitCodes.ERROR);
132
- }
133
-
134
- const teamId = generateTeamId();
135
- const teamDir = path.join(getTeamsDir(paths.globalDir), teamId);
136
-
137
- // Save config first
138
- saveTeamConfig(teamDir, { backend, repo });
139
-
140
- // Create storage adapter
141
- const storage = createStorageAdapter(teamDir, backend, repo);
142
-
143
- const team = await storage.initTeam(name);
144
- linkTeam(process.cwd(), teamId);
145
-
146
- await storage.appendEvent({
147
- event: 'team_created',
148
- id: teamId,
149
- name,
150
- backend,
151
- repo,
152
- actor: 'human',
153
- ts: new Date().toISOString(),
154
- });
155
-
156
- if (flags.json) {
157
- ui.json({ team, backend, repo, linked: process.cwd() });
158
- } else {
159
- ui.success(`Created team '${name}' (${teamId})`);
160
- if (backend === 'github') {
161
- ui.info(`Backend: GitHub (${repo})`);
162
- }
163
- ui.info(`Linked to ${process.cwd()}`);
164
- }
165
- }
166
-
167
- export async function cmdPmMilestone(ctx: Context, args: string[]): Promise<void> {
168
- const [subcommand, ...rest] = args;
169
-
170
- switch (subcommand) {
171
- case 'add':
172
- return cmdMilestoneAdd(ctx, rest);
173
- case 'list':
174
- case 'ls':
175
- case undefined:
176
- return cmdMilestoneList(ctx, rest);
177
- case 'done':
178
- return cmdMilestoneDone(ctx, rest);
179
- case 'delete':
180
- case 'rm':
181
- return cmdMilestoneDelete(ctx, rest);
182
- case 'doc':
183
- return cmdMilestoneDoc(ctx, rest);
184
- default:
185
- ctx.ui.error(`Unknown milestone command: ${subcommand}. Use: add, list, done, delete, doc`);
186
- ctx.exit(ExitCodes.ERROR);
187
- }
188
- }
189
-
190
- async function cmdMilestoneAdd(ctx: Context, args: string[]): Promise<void> {
191
- requirePermission(ctx, PermissionChecks.milestoneCreate());
192
-
193
- const { ui, flags } = ctx;
194
- const { storage } = await requireTeam(ctx);
195
-
196
- // Parse args: <name> [--description <text>]
197
- let name = '';
198
- let description: string | undefined;
199
-
200
- for (let i = 0; i < args.length; i++) {
201
- if (args[i] === '--description' || args[i] === '-d') {
202
- description = args[++i];
203
- } else if (args[i].startsWith('--description=')) {
204
- description = args[i].slice(14);
205
- } else if (!name) {
206
- name = args[i];
207
- }
208
- }
209
-
210
- if (!name) {
211
- ui.error('Usage: tmux-team pm milestone add <name> [--description <text>]');
212
- ctx.exit(ExitCodes.ERROR);
213
- }
214
-
215
- const milestone = await storage.createMilestone({ name, description });
216
-
217
- await storage.appendEvent({
218
- event: 'milestone_created',
219
- id: milestone.id,
220
- name,
221
- actor: 'human',
222
- ts: new Date().toISOString(),
223
- });
224
-
225
- if (flags.json) {
226
- ui.json(milestone);
227
- } else {
228
- ui.success(`Created milestone #${milestone.id}: ${name}`);
229
- }
230
- }
231
-
232
- async function cmdMilestoneList(ctx: Context, _args: string[]): Promise<void> {
233
- requirePermission(ctx, PermissionChecks.milestoneList());
234
-
235
- const { ui, flags } = ctx;
236
- const { storage } = await requireTeam(ctx);
237
-
238
- const milestones = await storage.listMilestones();
239
-
240
- if (flags.json) {
241
- ui.json(milestones);
242
- return;
243
- }
244
-
245
- if (milestones.length === 0) {
246
- ui.info('No milestones. Use: tmux-team pm milestone add <name>');
247
- return;
248
- }
249
-
250
- console.log();
251
- ui.table(
252
- ['ID', 'NAME', 'STATUS'],
253
- milestones.map((m) => [m.id, m.name, formatStatus(m.status)])
254
- );
255
- console.log();
256
- }
257
-
258
- async function cmdMilestoneDone(ctx: Context, args: string[]): Promise<void> {
259
- requirePermission(ctx, PermissionChecks.milestoneUpdate(['status']));
260
-
261
- const { ui, flags } = ctx;
262
- const { storage } = await requireTeam(ctx);
263
-
264
- const id = args[0];
265
- if (!id) {
266
- ui.error('Usage: tmux-team pm milestone done <id>');
267
- ctx.exit(ExitCodes.ERROR);
268
- }
269
-
270
- const milestone = await storage.getMilestone(id);
271
- if (!milestone) {
272
- ui.error(`Milestone ${id} not found`);
273
- ctx.exit(ExitCodes.PANE_NOT_FOUND);
274
- }
275
-
276
- const updated = await storage.updateMilestone(id, { status: 'done' });
277
-
278
- await storage.appendEvent({
279
- event: 'milestone_updated',
280
- id,
281
- field: 'status',
282
- from: milestone.status,
283
- to: 'done',
284
- actor: 'human',
285
- ts: new Date().toISOString(),
286
- });
287
-
288
- if (flags.json) {
289
- ui.json(updated);
290
- } else {
291
- ui.success(`Milestone #${id} marked as done`);
292
- }
293
- }
294
-
295
- async function cmdMilestoneDelete(ctx: Context, args: string[]): Promise<void> {
296
- requirePermission(ctx, PermissionChecks.milestoneDelete());
297
-
298
- const { ui, flags } = ctx;
299
- const { storage } = await requireTeam(ctx);
300
-
301
- const id = args[0];
302
- if (!id) {
303
- ui.error('Usage: tmux-team pm milestone delete <id>');
304
- ctx.exit(ExitCodes.ERROR);
305
- }
306
-
307
- const milestone = await storage.getMilestone(id);
308
- if (!milestone) {
309
- ui.error(`Milestone ${id} not found`);
310
- ctx.exit(ExitCodes.PANE_NOT_FOUND);
311
- }
312
-
313
- await storage.deleteMilestone(id);
314
-
315
- await storage.appendEvent({
316
- event: 'milestone_deleted',
317
- id,
318
- name: milestone.name,
319
- actor: 'human',
320
- ts: new Date().toISOString(),
321
- });
322
-
323
- if (flags.json) {
324
- ui.json({ deleted: true, id, name: milestone.name });
325
- } else {
326
- ui.success(`Milestone #${id} "${milestone.name}" deleted`);
327
- }
328
- }
329
-
330
- async function cmdMilestoneDoc(ctx: Context, args: string[]): Promise<void> {
331
- const { ui, flags } = ctx;
332
-
333
- // Parse arguments
334
- let id: string | undefined;
335
- let body: string | undefined;
336
- let bodyFile: string | undefined;
337
- let showRef = false;
338
- let editMode = false;
339
-
340
- for (let i = 0; i < args.length; i++) {
341
- const arg = args[i];
342
- if (arg === 'ref') {
343
- showRef = true;
344
- } else if (arg === '--edit' || arg === '-e') {
345
- editMode = true;
346
- } else if (arg === '--body' || arg === '-b') {
347
- body = args[++i];
348
- if (body === undefined) {
349
- ui.error('--body requires a value');
350
- ctx.exit(ExitCodes.ERROR);
351
- }
352
- } else if (arg.startsWith('--body=')) {
353
- body = arg.slice(7);
354
- } else if (arg === '--body-file' || arg === '-f') {
355
- bodyFile = args[++i];
356
- if (bodyFile === undefined) {
357
- ui.error('--body-file requires a value');
358
- ctx.exit(ExitCodes.ERROR);
359
- }
360
- } else if (arg.startsWith('--body-file=')) {
361
- bodyFile = arg.slice(12);
362
- } else if (!id) {
363
- id = arg;
364
- }
365
- }
366
-
367
- if (!id) {
368
- ui.error(
369
- 'Usage: tmux-team pm milestone doc <id> [ref | --edit | --body <text> | --body-file <path>]'
370
- );
371
- ctx.exit(ExitCodes.ERROR);
372
- }
373
-
374
- const isWriteMode = editMode || body !== undefined || bodyFile !== undefined;
375
-
376
- // Check permission based on mode
377
- if (isWriteMode) {
378
- requirePermission(ctx, PermissionChecks.docUpdate());
379
- } else {
380
- requirePermission(ctx, PermissionChecks.docRead());
381
- }
382
-
383
- const { storage } = await requireTeam(ctx);
384
- const milestone = await storage.getMilestone(id);
385
- if (!milestone) {
386
- ui.error(`Milestone ${id} not found`);
387
- ctx.exit(ExitCodes.PANE_NOT_FOUND);
388
- }
389
-
390
- // Show reference (docPath)
391
- if (showRef) {
392
- if (flags.json) {
393
- ui.json({ id, docPath: milestone.docPath });
394
- } else {
395
- console.log(milestone.docPath || '(no docPath)');
396
- }
397
- return;
398
- }
399
-
400
- // --body: set content directly
401
- if (body !== undefined) {
402
- await storage.setMilestoneDoc(id, body);
403
- ui.success(`Saved documentation for milestone #${id}`);
404
- return;
405
- }
406
-
407
- // --body-file: read content from file
408
- if (bodyFile !== undefined) {
409
- if (!fs.existsSync(bodyFile)) {
410
- ui.error(`File not found: ${bodyFile}`);
411
- ctx.exit(ExitCodes.ERROR);
412
- }
413
- const content = fs.readFileSync(bodyFile, 'utf-8');
414
- await storage.setMilestoneDoc(id, content);
415
- ui.success(`Saved documentation for milestone #${id} (from ${bodyFile})`);
416
- return;
417
- }
418
-
419
- const doc = await storage.getMilestoneDoc(id);
420
-
421
- // Default: print doc content
422
- if (!editMode) {
423
- if (flags.json) {
424
- ui.json({ id, doc });
425
- } else {
426
- console.log(doc || '(empty)');
427
- }
428
- return;
429
- }
430
-
431
- // Edit mode: open in editor using temp file
432
- const editor = process.env.EDITOR || 'vim';
433
- const tempDir = os.tmpdir();
434
- const tempFile = path.join(tempDir, `tmux-team-milestone-${id}.md`);
435
-
436
- // Write current content to temp file
437
- fs.writeFileSync(tempFile, doc || `# ${milestone.name}\n\n`);
438
-
439
- const { spawnSync } = await import('child_process');
440
- spawnSync(editor, [tempFile], { stdio: 'inherit' });
441
-
442
- // Read edited content and sync back to storage
443
- const newContent = fs.readFileSync(tempFile, 'utf-8');
444
- await storage.setMilestoneDoc(id, newContent);
445
-
446
- // Clean up temp file
447
- try {
448
- fs.unlinkSync(tempFile);
449
- } catch {
450
- // Ignore cleanup errors
451
- }
452
-
453
- ui.success(`Saved documentation for milestone #${id}`);
454
- }
455
-
456
- export async function cmdPmTask(ctx: Context, args: string[]): Promise<void> {
457
- const [subcommand, ...rest] = args;
458
-
459
- switch (subcommand) {
460
- case 'add':
461
- return cmdTaskAdd(ctx, rest);
462
- case 'list':
463
- case 'ls':
464
- case undefined:
465
- return cmdTaskList(ctx, rest);
466
- case 'show':
467
- return cmdTaskShow(ctx, rest);
468
- case 'update':
469
- return cmdTaskUpdate(ctx, rest);
470
- case 'done':
471
- return cmdTaskDone(ctx, rest);
472
- case 'doc':
473
- return cmdTaskDoc(ctx, rest);
474
- default:
475
- ctx.ui.error(`Unknown task command: ${subcommand}. Use: add, list, show, update, done, doc`);
476
- ctx.exit(ExitCodes.ERROR);
477
- }
478
- }
479
-
480
- async function cmdTaskAdd(ctx: Context, args: string[]): Promise<void> {
481
- requirePermission(ctx, PermissionChecks.taskCreate());
482
-
483
- const { ui, flags } = ctx;
484
- const { storage } = await requireTeam(ctx);
485
-
486
- // Parse args: <title> [--milestone <id>] [--assignee <name>] [--body <text>]
487
- let title = '';
488
- let body: string | undefined;
489
- let milestone: string | undefined;
490
- let assignee: string | undefined;
491
-
492
- for (let i = 0; i < args.length; i++) {
493
- if (args[i] === '--milestone' || args[i] === '-m') {
494
- milestone = args[++i];
495
- } else if (args[i].startsWith('--milestone=')) {
496
- milestone = args[i].slice(12);
497
- } else if (args[i] === '--assignee' || args[i] === '-a') {
498
- assignee = args[++i];
499
- } else if (args[i].startsWith('--assignee=')) {
500
- assignee = args[i].slice(11);
501
- } else if (args[i] === '--body' || args[i] === '-b') {
502
- body = args[++i];
503
- } else if (args[i].startsWith('--body=')) {
504
- body = args[i].slice(7);
505
- } else if (!title) {
506
- title = args[i];
507
- }
508
- }
509
-
510
- if (!title) {
511
- ui.error('Usage: tmux-team pm task add <title> [--milestone <id>]');
512
- ctx.exit(ExitCodes.ERROR);
513
- }
514
-
515
- const task = await storage.createTask({ title, body, milestone, assignee });
516
-
517
- await storage.appendEvent({
518
- event: 'task_created',
519
- id: task.id,
520
- title,
521
- milestone,
522
- actor: 'human',
523
- ts: new Date().toISOString(),
524
- });
525
-
526
- if (flags.json) {
527
- ui.json(task);
528
- } else {
529
- ui.success(`Created task #${task.id}: ${title}`);
530
- }
531
- }
532
-
533
- async function cmdTaskList(ctx: Context, args: string[]): Promise<void> {
534
- requirePermission(ctx, PermissionChecks.taskList());
535
-
536
- const { ui, flags, config } = ctx;
537
- const { storage } = await requireTeam(ctx);
538
-
539
- // Parse filters
540
- let milestone: string | undefined;
541
- let status: TaskStatus | undefined;
542
- let showAll = false;
543
-
544
- for (let i = 0; i < args.length; i++) {
545
- if (args[i] === '--milestone' || args[i] === '-m') {
546
- milestone = args[++i];
547
- } else if (args[i].startsWith('--milestone=')) {
548
- milestone = args[i].slice(12);
549
- } else if (args[i] === '--status' || args[i] === '-s') {
550
- status = parseStatus(args[++i]);
551
- } else if (args[i].startsWith('--status=')) {
552
- status = parseStatus(args[i].slice(9));
553
- } else if (args[i] === '--all' || args[i] === '-a') {
554
- showAll = true;
555
- }
556
- }
557
-
558
- const hideOrphanTasks = config.defaults.hideOrphanTasks;
559
-
560
- // By default, exclude tasks in completed milestones (unless --all)
561
- const tasks = await storage.listTasks({
562
- milestone,
563
- status,
564
- excludeCompletedMilestones: !showAll,
565
- hideOrphanTasks,
566
- });
567
-
568
- if (flags.json) {
569
- ui.json(tasks);
570
- return;
571
- }
572
-
573
- if (tasks.length === 0) {
574
- ui.info('No tasks. Use: tmux-team pm task add <title>');
575
- return;
576
- }
577
-
578
- console.log();
579
- ui.table(
580
- ['ID', 'TITLE', 'STATUS', 'MILESTONE'],
581
- tasks.map((t) => [t.id, t.title.slice(0, 40), formatStatus(t.status), t.milestone || '-'])
582
- );
583
- console.log();
584
-
585
- if (!flags.json) {
586
- const modeHint = hideOrphanTasks
587
- ? 'hiding tasks without milestones'
588
- : 'showing tasks without milestones';
589
- const toggleHint = hideOrphanTasks ? 'false' : 'true';
590
- ui.info(
591
- `List mode: ${modeHint}. Use: ${colors.cyan(`tmt config set hideOrphanTasks ${toggleHint}`)}`
592
- );
593
- }
594
- }
595
-
596
- async function cmdTaskShow(ctx: Context, args: string[]): Promise<void> {
597
- requirePermission(ctx, PermissionChecks.taskShow());
598
-
599
- const { ui, flags } = ctx;
600
- const { storage } = await requireTeam(ctx);
601
-
602
- const id = args[0];
603
- if (!id) {
604
- ui.error('Usage: tmux-team pm task show <id>');
605
- ctx.exit(ExitCodes.ERROR);
606
- }
607
-
608
- const task = await storage.getTask(id);
609
- if (!task) {
610
- ui.error(`Task ${id} not found`);
611
- ctx.exit(ExitCodes.PANE_NOT_FOUND);
612
- }
613
-
614
- if (flags.json) {
615
- ui.json(task);
616
- return;
617
- }
618
-
619
- console.log();
620
- console.log(colors.cyan(`Task #${task.id}: ${task.title}`));
621
- console.log(`Status: ${formatStatus(task.status)}`);
622
- if (task.milestone) console.log(`Milestone: #${task.milestone}`);
623
- if (task.assignee) console.log(`Assignee: ${task.assignee}`);
624
- console.log(`Created: ${task.createdAt}`);
625
- console.log(`Updated: ${task.updatedAt}`);
626
- console.log();
627
- }
628
-
629
- async function cmdTaskUpdate(ctx: Context, args: string[]): Promise<void> {
630
- const { ui, flags } = ctx;
631
-
632
- // Parse: <id> --status <status> [--assignee <name>]
633
- const id = args[0];
634
- if (!id) {
635
- ui.error('Usage: tmux-team pm task update <id> --status <status>');
636
- ctx.exit(ExitCodes.ERROR);
637
- }
638
-
639
- let status: TaskStatus | undefined;
640
- let assignee: string | undefined;
641
-
642
- for (let i = 1; i < args.length; i++) {
643
- if (args[i] === '--status' || args[i] === '-s') {
644
- status = parseStatus(args[++i]);
645
- } else if (args[i].startsWith('--status=')) {
646
- status = parseStatus(args[i].slice(9));
647
- } else if (args[i] === '--assignee' || args[i] === '-a') {
648
- assignee = args[++i];
649
- } else if (args[i].startsWith('--assignee=')) {
650
- assignee = args[i].slice(11);
651
- }
652
- }
653
-
654
- // Check permissions based on which fields are being updated
655
- const fields: string[] = [];
656
- if (status) fields.push('status');
657
- if (assignee) fields.push('assignee');
658
- if (fields.length > 0) {
659
- requirePermission(ctx, PermissionChecks.taskUpdate(fields));
660
- }
661
-
662
- const { storage } = await requireTeam(ctx);
663
- const task = await storage.getTask(id);
664
- if (!task) {
665
- ui.error(`Task ${id} not found`);
666
- ctx.exit(ExitCodes.PANE_NOT_FOUND);
667
- }
668
-
669
- const updates: { status?: TaskStatus; assignee?: string } = {};
670
- if (status) updates.status = status;
671
- if (assignee) updates.assignee = assignee;
672
-
673
- if (Object.keys(updates).length === 0) {
674
- ui.error('No updates specified. Use --status or --assignee');
675
- ctx.exit(ExitCodes.ERROR);
676
- }
677
-
678
- const updated = await storage.updateTask(id, updates);
679
-
680
- if (status) {
681
- await storage.appendEvent({
682
- event: 'task_updated',
683
- id,
684
- field: 'status',
685
- from: task.status,
686
- to: status,
687
- actor: 'human',
688
- ts: new Date().toISOString(),
689
- });
690
- }
691
-
692
- if (flags.json) {
693
- ui.json(updated);
694
- } else {
695
- ui.success(`Updated task #${id}`);
696
- }
697
- }
698
-
699
- async function cmdTaskDone(ctx: Context, args: string[]): Promise<void> {
700
- requirePermission(ctx, PermissionChecks.taskUpdate(['status']));
701
-
702
- const { ui, flags } = ctx;
703
- const { storage } = await requireTeam(ctx);
704
-
705
- const id = args[0];
706
- if (!id) {
707
- ui.error('Usage: tmux-team pm task done <id>');
708
- ctx.exit(ExitCodes.ERROR);
709
- }
710
-
711
- const task = await storage.getTask(id);
712
- if (!task) {
713
- ui.error(`Task ${id} not found`);
714
- ctx.exit(ExitCodes.PANE_NOT_FOUND);
715
- }
716
-
717
- const updated = await storage.updateTask(id, { status: 'done' });
718
-
719
- await storage.appendEvent({
720
- event: 'task_updated',
721
- id,
722
- field: 'status',
723
- from: task.status,
724
- to: 'done',
725
- actor: 'human',
726
- ts: new Date().toISOString(),
727
- });
728
-
729
- if (flags.json) {
730
- ui.json(updated);
731
- } else {
732
- ui.success(`Task #${id} marked as done`);
733
- }
734
- }
735
-
736
- async function cmdTaskDoc(ctx: Context, args: string[]): Promise<void> {
737
- const { ui, flags } = ctx;
738
-
739
- // Parse arguments
740
- let id: string | undefined;
741
- let body: string | undefined;
742
- let bodyFile: string | undefined;
743
- let showRef = false;
744
- let editMode = false;
745
-
746
- for (let i = 0; i < args.length; i++) {
747
- const arg = args[i];
748
- if (arg === 'ref') {
749
- showRef = true;
750
- } else if (arg === '--edit' || arg === '-e') {
751
- editMode = true;
752
- } else if (arg === '--body' || arg === '-b') {
753
- body = args[++i];
754
- if (body === undefined) {
755
- ui.error('--body requires a value');
756
- ctx.exit(ExitCodes.ERROR);
757
- }
758
- } else if (arg.startsWith('--body=')) {
759
- body = arg.slice(7);
760
- } else if (arg === '--body-file' || arg === '-f') {
761
- bodyFile = args[++i];
762
- if (bodyFile === undefined) {
763
- ui.error('--body-file requires a value');
764
- ctx.exit(ExitCodes.ERROR);
765
- }
766
- } else if (arg.startsWith('--body-file=')) {
767
- bodyFile = arg.slice(12);
768
- } else if (!id) {
769
- id = arg;
770
- }
771
- }
772
-
773
- if (!id) {
774
- ui.error(
775
- 'Usage: tmux-team pm task doc <id> [ref | --edit | --body <text> | --body-file <path>]'
776
- );
777
- ctx.exit(ExitCodes.ERROR);
778
- }
779
-
780
- const isWriteMode = editMode || body !== undefined || bodyFile !== undefined;
781
-
782
- // Check permission based on mode
783
- if (isWriteMode) {
784
- requirePermission(ctx, PermissionChecks.docUpdate());
785
- } else {
786
- requirePermission(ctx, PermissionChecks.docRead());
787
- }
788
-
789
- const { storage } = await requireTeam(ctx);
790
- const task = await storage.getTask(id);
791
- if (!task) {
792
- ui.error(`Task ${id} not found`);
793
- ctx.exit(ExitCodes.PANE_NOT_FOUND);
794
- }
795
-
796
- // Show reference (docPath)
797
- if (showRef) {
798
- if (flags.json) {
799
- ui.json({ id, docPath: task.docPath });
800
- } else {
801
- console.log(task.docPath || '(no docPath)');
802
- }
803
- return;
804
- }
805
-
806
- // --body: set content directly
807
- if (body !== undefined) {
808
- await storage.setTaskDoc(id, body);
809
- ui.success(`Saved documentation for task #${id}`);
810
- return;
811
- }
812
-
813
- // --body-file: read content from file
814
- if (bodyFile !== undefined) {
815
- if (!fs.existsSync(bodyFile)) {
816
- ui.error(`File not found: ${bodyFile}`);
817
- ctx.exit(ExitCodes.ERROR);
818
- }
819
- const content = fs.readFileSync(bodyFile, 'utf-8');
820
- await storage.setTaskDoc(id, content);
821
- ui.success(`Saved documentation for task #${id} (from ${bodyFile})`);
822
- return;
823
- }
824
-
825
- const doc = await storage.getTaskDoc(id);
826
-
827
- // Default: print doc content
828
- if (!editMode) {
829
- if (flags.json) {
830
- ui.json({ id, doc });
831
- } else {
832
- console.log(doc || '(empty)');
833
- }
834
- return;
835
- }
836
-
837
- // Edit mode: open in editor using temp file
838
- const editor = process.env.EDITOR || 'vim';
839
- const tempDir = os.tmpdir();
840
- const tempFile = path.join(tempDir, `tmux-team-task-${id}.md`);
841
-
842
- // Write current content to temp file
843
- fs.writeFileSync(tempFile, doc || `# ${task.title}\n\n`);
844
-
845
- const { spawnSync } = await import('child_process');
846
- spawnSync(editor, [tempFile], { stdio: 'inherit' });
847
-
848
- // Read edited content and sync back to storage
849
- const newContent = fs.readFileSync(tempFile, 'utf-8');
850
- await storage.setTaskDoc(id, newContent);
851
-
852
- // Clean up temp file
853
- try {
854
- fs.unlinkSync(tempFile);
855
- } catch {
856
- // Ignore cleanup errors
857
- }
858
-
859
- ui.success(`Saved documentation for task #${id}`);
860
- }
861
-
862
- export async function cmdPmLog(ctx: Context, args: string[]): Promise<void> {
863
- requirePermission(ctx, PermissionChecks.logRead());
864
-
865
- const { ui, flags } = ctx;
866
- const { storage } = await requireTeam(ctx);
867
-
868
- // Parse --limit flag
869
- let limit: number | undefined;
870
- for (let i = 0; i < args.length; i++) {
871
- if (args[i] === '--limit' || args[i] === '-n') {
872
- limit = parseInt(args[++i], 10);
873
- } else if (args[i].startsWith('--limit=')) {
874
- limit = parseInt(args[i].slice(8), 10);
875
- }
876
- }
877
-
878
- const events = await storage.getEvents(limit);
879
-
880
- if (flags.json) {
881
- ui.json(events);
882
- return;
883
- }
884
-
885
- if (events.length === 0) {
886
- ui.info('No events logged yet.');
887
- return;
888
- }
889
-
890
- console.log();
891
- for (const event of events) {
892
- const time = event.ts.slice(0, 19).replace('T', ' ');
893
- const actor = colors.cyan(event.actor);
894
- const action = colors.yellow(event.event);
895
- const id = event.id ? `#${event.id}` : '';
896
- console.log(`${colors.dim(time)} ${actor} ${action} ${id}`);
897
- }
898
- console.log();
899
- }
900
-
901
- export async function cmdPmList(ctx: Context, _args: string[]): Promise<void> {
902
- requirePermission(ctx, PermissionChecks.teamList());
903
-
904
- const { ui, flags, paths } = ctx;
905
-
906
- const teams = listTeams(paths.globalDir);
907
- const currentTeamId = findCurrentTeamId(process.cwd(), paths.globalDir);
908
-
909
- if (flags.json) {
910
- ui.json({ teams, currentTeamId });
911
- return;
912
- }
913
-
914
- if (teams.length === 0) {
915
- ui.info("No teams. Use: tmux-team pm init --name 'My Project'");
916
- return;
917
- }
918
-
919
- console.log();
920
- ui.table(
921
- ['', 'ID', 'NAME', 'BACKEND', 'CREATED'],
922
- teams.map((t) => [
923
- t.id === currentTeamId ? colors.green('→') : ' ',
924
- t.id.slice(0, 8) + '...',
925
- t.name,
926
- t.backend === 'github' ? colors.cyan('github') : colors.dim('fs'),
927
- t.createdAt.slice(0, 10),
928
- ])
929
- );
930
- console.log();
931
- }
932
-
933
- // ─────────────────────────────────────────────────────────────
934
- // Main PM router
935
- // ─────────────────────────────────────────────────────────────
936
-
937
- export async function cmdPm(ctx: Context, args: string[]): Promise<void> {
938
- const [subcommand, ...rest] = args;
939
-
940
- // Handle shorthands
941
- const cmd = subcommand === 'm' ? 'milestone' : subcommand === 't' ? 'task' : subcommand;
942
-
943
- switch (cmd) {
944
- case 'init':
945
- return cmdPmInit(ctx, rest);
946
- case 'milestone':
947
- return cmdPmMilestone(ctx, rest);
948
- case 'task':
949
- return cmdPmTask(ctx, rest);
950
- case 'log':
951
- return cmdPmLog(ctx, rest);
952
- case 'list':
953
- case 'ls':
954
- return cmdPmList(ctx, rest);
955
- case undefined:
956
- case 'help':
957
- return cmdPmHelp(ctx);
958
- default:
959
- ctx.ui.error(`Unknown pm command: ${subcommand}. Run 'tmux-team pm help'`);
960
- ctx.exit(ExitCodes.ERROR);
961
- }
962
- }
963
-
964
- function cmdPmHelp(_ctx: Context): void {
965
- console.log(`
966
- ${colors.cyan('tmux-team pm')} - Project management
967
-
968
- ${colors.yellow('COMMANDS')}
969
- ${colors.green('init')} [options] Create a new team/project
970
- --name <name> Project name
971
- --backend <fs|github> Storage backend (default: fs)
972
- --repo <owner/repo> GitHub repo (required for github backend)
973
- ${colors.green('list')} List all teams
974
- ${colors.green('milestone')} add <name> [-d <desc>] Add milestone (shorthand: m)
975
- ${colors.green('milestone')} list List milestones
976
- ${colors.green('milestone')} done <id> Mark milestone complete
977
- ${colors.green('milestone')} delete <id> Delete milestone (rm)
978
- ${colors.green('milestone')} doc <id> [options] Print/update doc
979
- ref: show path, --edit: edit, --body: set text, --body-file: set from file
980
- ${colors.green('task')} add <title> [--milestone] Add task (shorthand: t)
981
- ${colors.green('task')} list [options] List tasks (hides done milestones by default)
982
- --all: include tasks in completed milestones
983
- ${colors.green('task')} show <id> Show task details
984
- ${colors.green('task')} update <id> --status <s> Update task status
985
- ${colors.green('task')} done <id> Mark task complete
986
- ${colors.green('task')} doc <id> [options] Print/update doc (same options as milestone doc)
987
- ${colors.green('log')} [--limit <n>] Show audit event log
988
-
989
- ${colors.yellow('BACKENDS')}
990
- ${colors.cyan('fs')} Local filesystem (default) - tasks in ~/.config/tmux-team/teams/
991
- ${colors.cyan('github')} GitHub Issues - tasks become issues, milestones sync with GH
992
-
993
- ${colors.yellow('SHORTHANDS')}
994
- pm m = pm milestone
995
- pm t = pm task
996
- pm ls = pm list
997
-
998
- ${colors.yellow('EXAMPLES')}
999
- # Local filesystem backend (default)
1000
- tmux-team pm init --name "Auth Refactor"
1001
-
1002
- # GitHub backend - uses gh CLI for auth
1003
- tmux-team pm init --name "Sprint 1" --backend github --repo owner/repo
1004
-
1005
- tmux-team pm m add "Phase 1"
1006
- tmux-team pm t add "Implement login" --milestone 1
1007
- tmux-team pm t list --status pending
1008
- tmux-team pm t done 1
1009
- tmux-team pm log --limit 10
1010
- `);
1011
- }