tmux-team 1.1.0 → 2.0.0-alpha.3
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 +195 -22
- package/bin/tmux-team +31 -430
- package/package.json +28 -6
- package/src/cli.ts +222 -0
- package/src/commands/add.ts +38 -0
- package/src/commands/check.ts +34 -0
- package/src/commands/completion.ts +118 -0
- package/src/commands/config.ts +187 -0
- package/src/commands/help.ts +66 -0
- package/src/commands/init.ts +24 -0
- package/src/commands/list.ts +27 -0
- package/src/commands/preamble.ts +153 -0
- package/src/commands/remove.ts +25 -0
- package/src/commands/talk.test.ts +679 -0
- package/src/commands/talk.ts +274 -0
- package/src/commands/update.ts +47 -0
- package/src/config.test.ts +246 -0
- package/src/config.ts +223 -0
- package/src/context.ts +38 -0
- package/src/exits.ts +14 -0
- package/src/pm/commands.test.ts +1127 -0
- package/src/pm/commands.ts +723 -0
- package/src/pm/manager.test.ts +377 -0
- package/src/pm/manager.ts +146 -0
- package/src/pm/permissions.test.ts +332 -0
- package/src/pm/permissions.ts +278 -0
- package/src/pm/storage/adapter.ts +55 -0
- package/src/pm/storage/fs.test.ts +384 -0
- package/src/pm/storage/fs.ts +256 -0
- package/src/pm/storage/github.ts +763 -0
- package/src/pm/types.ts +85 -0
- package/src/state.test.ts +311 -0
- package/src/state.ts +83 -0
- package/src/tmux.test.ts +205 -0
- package/src/tmux.ts +27 -0
- package/src/types.ts +97 -0
- package/src/ui.ts +76 -0
- package/src/version.ts +21 -0
|
@@ -0,0 +1,723 @@
|
|
|
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
|
+
|
|
28
|
+
// ─────────────────────────────────────────────────────────────
|
|
29
|
+
// Helpers
|
|
30
|
+
// ─────────────────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
async function requireTeam(ctx: Context): Promise<{ teamId: string; storage: StorageAdapter }> {
|
|
33
|
+
const teamId = findCurrentTeamId(process.cwd(), ctx.paths.globalDir);
|
|
34
|
+
if (!teamId) {
|
|
35
|
+
ctx.ui.error("No team found. Run 'tmux-team pm init' first or navigate to a linked directory.");
|
|
36
|
+
ctx.exit(ExitCodes.CONFIG_MISSING);
|
|
37
|
+
}
|
|
38
|
+
const storage = getStorageAdapter(teamId, ctx.paths.globalDir);
|
|
39
|
+
|
|
40
|
+
// Validate team exists
|
|
41
|
+
const team = await storage.getTeam();
|
|
42
|
+
if (!team) {
|
|
43
|
+
ctx.ui.error(`Team ${teamId} not found. The .tmux-team-id file may be stale.`);
|
|
44
|
+
ctx.exit(ExitCodes.CONFIG_MISSING);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return { teamId, storage };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function formatStatus(status: TaskStatus | MilestoneStatus): string {
|
|
51
|
+
switch (status) {
|
|
52
|
+
case 'pending':
|
|
53
|
+
return colors.yellow('pending');
|
|
54
|
+
case 'in_progress':
|
|
55
|
+
return colors.blue('in_progress');
|
|
56
|
+
case 'done':
|
|
57
|
+
return colors.green('done');
|
|
58
|
+
default:
|
|
59
|
+
return status;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function parseStatus(s: string): TaskStatus {
|
|
64
|
+
const normalized = s.toLowerCase().replace(/-/g, '_');
|
|
65
|
+
if (normalized === 'pending' || normalized === 'in_progress' || normalized === 'done') {
|
|
66
|
+
return normalized as TaskStatus;
|
|
67
|
+
}
|
|
68
|
+
throw new Error(`Invalid status: ${s}. Use: pending, in_progress, done`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function requirePermission(ctx: Context, check: PermissionCheck): void {
|
|
72
|
+
const result = checkPermission(ctx.config, check);
|
|
73
|
+
|
|
74
|
+
// Display warning if there's an identity conflict
|
|
75
|
+
if (result.warning) {
|
|
76
|
+
ctx.ui.warn(result.warning);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (!result.allowed) {
|
|
80
|
+
const permPath = buildPermissionPath(check);
|
|
81
|
+
ctx.ui.error(`Permission denied: ${result.actor} cannot perform ${permPath}`);
|
|
82
|
+
ctx.exit(ExitCodes.ERROR);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ─────────────────────────────────────────────────────────────
|
|
87
|
+
// Commands
|
|
88
|
+
// ─────────────────────────────────────────────────────────────
|
|
89
|
+
|
|
90
|
+
export async function cmdPmInit(ctx: Context, args: string[]): Promise<void> {
|
|
91
|
+
requirePermission(ctx, PermissionChecks.teamCreate());
|
|
92
|
+
|
|
93
|
+
const { ui, flags, paths } = ctx;
|
|
94
|
+
|
|
95
|
+
// Parse flags: --name, --backend, --repo
|
|
96
|
+
let name = 'Unnamed Project';
|
|
97
|
+
let backend: StorageBackend = 'fs';
|
|
98
|
+
let repo: string | undefined;
|
|
99
|
+
|
|
100
|
+
for (let i = 0; i < args.length; i++) {
|
|
101
|
+
if (args[i] === '--name' && args[i + 1]) {
|
|
102
|
+
name = args[++i];
|
|
103
|
+
} else if (args[i].startsWith('--name=')) {
|
|
104
|
+
name = args[i].slice(7);
|
|
105
|
+
} else if (args[i] === '--backend' && args[i + 1]) {
|
|
106
|
+
const b = args[++i];
|
|
107
|
+
if (b !== 'fs' && b !== 'github') {
|
|
108
|
+
ui.error(`Invalid backend: ${b}. Use: fs, github`);
|
|
109
|
+
ctx.exit(ExitCodes.ERROR);
|
|
110
|
+
}
|
|
111
|
+
backend = b;
|
|
112
|
+
} else if (args[i].startsWith('--backend=')) {
|
|
113
|
+
const b = args[i].slice(10);
|
|
114
|
+
if (b !== 'fs' && b !== 'github') {
|
|
115
|
+
ui.error(`Invalid backend: ${b}. Use: fs, github`);
|
|
116
|
+
ctx.exit(ExitCodes.ERROR);
|
|
117
|
+
}
|
|
118
|
+
backend = b as StorageBackend;
|
|
119
|
+
} else if (args[i] === '--repo' && args[i + 1]) {
|
|
120
|
+
repo = args[++i];
|
|
121
|
+
} else if (args[i].startsWith('--repo=')) {
|
|
122
|
+
repo = args[i].slice(7);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Validate GitHub backend requires repo
|
|
127
|
+
if (backend === 'github' && !repo) {
|
|
128
|
+
ui.error('GitHub backend requires --repo flag (e.g., --repo owner/repo)');
|
|
129
|
+
ctx.exit(ExitCodes.ERROR);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const teamId = generateTeamId();
|
|
133
|
+
const teamDir = path.join(getTeamsDir(paths.globalDir), teamId);
|
|
134
|
+
|
|
135
|
+
// Save config first
|
|
136
|
+
saveTeamConfig(teamDir, { backend, repo });
|
|
137
|
+
|
|
138
|
+
// Create storage adapter
|
|
139
|
+
const storage = createStorageAdapter(teamDir, backend, repo);
|
|
140
|
+
|
|
141
|
+
const team = await storage.initTeam(name);
|
|
142
|
+
linkTeam(process.cwd(), teamId);
|
|
143
|
+
|
|
144
|
+
await storage.appendEvent({
|
|
145
|
+
event: 'team_created',
|
|
146
|
+
id: teamId,
|
|
147
|
+
name,
|
|
148
|
+
backend,
|
|
149
|
+
repo,
|
|
150
|
+
actor: 'human',
|
|
151
|
+
ts: new Date().toISOString(),
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
if (flags.json) {
|
|
155
|
+
ui.json({ team, backend, repo, linked: process.cwd() });
|
|
156
|
+
} else {
|
|
157
|
+
ui.success(`Created team '${name}' (${teamId})`);
|
|
158
|
+
if (backend === 'github') {
|
|
159
|
+
ui.info(`Backend: GitHub (${repo})`);
|
|
160
|
+
}
|
|
161
|
+
ui.info(`Linked to ${process.cwd()}`);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export async function cmdPmMilestone(ctx: Context, args: string[]): Promise<void> {
|
|
166
|
+
const [subcommand, ...rest] = args;
|
|
167
|
+
|
|
168
|
+
switch (subcommand) {
|
|
169
|
+
case 'add':
|
|
170
|
+
return cmdMilestoneAdd(ctx, rest);
|
|
171
|
+
case 'list':
|
|
172
|
+
case 'ls':
|
|
173
|
+
return cmdMilestoneList(ctx, rest);
|
|
174
|
+
case 'done':
|
|
175
|
+
return cmdMilestoneDone(ctx, rest);
|
|
176
|
+
default:
|
|
177
|
+
ctx.ui.error(`Unknown milestone command: ${subcommand}. Use: add, list, done`);
|
|
178
|
+
ctx.exit(ExitCodes.ERROR);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async function cmdMilestoneAdd(ctx: Context, args: string[]): Promise<void> {
|
|
183
|
+
requirePermission(ctx, PermissionChecks.milestoneCreate());
|
|
184
|
+
|
|
185
|
+
const { ui, flags } = ctx;
|
|
186
|
+
const { storage } = await requireTeam(ctx);
|
|
187
|
+
|
|
188
|
+
const name = args[0];
|
|
189
|
+
if (!name) {
|
|
190
|
+
ui.error('Usage: tmux-team pm milestone add <name>');
|
|
191
|
+
ctx.exit(ExitCodes.ERROR);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const milestone = await storage.createMilestone({ name });
|
|
195
|
+
|
|
196
|
+
await storage.appendEvent({
|
|
197
|
+
event: 'milestone_created',
|
|
198
|
+
id: milestone.id,
|
|
199
|
+
name,
|
|
200
|
+
actor: 'human',
|
|
201
|
+
ts: new Date().toISOString(),
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
if (flags.json) {
|
|
205
|
+
ui.json(milestone);
|
|
206
|
+
} else {
|
|
207
|
+
ui.success(`Created milestone #${milestone.id}: ${name}`);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async function cmdMilestoneList(ctx: Context, _args: string[]): Promise<void> {
|
|
212
|
+
requirePermission(ctx, PermissionChecks.milestoneList());
|
|
213
|
+
|
|
214
|
+
const { ui, flags } = ctx;
|
|
215
|
+
const { storage } = await requireTeam(ctx);
|
|
216
|
+
|
|
217
|
+
const milestones = await storage.listMilestones();
|
|
218
|
+
|
|
219
|
+
if (flags.json) {
|
|
220
|
+
ui.json(milestones);
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (milestones.length === 0) {
|
|
225
|
+
ui.info('No milestones. Use: tmux-team pm milestone add <name>');
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
console.log();
|
|
230
|
+
ui.table(
|
|
231
|
+
['ID', 'NAME', 'STATUS'],
|
|
232
|
+
milestones.map((m) => [m.id, m.name, formatStatus(m.status)])
|
|
233
|
+
);
|
|
234
|
+
console.log();
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
async function cmdMilestoneDone(ctx: Context, args: string[]): Promise<void> {
|
|
238
|
+
requirePermission(ctx, PermissionChecks.milestoneUpdate(['status']));
|
|
239
|
+
|
|
240
|
+
const { ui, flags } = ctx;
|
|
241
|
+
const { storage } = await requireTeam(ctx);
|
|
242
|
+
|
|
243
|
+
const id = args[0];
|
|
244
|
+
if (!id) {
|
|
245
|
+
ui.error('Usage: tmux-team pm milestone done <id>');
|
|
246
|
+
ctx.exit(ExitCodes.ERROR);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const milestone = await storage.getMilestone(id);
|
|
250
|
+
if (!milestone) {
|
|
251
|
+
ui.error(`Milestone ${id} not found`);
|
|
252
|
+
ctx.exit(ExitCodes.PANE_NOT_FOUND);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const updated = await storage.updateMilestone(id, { status: 'done' });
|
|
256
|
+
|
|
257
|
+
await storage.appendEvent({
|
|
258
|
+
event: 'milestone_updated',
|
|
259
|
+
id,
|
|
260
|
+
field: 'status',
|
|
261
|
+
from: milestone.status,
|
|
262
|
+
to: 'done',
|
|
263
|
+
actor: 'human',
|
|
264
|
+
ts: new Date().toISOString(),
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
if (flags.json) {
|
|
268
|
+
ui.json(updated);
|
|
269
|
+
} else {
|
|
270
|
+
ui.success(`Milestone #${id} marked as done`);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export async function cmdPmTask(ctx: Context, args: string[]): Promise<void> {
|
|
275
|
+
const [subcommand, ...rest] = args;
|
|
276
|
+
|
|
277
|
+
switch (subcommand) {
|
|
278
|
+
case 'add':
|
|
279
|
+
return cmdTaskAdd(ctx, rest);
|
|
280
|
+
case 'list':
|
|
281
|
+
case 'ls':
|
|
282
|
+
return cmdTaskList(ctx, rest);
|
|
283
|
+
case 'show':
|
|
284
|
+
return cmdTaskShow(ctx, rest);
|
|
285
|
+
case 'update':
|
|
286
|
+
return cmdTaskUpdate(ctx, rest);
|
|
287
|
+
case 'done':
|
|
288
|
+
return cmdTaskDone(ctx, rest);
|
|
289
|
+
default:
|
|
290
|
+
ctx.ui.error(`Unknown task command: ${subcommand}. Use: add, list, show, update, done`);
|
|
291
|
+
ctx.exit(ExitCodes.ERROR);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
async function cmdTaskAdd(ctx: Context, args: string[]): Promise<void> {
|
|
296
|
+
requirePermission(ctx, PermissionChecks.taskCreate());
|
|
297
|
+
|
|
298
|
+
const { ui, flags } = ctx;
|
|
299
|
+
const { storage } = await requireTeam(ctx);
|
|
300
|
+
|
|
301
|
+
// Parse args: <title> [--milestone <id>] [--assignee <name>] [--body <text>]
|
|
302
|
+
let title = '';
|
|
303
|
+
let body: string | undefined;
|
|
304
|
+
let milestone: string | undefined;
|
|
305
|
+
let assignee: string | undefined;
|
|
306
|
+
|
|
307
|
+
for (let i = 0; i < args.length; i++) {
|
|
308
|
+
if (args[i] === '--milestone' || args[i] === '-m') {
|
|
309
|
+
milestone = args[++i];
|
|
310
|
+
} else if (args[i].startsWith('--milestone=')) {
|
|
311
|
+
milestone = args[i].slice(12);
|
|
312
|
+
} else if (args[i] === '--assignee' || args[i] === '-a') {
|
|
313
|
+
assignee = args[++i];
|
|
314
|
+
} else if (args[i].startsWith('--assignee=')) {
|
|
315
|
+
assignee = args[i].slice(11);
|
|
316
|
+
} else if (args[i] === '--body' || args[i] === '-b') {
|
|
317
|
+
body = args[++i];
|
|
318
|
+
} else if (args[i].startsWith('--body=')) {
|
|
319
|
+
body = args[i].slice(7);
|
|
320
|
+
} else if (!title) {
|
|
321
|
+
title = args[i];
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
if (!title) {
|
|
326
|
+
ui.error('Usage: tmux-team pm task add <title> [--milestone <id>]');
|
|
327
|
+
ctx.exit(ExitCodes.ERROR);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const task = await storage.createTask({ title, body, milestone, assignee });
|
|
331
|
+
|
|
332
|
+
await storage.appendEvent({
|
|
333
|
+
event: 'task_created',
|
|
334
|
+
id: task.id,
|
|
335
|
+
title,
|
|
336
|
+
milestone,
|
|
337
|
+
actor: 'human',
|
|
338
|
+
ts: new Date().toISOString(),
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
if (flags.json) {
|
|
342
|
+
ui.json(task);
|
|
343
|
+
} else {
|
|
344
|
+
ui.success(`Created task #${task.id}: ${title}`);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
async function cmdTaskList(ctx: Context, args: string[]): Promise<void> {
|
|
349
|
+
requirePermission(ctx, PermissionChecks.taskList());
|
|
350
|
+
|
|
351
|
+
const { ui, flags } = ctx;
|
|
352
|
+
const { storage } = await requireTeam(ctx);
|
|
353
|
+
|
|
354
|
+
// Parse filters
|
|
355
|
+
let milestone: string | undefined;
|
|
356
|
+
let status: TaskStatus | undefined;
|
|
357
|
+
|
|
358
|
+
for (let i = 0; i < args.length; i++) {
|
|
359
|
+
if (args[i] === '--milestone' || args[i] === '-m') {
|
|
360
|
+
milestone = args[++i];
|
|
361
|
+
} else if (args[i].startsWith('--milestone=')) {
|
|
362
|
+
milestone = args[i].slice(12);
|
|
363
|
+
} else if (args[i] === '--status' || args[i] === '-s') {
|
|
364
|
+
status = parseStatus(args[++i]);
|
|
365
|
+
} else if (args[i].startsWith('--status=')) {
|
|
366
|
+
status = parseStatus(args[i].slice(9));
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
const tasks = await storage.listTasks({ milestone, status });
|
|
371
|
+
|
|
372
|
+
if (flags.json) {
|
|
373
|
+
ui.json(tasks);
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
if (tasks.length === 0) {
|
|
378
|
+
ui.info('No tasks. Use: tmux-team pm task add <title>');
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
console.log();
|
|
383
|
+
ui.table(
|
|
384
|
+
['ID', 'TITLE', 'STATUS', 'MILESTONE'],
|
|
385
|
+
tasks.map((t) => [t.id, t.title.slice(0, 40), formatStatus(t.status), t.milestone || '-'])
|
|
386
|
+
);
|
|
387
|
+
console.log();
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
async function cmdTaskShow(ctx: Context, args: string[]): Promise<void> {
|
|
391
|
+
requirePermission(ctx, PermissionChecks.taskShow());
|
|
392
|
+
|
|
393
|
+
const { ui, flags } = ctx;
|
|
394
|
+
const { storage } = await requireTeam(ctx);
|
|
395
|
+
|
|
396
|
+
const id = args[0];
|
|
397
|
+
if (!id) {
|
|
398
|
+
ui.error('Usage: tmux-team pm task show <id>');
|
|
399
|
+
ctx.exit(ExitCodes.ERROR);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
const task = await storage.getTask(id);
|
|
403
|
+
if (!task) {
|
|
404
|
+
ui.error(`Task ${id} not found`);
|
|
405
|
+
ctx.exit(ExitCodes.PANE_NOT_FOUND);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
if (flags.json) {
|
|
409
|
+
ui.json(task);
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
console.log();
|
|
414
|
+
console.log(colors.cyan(`Task #${task.id}: ${task.title}`));
|
|
415
|
+
console.log(`Status: ${formatStatus(task.status)}`);
|
|
416
|
+
if (task.milestone) console.log(`Milestone: #${task.milestone}`);
|
|
417
|
+
if (task.assignee) console.log(`Assignee: ${task.assignee}`);
|
|
418
|
+
console.log(`Created: ${task.createdAt}`);
|
|
419
|
+
console.log(`Updated: ${task.updatedAt}`);
|
|
420
|
+
console.log();
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
async function cmdTaskUpdate(ctx: Context, args: string[]): Promise<void> {
|
|
424
|
+
const { ui, flags } = ctx;
|
|
425
|
+
|
|
426
|
+
// Parse: <id> --status <status> [--assignee <name>]
|
|
427
|
+
const id = args[0];
|
|
428
|
+
if (!id) {
|
|
429
|
+
ui.error('Usage: tmux-team pm task update <id> --status <status>');
|
|
430
|
+
ctx.exit(ExitCodes.ERROR);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
let status: TaskStatus | undefined;
|
|
434
|
+
let assignee: string | undefined;
|
|
435
|
+
|
|
436
|
+
for (let i = 1; i < args.length; i++) {
|
|
437
|
+
if (args[i] === '--status' || args[i] === '-s') {
|
|
438
|
+
status = parseStatus(args[++i]);
|
|
439
|
+
} else if (args[i].startsWith('--status=')) {
|
|
440
|
+
status = parseStatus(args[i].slice(9));
|
|
441
|
+
} else if (args[i] === '--assignee' || args[i] === '-a') {
|
|
442
|
+
assignee = args[++i];
|
|
443
|
+
} else if (args[i].startsWith('--assignee=')) {
|
|
444
|
+
assignee = args[i].slice(11);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// Check permissions based on which fields are being updated
|
|
449
|
+
const fields: string[] = [];
|
|
450
|
+
if (status) fields.push('status');
|
|
451
|
+
if (assignee) fields.push('assignee');
|
|
452
|
+
if (fields.length > 0) {
|
|
453
|
+
requirePermission(ctx, PermissionChecks.taskUpdate(fields));
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
const { storage } = await requireTeam(ctx);
|
|
457
|
+
const task = await storage.getTask(id);
|
|
458
|
+
if (!task) {
|
|
459
|
+
ui.error(`Task ${id} not found`);
|
|
460
|
+
ctx.exit(ExitCodes.PANE_NOT_FOUND);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
const updates: { status?: TaskStatus; assignee?: string } = {};
|
|
464
|
+
if (status) updates.status = status;
|
|
465
|
+
if (assignee) updates.assignee = assignee;
|
|
466
|
+
|
|
467
|
+
if (Object.keys(updates).length === 0) {
|
|
468
|
+
ui.error('No updates specified. Use --status or --assignee');
|
|
469
|
+
ctx.exit(ExitCodes.ERROR);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
const updated = await storage.updateTask(id, updates);
|
|
473
|
+
|
|
474
|
+
if (status) {
|
|
475
|
+
await storage.appendEvent({
|
|
476
|
+
event: 'task_updated',
|
|
477
|
+
id,
|
|
478
|
+
field: 'status',
|
|
479
|
+
from: task.status,
|
|
480
|
+
to: status,
|
|
481
|
+
actor: 'human',
|
|
482
|
+
ts: new Date().toISOString(),
|
|
483
|
+
});
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
if (flags.json) {
|
|
487
|
+
ui.json(updated);
|
|
488
|
+
} else {
|
|
489
|
+
ui.success(`Updated task #${id}`);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
async function cmdTaskDone(ctx: Context, args: string[]): Promise<void> {
|
|
494
|
+
requirePermission(ctx, PermissionChecks.taskUpdate(['status']));
|
|
495
|
+
|
|
496
|
+
const { ui, flags } = ctx;
|
|
497
|
+
const { storage } = await requireTeam(ctx);
|
|
498
|
+
|
|
499
|
+
const id = args[0];
|
|
500
|
+
if (!id) {
|
|
501
|
+
ui.error('Usage: tmux-team pm task done <id>');
|
|
502
|
+
ctx.exit(ExitCodes.ERROR);
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
const task = await storage.getTask(id);
|
|
506
|
+
if (!task) {
|
|
507
|
+
ui.error(`Task ${id} not found`);
|
|
508
|
+
ctx.exit(ExitCodes.PANE_NOT_FOUND);
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
const updated = await storage.updateTask(id, { status: 'done' });
|
|
512
|
+
|
|
513
|
+
await storage.appendEvent({
|
|
514
|
+
event: 'task_updated',
|
|
515
|
+
id,
|
|
516
|
+
field: 'status',
|
|
517
|
+
from: task.status,
|
|
518
|
+
to: 'done',
|
|
519
|
+
actor: 'human',
|
|
520
|
+
ts: new Date().toISOString(),
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
if (flags.json) {
|
|
524
|
+
ui.json(updated);
|
|
525
|
+
} else {
|
|
526
|
+
ui.success(`Task #${id} marked as done`);
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
export async function cmdPmDoc(ctx: Context, args: string[]): Promise<void> {
|
|
531
|
+
const { ui, flags } = ctx;
|
|
532
|
+
|
|
533
|
+
const id = args[0];
|
|
534
|
+
if (!id) {
|
|
535
|
+
ui.error('Usage: tmux-team pm doc <id> [--print]');
|
|
536
|
+
ctx.exit(ExitCodes.ERROR);
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
const printOnly = args.includes('--print') || args.includes('-p');
|
|
540
|
+
|
|
541
|
+
// Check permission based on mode
|
|
542
|
+
if (printOnly || flags.json) {
|
|
543
|
+
requirePermission(ctx, PermissionChecks.docRead());
|
|
544
|
+
} else {
|
|
545
|
+
requirePermission(ctx, PermissionChecks.docUpdate());
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
const { teamId, storage } = await requireTeam(ctx);
|
|
549
|
+
const task = await storage.getTask(id);
|
|
550
|
+
if (!task) {
|
|
551
|
+
ui.error(`Task ${id} not found`);
|
|
552
|
+
ctx.exit(ExitCodes.PANE_NOT_FOUND);
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
const doc = await storage.getTaskDoc(id);
|
|
556
|
+
|
|
557
|
+
if (printOnly || flags.json) {
|
|
558
|
+
if (flags.json) {
|
|
559
|
+
ui.json({ id, doc });
|
|
560
|
+
} else {
|
|
561
|
+
console.log(doc || '(empty)');
|
|
562
|
+
}
|
|
563
|
+
return;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
// Open in editor
|
|
567
|
+
const editor = process.env.EDITOR || 'vim';
|
|
568
|
+
const docPath = path.join(getTeamsDir(ctx.paths.globalDir), teamId, 'tasks', `${id}.md`);
|
|
569
|
+
|
|
570
|
+
const { spawnSync } = await import('child_process');
|
|
571
|
+
spawnSync(editor, [docPath], { stdio: 'inherit' });
|
|
572
|
+
|
|
573
|
+
ui.success(`Saved documentation for task #${id}`);
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
export async function cmdPmLog(ctx: Context, args: string[]): Promise<void> {
|
|
577
|
+
requirePermission(ctx, PermissionChecks.logRead());
|
|
578
|
+
|
|
579
|
+
const { ui, flags } = ctx;
|
|
580
|
+
const { storage } = await requireTeam(ctx);
|
|
581
|
+
|
|
582
|
+
// Parse --limit flag
|
|
583
|
+
let limit: number | undefined;
|
|
584
|
+
for (let i = 0; i < args.length; i++) {
|
|
585
|
+
if (args[i] === '--limit' || args[i] === '-n') {
|
|
586
|
+
limit = parseInt(args[++i], 10);
|
|
587
|
+
} else if (args[i].startsWith('--limit=')) {
|
|
588
|
+
limit = parseInt(args[i].slice(8), 10);
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
const events = await storage.getEvents(limit);
|
|
593
|
+
|
|
594
|
+
if (flags.json) {
|
|
595
|
+
ui.json(events);
|
|
596
|
+
return;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
if (events.length === 0) {
|
|
600
|
+
ui.info('No events logged yet.');
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
console.log();
|
|
605
|
+
for (const event of events) {
|
|
606
|
+
const time = event.ts.slice(0, 19).replace('T', ' ');
|
|
607
|
+
const actor = colors.cyan(event.actor);
|
|
608
|
+
const action = colors.yellow(event.event);
|
|
609
|
+
const id = event.id ? `#${event.id}` : '';
|
|
610
|
+
console.log(`${colors.dim(time)} ${actor} ${action} ${id}`);
|
|
611
|
+
}
|
|
612
|
+
console.log();
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
export async function cmdPmList(ctx: Context, _args: string[]): Promise<void> {
|
|
616
|
+
requirePermission(ctx, PermissionChecks.teamList());
|
|
617
|
+
|
|
618
|
+
const { ui, flags, paths } = ctx;
|
|
619
|
+
|
|
620
|
+
const teams = listTeams(paths.globalDir);
|
|
621
|
+
const currentTeamId = findCurrentTeamId(process.cwd(), paths.globalDir);
|
|
622
|
+
|
|
623
|
+
if (flags.json) {
|
|
624
|
+
ui.json({ teams, currentTeamId });
|
|
625
|
+
return;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
if (teams.length === 0) {
|
|
629
|
+
ui.info("No teams. Use: tmux-team pm init --name 'My Project'");
|
|
630
|
+
return;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
console.log();
|
|
634
|
+
ui.table(
|
|
635
|
+
['', 'ID', 'NAME', 'BACKEND', 'CREATED'],
|
|
636
|
+
teams.map((t) => [
|
|
637
|
+
t.id === currentTeamId ? colors.green('→') : ' ',
|
|
638
|
+
t.id.slice(0, 8) + '...',
|
|
639
|
+
t.name,
|
|
640
|
+
t.backend === 'github' ? colors.cyan('github') : colors.dim('fs'),
|
|
641
|
+
t.createdAt.slice(0, 10),
|
|
642
|
+
])
|
|
643
|
+
);
|
|
644
|
+
console.log();
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
// ─────────────────────────────────────────────────────────────
|
|
648
|
+
// Main PM router
|
|
649
|
+
// ─────────────────────────────────────────────────────────────
|
|
650
|
+
|
|
651
|
+
export async function cmdPm(ctx: Context, args: string[]): Promise<void> {
|
|
652
|
+
const [subcommand, ...rest] = args;
|
|
653
|
+
|
|
654
|
+
// Handle shorthands
|
|
655
|
+
const cmd = subcommand === 'm' ? 'milestone' : subcommand === 't' ? 'task' : subcommand;
|
|
656
|
+
|
|
657
|
+
switch (cmd) {
|
|
658
|
+
case 'init':
|
|
659
|
+
return cmdPmInit(ctx, rest);
|
|
660
|
+
case 'milestone':
|
|
661
|
+
return cmdPmMilestone(ctx, rest);
|
|
662
|
+
case 'task':
|
|
663
|
+
return cmdPmTask(ctx, rest);
|
|
664
|
+
case 'doc':
|
|
665
|
+
return cmdPmDoc(ctx, rest);
|
|
666
|
+
case 'log':
|
|
667
|
+
return cmdPmLog(ctx, rest);
|
|
668
|
+
case 'list':
|
|
669
|
+
case 'ls':
|
|
670
|
+
return cmdPmList(ctx, rest);
|
|
671
|
+
case undefined:
|
|
672
|
+
case 'help':
|
|
673
|
+
return cmdPmHelp(ctx);
|
|
674
|
+
default:
|
|
675
|
+
ctx.ui.error(`Unknown pm command: ${subcommand}. Run 'tmux-team pm help'`);
|
|
676
|
+
ctx.exit(ExitCodes.ERROR);
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
function cmdPmHelp(_ctx: Context): void {
|
|
681
|
+
console.log(`
|
|
682
|
+
${colors.cyan('tmux-team pm')} - Project management
|
|
683
|
+
|
|
684
|
+
${colors.yellow('COMMANDS')}
|
|
685
|
+
${colors.green('init')} [options] Create a new team/project
|
|
686
|
+
--name <name> Project name
|
|
687
|
+
--backend <fs|github> Storage backend (default: fs)
|
|
688
|
+
--repo <owner/repo> GitHub repo (required for github backend)
|
|
689
|
+
${colors.green('list')} List all teams
|
|
690
|
+
${colors.green('milestone')} add <name> Add milestone (shorthand: m)
|
|
691
|
+
${colors.green('milestone')} list List milestones
|
|
692
|
+
${colors.green('milestone')} done <id> Mark milestone complete
|
|
693
|
+
${colors.green('task')} add <title> [--milestone] Add task (shorthand: t)
|
|
694
|
+
${colors.green('task')} list [--status] [--milestone] List tasks
|
|
695
|
+
${colors.green('task')} show <id> Show task details
|
|
696
|
+
${colors.green('task')} update <id> --status <s> Update task status
|
|
697
|
+
${colors.green('task')} done <id> Mark task complete
|
|
698
|
+
${colors.green('doc')} <id> [--print] View/edit task documentation
|
|
699
|
+
${colors.green('log')} [--limit <n>] Show audit event log
|
|
700
|
+
|
|
701
|
+
${colors.yellow('BACKENDS')}
|
|
702
|
+
${colors.cyan('fs')} Local filesystem (default) - tasks in ~/.config/tmux-team/teams/
|
|
703
|
+
${colors.cyan('github')} GitHub Issues - tasks become issues, milestones sync with GH
|
|
704
|
+
|
|
705
|
+
${colors.yellow('SHORTHANDS')}
|
|
706
|
+
pm m = pm milestone
|
|
707
|
+
pm t = pm task
|
|
708
|
+
pm ls = pm list
|
|
709
|
+
|
|
710
|
+
${colors.yellow('EXAMPLES')}
|
|
711
|
+
# Local filesystem backend (default)
|
|
712
|
+
tmux-team pm init --name "Auth Refactor"
|
|
713
|
+
|
|
714
|
+
# GitHub backend - uses gh CLI for auth
|
|
715
|
+
tmux-team pm init --name "Sprint 1" --backend github --repo owner/repo
|
|
716
|
+
|
|
717
|
+
tmux-team pm m add "Phase 1"
|
|
718
|
+
tmux-team pm t add "Implement login" --milestone 1
|
|
719
|
+
tmux-team pm t list --status pending
|
|
720
|
+
tmux-team pm t done 1
|
|
721
|
+
tmux-team pm log --limit 10
|
|
722
|
+
`);
|
|
723
|
+
}
|