trekoon 0.1.8 → 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;
@@ -323,7 +379,157 @@ export async function runSubtask(context: CliContext): Promise<CliResult> {
323
379
  command: "subtask.list",
324
380
  human,
325
381
  data: { subtasks },
326
- ...(context.mode === "human" ? {} : { meta: { pagination: listed.pagination } }),
382
+ ...(context.mode === "human"
383
+ ? {}
384
+ : {
385
+ meta: {
386
+ pagination: listed.pagination,
387
+ defaults: {
388
+ statuses: !includeAll && statuses === undefined ? [...DEFAULT_OPEN_SUBTASK_STATUSES] : null,
389
+ limit: !includeAll && parsedLimit === undefined ? DEFAULT_SUBTASK_LIST_LIMIT : null,
390
+ cursor: parsedCursor === undefined ? 0 : null,
391
+ view: view === undefined ? "table" : null,
392
+ },
393
+ filters: {
394
+ taskId: taskId ?? null,
395
+ statuses: selectedStatuses ?? null,
396
+ includeAll,
397
+ },
398
+ truncation: {
399
+ applied: listed.pagination.hasMore,
400
+ returned: subtasks.length,
401
+ limit: selectedLimit ?? null,
402
+ },
403
+ },
404
+ }),
405
+ });
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
+ },
327
533
  });
328
534
  }
