@taskset/cli 0.1.2 → 3.0.1

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