trekoon 0.1.9 → 0.2.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.
@@ -1,20 +1,40 @@
1
1
  import {
2
+ SEARCH_REPLACE_FIELDS,
3
+ findUnknownOption,
2
4
  hasFlag,
5
+ isValidCompactTempKey,
3
6
  parseArgs,
7
+ parseCompactEntityRef,
8
+ parseCompactFields,
9
+ parseCsvEnumOption,
4
10
  parseStrictNonNegativeInt,
5
11
  parseStrictPositiveInt,
6
12
  readEnumOption,
7
13
  readMissingOptionValue,
8
14
  readOption,
15
+ readOptions,
16
+ readUnexpectedPositionals,
17
+ resolvePreviewApplyMode,
18
+ suggestOptions,
9
19
  } from "./arg-parser";
20
+ import { unexpectedFailureResult } from "./error-utils";
10
21
 
11
22
  import { MutationService } from "../domain/mutation-service";
12
23
  import { TrackerDomain } from "../domain/tracker-domain";
13
- import { DomainError, type EpicRecord } from "../domain/types";
24
+ import {
25
+ COMPACT_TEMP_KEY_PREFIX,
26
+ type CompactBatchResultContract,
27
+ type CompactDependencySpec,
28
+ type CompactEntityRef,
29
+ type CompactSubtaskSpec,
30
+ type CompactTaskSpec,
31
+ type EpicRecord,
32
+ type SearchEntityMatch,
33
+ } from "../domain/types";
14
34
  import { formatHumanTable } from "../io/human-table";
15
35
  import { failResult, okResult } from "../io/output";
16
36
  import { type CliContext, type CliResult } from "../runtime/command-types";
17
- import { openTrekoonDatabase } from "../storage/database";
37
+ import { openTrekoonDatabase, type TrekoonDatabase } from "../storage/database";
18
38
 
