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