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,30 @@
1
1
  import {
2
+ SEARCH_REPLACE_FIELDS,
3
+ findUnknownOption,
2
4
  hasFlag,
5
+ isValidCompactTempKey,
3
6
  parseArgs,
7
+ parseCompactFields,
8
+ parseCsvEnumOption,
4
9
  parseStrictNonNegativeInt,
5
10
  parseStrictPositiveInt,
6
11
  readEnumOption,
7
12
  readMissingOptionValue,
8
13
  readOption,
14
+ readOptions,
15
+ readUnexpectedPositionals,
16
+ resolvePreviewApplyMode,
17
+ suggestOptions,
9
18
  } from "./arg-parser";
19
+ import { unexpectedFailureResult } from "./error-utils";
10
20
 
11
21
  import { MutationService } from "../domain/mutation-service";
12
22
  import { TrackerDomain } from "../domain/tracker-domain";
13
- import { DomainError, type SubtaskRecord } from "../domain/types";
23
+ import { type CompactBatchResultContract, type CompactSubtaskSpec, type SearchEntityMatch, type SubtaskRecord } from "../domain/types";
14
24
  import { formatHumanTable } from "../io/human-table";
15
25
  import { failResult, okResult } from "../io/output";
16
26
  import { type CliContext, type CliResult } from "../runtime/command-types";
17
- import { openTrekoonDatabase } from "../storage/database";
27
+ import { openTrekoonDatabase, type TrekoonDatabase } from "../storage/database";
18
28
 
