@taskset/cli 0.1.2 → 2.0.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.
package/src/cli.ts CHANGED
@@ -1,34 +1,66 @@
1
1
  import path from 'node:path'
2
2
  import { parseArgs } from 'node:util'
3
3
  import {
4
- TASK_PRIORITIES,
5
- TASK_STATUSES,
6
- type TaskPriority,
7
- type TaskStatus,
4
+ TaskIdSchema,
5
+ TaskPrioritySchema,
6
+ TaskRiskSchema,
7
+ TaskStatusSchema,
8
+ TaskTimestampSchema,
8
9
  } from '@taskset/contracts'
9
10
  import {
11
+ buildTaskIndex,
12
+ createSnapshot,
10
13
  createTask,
14
+ type DerivedTaskRelationships,
15
+ deleteTask,
16
+ diagnoseRepository,
11
17
  discoverRepository,
18
+ generateViews,
12
19
  initializeRepository,
13
- listTasks,
20
+ listSnapshots,
21
+ normalizeRepositoryPath,
22
+ queryTasks,
23
+ RepositoryPathError,
14
24
  readTask,
25
+ restoreSnapshot,
15
26
  serializeTaskFile,
27
+ TASK_SORT_DIRECTIONS,
28
+ TASK_SORT_KEYS,
29
+ type TaskQuery,
30
+ TaskQuerySchema,
31
+ type TaskRecord,
32
+ type UpdateTaskInput,
33
+ updateTask,
16
34
  } from '@taskset/core'
35
+ import * as z from 'zod'
17
36
 
18
37
  const USAGE = `Usage:
19
38
  taskset init [--cwd <path>]
20
39
  taskset config [--json] [--cwd <path>]
21
- taskset task create --title <title> [options]
22
- taskset task list [--json] [--cwd <path>]
23
- taskset task show <task-id> [--json] [--cwd <path>]
24
-
25
- Create options:
26
- --status <status>
27
- --priority <priority>
28
- --label <label> Repeatable
29
- --depends-on <task-id> Repeatable
30
- --file <path> Repeatable
31
- --body <markdown>
40
+ taskset doctor [--json] [--cwd <path>]
41
+ taskset generate [--json] [--cwd <path>]
42
+ taskset snapshot create|list [--json] [--cwd <path>]
43
+ taskset snapshot restore <snapshot-id> [--apply] [--json] [--cwd <path>]
44
+ taskset task create --title <title> [metadata options]
45
+ taskset task list [query options]
46
+ taskset task show <task-id> [--include-derived] [--json] [--cwd <path>]
47
+ taskset task update <task-id> [metadata options]
48
+ taskset task status <task-id> <status> [--json] [--cwd <path>]
49
+ taskset task delete <task-id> [--remove-dependencies] [--json] [--cwd <path>]
50
+
51
+ Metadata options:
52
+ --status --priority --owner --team --estimate --effort --risk --due-date
53
+ --label --assignee --reviewer --depends-on --related --duplicate
54
+ --parent --file --directory --project --body
55
+ Repeat array options. Update commands also accept matching --clear-* options.
56
+
57
+ List query options:
58
+ --status --priority --label --owner --assignee --reviewer --team --risk
59
+ --project --depends-on --related --duplicate --parent --file --directory
60
+ --estimate-min --estimate-max --effort-min --effort-max
61
+ --due-before --due-after --created-before --created-after
62
+ --updated-before --updated-after --search --sort --direction
63
+ --impact --include-derived --json
32
64
  `
33
65
 