329
535
  case "update": {
@@ -463,7 +669,7 @@ export async function runSubtask(context: CliContext): Promise<CliResult> {
463
669
  default:
464
670
  return failResult({
465
671
  command: "subtask",
466
- human: "Usage: trekoon subtask <create|list|update|delete>",
672
+ human: "Usage: trekoon subtask <create|list|search|replace|update|delete>",
467
673
  data: {
468
674
  args: context.args,
469
675
  },
@@ -1,22 +1,48 @@
1
+ import { findUnknownOption, parseArgs, readMissingOptionValue, readOption, suggestOptions } from "./arg-parser";
2
+
1
3
  import { failResult, okResult } from "../io/output";
2
4
  import { type CliContext, type CliResult } from "../runtime/command-types";
3
5
  import { MissingBranchDatabaseError } from "../sync/branch-db";
4
6
  import { getSyncConflict, listSyncConflicts, syncPull, syncResolve, syncStatus } from "../sync/service";
5
- import { type SyncConflictMode, type SyncResolution } from "../sync/types";
7
+ import { type SyncResolution } from "../sync/types";
8
+
9
+ const STATUS_OPTIONS = ["from"] as const;
10
+ const PULL_OPTIONS = ["from"] as const;
11
+ const RESOLVE_OPTIONS = ["use"] as const;
12
+ const CONFLICTS_LIST_OPTIONS = ["mode"] as const;
13
+ const CONFLICTS_SHOW_OPTIONS: readonly string[] = [];
14
+
15
+ function resolveSyncCommandId(subcommand: string | undefined, conflictsSubcommand: string | undefined): string {
16
+ if (subcommand === "status") {
17
+ return "sync.status";
18
+ }
19
+
20
+ if (subcommand === "pull") {
21
+ return "sync.pull";
22
+ }
23
+
24
+ if (subcommand === "resolve") {
25
+ return "sync.resolve";
26
+ }
27
+
28
+ if (subcommand !== "conflicts") {
29
+ return "sync";
30
+ }
31
+
32
+ if (conflictsSubcommand === "list") {
33
+ return "sync.conflicts.list";
34
+ }
6
35
 
7
- function parseOption(args: readonly string[], option: string): string | null {
8
- const index: number = args.indexOf(option);
9
- if (index < 0) {
10
- return null;
36
+ if (conflictsSubcommand === "show") {
37
+ return "sync.conflicts.show";
11
38
  }
12
39
 
13
- const value: string | undefined = args[index + 1];
14
- return value && !value.startsWith("--") ? value : null;
40
+ return "sync.conflicts";
15
41
  }
16
42
 
17
- function usage(message: string): CliResult {
43
+ function usage(message: string, command = "sync"): CliResult {
18
44
  return failResult({
19
- command: "sync",
45
+ command,
20
46
  human: `${message}\nUsage: trekoon sync <status|pull|resolve|conflicts> [options]`,
21
47
  data: { message },
22
48
  error: {
@@ -26,6 +52,28 @@ function usage(message: string): CliResult {
26
52
  });
27
53
  }
28
54
 
55
+ function prefixedOptions(options: readonly string[]): string[] {
56
+ return options.map((option) => `--${option}`);
57
+ }
58
+
59
+ function unknownOption(command: string, option: string, allowedOptions: readonly string[]): CliResult {
60
+ const suggestions = suggestOptions(option, allowedOptions).map((suggestion) => `--${suggestion}`);
61
+ const suggestionMessage = suggestions.length > 0 ? ` Did you mean ${suggestions.join(" or ")}?` : "";
62
+ return failResult({
63
+ command,
64
+ human: `Unknown option --${option}.${suggestionMessage}`,
65
+ data: {
66
+ option: `--${option}`,
67
+ allowedOptions: prefixedOptions(allowedOptions),
68
+ suggestions,
69
+ },
70
+ error: {
71
+ code: "unknown_option",
72
+ message: `Unknown option --${option}`,
73
+ },
74
+ });
75
+ }
76
+
29
77
  function statusMessage(sourceBranch: string, ahead: number, behind: number, conflicts: number): string {
30
78
  return [
31
79
  `Sync status against '${sourceBranch}'`,
@@ -61,26 +109,11 @@ function formatConflictList(
61
109
  .join("\n");
62
110
  }
63
111
 
64
- function parseConflictMode(args: readonly string[]): SyncConflictMode | null {
65
- const modeIndex = args.indexOf("--mode");
66
- if (modeIndex < 0) {
67
- return "pending";
68
- }
69
-
70
- const explicitMode = args[modeIndex + 1];
71
- if (!explicitMode || explicitMode.startsWith("--")) {
72
- return null;
73
- }
74
-
75
- if (explicitMode === "pending" || explicitMode === "all") {
76
- return explicitMode;
77
- }
78
-
79
- return null;
80
- }
81
-
82
112
  export async function runSync(context: CliContext): Promise<CliResult> {
83
- const subcommand: string | undefined = context.args[0];
113
+ const parsed = parseArgs(context.args);
114
+ const subcommand: string | undefined = parsed.positional[0];
115
+ const conflictsSubcommand: string | undefined = subcommand === "conflicts" ? parsed.positional[1] : undefined;
116
+ const resolvedCommand: string = resolveSyncCommandId(subcommand, conflictsSubcommand);
84
117
 
85
118
  if (!subcommand) {
86
119
  return usage("Missing sync subcommand.");
@@ -88,26 +121,46 @@ export async function runSync(context: CliContext): Promise<CliResult> {
88
121
 
89
122
  try {
90
123
  if (subcommand === "status") {
91
- const sourceBranch: string = parseOption(context.args, "--from") ?? "main";
124
+ const statusUnknownOption = findUnknownOption(parsed, STATUS_OPTIONS);
125
+ if (statusUnknownOption !== undefined) {
126
+ return unknownOption("sync.status", statusUnknownOption, STATUS_OPTIONS);
127
+ }
128
+
129
+ const missingFromOption = readMissingOptionValue(parsed.missingOptionValues, "from");
130
+ if (missingFromOption !== undefined) {
131
+ return usage("sync status requires --from <branch> when provided.", "sync.status");
132
+ }
133
+
134
+ const sourceBranch: string = readOption(parsed.options, "from") ?? "main";
92
135
  const summary = syncStatus(context.cwd, sourceBranch);
93
136
 
94
137
  return okResult({
95
- command: "sync status",
138
+ command: "sync.status",
96
139
  human: statusMessage(summary.sourceBranch, summary.ahead, summary.behind, summary.pendingConflicts),
97
140
  data: summary,
98
141
  });
99
142
  }
100
143
 
101
144
  if (subcommand === "pull") {
102
- const sourceBranch: string | null = parseOption(context.args, "--from");
103
- if (!sourceBranch) {
104
- return usage("sync pull requires --from <branch>.");
145
+ const pullUnknownOption = findUnknownOption(parsed, PULL_OPTIONS);
146
+ if (pullUnknownOption !== undefined) {
147
+ return unknownOption("sync.pull", pullUnknownOption, PULL_OPTIONS);
148
+ }
149
+
150
+ const missingFromOption = readMissingOptionValue(parsed.missingOptionValues, "from");
151
+ if (missingFromOption !== undefined) {
152
+ return usage("sync pull requires --from <branch>.", "sync.pull");
153
+ }
154
+
155
+ const sourceBranch: string | undefined = readOption(parsed.options, "from");
156
+ if (sourceBranch === undefined) {
157
+ return usage("sync pull requires --from <branch>.", "sync.pull");
105
158
  }
106
159
 
107
160
  const summary = syncPull(context.cwd, sourceBranch);
108
161
 
109
162
  return okResult({
110
- command: "sync pull",
163
+ command: "sync.pull",
111
164
  human: [
112
165
  `Pulled from '${summary.sourceBranch}'`,
113
166
  `Scanned events: ${summary.scannedEvents}`,
@@ -123,42 +176,62 @@ export async function runSync(context: CliContext): Promise<CliResult> {
123
176
  }
124
177
 
125
178
  if (subcommand === "resolve") {
126
- const conflictId: string | undefined = context.args[1];
127
- const rawResolution: string | null = parseOption(context.args, "--use");
179
+ const resolveUnknownOption = findUnknownOption(parsed, RESOLVE_OPTIONS);
180
+ if (resolveUnknownOption !== undefined) {
181
+ return unknownOption("sync.resolve", resolveUnknownOption, RESOLVE_OPTIONS);
182
+ }
183
+
184
+ const conflictId: string | undefined = parsed.positional[1];
185
+ const missingResolutionOption = readMissingOptionValue(parsed.missingOptionValues, "use");
186
+ if (missingResolutionOption !== undefined) {
187
+ return usage("sync resolve requires <conflict-id> --use ours|theirs.", "sync.resolve");
188
+ }
189
+
190
+ const rawResolution: string | undefined = readOption(parsed.options, "use");
128
191
 
129
192
  if (!conflictId || !rawResolution) {
130
- return usage("sync resolve requires <conflict-id> --use ours|theirs.");
193
+ return usage("sync resolve requires <conflict-id> --use ours|theirs.", "sync.resolve");
131
194
  }
132
195
 
133
196
  if (rawResolution !== "ours" && rawResolution !== "theirs") {
134
- return usage("sync resolve --use only accepts ours|theirs.");
197
+ return usage("sync resolve --use only accepts ours|theirs.", "sync.resolve");
135
198
  }
136
199
 
137
200
  const summary = syncResolve(context.cwd, conflictId, rawResolution as SyncResolution);
138
201
 
139
202
  return okResult({
140
- command: "sync resolve",
203
+ command: "sync.resolve",
141
204
  human: `Resolved ${summary.conflictId} using ${summary.resolution}.`,
142
205
  data: summary,
143
206
  });
144
207
  }
145
208
 
146
209
  if (subcommand === "conflicts") {
147
- const conflictsCommand: string | undefined = context.args[1];
210
+ const conflictsCommand: string | undefined = parsed.positional[1];
148
211
  if (!conflictsCommand) {
149
- return usage("sync conflicts requires list|show.");
212
+ return usage("sync conflicts requires list|show.", "sync.conflicts");
150
213
  }
151
214
 
152
215
  if (conflictsCommand === "list") {
153
- const mode = parseConflictMode(context.args);
154
- if (!mode) {
155
- return usage("sync conflicts list --mode only accepts pending|all.");
216
+ const listUnknownOption = findUnknownOption(parsed, CONFLICTS_LIST_OPTIONS);
217
+ if (listUnknownOption !== undefined) {
218
+ return unknownOption("sync.conflicts.list", listUnknownOption, CONFLICTS_LIST_OPTIONS);
219
+ }
220
+
221
+ const missingModeOption = readMissingOptionValue(parsed.missingOptionValues, "mode");
222
+ if (missingModeOption !== undefined) {
223
+ return usage("sync conflicts list --mode only accepts pending|all.", "sync.conflicts.list");
224
+ }
225
+
226
+ const mode = readOption(parsed.options, "mode") ?? "pending";
227
+ if (mode !== "pending" && mode !== "all") {
228
+ return usage("sync conflicts list --mode only accepts pending|all.", "sync.conflicts.list");
156
229
  }
157
230
 
158
231
  const conflicts = listSyncConflicts(context.cwd, mode);
159
232
 
160
233
  return okResult({
161
- command: "sync conflicts list",
234
+ command: "sync.conflicts.list",
162
235
  human: formatConflictList(conflicts),
163
236
  data: {
164
237
  mode,
@@ -168,15 +241,20 @@ export async function runSync(context: CliContext): Promise<CliResult> {
168
241
  }
169
242
 
170
243
  if (conflictsCommand === "show") {
171
- const conflictId: string | undefined = context.args[2];
172
- if (!conflictId) {
173
- return usage("sync conflicts show requires <conflict-id>.");
244
+ const showUnknownOption = findUnknownOption(parsed, CONFLICTS_SHOW_OPTIONS);
245
+ if (showUnknownOption !== undefined) {
246
+ return unknownOption("sync.conflicts.show", showUnknownOption, CONFLICTS_SHOW_OPTIONS);
174
247
  }
175
248
 
249
+ const conflictId: string | undefined = parsed.positional[2];
250
+ if (!conflictId) {
251
+ return usage("sync conflicts show requires <conflict-id>.", "sync.conflicts.show");
252
+ }
253
+
176
254
  const conflict = getSyncConflict(context.cwd, conflictId);
177
255
 
178
256
  return okResult({
179
- command: "sync conflicts show",
257
+ command: "sync.conflicts.show",
180
258
  human: [
181
259
  `Conflict: ${conflict.id}`,
182
260
  `Entity: ${conflict.entityKind} ${conflict.entityId}`,
@@ -191,14 +269,14 @@ export async function runSync(context: CliContext): Promise<CliResult> {
191
269
  });
192
270
  }
193
271
 
194
- return usage(`Unknown sync conflicts subcommand '${conflictsCommand}'.`);
272
+ return usage(`Unknown sync conflicts subcommand '${conflictsCommand}'.`, "sync.conflicts");
195
273
  }
196
274
 
197
275
  return usage(`Unknown sync subcommand '${subcommand}'.`);
198
276
  } catch (error) {
199
277
  if (error instanceof MissingBranchDatabaseError) {
200
278
  return failResult({
201
- command: "sync",
279
+ command: resolvedCommand,
202
280
  human: error.message,
203
281
  data: {
204
282
  reason: "missing_branch_db",
@@ -213,7 +291,7 @@ export async function runSync(context: CliContext): Promise<CliResult> {
213
291
  const message = error instanceof Error ? error.message : "Unknown sync error.";
214
292
 
215
293
  return failResult({
216
- command: "sync",
294
+ command: resolvedCommand,
217
295
  human: message,
218
296
  data: {
219
297
  reason: "sync_failed",