19
39
  function formatEpic(epic: EpicRecord): string {
20
40
  return `${epic.id} | ${epic.title} | ${epic.status}`;
@@ -24,6 +44,10 @@ const VIEW_MODES = ["table", "compact", "tree", "detail"] as const;
24
44
  const LIST_VIEW_MODES = ["table", "compact"] as const;
25
45
  const DEFAULT_LIST_LIMIT = 10;
26
46
  const DEFAULT_OPEN_STATUSES = ["in_progress", "in-progress", "todo"] as const;
47
+ const CREATE_OPTIONS = ["title", "t", "description", "d", "status", "s", "task", "subtask", "dep"] as const;
48
+ const SEARCH_OPTIONS = ["fields", "preview"] as const;
49
+ const REPLACE_OPTIONS = ["search", "replace", "fields", "preview", "apply"] as const;
50
+ const EXPAND_OPTIONS = ["task", "subtask", "dep"] as const;
27
51
 
28
52
  function parseStatusCsv(rawStatuses: string | undefined): string[] | undefined {
29
53
  if (rawStatuses === undefined) {
@@ -36,6 +60,55 @@ function parseStatusCsv(rawStatuses: string | undefined): string[] | undefined {
36
60
  .filter((value) => value.length > 0);
37
61
  }
38
62
 
63
+ function prefixedOptions(options: readonly string[]): string[] {
64
+ return options.map((option) => `--${option}`);
65
+ }
66
+
67
+ function unknownOption(command: string, option: string, allowedOptions: readonly string[]): CliResult {
68
+ const suggestions = suggestOptions(option, allowedOptions).map((suggestion) => `--${suggestion}`);
69
+ const suggestionMessage = suggestions.length > 0 ? ` Did you mean ${suggestions.join(" or ")}?` : "";
70
+ return failResult({
71
+ command,
72
+ human: `Unknown option --${option}.${suggestionMessage}`,
73
+ data: {
74
+ option: `--${option}`,
75
+ allowedOptions: prefixedOptions(allowedOptions),
76
+ suggestions,
77
+ },
78
+ error: {
79
+ code: "unknown_option",
80
+ message: `Unknown option --${option}`,
81
+ },
82
+ });
83
+ }
84
+
85
+ function invalidSearchInput(command: string, human: string, message: string, data: Record<string, unknown>): CliResult {
86
+ return failResult({
87
+ command,
88
+ human,
89
+ data,
90
+ error: {
91
+ code: "invalid_input",
92
+ message,
93
+ },
94
+ });
95
+ }
96
+
97
+ function formatSearchHuman(matches: readonly SearchEntityMatch[], emptyMessage: string): string {
98
+ if (matches.length === 0) {
99
+ return emptyMessage;
100
+ }
101
+
102
+ return matches
103
+ .map(
104
+ (match) =>
105
+ `${match.kind} ${match.id}: ${match.fields
106
+ .map((field) => `${field.field}(${field.count}) "${field.snippet}"`)
107
+ .join(", ")}`,
108
+ )
109
+ .join("\n");
110
+ }
111
+
39
112
  function getStatusPriority(status: string): number {
40
113
  if (status === "in_progress" || status === "in-progress") {
41
114
  return 0;
@@ -248,36 +321,346 @@ function formatEpicShowTable(tree: {
248
321
  }
249
322
 
250
323
  function failFromError(error: unknown, command: string): CliResult {
251
- if (error instanceof DomainError) {
252
- return failResult({
253
- command,
254
- human: error.message,
255
- data: {
256
- code: error.code,
257
- ...(error.details ?? {}),
258
- },
259
- error: {
260
- code: error.code,
261
- message: error.message,
262
- },
263
- });
264
- }
324
+ return unexpectedFailureResult(error, {
325
+ command,
326
+ human: "Unexpected epic command failure",
327
+ });
328
+ }
265
329
 
330
+ function failBatchSpec(command: string, human: string, data: Record<string, unknown>): CliResult {
266
331
  return failResult({
267
332
  command,
268
- human: "Unexpected epic command failure",
269
- data: {},
333
+ human,
334
+ data,
270
335
  error: {
271
- code: "internal_error",
272
- message: "Unexpected epic command failure",
336
+ code: "invalid_input",
337
+ message: human,
273
338
  },
274
339
  });
275
340
  }
276
341
 
342
+ function failUnexpectedPositionals(command: string, unexpected: readonly string[]): CliResult {
343
+ return failBatchSpec(command, `Unexpected positional arguments: ${unexpected.join(", ")}.`, {
344
+ unexpectedPositionals: unexpected,
345
+ });
346
+ }
347
+
348
+ function failEmptyCompactField(command: string, option: string, index: number, rawSpec: string, field: string): CliResult {
349
+ const label = option === "task" ? "Task" : "Subtask";
350
+ return failBatchSpec(command, `${label} spec ${index + 1} is missing a ${field}.`, {
351
+ option,
352
+ index,
353
+ rawSpec,
354
+ field,
355
+ });
356
+ }
357
+
358
+ function validateCompactEntityRef(
359
+ command: string,
360
+ option: string,
361
+ index: number,
362
+ rawSpec: string,
363
+ label: string,
364
+ reference: CompactEntityRef,
365
+ ): CliResult | undefined {
366
+ if (reference.kind === "temp_key" && !isValidCompactTempKey(reference.tempKey)) {
367
+ return failBatchSpec(command, `${label} in --${option} spec ${index + 1} must use ${COMPACT_TEMP_KEY_PREFIX}<temp-key> with letters, numbers, dot, dash, or underscore.`, {
368
+ option,
369
+ index,
370
+ rawSpec,
371
+ reference,
372
+ });
373
+ }
374
+
375
+ if (reference.kind === "id" && reference.id.trim().length === 0) {
376
+ return failBatchSpec(command, `${label} in --${option} spec ${index + 1} is required.`, {
377
+ option,
378
+ index,
379
+ rawSpec,
380
+ reference,
381
+ });
382
+ }
383
+
384
+ return undefined;
385
+ }
386
+
387
+ function parseExpandTaskSpecs(rawSpecs: readonly string[]): { specs: CompactTaskSpec[]; error?: CliResult } {
388
+ const specs: CompactTaskSpec[] = [];
389
+ const seenTempKeys = new Set<string>();
390
+
391
+ for (const [index, rawSpec] of rawSpecs.entries()) {
392
+ const parsed = parseCompactFields(rawSpec);
393
+ if (parsed.invalidEscape !== null) {
394
+ return {
395
+ specs: [],
396
+ error: failBatchSpec("epic.expand", `Invalid escape sequence ${parsed.invalidEscape} in --task spec ${index + 1}.`, {
397
+ option: "task",
398
+ index,
399
+ rawSpec,
400
+ }),
401
+ };
402
+ }
403
+
404
+ if (parsed.hasDanglingEscape) {
405
+ return {
406
+ specs: [],
407
+ error: failBatchSpec("epic.expand", `Trailing escape in --task spec ${index + 1}.`, {
408
+ option: "task",
409
+ index,
410
+ rawSpec,
411
+ }),
412
+ };
413
+ }
414
+
415
+ if (parsed.fields.length !== 4) {
416
+ return {
417
+ specs: [],
418
+ error: failBatchSpec("epic.expand", `Task specs must use <temp-key>|<title>|<description>|<status> in --task spec ${index + 1}.`, {
419
+ option: "task",
420
+ index,
421
+ rawSpec,
422
+ fields: parsed.fields,
423
+ }),
424
+ };
425
+ }
426
+
427
+ const tempKey = parsed.fields[0] ?? "";
428
+ const title = parsed.fields[1] ?? "";
429
+ const description = parsed.fields[2] ?? "";
430
+ const status = parsed.fields[3] ?? "";
431
+ if (!tempKey || !isValidCompactTempKey(tempKey)) {
432
+ return {
433
+ specs: [],
434
+ error: failBatchSpec("epic.expand", `Task spec ${index + 1} must start with a temp key like seed-1.`, {
435
+ option: "task",
436
+ index,
437
+ rawSpec,
438
+ tempKey,
439
+ }),
440
+ };
441
+ }
442
+
443
+ if (seenTempKeys.has(tempKey)) {
444
+ return {
445
+ specs: [],
446
+ error: failBatchSpec("epic.expand", `Duplicate temp key '${tempKey}' across --task specs.`, {
447
+ option: "task",
448
+ index,
449
+ rawSpec,
450
+ tempKey,
451
+ }),
452
+ };
453
+ }
454
+
455
+ if (!title || title.trim().length === 0) {
456
+ return {
457
+ specs: [],
458
+ error: failBatchSpec("epic.expand", `Task spec ${index + 1} is missing a title.`, {
459
+ option: "task",
460
+ index,
461
+ rawSpec,
462
+ }),
463
+ };
464
+ }
465
+
466
+ if (description.trim().length === 0) {
467
+ return {
468
+ specs: [],
469
+ error: failEmptyCompactField("epic.expand", "task", index, rawSpec, "description"),
470
+ };
471
+ }
472
+
473
+ seenTempKeys.add(tempKey);
474
+ const spec: CompactTaskSpec = status.length > 0
475
+ ? { tempKey, title, description, status }
476
+ : { tempKey, title, description };
477
+ specs.push(spec);
478
+ }
479
+
480
+ return { specs };
481
+ }
482
+
483
+ function parseExpandSubtaskSpecs(rawSpecs: readonly string[]): { specs: CompactSubtaskSpec[]; error?: CliResult } {
484
+ const specs: CompactSubtaskSpec[] = [];
485
+ const seenTempKeys = new Set<string>();
486
+
487
+ for (const [index, rawSpec] of rawSpecs.entries()) {
488
+ const parsed = parseCompactFields(rawSpec);
489
+ if (parsed.invalidEscape !== null) {
490
+ return {
491
+ specs: [],
492
+ error: failBatchSpec("epic.expand", `Invalid escape sequence ${parsed.invalidEscape} in --subtask spec ${index + 1}.`, {
493
+ option: "subtask",
494
+ index,
495
+ rawSpec,
496
+ }),
497
+ };
498
+ }
499
+
500
+ if (parsed.hasDanglingEscape) {
501
+ return {
502
+ specs: [],
503
+ error: failBatchSpec("epic.expand", `Trailing escape in --subtask spec ${index + 1}.`, {
504
+ option: "subtask",
505
+ index,
506
+ rawSpec,
507
+ }),
508
+ };
509
+ }
510
+
511
+ if (parsed.fields.length !== 5) {
512
+ return {
513
+ specs: [],
514
+ error: failBatchSpec("epic.expand", `Subtask specs must use <parent-ref>|<temp-key>|<title>|<description>|<status> in --subtask spec ${index + 1}.`, {
515
+ option: "subtask",
516
+ index,
517
+ rawSpec,
518
+ fields: parsed.fields,
519
+ }),
520
+ };
521
+ }
522
+
523
+ const parent = parseCompactEntityRef(parsed.fields[0] ?? "");
524
+ const parentError = validateCompactEntityRef("epic.expand", "subtask", index, rawSpec, "Parent ref", parent);
525
+ if (parentError !== undefined) {
526
+ return { specs: [], error: parentError };
527
+ }
528
+
529
+ const tempKey = parsed.fields[1] ?? "";
530
+ const title = parsed.fields[2] ?? "";
531
+ const description = parsed.fields[3] ?? "";
532
+ const status = parsed.fields[4] ?? "";
533
+ if (!tempKey || !isValidCompactTempKey(tempKey)) {
534
+ return {
535
+ specs: [],
536
+ error: failBatchSpec("epic.expand", `Subtask spec ${index + 1} must include a valid temp key.`, {
537
+ option: "subtask",
538
+ index,
539
+ rawSpec,
540
+ tempKey,
541
+ }),
542
+ };
543
+ }
544
+
545
+ if (seenTempKeys.has(tempKey)) {
546
+ return {
547
+ specs: [],
548
+ error: failBatchSpec("epic.expand", `Duplicate temp key '${tempKey}' across --subtask specs.`, {
549
+ option: "subtask",
550
+ index,
551
+ rawSpec,
552
+ tempKey,
553
+ }),
554
+ };
555
+ }
556
+
557
+ if (!title || title.trim().length === 0) {
558
+ return {
559
+ specs: [],
560
+ error: failBatchSpec("epic.expand", `Subtask spec ${index + 1} is missing a title.`, {
561
+ option: "subtask",
562
+ index,
563
+ rawSpec,
564
+ }),
565
+ };
566
+ }
567
+
568
+ if (description.trim().length === 0) {
569
+ return {
570
+ specs: [],
571
+ error: failEmptyCompactField("epic.expand", "subtask", index, rawSpec, "description"),
572
+ };
573
+ }
574
+
575
+ seenTempKeys.add(tempKey);
576
+ const spec: CompactSubtaskSpec = status.length > 0
577
+ ? { parent, tempKey, title, description, status }
578
+ : { parent, tempKey, title, description };
579
+ specs.push(spec);
580
+ }
581
+
582
+ return { specs };
583
+ }
584
+
585
+ function parseExpandDependencySpecs(rawSpecs: readonly string[]): { specs: CompactDependencySpec[]; error?: CliResult } {
586
+ const specs: CompactDependencySpec[] = [];
587
+
588
+ for (const [index, rawSpec] of rawSpecs.entries()) {
589
+ const parsed = parseCompactFields(rawSpec);
590
+ if (parsed.invalidEscape !== null) {
591
+ return {
592
+ specs: [],
593
+ error: failBatchSpec("epic.expand", `Invalid escape sequence ${parsed.invalidEscape} in --dep spec ${index + 1}.`, {
594
+ option: "dep",
595
+ index,
596
+ rawSpec,
597
+ }),
598
+ };
599
+ }
600
+
601
+ if (parsed.hasDanglingEscape) {
602
+ return {
603
+ specs: [],
604
+ error: failBatchSpec("epic.expand", `Trailing escape in --dep spec ${index + 1}.`, {
605
+ option: "dep",
606
+ index,
607
+ rawSpec,
608
+ }),
609
+ };
610
+ }
611
+
612
+ if (parsed.fields.length !== 2) {
613
+ return {
614
+ specs: [],
615
+ error: failBatchSpec("epic.expand", `Dependency specs must use <source-ref>|<depends-on-ref> in --dep spec ${index + 1}.`, {
616
+ option: "dep",
617
+ index,
618
+ rawSpec,
619
+ fields: parsed.fields,
620
+ }),
621
+ };
622
+ }
623
+
624
+ const source = parseCompactEntityRef(parsed.fields[0] ?? "");
625
+ const sourceError = validateCompactEntityRef("epic.expand", "dep", index, rawSpec, "Source ref", source);
626
+ if (sourceError !== undefined) {
627
+ return { specs: [], error: sourceError };
628
+ }
629
+
630
+ const dependsOn = parseCompactEntityRef(parsed.fields[1] ?? "");
631
+ const dependsOnError = validateCompactEntityRef("epic.expand", "dep", index, rawSpec, "Depends-on ref", dependsOn);
632
+ if (dependsOnError !== undefined) {
633
+ return { specs: [], error: dependsOnError };
634
+ }
635
+
636
+ specs.push({ source, dependsOn });
637
+ }
638
+
639
+ return { specs };
640
+ }
641
+
642
+ function findDuplicateExpandTempKey(tasks: readonly CompactTaskSpec[], subtasks: readonly CompactSubtaskSpec[]): string | null {
643
+ const seen = new Set<string>();
644
+ for (const task of tasks) {
645
+ seen.add(task.tempKey);
646
+ }
647
+
648
+ for (const subtask of subtasks) {
649
+ if (seen.has(subtask.tempKey)) {
650
+ return subtask.tempKey;
651
+ }
652
+
653
+ seen.add(subtask.tempKey);
654
+ }
655
+
656
+ return null;
657
+ }
658
+
277
659
  export async function runEpic(context: CliContext): Promise<CliResult> {
278
- const database = openTrekoonDatabase(context.cwd);
660
+ let database: TrekoonDatabase | undefined;
279
661
 
280
662
  try {
663
+ database = openTrekoonDatabase(context.cwd);
281
664
  const parsed = parseArgs(context.args);
282
665
  const subcommand: string | undefined = parsed.positional[0];
283
666
  const domain = new TrackerDomain(database.db);
@@ -285,26 +668,161 @@ export async function runEpic(context: CliContext): Promise<CliResult> {
285
668
 
286
669
  switch (subcommand) {
287
670
  case "create": {
288
- const missingCreateOption =
671
+ const missingCreateOption =
672
+ readMissingOptionValue(parsed.missingOptionValues, "title", "t") ??
289
673
  readMissingOptionValue(parsed.missingOptionValues, "status", "s") ??
290
- readMissingOptionValue(parsed.missingOptionValues, "description", "d");
674
+ readMissingOptionValue(parsed.missingOptionValues, "description", "d") ??
675
+ readMissingOptionValue(parsed.missingOptionValues, "task", "subtask", "dep");
291
676
  if (missingCreateOption !== undefined) {
292
677
  return failMissingOptionValue("epic.create", missingCreateOption);
293
678
  }
294
679
 
680
+ const createUnknownOption = findUnknownOption(parsed, CREATE_OPTIONS);
681
+ if (createUnknownOption !== undefined) {
682
+ return unknownOption("epic.create", createUnknownOption, CREATE_OPTIONS);
683
+ }
684
+
685
+ const unexpectedPositionals = readUnexpectedPositionals(parsed, 1);
686
+ if (unexpectedPositionals.length > 0) {
687
+ return failUnexpectedPositionals("epic.create", unexpectedPositionals);
688
+ }
689
+
295
690
  const title: string | undefined = readOption(parsed.options, "title", "t");
296
691
  const description: string | undefined = readOption(parsed.options, "description", "d");
297
692
  const status: string | undefined = readOption(parsed.options, "status", "s");
298
- const epic = mutations.createEpic({
693
+
694
+ const taskSpecs = readOptions(parsed.optionEntries, "task");
695
+ const subtaskSpecs = readOptions(parsed.optionEntries, "subtask");
696
+ const dependencySpecs = readOptions(parsed.optionEntries, "dep");
697
+
698
+ if (taskSpecs.length === 0 && subtaskSpecs.length === 0 && dependencySpecs.length === 0) {
699
+ const epic = mutations.createEpic({
700
+ title: title ?? "",
701
+ description: description ?? "",
702
+ status,
703
+ });
704
+
705
+ return okResult({
706
+ command: "epic.create",
707
+ human: `Created epic ${formatEpic(epic)}`,
708
+ data: { epic },
709
+ });
710
+ }
711
+
712
+ const parsedTasks = parseExpandTaskSpecs(taskSpecs);
713
+ if (parsedTasks.error !== undefined) {
714
+ return parsedTasks.error;
715
+ }
716
+
717
+ const parsedSubtasks = parseExpandSubtaskSpecs(subtaskSpecs);
718
+ if (parsedSubtasks.error !== undefined) {
719
+ return parsedSubtasks.error;
720
+ }
721
+
722
+ const parsedDeps = parseExpandDependencySpecs(dependencySpecs);
723
+ if (parsedDeps.error !== undefined) {
724
+ return parsedDeps.error;
725
+ }
726
+
727
+ const duplicateTempKey = findDuplicateExpandTempKey(parsedTasks.specs, parsedSubtasks.specs);
728
+ if (duplicateTempKey !== null) {
729
+ return failBatchSpec("epic.create", `Duplicate temp key '${duplicateTempKey}' across --task and --subtask specs.`, {
730
+ tempKey: duplicateTempKey,
731
+ });
732
+ }
733
+
734
+ const created = mutations.createEpicGraph({
299
735
  title: title ?? "",
300
736
  description: description ?? "",
301
737
  status,
738
+ taskSpecs: parsedTasks.specs,
739
+ subtaskSpecs: parsedSubtasks.specs,
740
+ dependencySpecs: parsedDeps.specs,
302
741
  });
303
742
 
304
743
  return okResult({
305
744
  command: "epic.create",
306
- human: `Created epic ${formatEpic(epic)}`,
307
- data: { epic },
745
+ human: `Created epic ${formatEpic(created.epic)} with ${created.tasks.length} task(s), ${created.subtasks.length} subtask(s), and ${created.dependencies.length} dependenc${created.dependencies.length === 1 ? "y" : "ies"}.`,
746
+ data: {
747
+ epic: created.epic,
748
+ tasks: created.tasks,
749
+ subtasks: created.subtasks,
750
+ dependencies: created.dependencies,
751
+ result: created.result,
752
+ },
753
+ });
754
+ }
755
+ case "expand": {
756
+ const expandUnknownOption = findUnknownOption(parsed, EXPAND_OPTIONS);
757
+ if (expandUnknownOption !== undefined) {
758
+ return unknownOption("epic.expand", expandUnknownOption, EXPAND_OPTIONS);
759
+ }
760
+
761
+ const missingExpandOption = readMissingOptionValue(parsed.missingOptionValues, "task", "subtask", "dep");
762
+ if (missingExpandOption !== undefined) {
763
+ return failMissingOptionValue("epic.expand", missingExpandOption);
764
+ }
765
+
766
+ const unexpectedPositionals = readUnexpectedPositionals(parsed, 2);
767
+ if (unexpectedPositionals.length > 0) {
768
+ return failUnexpectedPositionals("epic.expand", unexpectedPositionals);
769
+ }
770
+
771
+ const epicId: string = parsed.positional[1] ?? "";
772
+ if (epicId.trim().length === 0) {
773
+ return failBatchSpec("epic.expand", "Provide an epic id for epic expand.", {
774
+ id: epicId,
775
+ });
776
+ }
777
+
778
+ const taskSpecs = readOptions(parsed.optionEntries, "task");
779
+ const subtaskSpecs = readOptions(parsed.optionEntries, "subtask");
780
+ const dependencySpecs = readOptions(parsed.optionEntries, "dep");
781
+ if (taskSpecs.length === 0 && subtaskSpecs.length === 0 && dependencySpecs.length === 0) {
782
+ return failBatchSpec("epic.expand", "Provide at least one --task, --subtask, or --dep spec.", {});
783
+ }
784
+
785
+ const parsedTasks = parseExpandTaskSpecs(taskSpecs);
786
+ if (parsedTasks.error !== undefined) {
787
+ return parsedTasks.error;
788
+ }
789
+
790
+ const parsedSubtasks = parseExpandSubtaskSpecs(subtaskSpecs);
791
+ if (parsedSubtasks.error !== undefined) {
792
+ return parsedSubtasks.error;
793
+ }
794
+
795
+ const parsedDeps = parseExpandDependencySpecs(dependencySpecs);
796
+ if (parsedDeps.error !== undefined) {
797
+ return parsedDeps.error;
798
+ }
799
+
800
+ const duplicateTempKey = findDuplicateExpandTempKey(parsedTasks.specs, parsedSubtasks.specs);
801
+ if (duplicateTempKey !== null) {
802
+ return failBatchSpec("epic.expand", `Duplicate temp key '${duplicateTempKey}' across --task and --subtask specs.`, {
803
+ tempKey: duplicateTempKey,
804
+ });
805
+ }
806
+
807
+ const created = mutations.expandEpic({
808
+ epicId,
809
+ taskSpecs: parsedTasks.specs,
810
+ subtaskSpecs: parsedSubtasks.specs,
811
+ dependencySpecs: parsedDeps.specs,
812
+ });
813
+ const result: CompactBatchResultContract & {
814
+ counts: { tasks: number; subtasks: number; dependencies: number };
815
+ } = created.result;
816
+ return okResult({
817
+ command: "epic.expand",
818
+ human: `Expanded epic ${epicId} with ${created.tasks.length} task(s), ${created.subtasks.length} subtask(s), and ${created.dependencies.length} dependenc${created.dependencies.length === 1 ? "y" : "ies"}.`,
819
+ data: {
820
+ epicId,
821
+ tasks: created.tasks,
822
+ subtasks: created.subtasks,
823
+ dependencies: created.dependencies,
824
+ result,
825
+ },
308
826
  });
309
827
  }
310
828
  case "list": {
@@ -520,6 +1038,134 @@ export async function runEpic(context: CliContext): Promise<CliResult> {
520
1038
  }),
521
1039
  });
522
1040
  }
1041
+ case "search": {
1042
+ const searchUnknownOption = findUnknownOption(parsed, SEARCH_OPTIONS);
1043
+ if (searchUnknownOption !== undefined) {
1044
+ return unknownOption("epic.search", searchUnknownOption, SEARCH_OPTIONS);
1045
+ }
1046
+
1047
+ const missingSearchOption = readMissingOptionValue(parsed.missingOptionValues, "fields");
1048
+ if (missingSearchOption !== undefined) {
1049
+ return failMissingOptionValue("epic.search", missingSearchOption);
1050
+ }
1051
+
1052
+ const epicId: string = parsed.positional[1] ?? "";
1053
+ const searchText: string = parsed.positional[2] ?? "";
1054
+ if (epicId.length === 0 || searchText.trim().length === 0) {
1055
+ return invalidSearchInput(
1056
+ "epic.search",
1057
+ "Usage: trekoon epic search <epic-id> \"search text\" [--fields <csv>] [--preview]",
1058
+ "Missing search target",
1059
+ {
1060
+ epicId,
1061
+ },
1062
+ );
1063
+ }
1064
+
1065
+ const parsedFields = parseCsvEnumOption(readOption(parsed.options, "fields"), SEARCH_REPLACE_FIELDS);
1066
+ if (parsedFields.empty || parsedFields.invalidValues.length > 0) {
1067
+ return invalidSearchInput("epic.search", "Invalid --fields value. Use title, description, or title,description.", "Invalid --fields value", {
1068
+ fields: readOption(parsed.options, "fields"),
1069
+ invalidFields: parsedFields.invalidValues,
1070
+ allowedFields: [...SEARCH_REPLACE_FIELDS],
1071
+ });
1072
+ }
1073
+
1074
+ const { matches, summary } = domain.searchEpicScope(epicId, searchText, parsedFields.values);
1075
+
1076
+ return okResult({
1077
+ command: "epic.search",
1078
+ human: formatSearchHuman(matches, "No matches found."),
1079
+ data: {
1080
+ scope: {
1081
+ kind: "epic",
1082
+ id: epicId,
1083
+ },
1084
+ query: {
1085
+ search: searchText,
1086
+ fields: parsedFields.values,
1087
+ mode: "preview",
1088
+ },
1089
+ summary,
1090
+ matches,
1091
+ },
1092
+ });
1093
+ }
1094
+ case "replace": {
1095
+ const replaceUnknownOption = findUnknownOption(parsed, REPLACE_OPTIONS);
1096
+ if (replaceUnknownOption !== undefined) {
1097
+ return unknownOption("epic.replace", replaceUnknownOption, REPLACE_OPTIONS);
1098
+ }
1099
+
1100
+ const missingReplaceOption =
1101
+ readMissingOptionValue(parsed.missingOptionValues, "search") ??
1102
+ readMissingOptionValue(parsed.missingOptionValues, "replace") ??
1103
+ readMissingOptionValue(parsed.missingOptionValues, "fields");
1104
+ if (missingReplaceOption !== undefined) {
1105
+ return failMissingOptionValue("epic.replace", missingReplaceOption);
1106
+ }
1107
+
1108
+ const epicId: string = parsed.positional[1] ?? "";
1109
+ const searchText = readOption(parsed.options, "search") ?? "";
1110
+ const replacementText = readOption(parsed.options, "replace") ?? "";
1111
+ if (epicId.length === 0 || searchText.trim().length === 0) {
1112
+ return invalidSearchInput(
1113
+ "epic.replace",
1114
+ "Usage: trekoon epic replace <epic-id> --search \"text\" --replace \"text\" [--fields <csv>] [--preview|--apply]",
1115
+ "Missing replace target",
1116
+ {
1117
+ epicId,
1118
+ search: searchText,
1119
+ },
1120
+ );
1121
+ }
1122
+
1123
+ const rawFields = readOption(parsed.options, "fields");
1124
+ const parsedFields = parseCsvEnumOption(rawFields, SEARCH_REPLACE_FIELDS);
1125
+ if (parsedFields.empty || parsedFields.invalidValues.length > 0) {
1126
+ return invalidSearchInput("epic.replace", "Invalid --fields value. Use title, description, or title,description.", "Invalid --fields value", {
1127
+ fields: rawFields,
1128
+ invalidFields: parsedFields.invalidValues,
1129
+ allowedFields: [...SEARCH_REPLACE_FIELDS],
1130
+ });
1131
+ }
1132
+
1133
+ const previewMode = resolvePreviewApplyMode(parsed.flags);
1134
+ if (previewMode.conflict) {
1135
+ return invalidSearchInput("epic.replace", "Use either --preview or --apply, not both.", "Conflicting mode flags", {
1136
+ flags: ["preview", "apply"],
1137
+ });
1138
+ }
1139
+
1140
+ const replacementSummary = previewMode.mode === "apply"
1141
+ ? mutations.applyEpicReplacement(epicId, searchText, replacementText, parsedFields.values)
1142
+ : mutations.previewEpicReplacement(epicId, searchText, replacementText, parsedFields.values);
1143
+ const { matches, summary: matchSummary } = replacementSummary;
1144
+
1145
+ const summary = {
1146
+ ...matchSummary,
1147
+ mode: previewMode.mode,
1148
+ };
1149
+
1150
+ return okResult({
1151
+ command: "epic.replace",
1152
+ human: formatSearchHuman(matches, `No ${previewMode.mode === "apply" ? "replacements" : "matches"} found.`),
1153
+ data: {
1154
+ scope: {
1155
+ kind: "epic",
1156
+ id: epicId,
1157
+ },
1158
+ query: {
1159
+ search: searchText,
1160
+ replace: replacementText,
1161
+ fields: parsedFields.values,
1162
+ mode: previewMode.mode,
1163
+ },
1164
+ summary,
1165
+ matches,
1166
+ },
1167
+ });
1168
+ }
523
1169
  case "update": {
524
1170
  const missingUpdateOption =
525
1171
  readMissingOptionValue(parsed.missingOptionValues, "ids") ??
@@ -657,7 +1303,7 @@ export async function runEpic(context: CliContext): Promise<CliResult> {
657
1303
  default:
658
1304
  return failResult({
659
1305
  command: "epic",
660
- human: "Usage: trekoon epic <create|list|show|update|delete>",
1306
+ human: "Usage: trekoon epic <create|expand|list|show|search|replace|update|delete>",
661
1307
  data: {
662
1308
  args: context.args,
663
1309
  },
@@ -670,6 +1316,6 @@ export async function runEpic(context: CliContext): Promise<CliResult> {
670
1316
  } catch (error: unknown) {
671
1317
  return failFromError(error, "epic");
672
1318
  } finally {
673
- database.close();
1319
+ database?.close();
674
1320
  }
675
1321
  }