34
66
  export interface CliContext {
@@ -37,58 +69,239 @@ export interface CliContext {
37
69
  readonly stdout?: (value: string) => void
38
70
  }
39
71
 
72
+ interface CliIssue {
73
+ readonly field: string
74
+ readonly message: string
75
+ }
76
+
40
77
  class CliUsageError extends Error {
41
- constructor(message: string) {
78
+ readonly issues: readonly CliIssue[]
79
+
80
+ constructor(message: string, issues: readonly CliIssue[] = []) {
42
81
  super(message)
43
82
  this.name = 'CliUsageError'
83
+ this.issues = issues
44
84
  }
45
85
  }
46
86
 
47
- function parseTaskStatus(value: string | undefined): TaskStatus | undefined {
48
- if (value === undefined) {
49
- return undefined
50
- }
87
+ const TrimmedStringSchema = z.string().trim().min(1)
88
+ function uniqueArray<T>(schema: z.ZodType<T>) {
89
+ return z
90
+ .array(schema)
91
+ .refine((values) => new Set(values).size === values.length, 'Values must be unique')
92
+ }
51
93
 
52
- if (!TASK_STATUSES.includes(value as TaskStatus)) {
53
- throw new CliUsageError(`Invalid task status "${value}"`)
54
- }
94
+ const StringListSchema = uniqueArray(TrimmedStringSchema)
95
+ const TaskIdListSchema = uniqueArray(TaskIdSchema)
96
+ const CwdSchema = z.string().min(1).optional()
97
+ const JsonSchema = z.boolean().optional()
98
+
99
+ const CommonValuesSchema = z.strictObject({
100
+ cwd: CwdSchema,
101
+ json: JsonSchema,
102
+ })
103
+
104
+ const CreateValuesSchema = z.strictObject({
105
+ title: TrimmedStringSchema,
106
+ status: TaskStatusSchema.optional(),
107
+ priority: TaskPrioritySchema.optional(),
108
+ label: StringListSchema.optional(),
109
+ 'depends-on': TaskIdListSchema.optional(),
110
+ file: StringListSchema.optional(),
111
+ owner: TrimmedStringSchema.optional(),
112
+ assignee: StringListSchema.optional(),
113
+ reviewer: StringListSchema.optional(),
114
+ team: TrimmedStringSchema.optional(),
115
+ estimate: z.coerce.number().int().nonnegative().optional(),
116
+ effort: z.coerce.number().finite().nonnegative().optional(),
117
+ risk: TaskRiskSchema.optional(),
118
+ 'due-date': TaskTimestampSchema.optional(),
119
+ related: TaskIdListSchema.optional(),
120
+ duplicate: TaskIdListSchema.optional(),
121
+ parent: TaskIdSchema.optional(),
122
+ directory: StringListSchema.optional(),
123
+ project: StringListSchema.optional(),
124
+ body: z.string().optional(),
125
+ cwd: CwdSchema,
126
+ json: JsonSchema,
127
+ })
128
+
129
+ const UPDATE_CLEAR_PAIRS = [
130
+ ['priority', 'clear-priority'],
131
+ ['label', 'clear-labels'],
132
+ ['depends-on', 'clear-dependencies'],
133
+ ['file', 'clear-files'],
134
+ ['owner', 'clear-owner'],
135
+ ['assignee', 'clear-assignees'],
136
+ ['reviewer', 'clear-reviewers'],
137
+ ['team', 'clear-team'],
138
+ ['estimate', 'clear-estimate'],
139
+ ['effort', 'clear-effort'],
140
+ ['risk', 'clear-risk'],
141
+ ['due-date', 'clear-due-date'],
142
+ ['related', 'clear-related'],
143
+ ['duplicate', 'clear-duplicates'],
144
+ ['parent', 'clear-parent'],
145
+ ['directory', 'clear-directories'],
146
+ ['project', 'clear-projects'],
147
+ ] as const
148
+
149
+ const UpdateValuesSchema = z
150
+ .strictObject({
151
+ title: TrimmedStringSchema.optional(),
152
+ status: TaskStatusSchema.optional(),
153
+ priority: TaskPrioritySchema.optional(),
154
+ label: StringListSchema.optional(),
155
+ 'depends-on': TaskIdListSchema.optional(),
156
+ file: StringListSchema.optional(),
157
+ owner: TrimmedStringSchema.optional(),
158
+ assignee: StringListSchema.optional(),
159
+ reviewer: StringListSchema.optional(),
160
+ team: TrimmedStringSchema.optional(),
161
+ estimate: z.coerce.number().int().nonnegative().optional(),
162
+ effort: z.coerce.number().finite().nonnegative().optional(),
163
+ risk: TaskRiskSchema.optional(),
164
+ 'due-date': TaskTimestampSchema.optional(),
165
+ related: TaskIdListSchema.optional(),
166
+ duplicate: TaskIdListSchema.optional(),
167
+ parent: TaskIdSchema.optional(),
168
+ directory: StringListSchema.optional(),
169
+ project: StringListSchema.optional(),
170
+ body: z.string().optional(),
171
+ 'clear-priority': z.boolean().optional(),
172
+ 'clear-labels': z.boolean().optional(),
173
+ 'clear-dependencies': z.boolean().optional(),
174
+ 'clear-files': z.boolean().optional(),
175
+ 'clear-owner': z.boolean().optional(),
176
+ 'clear-assignees': z.boolean().optional(),
177
+ 'clear-reviewers': z.boolean().optional(),
178
+ 'clear-team': z.boolean().optional(),
179
+ 'clear-estimate': z.boolean().optional(),
180
+ 'clear-effort': z.boolean().optional(),
181
+ 'clear-risk': z.boolean().optional(),
182
+ 'clear-due-date': z.boolean().optional(),
183
+ 'clear-related': z.boolean().optional(),
184
+ 'clear-duplicates': z.boolean().optional(),
185
+ 'clear-parent': z.boolean().optional(),
186
+ 'clear-directories': z.boolean().optional(),
187
+ 'clear-projects': z.boolean().optional(),
188
+ cwd: CwdSchema,
189
+ json: JsonSchema,
190
+ })
191
+ .superRefine((values, context) => {
192
+ for (const [valueKey, clearKey] of UPDATE_CLEAR_PAIRS) {
193
+ if (values[valueKey] !== undefined && values[clearKey]) {
194
+ context.addIssue({
195
+ code: 'custom',
196
+ path: [clearKey],
197
+ message: `Cannot set and clear ${valueKey} in the same update`,
198
+ })
199
+ }
200
+ }
55
201
 
56
- return value as TaskStatus
57
- }
202
+ const nonControlKeys = Object.keys(values).filter((key) => key !== 'cwd' && key !== 'json')
58
203
 
59
- function parseTaskPriority(value: string | undefined): TaskPriority | undefined {
60
- if (value === undefined) {
61
- return undefined
62
- }
204
+ if (nonControlKeys.length === 0) {
205
+ context.addIssue({
206
+ code: 'custom',
207
+ message: 'Task update requires at least one field option',
208
+ })
209
+ }
210
+ })
63
211
 
64
- if (!TASK_PRIORITIES.includes(value as TaskPriority)) {
65
- throw new CliUsageError(`Invalid task priority "${value}"`)
212
+ const ListValuesSchema = z.strictObject({
213
+ status: z.array(TaskStatusSchema).optional(),
214
+ priority: z.array(TaskPrioritySchema).optional(),
215
+ label: StringListSchema.optional(),
216
+ owner: StringListSchema.optional(),
217
+ assignee: StringListSchema.optional(),
218
+ reviewer: StringListSchema.optional(),
219
+ team: StringListSchema.optional(),
220
+ risk: z.array(TaskRiskSchema).optional(),
221
+ project: StringListSchema.optional(),
222
+ 'depends-on': TaskIdSchema.optional(),
223
+ related: TaskIdSchema.optional(),
224
+ duplicate: TaskIdSchema.optional(),
225
+ parent: TaskIdSchema.optional(),
226
+ file: StringListSchema.optional(),
227
+ directory: StringListSchema.optional(),
228
+ 'estimate-min': z.coerce.number().finite().nonnegative().optional(),
229
+ 'estimate-max': z.coerce.number().finite().nonnegative().optional(),
230
+ 'effort-min': z.coerce.number().finite().nonnegative().optional(),
231
+ 'effort-max': z.coerce.number().finite().nonnegative().optional(),
232
+ 'due-before': TaskTimestampSchema.optional(),
233
+ 'due-after': TaskTimestampSchema.optional(),
234
+ 'created-before': TaskTimestampSchema.optional(),
235
+ 'created-after': TaskTimestampSchema.optional(),
236
+ 'updated-before': TaskTimestampSchema.optional(),
237
+ 'updated-after': TaskTimestampSchema.optional(),
238
+ search: z.string().optional(),
239
+ sort: z.enum(TASK_SORT_KEYS).optional(),
240
+ direction: z.enum(TASK_SORT_DIRECTIONS).optional(),
241
+ impact: z.boolean().optional(),
242
+ 'include-derived': z.boolean().optional(),
243
+ cwd: CwdSchema,
244
+ json: JsonSchema,
245
+ })
246
+
247
+ const metadataOptionDefinitions = {
248
+ body: { type: 'string' },
249
+ 'depends-on': { type: 'string', multiple: true },
250
+ file: { type: 'string', multiple: true },
251
+ label: { type: 'string', multiple: true },
252
+ priority: { type: 'string' },
253
+ status: { type: 'string' },
254
+ title: { type: 'string' },
255
+ owner: { type: 'string' },
256
+ assignee: { type: 'string', multiple: true },
257
+ reviewer: { type: 'string', multiple: true },
258
+ team: { type: 'string' },
259
+ estimate: { type: 'string' },
260
+ effort: { type: 'string' },
261
+ risk: { type: 'string' },
262
+ 'due-date': { type: 'string' },
263
+ related: { type: 'string', multiple: true },
264
+ duplicate: { type: 'string', multiple: true },
265
+ parent: { type: 'string' },
266
+ directory: { type: 'string', multiple: true },
267
+ project: { type: 'string', multiple: true },
268
+ } as const
269
+
270
+ const commonOptionDefinitions = {
271
+ cwd: { type: 'string' },
272
+ json: { type: 'boolean' },
273
+ } as const
274
+
275
+ function parseSchema<T>(schema: z.ZodType<T>, value: unknown, label: string): T {
276
+ const result = schema.safeParse(value)
277
+
278
+ if (!result.success) {
279
+ throw new CliUsageError(
280
+ `Invalid ${label}`,
281
+ result.error.issues.map((issue) => ({
282
+ field: issue.path.length > 0 ? issue.path.map(String).join('.') : label,
283
+ message: issue.message,
284
+ })),
285
+ )
66
286
  }
67
287
 
68
- return value as TaskPriority
288
+ return result.data
69
289
  }
70
290
 
71
291
  function resolveCommandCwd(baseDirectory: string, cwdOption: string | undefined): string {
72
292
  return cwdOption ? path.resolve(baseDirectory, cwdOption) : baseDirectory
73
293
  }
74
294
 
75
- function parseCommonOptions(args: readonly string[]) {
76
- return parseArgs({
77
- args,
78
- allowPositionals: false,
79
- options: {
80
- cwd: { type: 'string' },
81
- json: { type: 'boolean' },
82
- },
83
- })
84
- }
85
-
86
- function requireSinglePositional(positionals: readonly string[], name: string): string {
87
- if (positionals.length !== 1 || !positionals[0]) {
88
- throw new CliUsageError(`Expected exactly one ${name}`)
295
+ function requirePositionals(
296
+ positionals: readonly string[],
297
+ count: number,
298
+ description: string,
299
+ ): readonly string[] {
300
+ if (positionals.length !== count || positionals.some((value) => value.length === 0)) {
301
+ throw new CliUsageError(`Expected ${description}`)
89
302
  }
90
303
 
91
- return positionals[0]
304
+ return positionals
92
305
  }
93
306
 
94
307
  function formatError(error: unknown): string {
@@ -110,50 +323,293 @@ function formatError(error: unknown): string {
110
323
  return `${error.message}${issues}`
111
324
  }
112
325
 
326
+ function taskRecordJson(
327
+ record: TaskRecord,
328
+ derived?: DerivedTaskRelationships,
329
+ ): Record<string, unknown> {
330
+ return {
331
+ relativePath: record.relativePath,
332
+ ...record.task.metadata,
333
+ ...(derived ? { derived } : {}),
334
+ }
335
+ }
336
+
337
+ function writeTaskRecord(
338
+ record: TaskRecord,
339
+ json: boolean | undefined,
340
+ stdout: (value: string) => void,
341
+ ): void {
342
+ if (json) {
343
+ stdout(`${JSON.stringify(taskRecordJson(record), null, 2)}\n`)
344
+ } else {
345
+ stdout(`${record.task.metadata.id}\n`)
346
+ }
347
+ }
348
+
349
+ function warningWriter(
350
+ stderr: (value: string) => void,
351
+ ): (warning: { readonly message: string }) => void {
352
+ return (warning) => stderr(`warning: ${warning.message}\n`)
353
+ }
354
+
355
+ function normalizeMutationPaths(
356
+ repository: Awaited<ReturnType<typeof discoverRepository>>,
357
+ values: readonly string[] | undefined,
358
+ ): string[] | undefined {
359
+ return values?.map((value) => normalizeRepositoryPath(repository, value))
360
+ }
361
+
362
+ function updateInputFromValues(
363
+ repository: Awaited<ReturnType<typeof discoverRepository>>,
364
+ values: z.infer<typeof UpdateValuesSchema>,
365
+ ): UpdateTaskInput {
366
+ return {
367
+ ...(values.title !== undefined ? { title: values.title } : {}),
368
+ ...(values.status !== undefined ? { status: values.status } : {}),
369
+ ...(values['clear-priority']
370
+ ? { priority: null }
371
+ : values.priority !== undefined
372
+ ? { priority: values.priority }
373
+ : {}),
374
+ ...(values['clear-labels']
375
+ ? { labels: [] }
376
+ : values.label !== undefined
377
+ ? { labels: values.label }
378
+ : {}),
379
+ ...(values['clear-dependencies']
380
+ ? { dependsOn: [] }
381
+ : values['depends-on'] !== undefined
382
+ ? { dependsOn: values['depends-on'] }
383
+ : {}),
384
+ ...(values['clear-files']
385
+ ? { files: [] }
386
+ : values.file !== undefined
387
+ ? { files: normalizeMutationPaths(repository, values.file) }
388
+ : {}),
389
+ ...(values['clear-owner']
390
+ ? { owner: null }
391
+ : values.owner !== undefined
392
+ ? { owner: values.owner }
393
+ : {}),
394
+ ...(values['clear-assignees']
395
+ ? { assignees: [] }
396
+ : values.assignee !== undefined
397
+ ? { assignees: values.assignee }
398
+ : {}),
399
+ ...(values['clear-reviewers']
400
+ ? { reviewers: [] }
401
+ : values.reviewer !== undefined
402
+ ? { reviewers: values.reviewer }
403
+ : {}),
404
+ ...(values['clear-team']
405
+ ? { team: null }
406
+ : values.team !== undefined
407
+ ? { team: values.team }
408
+ : {}),
409
+ ...(values['clear-estimate']
410
+ ? { estimate: null }
411
+ : values.estimate !== undefined
412
+ ? { estimate: values.estimate }
413
+ : {}),
414
+ ...(values['clear-effort']
415
+ ? { effort: null }
416
+ : values.effort !== undefined
417
+ ? { effort: values.effort }
418
+ : {}),
419
+ ...(values['clear-risk']
420
+ ? { risk: null }
421
+ : values.risk !== undefined
422
+ ? { risk: values.risk }
423
+ : {}),
424
+ ...(values['clear-due-date']
425
+ ? { dueDate: null }
426
+ : values['due-date'] !== undefined
427
+ ? { dueDate: values['due-date'] }
428
+ : {}),
429
+ ...(values['clear-related']
430
+ ? { related: [] }
431
+ : values.related !== undefined
432
+ ? { related: values.related }
433
+ : {}),
434
+ ...(values['clear-duplicates']
435
+ ? { duplicates: [] }
436
+ : values.duplicate !== undefined
437
+ ? { duplicates: values.duplicate }
438
+ : {}),
439
+ ...(values['clear-parent']
440
+ ? { parent: null }
441
+ : values.parent !== undefined
442
+ ? { parent: values.parent }
443
+ : {}),
444
+ ...(values['clear-directories']
445
+ ? { directories: [] }
446
+ : values.directory !== undefined
447
+ ? { directories: normalizeMutationPaths(repository, values.directory) }
448
+ : {}),
449
+ ...(values['clear-projects']
450
+ ? { projects: [] }
451
+ : values.project !== undefined
452
+ ? { projects: values.project }
453
+ : {}),
454
+ ...(values.body !== undefined ? { body: values.body } : {}),
455
+ }
456
+ }
457
+
113
458
  export async function runCli(args: readonly string[], context: CliContext = {}): Promise<number> {
114
459
  const cwd = path.resolve(context.cwd ?? process.cwd())
115
460
  const stdout = context.stdout ?? ((value: string) => process.stdout.write(value))
116
461
  const stderr = context.stderr ?? ((value: string) => process.stderr.write(value))
462
+ const onWarning = warningWriter(stderr)
117
463
 
118
464
  try {
119
465
  const [command, subcommand] = args
120
466
  const commandArgs = args.slice(1)
121
- const taskArgs = args.slice(2)
467
+ const subcommandArgs = args.slice(2)
122
468
 
123
469
  if (!command || command === 'help' || command === '--help' || command === '-h') {
124
470
  stdout(USAGE)
125
471
  return 0
126
472
  }
127
473
 
128
- if (command === 'init') {
129
- const parsed = parseCommonOptions(commandArgs)
130
- const repository = await initializeRepository(resolveCommandCwd(cwd, parsed.values.cwd))
131
- stdout(`Initialized Taskset in ${repository.rootDirectory}\n`)
474
+ if (command === 'init' || command === 'config' || command === 'doctor') {
475
+ const parsed = parseArgs({
476
+ args: commandArgs,
477
+ allowPositionals: false,
478
+ options: commonOptionDefinitions,
479
+ })
480
+ const values = parseSchema(CommonValuesSchema, parsed.values, `${command} options`)
481
+ const commandCwd = resolveCommandCwd(cwd, values.cwd)
482
+
483
+ if (command === 'init') {
484
+ const repository = await initializeRepository(commandCwd)
485
+ stdout(`Initialized Taskset in ${repository.rootDirectory}\n`)
486
+ return 0
487
+ }
488
+
489
+ const repository = await discoverRepository(commandCwd)
490
+
491
+ if (command === 'config') {
492
+ if (values.json) {
493
+ stdout(
494
+ `${JSON.stringify(
495
+ {
496
+ rootDirectory: repository.rootDirectory,
497
+ configPath: repository.configPath,
498
+ dataDirectory: repository.dataDirectory,
499
+ config: repository.config,
500
+ },
501
+ null,
502
+ 2,
503
+ )}\n`,
504
+ )
505
+ } else {
506
+ stdout(`${repository.configPath}\n`)
507
+ }
508
+ return 0
509
+ }
510
+
511
+ const result = await diagnoseRepository(repository)
512
+
513
+ if (values.json) {
514
+ stdout(`${JSON.stringify(result, null, 2)}\n`)
515
+ } else if (result.valid) {
516
+ stdout(`Taskset repository is valid (${result.taskCount} tasks)\n`)
517
+ } else {
518
+ for (const diagnostic of result.diagnostics) {
519
+ stdout(
520
+ `${diagnostic.code}\t${diagnostic.path ?? '-'}\t${diagnostic.message}\t${diagnostic.remediation}\n`,
521
+ )
522
+ }
523
+ }
524
+
525
+ return result.valid ? 0 : 1
526
+ }
527
+
528
+ if (command === 'generate') {
529
+ const parsed = parseArgs({
530
+ args: commandArgs,
531
+ allowPositionals: false,
532
+ options: commonOptionDefinitions,
533
+ })
534
+ const values = parseSchema(CommonValuesSchema, parsed.values, 'generate options')
535
+ const repository = await discoverRepository(resolveCommandCwd(cwd, values.cwd))
536
+ const result = await generateViews(repository)
537
+ stdout(
538
+ values.json
539
+ ? `${JSON.stringify(result, null, 2)}\n`
540
+ : `Generated ${result.files.length} views in ${result.directory}\n`,
541
+ )
132
542
  return 0
133
543
  }
134
544
 
135
- if (command === 'config') {
136
- const parsed = parseCommonOptions(commandArgs)
137
- const repository = await discoverRepository(resolveCommandCwd(cwd, parsed.values.cwd))
545
+ if (command === 'snapshot') {
546
+ if (subcommand === 'create' || subcommand === 'list') {
547
+ const parsed = parseArgs({
548
+ args: subcommandArgs,
549
+ allowPositionals: false,
550
+ options: commonOptionDefinitions,
551
+ })
552
+ const values = parseSchema(
553
+ CommonValuesSchema,
554
+ parsed.values,
555
+ `snapshot ${subcommand} options`,
556
+ )
557
+ const repository = await discoverRepository(resolveCommandCwd(cwd, values.cwd))
558
+
559
+ if (subcommand === 'create') {
560
+ const result = await createSnapshot(repository)
561
+ stdout(values.json ? `${JSON.stringify(result, null, 2)}\n` : `${result.id}\n`)
562
+ } else {
563
+ const results = await listSnapshots(repository)
564
+ if (values.json) {
565
+ stdout(`${JSON.stringify(results, null, 2)}\n`)
566
+ } else {
567
+ for (const result of results) {
568
+ stdout(`${result.id}\t${result.createdAt}\t${result.files.length}\n`)
569
+ }
570
+ }
571
+ }
572
+ return 0
573
+ }
138
574
 
139
- if (parsed.values.json) {
575
+ if (subcommand === 'restore') {
576
+ const parsed = parseArgs({
577
+ args: subcommandArgs,
578
+ allowPositionals: true,
579
+ options: {
580
+ ...commonOptionDefinitions,
581
+ apply: { type: 'boolean' },
582
+ },
583
+ })
584
+ const [snapshotId] = requirePositionals(parsed.positionals, 1, 'exactly one snapshot ID')
585
+ const values = parseSchema(
586
+ z.strictObject({
587
+ apply: z.boolean().optional(),
588
+ cwd: CwdSchema,
589
+ json: JsonSchema,
590
+ }),
591
+ parsed.values,
592
+ 'snapshot restore options',
593
+ )
594
+ const validatedSnapshotId = parseSchema(
595
+ z.string().regex(/^\d{8}T\d{6}Z-[a-f0-9]{12}$/u),
596
+ snapshotId,
597
+ 'snapshot ID',
598
+ )
599
+ const repository = await discoverRepository(resolveCommandCwd(cwd, values.cwd))
600
+ const result = await restoreSnapshot(repository, validatedSnapshotId, {
601
+ apply: values.apply,
602
+ onWarning,
603
+ })
140
604
  stdout(
141
- `${JSON.stringify(
142
- {
143
- rootDirectory: repository.rootDirectory,
144
- configPath: repository.configPath,
145
- dataDirectory: repository.dataDirectory,
146
- config: repository.config,
147
- },
148
- null,
149
- 2,
150
- )}\n`,
605
+ values.json
606
+ ? `${JSON.stringify(result, null, 2)}\n`
607
+ : `${result.applied ? 'Restored' : 'Would restore'} ${result.changes.length} task files from ${validatedSnapshotId}\n`,
151
608
  )
152
- } else {
153
- stdout(`${repository.configPath}\n`)
609
+ return 0
154
610
  }
155
611
 
156
- return 0
612
+ throw new CliUsageError(`Unknown snapshot command "${subcommand ?? ''}"`)
157
613
  }
158
614
 
159
615
  if (command !== 'task') {
@@ -162,101 +618,187 @@ export async function runCli(args: readonly string[], context: CliContext = {}):
162
618
 
163
619
  if (subcommand === 'create') {
164
620
  const parsed = parseArgs({
165
- args: taskArgs,
621
+ args: subcommandArgs,
622
+ allowPositionals: false,
623
+ options: { ...metadataOptionDefinitions, ...commonOptionDefinitions },
624
+ })
625
+ const values = parseSchema(CreateValuesSchema, parsed.values, 'task create options')
626
+ const repository = await discoverRepository(resolveCommandCwd(cwd, values.cwd))
627
+ const record = await createTask(
628
+ repository,
629
+ {
630
+ title: values.title,
631
+ ...(values.status ? { status: values.status } : {}),
632
+ ...(values.priority ? { priority: values.priority } : {}),
633
+ ...(values.label ? { labels: values.label } : {}),
634
+ ...(values['depends-on'] ? { dependsOn: values['depends-on'] } : {}),
635
+ ...(values.file ? { files: normalizeMutationPaths(repository, values.file) } : {}),
636
+ ...(values.owner ? { owner: values.owner } : {}),
637
+ ...(values.assignee ? { assignees: values.assignee } : {}),
638
+ ...(values.reviewer ? { reviewers: values.reviewer } : {}),
639
+ ...(values.team ? { team: values.team } : {}),
640
+ ...(values.estimate !== undefined ? { estimate: values.estimate } : {}),
641
+ ...(values.effort !== undefined ? { effort: values.effort } : {}),
642
+ ...(values.risk ? { risk: values.risk } : {}),
643
+ ...(values['due-date'] ? { dueDate: values['due-date'] } : {}),
644
+ ...(values.related ? { related: values.related } : {}),
645
+ ...(values.duplicate ? { duplicates: values.duplicate } : {}),
646
+ ...(values.parent ? { parent: values.parent } : {}),
647
+ ...(values.directory
648
+ ? { directories: normalizeMutationPaths(repository, values.directory) }
649
+ : {}),
650
+ ...(values.project ? { projects: values.project } : {}),
651
+ ...(values.body !== undefined ? { body: values.body } : {}),
652
+ },
653
+ { onWarning },
654
+ )
655
+ writeTaskRecord(record, values.json, stdout)
656
+ return 0
657
+ }
658
+
659
+ if (subcommand === 'list') {
660
+ const parsed = parseArgs({
661
+ args: subcommandArgs,
166
662
  allowPositionals: false,
167
663
  options: {
168
- body: { type: 'string' },
169
664
  cwd: { type: 'string' },
170
- 'depends-on': { type: 'string', multiple: true },
171
- file: { type: 'string', multiple: true },
172
665
  json: { type: 'boolean' },
666
+ status: { type: 'string', multiple: true },
667
+ priority: { type: 'string', multiple: true },
173
668
  label: { type: 'string', multiple: true },
174
- priority: { type: 'string' },
175
- status: { type: 'string' },
176
- title: { type: 'string' },
669
+ owner: { type: 'string', multiple: true },
670
+ assignee: { type: 'string', multiple: true },
671
+ reviewer: { type: 'string', multiple: true },
672
+ team: { type: 'string', multiple: true },
673
+ risk: { type: 'string', multiple: true },
674
+ project: { type: 'string', multiple: true },
675
+ 'depends-on': { type: 'string' },
676
+ related: { type: 'string' },
677
+ duplicate: { type: 'string' },
678
+ parent: { type: 'string' },
679
+ file: { type: 'string', multiple: true },
680
+ directory: { type: 'string', multiple: true },
681
+ 'estimate-min': { type: 'string' },
682
+ 'estimate-max': { type: 'string' },
683
+ 'effort-min': { type: 'string' },
684
+ 'effort-max': { type: 'string' },
685
+ 'due-before': { type: 'string' },
686
+ 'due-after': { type: 'string' },
687
+ 'created-before': { type: 'string' },
688
+ 'created-after': { type: 'string' },
689
+ 'updated-before': { type: 'string' },
690
+ 'updated-after': { type: 'string' },
691
+ search: { type: 'string' },
692
+ sort: { type: 'string' },
693
+ direction: { type: 'string' },
694
+ impact: { type: 'boolean' },
695
+ 'include-derived': { type: 'boolean' },
177
696
  },
178
697
  })
179
-
180
- if (!parsed.values.title) {
181
- throw new CliUsageError('The --title option is required')
182
- }
183
-
184
- const status = parseTaskStatus(parsed.values.status)
185
- const priority = parseTaskPriority(parsed.values.priority)
186
-
187
- const repository = await discoverRepository(resolveCommandCwd(cwd, parsed.values.cwd))
188
- const record = await createTask(repository, {
189
- title: parsed.values.title,
190
- ...(status ? { status } : {}),
191
- ...(priority ? { priority } : {}),
192
- ...(parsed.values.label ? { labels: parsed.values.label } : {}),
193
- ...(parsed.values['depends-on'] ? { dependsOn: parsed.values['depends-on'] } : {}),
194
- ...(parsed.values.file ? { files: parsed.values.file } : {}),
195
- ...(parsed.values.body !== undefined ? { body: parsed.values.body } : {}),
196
- })
197
-
198
- if (parsed.values.json) {
199
- stdout(
200
- `${JSON.stringify(
201
- { relativePath: record.relativePath, ...record.task.metadata },
202
- null,
203
- 2,
204
- )}\n`,
205
- )
206
- } else {
207
- stdout(`${record.task.metadata.id}\n`)
208
- }
209
-
210
- return 0
211
- }
212
-
213
- if (subcommand === 'list') {
214
- const parsed = parseCommonOptions(taskArgs)
215
- const repository = await discoverRepository(resolveCommandCwd(cwd, parsed.values.cwd))
216
- const records = await listTasks(repository)
217
-
218
- if (parsed.values.json) {
698
+ const values = parseSchema(ListValuesSchema, parsed.values, 'task list options')
699
+ const query = parseSchema(
700
+ TaskQuerySchema,
701
+ {
702
+ ...(values.status ? { statuses: values.status } : {}),
703
+ ...(values.priority ? { priorities: values.priority } : {}),
704
+ ...(values.label ? { labels: values.label } : {}),
705
+ ...(values.owner ? { owners: values.owner } : {}),
706
+ ...(values.assignee ? { assignees: values.assignee } : {}),
707
+ ...(values.reviewer ? { reviewers: values.reviewer } : {}),
708
+ ...(values.team ? { teams: values.team } : {}),
709
+ ...(values.risk ? { risks: values.risk } : {}),
710
+ ...(values.project ? { projects: values.project } : {}),
711
+ ...(values['depends-on'] ? { dependsOn: values['depends-on'] } : {}),
712
+ ...(values.related ? { related: values.related } : {}),
713
+ ...(values.duplicate ? { duplicate: values.duplicate } : {}),
714
+ ...(values.parent ? { parent: values.parent } : {}),
715
+ ...(values.file ? { files: values.file } : {}),
716
+ ...(values.directory ? { directories: values.directory } : {}),
717
+ ...(values['estimate-min'] !== undefined ? { estimateMin: values['estimate-min'] } : {}),
718
+ ...(values['estimate-max'] !== undefined ? { estimateMax: values['estimate-max'] } : {}),
719
+ ...(values['effort-min'] !== undefined ? { effortMin: values['effort-min'] } : {}),
720
+ ...(values['effort-max'] !== undefined ? { effortMax: values['effort-max'] } : {}),
721
+ ...(values['due-before'] ? { dueBefore: values['due-before'] } : {}),
722
+ ...(values['due-after'] ? { dueAfter: values['due-after'] } : {}),
723
+ ...(values['created-before'] ? { createdBefore: values['created-before'] } : {}),
724
+ ...(values['created-after'] ? { createdAfter: values['created-after'] } : {}),
725
+ ...(values['updated-before'] ? { updatedBefore: values['updated-before'] } : {}),
726
+ ...(values['updated-after'] ? { updatedAfter: values['updated-after'] } : {}),
727
+ ...(values.search !== undefined ? { text: values.search } : {}),
728
+ ...(values.sort ? { sortBy: values.sort } : {}),
729
+ ...(values.direction ? { direction: values.direction } : {}),
730
+ ...(values.impact ? { impact: true } : {}),
731
+ } satisfies TaskQuery,
732
+ 'task list query',
733
+ )
734
+ const repository = await discoverRepository(resolveCommandCwd(cwd, values.cwd))
735
+ const result = await queryTasks(repository, query)
736
+ const graph = values['include-derived'] ? (await buildTaskIndex(repository)).graph : undefined
737
+ const serialize = (record: TaskRecord) =>
738
+ taskRecordJson(record, graph?.derive(record.task.metadata.id))
739
+
740
+ if (values.json) {
219
741
  stdout(
220
742
  `${JSON.stringify(
221
- records.map((record) => ({
222
- relativePath: record.relativePath,
223
- ...record.task.metadata,
224
- })),
743
+ values.impact
744
+ ? {
745
+ direct: result.direct.map(serialize),
746
+ impacted: result.impacted.map(serialize),
747
+ }
748
+ : result.direct.map(serialize),
225
749
  null,
226
750
  2,
227
751
  )}\n`,
228
752
  )
229
753
  } else {
230
- for (const record of records) {
754
+ for (const record of result.direct) {
231
755
  stdout(
232
- `${record.task.metadata.id}\t${record.task.metadata.status}\t${record.task.metadata.title}\n`,
756
+ `${values.impact ? 'direct\t' : ''}${record.task.metadata.id}\t${record.task.metadata.status}\t${record.task.metadata.title}\n`,
757
+ )
758
+ }
759
+ for (const record of result.impacted) {
760
+ stdout(
761
+ `impact\t${record.task.metadata.id}\t${record.task.metadata.status}\t${record.task.metadata.title}\n`,
233
762
  )
234
763
  }
235
764
  }
236
-
237
765
  return 0
238
766
  }
239
767
 
240
768
  if (subcommand === 'show') {
241
769
  const parsed = parseArgs({
242
- args: taskArgs,
770
+ args: subcommandArgs,
243
771
  allowPositionals: true,
244
772
  options: {
245
- cwd: { type: 'string' },
246
- json: { type: 'boolean' },
773
+ ...commonOptionDefinitions,
774
+ 'include-derived': { type: 'boolean' },
247
775
  },
248
776
  })
249
- const taskId = requireSinglePositional(parsed.positionals, 'task ID')
250
- const repository = await discoverRepository(resolveCommandCwd(cwd, parsed.values.cwd))
251
- const record = await readTask(repository, taskId)
252
-
253
- if (parsed.values.json) {
777
+ const [taskId] = requirePositionals(parsed.positionals, 1, 'exactly one task ID')
778
+ const validatedTaskId = parseSchema(TaskIdSchema, taskId, 'task ID')
779
+ const values = parseSchema(
780
+ z.strictObject({
781
+ cwd: CwdSchema,
782
+ json: JsonSchema,
783
+ 'include-derived': z.boolean().optional(),
784
+ }),
785
+ parsed.values,
786
+ 'task show options',
787
+ )
788
+ const repository = await discoverRepository(resolveCommandCwd(cwd, values.cwd))
789
+ const record = await readTask(repository, validatedTaskId)
790
+
791
+ if (values.json) {
792
+ const derived = values['include-derived']
793
+ ? (await buildTaskIndex(repository)).graph.derive(validatedTaskId)
794
+ : undefined
254
795
  stdout(
255
796
  `${JSON.stringify(
256
797
  {
257
798
  relativePath: record.relativePath,
258
799
  metadata: record.task.metadata,
259
800
  body: record.task.body,
801
+ ...(derived ? { derived } : {}),
260
802
  },
261
803
  null,
262
804
  2,
@@ -265,13 +807,98 @@ export async function runCli(args: readonly string[], context: CliContext = {}):
265
807
  } else {
266
808
  stdout(serializeTaskFile(record.task, { filePath: record.relativePath }))
267
809
  }
810
+ return 0
811
+ }
812
+
813
+ if (subcommand === 'update') {
814
+ const clearDefinitions = Object.fromEntries(
815
+ UPDATE_CLEAR_PAIRS.map(([, clear]) => [clear, { type: 'boolean' as const }]),
816
+ )
817
+ const parsed = parseArgs({
818
+ args: subcommandArgs,
819
+ allowPositionals: true,
820
+ options: {
821
+ ...metadataOptionDefinitions,
822
+ ...clearDefinitions,
823
+ ...commonOptionDefinitions,
824
+ },
825
+ })
826
+ const [taskId] = requirePositionals(parsed.positionals, 1, 'exactly one task ID')
827
+ const validatedTaskId = parseSchema(TaskIdSchema, taskId, 'task ID')
828
+ const values = parseSchema(UpdateValuesSchema, parsed.values, 'task update options')
829
+ const repository = await discoverRepository(resolveCommandCwd(cwd, values.cwd))
830
+ const record = await updateTask(
831
+ repository,
832
+ validatedTaskId,
833
+ updateInputFromValues(repository, values),
834
+ { onWarning },
835
+ )
836
+ writeTaskRecord(record, values.json, stdout)
837
+ return 0
838
+ }
839
+
840
+ if (subcommand === 'status') {
841
+ const parsed = parseArgs({
842
+ args: subcommandArgs,
843
+ allowPositionals: true,
844
+ options: commonOptionDefinitions,
845
+ })
846
+ const [taskId, status] = requirePositionals(parsed.positionals, 2, 'a task ID and status')
847
+ const validatedTaskId = parseSchema(TaskIdSchema, taskId, 'task ID')
848
+ const validatedStatus = parseSchema(TaskStatusSchema, status, 'task status')
849
+ const values = parseSchema(CommonValuesSchema, parsed.values, 'task status options')
850
+ const repository = await discoverRepository(resolveCommandCwd(cwd, values.cwd))
851
+ const record = await updateTask(
852
+ repository,
853
+ validatedTaskId,
854
+ { status: validatedStatus },
855
+ { onWarning },
856
+ )
857
+ writeTaskRecord(record, values.json, stdout)
858
+ return 0
859
+ }
860
+
861
+ if (subcommand === 'delete') {
862
+ const parsed = parseArgs({
863
+ args: subcommandArgs,
864
+ allowPositionals: true,
865
+ options: {
866
+ ...commonOptionDefinitions,
867
+ 'remove-dependencies': { type: 'boolean' },
868
+ },
869
+ })
870
+ const [taskId] = requirePositionals(parsed.positionals, 1, 'exactly one task ID')
871
+ const validatedTaskId = parseSchema(TaskIdSchema, taskId, 'task ID')
872
+ const values = parseSchema(
873
+ z.strictObject({
874
+ cwd: CwdSchema,
875
+ json: JsonSchema,
876
+ 'remove-dependencies': z.boolean().optional(),
877
+ }),
878
+ parsed.values,
879
+ 'task delete options',
880
+ )
881
+ const repository = await discoverRepository(resolveCommandCwd(cwd, values.cwd))
882
+ const record = await deleteTask(repository, validatedTaskId, {
883
+ removeDependencies: values['remove-dependencies'],
884
+ onWarning,
885
+ })
268
886
 
887
+ if (values.json) {
888
+ stdout(`${JSON.stringify({ deleted: true, ...taskRecordJson(record) }, null, 2)}\n`)
889
+ } else {
890
+ stdout(`${validatedTaskId}\n`)
891
+ }
269
892
  return 0
270
893
  }
271
894
 
272
895
  throw new CliUsageError(`Unknown task command "${subcommand ?? ''}"`)
273
896
  } catch (error) {
274
- if (error instanceof CliUsageError || error instanceof TypeError) {
897
+ if (
898
+ error instanceof CliUsageError ||
899
+ error instanceof RepositoryPathError ||
900
+ error instanceof TypeError
901
+ ) {
275
902
  stderr(`${formatError(error)}\n\n${USAGE}`)
276
903
  return 2
277
904
  }