19
29
  function formatSubtask(subtask: SubtaskRecord): string {
20
30
  return `${subtask.id} | task=${subtask.taskId} | ${subtask.title} | ${subtask.status}`;
@@ -23,6 +33,9 @@ function formatSubtask(subtask: SubtaskRecord): string {
23
33
  const VIEW_MODES = ["table", "compact"] as const;
24
34
  const DEFAULT_SUBTASK_LIST_LIMIT = 10;
25
35
  const DEFAULT_OPEN_SUBTASK_STATUSES = ["in_progress", "in-progress", "todo"] as const;
36
+ const SEARCH_OPTIONS = ["fields", "preview"] as const;
37
+ const REPLACE_OPTIONS = ["search", "replace", "fields", "preview", "apply"] as const;
38
+ const CREATE_MANY_OPTIONS = ["task", "t", "subtask"] as const;
26
39
 
27
40
  function parseIdsOption(rawIds: string | undefined): string[] {
28
41
  if (rawIds === undefined) {
@@ -35,6 +48,55 @@ function parseIdsOption(rawIds: string | undefined): string[] {
35
48
  .filter((value) => value.length > 0);
36
49
  }
37
50
 
51
+ function prefixedOptions(options: readonly string[]): string[] {
52
+ return options.map((option) => `--${option}`);
53
+ }
54
+
55
+ function unknownOption(command: string, option: string, allowedOptions: readonly string[]): CliResult {
56
+ const suggestions = suggestOptions(option, allowedOptions).map((suggestion) => `--${suggestion}`);
57
+ const suggestionMessage = suggestions.length > 0 ? ` Did you mean ${suggestions.join(" or ")}?` : "";
58
+ return failResult({
59
+ command,
60
+ human: `Unknown option --${option}.${suggestionMessage}`,
61
+ data: {
62
+ option: `--${option}`,
63
+ allowedOptions: prefixedOptions(allowedOptions),
64
+ suggestions,
65
+ },
66
+ error: {
67
+ code: "unknown_option",
68
+ message: `Unknown option --${option}`,
69
+ },
70
+ });
71
+ }
72
+
73
+ function invalidSearchInput(command: string, human: string, message: string, data: Record<string, unknown>): CliResult {
74
+ return failResult({
75
+ command,
76
+ human,
77
+ data,
78
+ error: {
79
+ code: "invalid_input",
80
+ message,
81
+ },
82
+ });
83
+ }
84
+
85
+ function formatSearchHuman(matches: readonly SearchEntityMatch[], emptyMessage: string): string {
86
+ if (matches.length === 0) {
87
+ return emptyMessage;
88
+ }
89
+
90
+ return matches
91
+ .map(
92
+ (match) =>
93
+ `${match.kind} ${match.id}: ${match.fields
94
+ .map((field) => `${field.field}(${field.count}) "${field.snippet}"`)
95
+ .join(", ")}`,
96
+ )
97
+ .join("\n");
98
+ }
99
+
38
100
  function parseStatusCsv(rawStatuses: string | undefined): string[] | undefined {
39
101
  if (rawStatuses === undefined) {
40
102
  return undefined;
@@ -115,29 +177,9 @@ function formatSubtaskListTable(subtasks: readonly SubtaskRecord[]): string {
115
177
  }
116
178
 
117
179
  function failFromError(error: unknown): CliResult {
118
- if (error instanceof DomainError) {
119
- return failResult({
120
- command: "subtask",
121
- human: error.message,
122
- data: {
123
- code: error.code,
124
- ...(error.details ?? {}),
125
- },
126
- error: {
127
- code: error.code,
128
- message: error.message,
129
- },
130
- });
131
- }
132
-
133
- return failResult({
180
+ return unexpectedFailureResult(error, {
134
181
  command: "subtask",
135
182
  human: "Unexpected subtask command failure",
136
- data: {},
137
- error: {
138
- code: "internal_error",
139
- message: "Unexpected subtask command failure",
140
- },
141
183
  });
142
184
  }
143
185
 
@@ -156,10 +198,160 @@ function failMissingOptionValue(command: string, option: string): CliResult {
156
198
  });
157
199
  }
158
200
 
201
+ function failBatchSpec(command: string, human: string, data: Record<string, unknown>): CliResult {
202
+ return failResult({
203
+ command,
204
+ human,
205
+ data,
206
+ error: {
207
+ code: "invalid_input",
208
+ message: human,
209
+ },
210
+ });
211
+ }
212
+
213
+ function failUnexpectedPositionals(command: string, unexpected: readonly string[]): CliResult {
214
+ return failBatchSpec(command, `Unexpected positional arguments: ${unexpected.join(", ")}.`, {
215
+ unexpectedPositionals: unexpected,
216
+ });
217
+ }
218
+
219
+ function failConflictingTaskIds(optionTaskId: string, positionalTaskId: string): CliResult {
220
+ return failBatchSpec("subtask.create-many", "Conflicting task ids for subtask create-many: positional task id must match --task.", {
221
+ option: "task",
222
+ optionTaskId,
223
+ positionalTaskId,
224
+ });
225
+ }
226
+
227
+ function failEmptyCompactField(command: string, option: string, index: number, rawSpec: string, field: string): CliResult {
228
+ return failBatchSpec(command, `${option === "subtask" ? "Subtask" : "Spec"} spec ${index + 1} is missing a ${field}.`, {
229
+ option,
230
+ index,
231
+ rawSpec,
232
+ field,
233
+ });
234
+ }
235
+
236
+ function parseSubtaskCreateManySpecs(parentTaskId: string, rawSpecs: readonly string[]): { specs: CompactSubtaskSpec[]; error?: CliResult } {
237
+ const specs: CompactSubtaskSpec[] = [];
238
+ const seenTempKeys = new Set<string>();
239
+
240
+ for (const [index, rawSpec] of rawSpecs.entries()) {
241
+ const parsed = parseCompactFields(rawSpec);
242
+ if (parsed.invalidEscape !== null) {
243
+ return {
244
+ specs: [],
245
+ error: failBatchSpec("subtask.create-many", `Invalid escape sequence ${parsed.invalidEscape} in --subtask spec ${index + 1}.`, {
246
+ option: "subtask",
247
+ index,
248
+ rawSpec,
249
+ invalidEscape: parsed.invalidEscape,
250
+ }),
251
+ };
252
+ }
253
+
254
+ if (parsed.hasDanglingEscape) {
255
+ return {
256
+ specs: [],
257
+ error: failBatchSpec("subtask.create-many", `Trailing escape in --subtask spec ${index + 1}.`, {
258
+ option: "subtask",
259
+ index,
260
+ rawSpec,
261
+ }),
262
+ };
263
+ }
264
+
265
+ if (parsed.fields.length !== 4) {
266
+ return {
267
+ specs: [],
268
+ error: failBatchSpec("subtask.create-many", `Subtask specs must use <temp-key>|<title>|<description>|<status> in --subtask spec ${index + 1}.`, {
269
+ option: "subtask",
270
+ index,
271
+ rawSpec,
272
+ fields: parsed.fields,
273
+ }),
274
+ };
275
+ }
276
+
277
+ const tempKey = parsed.fields[0] ?? "";
278
+ const title = parsed.fields[1] ?? "";
279
+ const description = parsed.fields[2] ?? "";
280
+ const status = parsed.fields[3] ?? "";
281
+ if (!tempKey || !isValidCompactTempKey(tempKey)) {
282
+ return {
283
+ specs: [],
284
+ error: failBatchSpec("subtask.create-many", `Subtask spec ${index + 1} must start with a temp key like seed-1.`, {
285
+ option: "subtask",
286
+ index,
287
+ rawSpec,
288
+ tempKey,
289
+ }),
290
+ };
291
+ }
292
+
293
+ if (seenTempKeys.has(tempKey)) {
294
+ return {
295
+ specs: [],
296
+ error: failBatchSpec("subtask.create-many", `Duplicate temp key '${tempKey}' in --subtask specs.`, {
297
+ option: "subtask",
298
+ index,
299
+ rawSpec,
300
+ tempKey,
301
+ }),
302
+ };
303
+ }
304
+
305
+ if (!title || title.trim().length === 0) {
306
+ return {
307
+ specs: [],
308
+ error: failBatchSpec("subtask.create-many", `Subtask spec ${index + 1} is missing a title.`, {
309
+ option: "subtask",
310
+ index,
311
+ rawSpec,
312
+ }),
313
+ };
314
+ }
315
+
316
+ if (description.trim().length === 0) {
317
+ return {
318
+ specs: [],
319
+ error: failEmptyCompactField("subtask.create-many", "subtask", index, rawSpec, "description"),
320
+ };
321
+ }
322
+
323
+ seenTempKeys.add(tempKey);
324
+ const spec: CompactSubtaskSpec = status.length > 0
325
+ ? {
326
+ parent: {
327
+ kind: "id",
328
+ id: parentTaskId,
329
+ },
330
+ tempKey,
331
+ title,
332
+ description,
333
+ status,
334
+ }
335
+ : {
336
+ parent: {
337
+ kind: "id",
338
+ id: parentTaskId,
339
+ },
340
+ tempKey,
341
+ title,
342
+ description,
343
+ };
344
+ specs.push(spec);
345
+ }
346
+
347
+ return { specs };
348
+ }
349
+
159
350
  export async function runSubtask(context: CliContext): Promise<CliResult> {
160
- const database = openTrekoonDatabase(context.cwd);
351
+ let database: TrekoonDatabase | undefined;
161
352
 
162
353
  try {
354
+ database = openTrekoonDatabase(context.cwd);
163
355
  const parsed = parseArgs(context.args);
164
356
  const subcommand: string | undefined = parsed.positional[0];
165
357
  const domain = new TrackerDomain(database.db);
@@ -192,6 +384,68 @@ export async function runSubtask(context: CliContext): Promise<CliResult> {
192
384
  data: { subtask },
193
385
  });
194
386
  }
387
+ case "create-many": {
388
+ const createManyUnknownOption = findUnknownOption(parsed, CREATE_MANY_OPTIONS);
389
+ if (createManyUnknownOption !== undefined) {
390
+ return unknownOption("subtask.create-many", createManyUnknownOption, CREATE_MANY_OPTIONS);
391
+ }
392
+
393
+ const missingCreateManyOption = readMissingOptionValue(parsed.missingOptionValues, "task", "t", "subtask");
394
+ if (missingCreateManyOption !== undefined) {
395
+ return failMissingOptionValue("subtask.create-many", missingCreateManyOption);
396
+ }
397
+
398
+ const optionTaskId = readOption(parsed.options, "task", "t");
399
+ const positionalTaskId = parsed.positional[1];
400
+ const unexpectedPositionals = readUnexpectedPositionals(parsed, positionalTaskId === undefined ? 1 : 2);
401
+ if (unexpectedPositionals.length > 0) {
402
+ return failUnexpectedPositionals("subtask.create-many", unexpectedPositionals);
403
+ }
404
+
405
+ if (
406
+ optionTaskId !== undefined
407
+ && positionalTaskId !== undefined
408
+ && optionTaskId.trim().length > 0
409
+ && positionalTaskId.trim().length > 0
410
+ && optionTaskId !== positionalTaskId
411
+ ) {
412
+ return failConflictingTaskIds(optionTaskId, positionalTaskId);
413
+ }
414
+
415
+ const taskId = optionTaskId ?? positionalTaskId;
416
+ if (taskId === undefined || taskId.trim().length === 0) {
417
+ return failBatchSpec("subtask.create-many", "Provide --task (or positional task id) for subtask create-many.", {
418
+ option: "task",
419
+ });
420
+ }
421
+
422
+ const rawSpecs = readOptions(parsed.optionEntries, "subtask");
423
+ if (rawSpecs.length === 0) {
424
+ return failBatchSpec("subtask.create-many", "Provide at least one --subtask spec.", {
425
+ option: "subtask",
426
+ });
427
+ }
428
+
429
+ const specResult = parseSubtaskCreateManySpecs(taskId, rawSpecs);
430
+ if (specResult.error !== undefined) {
431
+ return specResult.error;
432
+ }
433
+
434
+ const created = mutations.createSubtaskBatch({
435
+ taskId,
436
+ specs: specResult.specs,
437
+ });
438
+ const result: CompactBatchResultContract = created.result;
439
+ return okResult({
440
+ command: "subtask.create-many",
441
+ human: `Created ${created.subtasks.length} subtask(s): ${created.subtasks.map(formatSubtask).join("\n")}`,
442
+ data: {
443
+ taskId,
444
+ subtasks: created.subtasks,
445
+ result,
446
+ },
447
+ });
448
+ }
195
449
  case "list": {
196
450
  const missingListOption =
197
451
  readMissingOptionValue(parsed.missingOptionValues, "view") ??
@@ -348,6 +602,134 @@ export async function runSubtask(context: CliContext): Promise<CliResult> {
348
602
  }),
349
603
  });
350
604
  }
605
+ case "search": {
606
+ const searchUnknownOption = findUnknownOption(parsed, SEARCH_OPTIONS);
607
+ if (searchUnknownOption !== undefined) {
608
+ return unknownOption("subtask.search", searchUnknownOption, SEARCH_OPTIONS);
609
+ }
610
+
611
+ const missingSearchOption = readMissingOptionValue(parsed.missingOptionValues, "fields");
612
+ if (missingSearchOption !== undefined) {
613
+ return failMissingOptionValue("subtask.search", missingSearchOption);
614
+ }
615
+
616
+ const subtaskId: string = parsed.positional[1] ?? "";
617
+ const searchText: string = parsed.positional[2] ?? "";
618
+ if (subtaskId.length === 0 || searchText.trim().length === 0) {
619
+ return invalidSearchInput(
620
+ "subtask.search",
621
+ "Usage: trekoon subtask search <subtask-id> \"search text\" [--fields <csv>] [--preview]",
622
+ "Missing search target",
623
+ {
624
+ subtaskId,
625
+ },
626
+ );
627
+ }
628
+
629
+ const parsedFields = parseCsvEnumOption(readOption(parsed.options, "fields"), SEARCH_REPLACE_FIELDS);
630
+ if (parsedFields.empty || parsedFields.invalidValues.length > 0) {
631
+ return invalidSearchInput("subtask.search", "Invalid --fields value. Use title, description, or title,description.", "Invalid --fields value", {
632
+ fields: readOption(parsed.options, "fields"),
633
+ invalidFields: parsedFields.invalidValues,
634
+ allowedFields: [...SEARCH_REPLACE_FIELDS],
635
+ });
636
+ }
637
+
638
+ const { matches, summary } = domain.searchSubtaskScope(subtaskId, searchText, parsedFields.values);
639
+
640
+ return okResult({
641
+ command: "subtask.search",
642
+ human: formatSearchHuman(matches, "No matches found."),
643
+ data: {
644
+ scope: {
645
+ kind: "subtask",
646
+ id: subtaskId,
647
+ },
648
+ query: {
649
+ search: searchText,
650
+ fields: parsedFields.values,
651
+ mode: "preview",
652
+ },
653
+ summary,
654
+ matches,
655
+ },
656
+ });
657
+ }
658
+ case "replace": {
659
+ const replaceUnknownOption = findUnknownOption(parsed, REPLACE_OPTIONS);
660
+ if (replaceUnknownOption !== undefined) {
661
+ return unknownOption("subtask.replace", replaceUnknownOption, REPLACE_OPTIONS);
662
+ }
663
+
664
+ const missingReplaceOption =
665
+ readMissingOptionValue(parsed.missingOptionValues, "search") ??
666
+ readMissingOptionValue(parsed.missingOptionValues, "replace") ??
667
+ readMissingOptionValue(parsed.missingOptionValues, "fields");
668
+ if (missingReplaceOption !== undefined) {
669
+ return failMissingOptionValue("subtask.replace", missingReplaceOption);
670
+ }
671
+
672
+ const subtaskId: string = parsed.positional[1] ?? "";
673
+ const searchText = readOption(parsed.options, "search") ?? "";
674
+ const replacementText = readOption(parsed.options, "replace") ?? "";
675
+ if (subtaskId.length === 0 || searchText.trim().length === 0) {
676
+ return invalidSearchInput(
677
+ "subtask.replace",
678
+ "Usage: trekoon subtask replace <subtask-id> --search \"text\" --replace \"text\" [--fields <csv>] [--preview|--apply]",
679
+ "Missing replace target",
680
+ {
681
+ subtaskId,
682
+ search: searchText,
683
+ },
684
+ );
685
+ }
686
+
687
+ const rawFields = readOption(parsed.options, "fields");
688
+ const parsedFields = parseCsvEnumOption(rawFields, SEARCH_REPLACE_FIELDS);
689
+ if (parsedFields.empty || parsedFields.invalidValues.length > 0) {
690
+ return invalidSearchInput("subtask.replace", "Invalid --fields value. Use title, description, or title,description.", "Invalid --fields value", {
691
+ fields: rawFields,
692
+ invalidFields: parsedFields.invalidValues,
693
+ allowedFields: [...SEARCH_REPLACE_FIELDS],
694
+ });
695
+ }
696
+
697
+ const previewMode = resolvePreviewApplyMode(parsed.flags);
698
+ if (previewMode.conflict) {
699
+ return invalidSearchInput("subtask.replace", "Use either --preview or --apply, not both.", "Conflicting mode flags", {
700
+ flags: ["preview", "apply"],
701
+ });
702
+ }
703
+
704
+ const replacementSummary = previewMode.mode === "apply"
705
+ ? mutations.applySubtaskReplacement(subtaskId, searchText, replacementText, parsedFields.values)
706
+ : mutations.previewSubtaskReplacement(subtaskId, searchText, replacementText, parsedFields.values);
707
+ const { matches, summary: matchSummary } = replacementSummary;
708
+
709
+ const summary = {
710
+ ...matchSummary,
711
+ mode: previewMode.mode,
712
+ };
713
+
714
+ return okResult({
715
+ command: "subtask.replace",
716
+ human: formatSearchHuman(matches, `No ${previewMode.mode === "apply" ? "replacements" : "matches"} found.`),
717
+ data: {
718
+ scope: {
719
+ kind: "subtask",
720
+ id: subtaskId,
721
+ },
722
+ query: {
723
+ search: searchText,
724
+ replace: replacementText,
725
+ fields: parsedFields.values,
726
+ mode: previewMode.mode,
727
+ },
728
+ summary,
729
+ matches,
730
+ },
731
+ });
732
+ }
351
733
  case "update": {
352
734
  const missingUpdateOption =
353
735
  readMissingOptionValue(parsed.missingOptionValues, "ids") ??
@@ -485,7 +867,7 @@ export async function runSubtask(context: CliContext): Promise<CliResult> {
485
867
  default:
486
868
  return failResult({
487
869
  command: "subtask",
488
- human: "Usage: trekoon subtask <create|list|update|delete>",
870
+ human: "Usage: trekoon subtask <create|create-many|list|search|replace|update|delete>",
489
871
  data: {
490
872
  args: context.args,
491
873
  },
@@ -498,6 +880,6 @@ export async function runSubtask(context: CliContext): Promise<CliResult> {
498
880
  } catch (error: unknown) {
499
881
  return failFromError(error);
500
882
  } finally {
501
- database.close();
883
+ database?.close();
502
884
  }
503
885
  }
@@ -1,4 +1,5 @@
1
1
  import { findUnknownOption, parseArgs, readMissingOptionValue, readOption, suggestOptions } from "./arg-parser";
2
+ import { safeErrorMessage, sqliteBusyFailure } from "./error-utils";
2
3
 
3
4
  import { failResult, okResult } from "../io/output";
4
5
  import { type CliContext, type CliResult } from "../runtime/command-types";
@@ -288,7 +289,12 @@ export async function runSync(context: CliContext): Promise<CliResult> {
288
289
  });
289
290
  }
290
291
 
291
- const message = error instanceof Error ? error.message : "Unknown sync error.";
292
+ const busyFailure = sqliteBusyFailure(resolvedCommand, error);
293
+ if (busyFailure !== null) {
294
+ return busyFailure;
295
+ }
296
+
297
+ const message = safeErrorMessage(error, "Unknown sync error.");
292
298
 
293
299
  return failResult({
294
300
  command: resolvedCommand,