trekoon 0.1.9 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,16 +1,21 @@
1
1
  import {
2
+ SEARCH_REPLACE_FIELDS,
3
+ findUnknownOption,
2
4
  hasFlag,
3
5
  parseArgs,
6
+ parseCsvEnumOption,
4
7
  parseStrictNonNegativeInt,
5
8
  parseStrictPositiveInt,
6
9
  readEnumOption,
7
10
  readMissingOptionValue,
8
11
  readOption,
12
+ resolvePreviewApplyMode,
13
+ suggestOptions,
9
14
  } from "./arg-parser";
10
15
 
11
16
  import { MutationService } from "../domain/mutation-service";
12
17
  import { TrackerDomain } from "../domain/tracker-domain";
13
- import { DomainError, type SubtaskRecord } from "../domain/types";
18
+ import { DomainError, type SearchEntityMatch, type SubtaskRecord } from "../domain/types";
14
19
  import { formatHumanTable } from "../io/human-table";
15
20
  import { failResult, okResult } from "../io/output";
16
21
  import { type CliContext, type CliResult } from "../runtime/command-types";
@@ -23,6 +28,8 @@ function formatSubtask(subtask: SubtaskRecord): string {
23
28
  const VIEW_MODES = ["table", "compact"] as const;
24
29
  const DEFAULT_SUBTASK_LIST_LIMIT = 10;
25
30
  const DEFAULT_OPEN_SUBTASK_STATUSES = ["in_progress", "in-progress", "todo"] as const;
31
+ const SEARCH_OPTIONS = ["fields", "preview"] as const;
32
+ const REPLACE_OPTIONS = ["search", "replace", "fields", "preview", "apply"] as const;
26
33
 
27
34
  function parseIdsOption(rawIds: string | undefined): string[] {
28
35
  if (rawIds === undefined) {
@@ -35,6 +42,55 @@ function parseIdsOption(rawIds: string | undefined): string[] {
35
42
  .filter((value) => value.length > 0);
36
43
  }
37
44
 
45
+ function prefixedOptions(options: readonly string[]): string[] {
46
+ return options.map((option) => `--${option}`);
47
+ }
48
+
49
+ function unknownOption(command: string, option: string, allowedOptions: readonly string[]): CliResult {
50
+ const suggestions = suggestOptions(option, allowedOptions).map((suggestion) => `--${suggestion}`);
51
+ const suggestionMessage = suggestions.length > 0 ? ` Did you mean ${suggestions.join(" or ")}?` : "";
52
+ return failResult({
53
+ command,
54
+ human: `Unknown option --${option}.${suggestionMessage}`,
55
+ data: {
56
+ option: `--${option}`,
57
+ allowedOptions: prefixedOptions(allowedOptions),
58
+ suggestions,
59
+ },
60
+ error: {
61
+ code: "unknown_option",
62
+ message: `Unknown option --${option}`,
63
+ },
64
+ });
65
+ }
66
+
67
+ function invalidSearchInput(command: string, human: string, message: string, data: Record<string, unknown>): CliResult {
68
+ return failResult({
69
+ command,
70
+ human,
71
+ data,
72
+ error: {
73
+ code: "invalid_input",
74
+ message,
75
+ },
76
+ });
77
+ }
78
+
79
+ function formatSearchHuman(matches: readonly SearchEntityMatch[], emptyMessage: string): string {
80
+ if (matches.length === 0) {
81
+ return emptyMessage;
82
+ }
83
+
84
+ return matches
85
+ .map(
86
+ (match) =>
87
+ `${match.kind} ${match.id}: ${match.fields
88
+ .map((field) => `${field.field}(${field.count}) "${field.snippet}"`)
89
+ .join(", ")}`,
90
+ )
91
+ .join("\n");
92
+ }
93
+
38
94
  function parseStatusCsv(rawStatuses: string | undefined): string[] | undefined {
39
95
  if (rawStatuses === undefined) {
40
96
  return undefined;
@@ -348,6 +404,134 @@ export async function runSubtask(context: CliContext): Promise<CliResult> {
348
404
  }),
349
405
  });
350
406
  }
407
+ case "search": {
408
+ const searchUnknownOption = findUnknownOption(parsed, SEARCH_OPTIONS);
409
+ if (searchUnknownOption !== undefined) {
410
+ return unknownOption("subtask.search", searchUnknownOption, SEARCH_OPTIONS);
411
+ }
412
+
413
+ const missingSearchOption = readMissingOptionValue(parsed.missingOptionValues, "fields");
414
+ if (missingSearchOption !== undefined) {
415
+ return failMissingOptionValue("subtask.search", missingSearchOption);
416
+ }
417
+
418
+ const subtaskId: string = parsed.positional[1] ?? "";
419
+ const searchText: string = parsed.positional[2] ?? "";
420
+ if (subtaskId.length === 0 || searchText.trim().length === 0) {
421
+ return invalidSearchInput(
422
+ "subtask.search",
423
+ "Usage: trekoon subtask search <subtask-id> \"search text\" [--fields <csv>] [--preview]",
424
+ "Missing search target",
425
+ {
426
+ subtaskId,
427
+ },
428
+ );
429
+ }
430
+
431
+ const parsedFields = parseCsvEnumOption(readOption(parsed.options, "fields"), SEARCH_REPLACE_FIELDS);
432
+ if (parsedFields.empty || parsedFields.invalidValues.length > 0) {
433
+ return invalidSearchInput("subtask.search", "Invalid --fields value. Use title, description, or title,description.", "Invalid --fields value", {
434
+ fields: readOption(parsed.options, "fields"),
435
+ invalidFields: parsedFields.invalidValues,
436
+ allowedFields: [...SEARCH_REPLACE_FIELDS],
437
+ });
438
+ }
439
+
440
+ const { matches, summary } = domain.searchSubtaskScope(subtaskId, searchText, parsedFields.values);
441
+
442
+ return okResult({
443
+ command: "subtask.search",
444
+ human: formatSearchHuman(matches, "No matches found."),
445
+ data: {
446
+ scope: {
447
+ kind: "subtask",
448
+ id: subtaskId,
449
+ },
450
+ query: {
451
+ search: searchText,
452
+ fields: parsedFields.values,
453
+ mode: "preview",
454
+ },
455
+ summary,
456
+ matches,
457
+ },
458
+ });
459
+ }
460
+ case "replace": {
461
+ const replaceUnknownOption = findUnknownOption(parsed, REPLACE_OPTIONS);
462
+ if (replaceUnknownOption !== undefined) {
463
+ return unknownOption("subtask.replace", replaceUnknownOption, REPLACE_OPTIONS);
464
+ }
465
+
466
+ const missingReplaceOption =
467
+ readMissingOptionValue(parsed.missingOptionValues, "search") ??
468
+ readMissingOptionValue(parsed.missingOptionValues, "replace") ??
469
+ readMissingOptionValue(parsed.missingOptionValues, "fields");
470
+ if (missingReplaceOption !== undefined) {
471
+ return failMissingOptionValue("subtask.replace", missingReplaceOption);
472
+ }
473
+
474
+ const subtaskId: string = parsed.positional[1] ?? "";
475
+ const searchText = readOption(parsed.options, "search") ?? "";
476
+ const replacementText = readOption(parsed.options, "replace") ?? "";
477
+ if (subtaskId.length === 0 || searchText.trim().length === 0) {
478
+ return invalidSearchInput(
479
+ "subtask.replace",
480
+ "Usage: trekoon subtask replace <subtask-id> --search \"text\" --replace \"text\" [--fields <csv>] [--preview|--apply]",
481
+ "Missing replace target",
482
+ {
483
+ subtaskId,
484
+ search: searchText,
485
+ },
486
+ );
487
+ }
488
+
489
+ const rawFields = readOption(parsed.options, "fields");
490
+ const parsedFields = parseCsvEnumOption(rawFields, SEARCH_REPLACE_FIELDS);
491
+ if (parsedFields.empty || parsedFields.invalidValues.length > 0) {
492
+ return invalidSearchInput("subtask.replace", "Invalid --fields value. Use title, description, or title,description.", "Invalid --fields value", {
493
+ fields: rawFields,
494
+ invalidFields: parsedFields.invalidValues,
495
+ allowedFields: [...SEARCH_REPLACE_FIELDS],
496
+ });
497
+ }
498
+
499
+ const previewMode = resolvePreviewApplyMode(parsed.flags);
500
+ if (previewMode.conflict) {
501
+ return invalidSearchInput("subtask.replace", "Use either --preview or --apply, not both.", "Conflicting mode flags", {
502
+ flags: ["preview", "apply"],
503
+ });
504
+ }
505
+
506
+ const replacementSummary = previewMode.mode === "apply"
507
+ ? mutations.applySubtaskReplacement(subtaskId, searchText, replacementText, parsedFields.values)
508
+ : mutations.previewSubtaskReplacement(subtaskId, searchText, replacementText, parsedFields.values);
509
+ const { matches, summary: matchSummary } = replacementSummary;
510
+
511
+ const summary = {
512
+ ...matchSummary,
513
+ mode: previewMode.mode,
514
+ };
515
+
516
+ return okResult({
517
+ command: "subtask.replace",
518
+ human: formatSearchHuman(matches, `No ${previewMode.mode === "apply" ? "replacements" : "matches"} found.`),
519
+ data: {
520
+ scope: {
521
+ kind: "subtask",
522
+ id: subtaskId,
523
+ },
524
+ query: {
525
+ search: searchText,
526
+ replace: replacementText,
527
+ fields: parsedFields.values,
528
+ mode: previewMode.mode,
529
+ },
530
+ summary,
531
+ matches,
532
+ },
533
+ });
534
+ }
351
535
  case "update": {
352
536
  const missingUpdateOption =
353
537
  readMissingOptionValue(parsed.missingOptionValues, "ids") ??
@@ -485,7 +669,7 @@ export async function runSubtask(context: CliContext): Promise<CliResult> {
485
669
  default:
486
670
  return failResult({
487
671
  command: "subtask",
488
- human: "Usage: trekoon subtask <create|list|update|delete>",
672
+ human: "Usage: trekoon subtask <create|list|search|replace|update|delete>",
489
673
  data: {
490
674
  args: context.args,
491
675
  },
@@ -1,16 +1,21 @@
1
1
  import {
2
+ SEARCH_REPLACE_FIELDS,
3
+ findUnknownOption,
2
4
  hasFlag,
3
5
  parseArgs,
6
+ parseCsvEnumOption,
4
7
  parseStrictNonNegativeInt,
5
8
  parseStrictPositiveInt,
6
9
  readEnumOption,
7
10
  readMissingOptionValue,
8
11
  readOption,
12
+ resolvePreviewApplyMode,
13
+ suggestOptions,
9
14
  } from "./arg-parser";
10
15
 
11
16
  import { MutationService } from "../domain/mutation-service";
12
17
  import { TrackerDomain } from "../domain/tracker-domain";
13
- import { DomainError, type TaskRecord } from "../domain/types";
18
+ import { DomainError, type SearchEntityMatch, type TaskRecord } from "../domain/types";
14
19
  import { formatHumanTable } from "../io/human-table";
15
20
  import { failResult, okResult } from "../io/output";
16
21
  import { type CliContext, type CliResult } from "../runtime/command-types";
@@ -26,6 +31,8 @@ const DEFAULT_TASK_LIST_LIMIT = 10;
26
31
  const DEFAULT_OPEN_TASK_STATUSES = ["in_progress", "in-progress", "todo"] as const;
27
32
  const READY_REASON_READY = "all_dependencies_done";
28
33
  const READY_REASON_BLOCKED = "blocked_by_dependencies";
34
+ const SEARCH_OPTIONS = ["fields", "preview"] as const;
35
+ const REPLACE_OPTIONS = ["search", "replace", "fields", "preview", "apply"] as const;
29
36
 
30
37
  interface DependencyBlocker {
31
38
  readonly id: string;
@@ -81,6 +88,55 @@ function parseIdsOption(rawIds: string | undefined): string[] {
81
88
  .filter((value) => value.length > 0);
82
89
  }
83
90
 
91
+ function prefixedOptions(options: readonly string[]): string[] {
92
+ return options.map((option) => `--${option}`);
93
+ }
94
+
95
+ function unknownOption(command: string, option: string, allowedOptions: readonly string[]): CliResult {
96
+ const suggestions = suggestOptions(option, allowedOptions).map((suggestion) => `--${suggestion}`);
97
+ const suggestionMessage = suggestions.length > 0 ? ` Did you mean ${suggestions.join(" or ")}?` : "";
98
+ return failResult({
99
+ command,
100
+ human: `Unknown option --${option}.${suggestionMessage}`,
101
+ data: {
102
+ option: `--${option}`,
103
+ allowedOptions: prefixedOptions(allowedOptions),
104
+ suggestions,
105
+ },
106
+ error: {
107
+ code: "unknown_option",
108
+ message: `Unknown option --${option}`,
109
+ },
110
+ });
111
+ }
112
+
113
+ function invalidSearchInput(command: string, human: string, message: string, data: Record<string, unknown>): CliResult {
114
+ return failResult({
115
+ command,
116
+ human,
117
+ data,
118
+ error: {
119
+ code: "invalid_input",
120
+ message,
121
+ },
122
+ });
123
+ }
124
+
125
+ function formatSearchHuman(matches: readonly SearchEntityMatch[], emptyMessage: string): string {
126
+ if (matches.length === 0) {
127
+ return emptyMessage;
128
+ }
129
+
130
+ return matches
131
+ .map(
132
+ (match) =>
133
+ `${match.kind} ${match.id}: ${match.fields
134
+ .map((field) => `${field.field}(${field.count}) "${field.snippet}"`)
135
+ .join(", ")}`,
136
+ )
137
+ .join("\n");
138
+ }
139
+
84
140
  function parseStatusCsv(rawStatuses: string | undefined): string[] | undefined {
85
141
  if (rawStatuses === undefined) {
86
142
  return undefined;
@@ -763,6 +819,134 @@ export async function runTask(context: CliContext): Promise<CliResult> {
763
819
  },
764
820
  });
765
821
  }
822
+ case "search": {
823
+ const searchUnknownOption = findUnknownOption(parsed, SEARCH_OPTIONS);
824
+ if (searchUnknownOption !== undefined) {
825
+ return unknownOption("task.search", searchUnknownOption, SEARCH_OPTIONS);
826
+ }
827
+
828
+ const missingSearchOption = readMissingOptionValue(parsed.missingOptionValues, "fields");
829
+ if (missingSearchOption !== undefined) {
830
+ return failMissingOptionValue("task.search", missingSearchOption);
831
+ }
832
+
833
+ const taskId: string = parsed.positional[1] ?? "";
834
+ const searchText: string = parsed.positional[2] ?? "";
835
+ if (taskId.length === 0 || searchText.trim().length === 0) {
836
+ return invalidSearchInput(
837
+ "task.search",
838
+ "Usage: trekoon task search <task-id> \"search text\" [--fields <csv>] [--preview]",
839
+ "Missing search target",
840
+ {
841
+ taskId,
842
+ },
843
+ );
844
+ }
845
+
846
+ const parsedFields = parseCsvEnumOption(readOption(parsed.options, "fields"), SEARCH_REPLACE_FIELDS);
847
+ if (parsedFields.empty || parsedFields.invalidValues.length > 0) {
848
+ return invalidSearchInput("task.search", "Invalid --fields value. Use title, description, or title,description.", "Invalid --fields value", {
849
+ fields: readOption(parsed.options, "fields"),
850
+ invalidFields: parsedFields.invalidValues,
851
+ allowedFields: [...SEARCH_REPLACE_FIELDS],
852
+ });
853
+ }
854
+
855
+ const { matches, summary } = domain.searchTaskScope(taskId, searchText, parsedFields.values);
856
+
857
+ return okResult({
858
+ command: "task.search",
859
+ human: formatSearchHuman(matches, "No matches found."),
860
+ data: {
861
+ scope: {
862
+ kind: "task",
863
+ id: taskId,
864
+ },
865
+ query: {
866
+ search: searchText,
867
+ fields: parsedFields.values,
868
+ mode: "preview",
869
+ },
870
+ summary,
871
+ matches,
872
+ },
873
+ });
874
+ }
875
+ case "replace": {
876
+ const replaceUnknownOption = findUnknownOption(parsed, REPLACE_OPTIONS);
877
+ if (replaceUnknownOption !== undefined) {
878
+ return unknownOption("task.replace", replaceUnknownOption, REPLACE_OPTIONS);
879
+ }
880
+
881
+ const missingReplaceOption =
882
+ readMissingOptionValue(parsed.missingOptionValues, "search") ??
883
+ readMissingOptionValue(parsed.missingOptionValues, "replace") ??
884
+ readMissingOptionValue(parsed.missingOptionValues, "fields");
885
+ if (missingReplaceOption !== undefined) {
886
+ return failMissingOptionValue("task.replace", missingReplaceOption);
887
+ }
888
+
889
+ const taskId: string = parsed.positional[1] ?? "";
890
+ const searchText = readOption(parsed.options, "search") ?? "";
891
+ const replacementText = readOption(parsed.options, "replace") ?? "";
892
+ if (taskId.length === 0 || searchText.trim().length === 0) {
893
+ return invalidSearchInput(
894
+ "task.replace",
895
+ "Usage: trekoon task replace <task-id> --search \"text\" --replace \"text\" [--fields <csv>] [--preview|--apply]",
896
+ "Missing replace target",
897
+ {
898
+ taskId,
899
+ search: searchText,
900
+ },
901
+ );
902
+ }
903
+
904
+ const rawFields = readOption(parsed.options, "fields");
905
+ const parsedFields = parseCsvEnumOption(rawFields, SEARCH_REPLACE_FIELDS);
906
+ if (parsedFields.empty || parsedFields.invalidValues.length > 0) {
907
+ return invalidSearchInput("task.replace", "Invalid --fields value. Use title, description, or title,description.", "Invalid --fields value", {
908
+ fields: rawFields,
909
+ invalidFields: parsedFields.invalidValues,
910
+ allowedFields: [...SEARCH_REPLACE_FIELDS],
911
+ });
912
+ }
913
+
914
+ const previewMode = resolvePreviewApplyMode(parsed.flags);
915
+ if (previewMode.conflict) {
916
+ return invalidSearchInput("task.replace", "Use either --preview or --apply, not both.", "Conflicting mode flags", {
917
+ flags: ["preview", "apply"],
918
+ });
919
+ }
920
+
921
+ const replacementSummary = previewMode.mode === "apply"
922
+ ? mutations.applyTaskReplacement(taskId, searchText, replacementText, parsedFields.values)
923
+ : mutations.previewTaskReplacement(taskId, searchText, replacementText, parsedFields.values);
924
+ const { matches, summary: matchSummary } = replacementSummary;
925
+
926
+ const summary = {
927
+ ...matchSummary,
928
+ mode: previewMode.mode,
929
+ };
930
+
931
+ return okResult({
932
+ command: "task.replace",
933
+ human: formatSearchHuman(matches, `No ${previewMode.mode === "apply" ? "replacements" : "matches"} found.`),
934
+ data: {
935
+ scope: {
936
+ kind: "task",
937
+ id: taskId,
938
+ },
939
+ query: {
940
+ search: searchText,
941
+ replace: replacementText,
942
+ fields: parsedFields.values,
943
+ mode: previewMode.mode,
944
+ },
945
+ summary,
946
+ matches,
947
+ },
948
+ });
949
+ }
766
950
  case "update": {
767
951
  const missingUpdateOption =
768
952
  readMissingOptionValue(parsed.missingOptionValues, "ids") ??
@@ -900,7 +1084,7 @@ export async function runTask(context: CliContext): Promise<CliResult> {
900
1084
  default:
901
1085
  return failResult({
902
1086
  command: "task",
903
- human: "Usage: trekoon task <create|list|show|ready|next|update|delete>",
1087
+ human: "Usage: trekoon task <create|list|show|ready|next|search|replace|update|delete>",
904
1088
  data: {
905
1089
  args: context.args,
906
1090
  },