trekoon 0.1.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.
- package/.agents/skills/trekoon/SKILL.md +91 -0
- package/AGENTS.md +54 -0
- package/CONTRIBUTING.md +18 -0
- package/README.md +151 -0
- package/bin/trekoon +5 -0
- package/bun.lock +28 -0
- package/package.json +24 -0
- package/src/commands/arg-parser.ts +93 -0
- package/src/commands/dep.ts +105 -0
- package/src/commands/epic.ts +539 -0
- package/src/commands/help.ts +61 -0
- package/src/commands/init.ts +24 -0
- package/src/commands/quickstart.ts +61 -0
- package/src/commands/subtask.ts +187 -0
- package/src/commands/sync.ts +128 -0
- package/src/commands/task.ts +554 -0
- package/src/commands/wipe.ts +39 -0
- package/src/domain/tracker-domain.ts +576 -0
- package/src/domain/types.ts +99 -0
- package/src/index.ts +21 -0
- package/src/io/human-table.ts +191 -0
- package/src/io/output.ts +70 -0
- package/src/runtime/cli-shell.ts +158 -0
- package/src/runtime/command-types.ts +33 -0
- package/src/storage/database.ts +35 -0
- package/src/storage/migrations.ts +46 -0
- package/src/storage/path.ts +22 -0
- package/src/storage/schema.ts +116 -0
- package/src/storage/types.ts +15 -0
- package/src/sync/branch-db.ts +49 -0
- package/src/sync/event-writes.ts +49 -0
- package/src/sync/git-context.ts +67 -0
- package/src/sync/service.ts +654 -0
- package/src/sync/types.ts +31 -0
- package/tests/commands/dep.test.ts +101 -0
- package/tests/commands/epic.test.ts +383 -0
- package/tests/commands/subtask.test.ts +132 -0
- package/tests/commands/sync/sync-command.test.ts +1 -0
- package/tests/commands/sync.test.ts +199 -0
- package/tests/commands/task.test.ts +474 -0
- package/tests/integration/sync-workflow.test.ts +279 -0
- package/tests/io/human-table.test.ts +81 -0
- package/tests/runtime/output-mode.test.ts +54 -0
- package/tests/storage/database.test.ts +91 -0
- package/tsconfig.json +19 -0
|
@@ -0,0 +1,539 @@
|
|
|
1
|
+
import { hasFlag, parseArgs, parseStrictPositiveInt, readEnumOption, readMissingOptionValue, readOption } from "./arg-parser";
|
|
2
|
+
|
|
3
|
+
import { DomainError, type EpicRecord } from "../domain/types";
|
|
4
|
+
import { TrackerDomain } from "../domain/tracker-domain";
|
|
5
|
+
import { formatHumanTable } from "../io/human-table";
|
|
6
|
+
import { failResult, okResult } from "../io/output";
|
|
7
|
+
import { type CliContext, type CliResult } from "../runtime/command-types";
|
|
8
|
+
import { openTrekoonDatabase } from "../storage/database";
|
|
9
|
+
|
|
10
|
+
function formatEpic(epic: EpicRecord): string {
|
|
11
|
+
return `${epic.id} | ${epic.title} | ${epic.status}`;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const VIEW_MODES = ["table", "compact", "tree", "detail"] as const;
|
|
15
|
+
const LIST_VIEW_MODES = ["table", "compact"] as const;
|
|
16
|
+
const DEFAULT_LIST_LIMIT = 10;
|
|
17
|
+
const DEFAULT_OPEN_STATUSES = ["in_progress", "in-progress", "todo"] as const;
|
|
18
|
+
|
|
19
|
+
function parseStatusCsv(rawStatuses: string | undefined): string[] | undefined {
|
|
20
|
+
if (rawStatuses === undefined) {
|
|
21
|
+
return undefined;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return rawStatuses
|
|
25
|
+
.split(",")
|
|
26
|
+
.map((value) => value.trim())
|
|
27
|
+
.filter((value) => value.length > 0);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function getStatusPriority(status: string): number {
|
|
31
|
+
if (status === "in_progress" || status === "in-progress") {
|
|
32
|
+
return 0;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (status === "todo") {
|
|
36
|
+
return 1;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return 2;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function sortByStatusPriority(epics: readonly EpicRecord[]): EpicRecord[] {
|
|
43
|
+
return [...epics].sort((left, right) => getStatusPriority(left.status) - getStatusPriority(right.status));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function filterSortAndLimitEpics(epics: readonly EpicRecord[], options: { includeAll: boolean; statuses: readonly string[] | undefined; limit: number | undefined }): EpicRecord[] {
|
|
47
|
+
const { includeAll, statuses, limit } = options;
|
|
48
|
+
const selectedStatuses = includeAll ? undefined : (statuses ?? DEFAULT_OPEN_STATUSES);
|
|
49
|
+
const selectedEpics = selectedStatuses === undefined ? [...epics] : epics.filter((epic) => selectedStatuses.includes(epic.status));
|
|
50
|
+
const sortedEpics = sortByStatusPriority(selectedEpics);
|
|
51
|
+
|
|
52
|
+
if (includeAll) {
|
|
53
|
+
return sortedEpics;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const effectiveLimit = limit ?? DEFAULT_LIST_LIMIT;
|
|
57
|
+
return sortedEpics.slice(0, effectiveLimit);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function invalidEpicListInput(human: string, message: string, data: Record<string, unknown>): CliResult {
|
|
61
|
+
return failResult({
|
|
62
|
+
command: "epic.list",
|
|
63
|
+
human,
|
|
64
|
+
data,
|
|
65
|
+
error: {
|
|
66
|
+
code: "invalid_input",
|
|
67
|
+
message,
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function failMissingOptionValue(command: string, option: string): CliResult {
|
|
73
|
+
return failResult({
|
|
74
|
+
command,
|
|
75
|
+
human: `Option --${option} requires a value.`,
|
|
76
|
+
data: {
|
|
77
|
+
code: "invalid_input",
|
|
78
|
+
option,
|
|
79
|
+
},
|
|
80
|
+
error: {
|
|
81
|
+
code: "invalid_input",
|
|
82
|
+
message: `Option --${option} requires a value`,
|
|
83
|
+
},
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function parseIdsOption(rawIds: string | undefined): string[] {
|
|
88
|
+
if (rawIds === undefined) {
|
|
89
|
+
return [];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return rawIds
|
|
93
|
+
.split(",")
|
|
94
|
+
.map((value) => value.trim())
|
|
95
|
+
.filter((value) => value.length > 0);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function appendLine(existing: string, line: string): string {
|
|
99
|
+
return existing.length > 0 ? `${existing}\n${line}` : line;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function formatEpicListTable(epics: readonly EpicRecord[]): string {
|
|
103
|
+
const rows = epics.map((epic) => [epic.id, epic.title, epic.status]);
|
|
104
|
+
return formatHumanTable(["ID", "TITLE", "STATUS"], rows, { wrapColumns: [1] });
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function formatEpicShowCompact(tree: {
|
|
108
|
+
id: string;
|
|
109
|
+
title: string;
|
|
110
|
+
status: string;
|
|
111
|
+
tasks: ReadonlyArray<{
|
|
112
|
+
id: string;
|
|
113
|
+
title: string;
|
|
114
|
+
status: string;
|
|
115
|
+
subtasks: ReadonlyArray<{ id: string; title: string; status: string }>;
|
|
116
|
+
}>;
|
|
117
|
+
}): string {
|
|
118
|
+
const humanLines: string[] = [`${tree.id} | ${tree.title} | ${tree.status}`];
|
|
119
|
+
|
|
120
|
+
for (const task of tree.tasks) {
|
|
121
|
+
humanLines.push(` task ${task.id} | ${task.title} | ${task.status}`);
|
|
122
|
+
for (const subtask of task.subtasks) {
|
|
123
|
+
humanLines.push(` subtask ${subtask.id} | ${subtask.title} | ${subtask.status}`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return humanLines.join("\n");
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function formatEpicShowDetailed(tree: {
|
|
131
|
+
id: string;
|
|
132
|
+
title: string;
|
|
133
|
+
description: string;
|
|
134
|
+
status: string;
|
|
135
|
+
tasks: ReadonlyArray<{
|
|
136
|
+
id: string;
|
|
137
|
+
title: string;
|
|
138
|
+
description: string;
|
|
139
|
+
status: string;
|
|
140
|
+
subtasks: ReadonlyArray<{ id: string; title: string; description: string; status: string }>;
|
|
141
|
+
}>;
|
|
142
|
+
}): string {
|
|
143
|
+
const humanLines: string[] = [`${tree.id} | ${tree.title} | ${tree.status} | desc=${tree.description}`];
|
|
144
|
+
|
|
145
|
+
for (const task of tree.tasks) {
|
|
146
|
+
humanLines.push(` task ${task.id} | ${task.title} | ${task.status} | desc=${task.description}`);
|
|
147
|
+
for (const subtask of task.subtasks) {
|
|
148
|
+
humanLines.push(` subtask ${subtask.id} | ${subtask.title} | ${subtask.status} | desc=${subtask.description}`);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return humanLines.join("\n");
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function formatEpicShowTable(tree: {
|
|
156
|
+
id: string;
|
|
157
|
+
title: string;
|
|
158
|
+
description: string;
|
|
159
|
+
status: string;
|
|
160
|
+
tasks: ReadonlyArray<{
|
|
161
|
+
id: string;
|
|
162
|
+
title: string;
|
|
163
|
+
description: string;
|
|
164
|
+
status: string;
|
|
165
|
+
subtasks: ReadonlyArray<{ id: string; title: string; description: string; status: string }>;
|
|
166
|
+
}>;
|
|
167
|
+
}): string {
|
|
168
|
+
const sections: string[] = [];
|
|
169
|
+
sections.push("EPIC");
|
|
170
|
+
sections.push(
|
|
171
|
+
formatHumanTable(["ID", "TITLE", "STATUS", "DESCRIPTION"], [[tree.id, tree.title, tree.status, tree.description]], {
|
|
172
|
+
wrapColumns: [1, 3],
|
|
173
|
+
}),
|
|
174
|
+
);
|
|
175
|
+
|
|
176
|
+
if (tree.tasks.length === 0) {
|
|
177
|
+
sections.push("\nTASKS\nNo tasks found.");
|
|
178
|
+
sections.push("\nSUBTASKS\nNo subtasks found.");
|
|
179
|
+
return sections.join("\n");
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
sections.push("\nTASKS");
|
|
183
|
+
sections.push(
|
|
184
|
+
formatHumanTable(
|
|
185
|
+
["ID", "TITLE", "STATUS", "DESCRIPTION"],
|
|
186
|
+
tree.tasks.map((task) => [task.id, task.title, task.status, task.description]),
|
|
187
|
+
{ wrapColumns: [1, 3] },
|
|
188
|
+
),
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
const subtaskRows = tree.tasks.flatMap((task) =>
|
|
192
|
+
task.subtasks.map((subtask) => [subtask.id, task.id, subtask.title, subtask.status, subtask.description]),
|
|
193
|
+
);
|
|
194
|
+
if (subtaskRows.length === 0) {
|
|
195
|
+
sections.push("\nSUBTASKS\nNo subtasks found.");
|
|
196
|
+
return sections.join("\n");
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
sections.push("\nSUBTASKS");
|
|
200
|
+
sections.push(formatHumanTable(["ID", "TASK", "TITLE", "STATUS", "DESCRIPTION"], subtaskRows, { wrapColumns: [2, 4] }));
|
|
201
|
+
return sections.join("\n");
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function failFromError(error: unknown, command: string): CliResult {
|
|
205
|
+
if (error instanceof DomainError) {
|
|
206
|
+
return failResult({
|
|
207
|
+
command,
|
|
208
|
+
human: error.message,
|
|
209
|
+
data: {
|
|
210
|
+
code: error.code,
|
|
211
|
+
...(error.details ?? {}),
|
|
212
|
+
},
|
|
213
|
+
error: {
|
|
214
|
+
code: error.code,
|
|
215
|
+
message: error.message,
|
|
216
|
+
},
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return failResult({
|
|
221
|
+
command,
|
|
222
|
+
human: "Unexpected epic command failure",
|
|
223
|
+
data: {},
|
|
224
|
+
error: {
|
|
225
|
+
code: "internal_error",
|
|
226
|
+
message: "Unexpected epic command failure",
|
|
227
|
+
},
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export async function runEpic(context: CliContext): Promise<CliResult> {
|
|
232
|
+
const database = openTrekoonDatabase(context.cwd);
|
|
233
|
+
|
|
234
|
+
try {
|
|
235
|
+
const parsed = parseArgs(context.args);
|
|
236
|
+
const subcommand: string | undefined = parsed.positional[0];
|
|
237
|
+
const domain = new TrackerDomain(database.db);
|
|
238
|
+
|
|
239
|
+
switch (subcommand) {
|
|
240
|
+
case "create": {
|
|
241
|
+
const missingCreateOption =
|
|
242
|
+
readMissingOptionValue(parsed.missingOptionValues, "status", "s") ??
|
|
243
|
+
readMissingOptionValue(parsed.missingOptionValues, "description", "d");
|
|
244
|
+
if (missingCreateOption !== undefined) {
|
|
245
|
+
return failMissingOptionValue("epic.create", missingCreateOption);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const title: string | undefined = readOption(parsed.options, "title", "t");
|
|
249
|
+
const description: string | undefined = readOption(parsed.options, "description", "d");
|
|
250
|
+
const status: string | undefined = readOption(parsed.options, "status", "s");
|
|
251
|
+
const epic = domain.createEpic({
|
|
252
|
+
title: title ?? "",
|
|
253
|
+
description: description ?? "",
|
|
254
|
+
status,
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
return okResult({
|
|
258
|
+
command: "epic.create",
|
|
259
|
+
human: `Created epic ${formatEpic(epic)}`,
|
|
260
|
+
data: { epic },
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
case "list": {
|
|
264
|
+
const missingListOption =
|
|
265
|
+
readMissingOptionValue(parsed.missingOptionValues, "status", "s") ??
|
|
266
|
+
readMissingOptionValue(parsed.missingOptionValues, "limit", "l") ??
|
|
267
|
+
readMissingOptionValue(parsed.missingOptionValues, "view");
|
|
268
|
+
if (missingListOption !== undefined) {
|
|
269
|
+
return failMissingOptionValue("epic.list", missingListOption);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const includeAll: boolean = hasFlag(parsed.flags, "all");
|
|
273
|
+
const rawStatuses: string | undefined = readOption(parsed.options, "status");
|
|
274
|
+
const rawLimit: string | undefined = readOption(parsed.options, "limit");
|
|
275
|
+
const rawView: string | undefined = readOption(parsed.options, "view");
|
|
276
|
+
const view = readEnumOption(parsed.options, VIEW_MODES, "view");
|
|
277
|
+
if (rawView !== undefined && view === undefined) {
|
|
278
|
+
return invalidEpicListInput("Invalid --view value. Use: table, compact", "Invalid --view value", {
|
|
279
|
+
view: rawView,
|
|
280
|
+
allowedViews: LIST_VIEW_MODES,
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
if (view !== undefined && view !== "table" && view !== "compact") {
|
|
285
|
+
return invalidEpicListInput("Invalid --view for epic list. Use: table, compact", "Invalid --view for epic list", {
|
|
286
|
+
view,
|
|
287
|
+
allowedViews: LIST_VIEW_MODES,
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
if (includeAll && rawStatuses !== undefined) {
|
|
292
|
+
return invalidEpicListInput("Use either --all or --status, not both.", "--all and --status are mutually exclusive", {
|
|
293
|
+
code: "invalid_input",
|
|
294
|
+
flags: ["all", "status"],
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
if (includeAll && rawLimit !== undefined) {
|
|
299
|
+
return invalidEpicListInput("Use either --all or --limit, not both.", "--all and --limit are mutually exclusive", {
|
|
300
|
+
code: "invalid_input",
|
|
301
|
+
flags: ["all", "limit"],
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const statuses = parseStatusCsv(rawStatuses);
|
|
306
|
+
if (rawStatuses !== undefined && statuses !== undefined && statuses.length === 0) {
|
|
307
|
+
return invalidEpicListInput("Invalid --status value. Provide at least one status.", "Invalid --status value", {
|
|
308
|
+
code: "invalid_input",
|
|
309
|
+
status: rawStatuses,
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const limit = parseStrictPositiveInt(rawLimit);
|
|
314
|
+
if (Number.isNaN(limit)) {
|
|
315
|
+
return invalidEpicListInput("Invalid --limit value. Use an integer >= 1.", "Invalid --limit value", {
|
|
316
|
+
code: "invalid_input",
|
|
317
|
+
limit: rawLimit,
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const epics = filterSortAndLimitEpics(domain.listEpics(), {
|
|
322
|
+
includeAll,
|
|
323
|
+
statuses,
|
|
324
|
+
limit,
|
|
325
|
+
});
|
|
326
|
+
const listView = view ?? "table";
|
|
327
|
+
const human = epics.length === 0 ? "No epics found." : listView === "compact" ? epics.map(formatEpic).join("\n") : formatEpicListTable(epics);
|
|
328
|
+
|
|
329
|
+
return okResult({
|
|
330
|
+
command: "epic.list",
|
|
331
|
+
human,
|
|
332
|
+
data: { epics },
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
case "show": {
|
|
336
|
+
const missingShowOption = readMissingOptionValue(parsed.missingOptionValues, "view");
|
|
337
|
+
if (missingShowOption !== undefined) {
|
|
338
|
+
return failMissingOptionValue("epic.show", missingShowOption);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const epicId: string = parsed.positional[1] ?? "";
|
|
342
|
+
const includeAll: boolean = hasFlag(parsed.flags, "all");
|
|
343
|
+
const rawView: string | undefined = readOption(parsed.options, "view");
|
|
344
|
+
const view = readEnumOption(parsed.options, VIEW_MODES, "view");
|
|
345
|
+
if (rawView !== undefined && view === undefined) {
|
|
346
|
+
return failResult({
|
|
347
|
+
command: "epic.show",
|
|
348
|
+
human: "Invalid --view value. Use: table, compact, tree, detail",
|
|
349
|
+
data: { view: rawView, allowedViews: VIEW_MODES },
|
|
350
|
+
error: {
|
|
351
|
+
code: "invalid_input",
|
|
352
|
+
message: "Invalid --view value",
|
|
353
|
+
},
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
const effectiveView = view ?? (context.mode === "human" ? "table" : includeAll ? "detail" : "tree");
|
|
358
|
+
|
|
359
|
+
if (effectiveView === "compact") {
|
|
360
|
+
const epic = domain.getEpicOrThrow(epicId);
|
|
361
|
+
|
|
362
|
+
return okResult({
|
|
363
|
+
command: "epic.show",
|
|
364
|
+
human: formatEpic(epic),
|
|
365
|
+
data: { epic, includeAll: false },
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
if (effectiveView === "tree") {
|
|
370
|
+
const tree = domain.buildEpicTree(epicId);
|
|
371
|
+
|
|
372
|
+
return okResult({
|
|
373
|
+
command: "epic.show",
|
|
374
|
+
human: formatEpicShowCompact(tree),
|
|
375
|
+
data: { tree, includeAll: false },
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
const tree = domain.buildEpicTreeDetailed(epicId);
|
|
380
|
+
|
|
381
|
+
return okResult({
|
|
382
|
+
command: "epic.show",
|
|
383
|
+
human: effectiveView === "table" ? formatEpicShowTable(tree) : formatEpicShowDetailed(tree),
|
|
384
|
+
data: { tree, includeAll: true },
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
case "update": {
|
|
388
|
+
const missingUpdateOption =
|
|
389
|
+
readMissingOptionValue(parsed.missingOptionValues, "ids") ??
|
|
390
|
+
readMissingOptionValue(parsed.missingOptionValues, "append") ??
|
|
391
|
+
readMissingOptionValue(parsed.missingOptionValues, "description", "d") ??
|
|
392
|
+
readMissingOptionValue(parsed.missingOptionValues, "status", "s");
|
|
393
|
+
if (missingUpdateOption !== undefined) {
|
|
394
|
+
return failMissingOptionValue("epic.update", missingUpdateOption);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
const epicId: string = parsed.positional[1] ?? "";
|
|
398
|
+
const updateAll: boolean = hasFlag(parsed.flags, "all");
|
|
399
|
+
const rawIds: string | undefined = readOption(parsed.options, "ids");
|
|
400
|
+
const ids = parseIdsOption(rawIds);
|
|
401
|
+
const title: string | undefined = readOption(parsed.options, "title", "t");
|
|
402
|
+
const description: string | undefined = readOption(parsed.options, "description", "d");
|
|
403
|
+
const append: string | undefined = readOption(parsed.options, "append");
|
|
404
|
+
const status: string | undefined = readOption(parsed.options, "status", "s");
|
|
405
|
+
|
|
406
|
+
if (updateAll && ids.length > 0) {
|
|
407
|
+
return failResult({
|
|
408
|
+
command: "epic.update",
|
|
409
|
+
human: "Use either --all or --ids, not both.",
|
|
410
|
+
data: { code: "invalid_input", target: ["all", "ids"] },
|
|
411
|
+
error: {
|
|
412
|
+
code: "invalid_input",
|
|
413
|
+
message: "--all and --ids are mutually exclusive",
|
|
414
|
+
},
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
if (append !== undefined && description !== undefined) {
|
|
419
|
+
return failResult({
|
|
420
|
+
command: "epic.update",
|
|
421
|
+
human: "Use either --append or --description, not both.",
|
|
422
|
+
data: { code: "invalid_input", fields: ["append", "description"] },
|
|
423
|
+
error: {
|
|
424
|
+
code: "invalid_input",
|
|
425
|
+
message: "--append and --description are mutually exclusive",
|
|
426
|
+
},
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
const hasBulkTarget = updateAll || ids.length > 0;
|
|
431
|
+
if (hasBulkTarget) {
|
|
432
|
+
if (epicId.length > 0) {
|
|
433
|
+
return failResult({
|
|
434
|
+
command: "epic.update",
|
|
435
|
+
human: "Do not pass an epic id when using --all or --ids.",
|
|
436
|
+
data: { code: "invalid_input", id: epicId },
|
|
437
|
+
error: {
|
|
438
|
+
code: "invalid_input",
|
|
439
|
+
message: "Positional id is not allowed with --all/--ids",
|
|
440
|
+
},
|
|
441
|
+
});
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
if (title !== undefined || description !== undefined) {
|
|
445
|
+
return failResult({
|
|
446
|
+
command: "epic.update",
|
|
447
|
+
human: "Bulk update supports only --append and/or --status.",
|
|
448
|
+
data: { code: "invalid_input" },
|
|
449
|
+
error: {
|
|
450
|
+
code: "invalid_input",
|
|
451
|
+
message: "Bulk update supports only --append and --status",
|
|
452
|
+
},
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
if (append === undefined && status === undefined) {
|
|
457
|
+
return failResult({
|
|
458
|
+
command: "epic.update",
|
|
459
|
+
human: "Bulk update requires --append and/or --status.",
|
|
460
|
+
data: { code: "invalid_input" },
|
|
461
|
+
error: {
|
|
462
|
+
code: "invalid_input",
|
|
463
|
+
message: "Missing bulk update fields",
|
|
464
|
+
},
|
|
465
|
+
});
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
const targets = updateAll ? [...domain.listEpics()] : ids.map((id) => domain.getEpicOrThrow(id));
|
|
469
|
+
const epics = targets.map((target) =>
|
|
470
|
+
domain.updateEpic(target.id, {
|
|
471
|
+
status,
|
|
472
|
+
description: append === undefined ? undefined : appendLine(target.description, append),
|
|
473
|
+
}),
|
|
474
|
+
);
|
|
475
|
+
|
|
476
|
+
return okResult({
|
|
477
|
+
command: "epic.update",
|
|
478
|
+
human: `Updated ${epics.length} epic(s)`,
|
|
479
|
+
data: {
|
|
480
|
+
epics,
|
|
481
|
+
target: updateAll ? "all" : "ids",
|
|
482
|
+
ids: epics.map((epic) => epic.id),
|
|
483
|
+
},
|
|
484
|
+
});
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
if (epicId.length === 0) {
|
|
488
|
+
return failResult({
|
|
489
|
+
command: "epic.update",
|
|
490
|
+
human: "Provide an epic id, or use --all/--ids for bulk update.",
|
|
491
|
+
data: { code: "invalid_input" },
|
|
492
|
+
error: {
|
|
493
|
+
code: "invalid_input",
|
|
494
|
+
message: "Missing epic id",
|
|
495
|
+
},
|
|
496
|
+
});
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
const nextDescription =
|
|
500
|
+
append === undefined
|
|
501
|
+
? description
|
|
502
|
+
: appendLine(domain.getEpicOrThrow(epicId).description, append);
|
|
503
|
+
const epic = domain.updateEpic(epicId, { title, description: nextDescription, status });
|
|
504
|
+
|
|
505
|
+
return okResult({
|
|
506
|
+
command: "epic.update",
|
|
507
|
+
human: `Updated epic ${formatEpic(epic)}`,
|
|
508
|
+
data: { epic },
|
|
509
|
+
});
|
|
510
|
+
}
|
|
511
|
+
case "delete": {
|
|
512
|
+
const epicId: string = parsed.positional[1] ?? "";
|
|
513
|
+
domain.deleteEpic(epicId);
|
|
514
|
+
|
|
515
|
+
return okResult({
|
|
516
|
+
command: "epic.delete",
|
|
517
|
+
human: `Deleted epic ${epicId}`,
|
|
518
|
+
data: { id: epicId },
|
|
519
|
+
});
|
|
520
|
+
}
|
|
521
|
+
default:
|
|
522
|
+
return failResult({
|
|
523
|
+
command: "epic",
|
|
524
|
+
human: "Usage: trekoon epic <create|list|show|update|delete>",
|
|
525
|
+
data: {
|
|
526
|
+
args: context.args,
|
|
527
|
+
},
|
|
528
|
+
error: {
|
|
529
|
+
code: "invalid_subcommand",
|
|
530
|
+
message: "Invalid epic subcommand",
|
|
531
|
+
},
|
|
532
|
+
});
|
|
533
|
+
}
|
|
534
|
+
} catch (error: unknown) {
|
|
535
|
+
return failFromError(error, "epic");
|
|
536
|
+
} finally {
|
|
537
|
+
database.close();
|
|
538
|
+
}
|
|
539
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { okResult } from "../io/output";
|
|
2
|
+
import { type CliContext, type CliResult } from "../runtime/command-types";
|
|
3
|
+
|
|
4
|
+
const ROOT_HELP = [
|
|
5
|
+
"Trekoon - AI-first local issue tracker",
|
|
6
|
+
"",
|
|
7
|
+
"Usage:",
|
|
8
|
+
" trekoon [global-options] <command> [command-options]",
|
|
9
|
+
"",
|
|
10
|
+
"Global options:",
|
|
11
|
+
" --json Emit stable JSON machine output",
|
|
12
|
+
" --toon Emit true TOON-encoded output",
|
|
13
|
+
" --help Show root or command help",
|
|
14
|
+
" --version Print CLI version",
|
|
15
|
+
"",
|
|
16
|
+
"Commands:",
|
|
17
|
+
" init Initialize .trekoon storage and local DB",
|
|
18
|
+
" quickstart Show workflow + where to see task descriptions",
|
|
19
|
+
" wipe Remove local Trekoon state (requires --yes)",
|
|
20
|
+
" epic Epic lifecycle commands",
|
|
21
|
+
" task Task lifecycle commands",
|
|
22
|
+
" subtask Subtask lifecycle commands",
|
|
23
|
+
" dep Dependency graph commands",
|
|
24
|
+
" sync Cross-branch sync commands",
|
|
25
|
+
].join("\n");
|
|
26
|
+
|
|
27
|
+
const COMMAND_HELP: Record<string, string> = {
|
|
28
|
+
init: "Usage: trekoon init [--json|--toon]",
|
|
29
|
+
quickstart: "Usage: trekoon quickstart [--json|--toon]",
|
|
30
|
+
wipe: "Usage: trekoon wipe --yes [--json|--toon]",
|
|
31
|
+
epic:
|
|
32
|
+
"Usage: trekoon epic <subcommand> [options] (list defaults: open statuses + limit 10; list flags: --status <csv> | --limit <n> | --all | --view table|compact; show: compact=epic summary, tree=hierarchy, detail=descriptions, and --all defaults to detail in machine modes; update bulk flags: --all | --ids <csv> with --append <text> and/or --status <status>)",
|
|
33
|
+
task:
|
|
34
|
+
"Usage: trekoon task <subcommand> [options] (list defaults: open statuses + limit 10; list flags: --status <csv> | --limit <n> | --all | --view table|compact; show: compact=task summary, tree=hierarchy, detail=descriptions, and --all defaults to detail in machine modes; update bulk flags: --all | --ids <csv> with --append <text> and/or --status <status>)",
|
|
35
|
+
subtask: "Usage: trekoon subtask <subcommand> [options] (list supports --view table|compact)",
|
|
36
|
+
dep: "Usage: trekoon dep <subcommand> [options]",
|
|
37
|
+
sync: "Usage: trekoon sync <subcommand> [options]",
|
|
38
|
+
help: "Usage: trekoon help [command] [--json|--toon]",
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export function resolveHelpText(topic: string | null): string {
|
|
42
|
+
if (!topic) {
|
|
43
|
+
return ROOT_HELP;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return COMMAND_HELP[topic] ?? `Unknown command for help: ${topic}`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function runHelp(context: CliContext): Promise<CliResult> {
|
|
50
|
+
const topic: string | null = context.args[0] ?? null;
|
|
51
|
+
const text: string = resolveHelpText(topic);
|
|
52
|
+
|
|
53
|
+
return okResult({
|
|
54
|
+
command: "help",
|
|
55
|
+
human: text,
|
|
56
|
+
data: {
|
|
57
|
+
topic,
|
|
58
|
+
text,
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { okResult } from "../io/output";
|
|
2
|
+
import { type CliContext, type CliResult } from "../runtime/command-types";
|
|
3
|
+
import { openTrekoonDatabase } from "../storage/database";
|
|
4
|
+
|
|
5
|
+
export async function runInit(context: CliContext): Promise<CliResult> {
|
|
6
|
+
const database = openTrekoonDatabase(context.cwd);
|
|
7
|
+
|
|
8
|
+
try {
|
|
9
|
+
return okResult({
|
|
10
|
+
command: "init",
|
|
11
|
+
human: [
|
|
12
|
+
"Trekoon initialized.",
|
|
13
|
+
`Storage directory: ${database.paths.storageDir}`,
|
|
14
|
+
`Database file: ${database.paths.databaseFile}`,
|
|
15
|
+
].join("\n"),
|
|
16
|
+
data: {
|
|
17
|
+
storageDir: database.paths.storageDir,
|
|
18
|
+
databaseFile: database.paths.databaseFile,
|
|
19
|
+
},
|
|
20
|
+
});
|
|
21
|
+
} finally {
|
|
22
|
+
database.close();
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { okResult } from "../io/output";
|
|
2
|
+
import { type CliContext, type CliResult } from "../runtime/command-types";
|
|
3
|
+
|
|
4
|
+
const QUICKSTART_TEXT = [
|
|
5
|
+
"Trekoon quickstart",
|
|
6
|
+
"",
|
|
7
|
+
"1) Local DB and worktree model",
|
|
8
|
+
"- Every worktree stores tracker state at .trekoon/trekoon.db.",
|
|
9
|
+
"- This DB stays local; it is not merged by Git automatically.",
|
|
10
|
+
"",
|
|
11
|
+
"2) Pre-merge sync flow",
|
|
12
|
+
"- Run: trekoon sync status",
|
|
13
|
+
"- Pull upstream tracker events: trekoon sync pull --from main",
|
|
14
|
+
"- Resolve conflicts if needed: trekoon sync resolve <id> --use ours",
|
|
15
|
+
"- Run sync status again before opening or merging a PR.",
|
|
16
|
+
"",
|
|
17
|
+
"3) Task details and description",
|
|
18
|
+
"- Human list and show views default to table format.",
|
|
19
|
+
"- Alternate list view: add --view compact.",
|
|
20
|
+
"- task/epic list defaults: open work only (in_progress/in-progress, todo), max 10.",
|
|
21
|
+
"- Filter list by status: --status in_progress,todo (CSV).",
|
|
22
|
+
"- Change page size: --limit <n>. Show all statuses and all rows with --all.",
|
|
23
|
+
"- --all cannot be combined with --status or --limit.",
|
|
24
|
+
"- Bulk update: use --all or --ids <csv> with --append and/or --status.",
|
|
25
|
+
"- Bulk update rejects positional id, and --all/--ids cannot be combined.",
|
|
26
|
+
"- Full tree + descriptions: trekoon epic show <epic-id> --all --json",
|
|
27
|
+
"- For full task payload (including description), use --json:",
|
|
28
|
+
" trekoon task show <task-id> --all --json",
|
|
29
|
+
"",
|
|
30
|
+
"4) Machine output examples",
|
|
31
|
+
"- trekoon quickstart --json",
|
|
32
|
+
"- trekoon task show <task-id> --all --json",
|
|
33
|
+
"- trekoon epic show <epic-id> --all --json",
|
|
34
|
+
"- trekoon sync status --toon",
|
|
35
|
+
].join("\n");
|
|
36
|
+
|
|
37
|
+
export async function runQuickstart(_: CliContext): Promise<CliResult> {
|
|
38
|
+
return okResult({
|
|
39
|
+
command: "quickstart",
|
|
40
|
+
human: QUICKSTART_TEXT,
|
|
41
|
+
data: {
|
|
42
|
+
localModel: {
|
|
43
|
+
storageDir: ".trekoon",
|
|
44
|
+
databaseFile: ".trekoon/trekoon.db",
|
|
45
|
+
mergeBehavior: "manual-sync",
|
|
46
|
+
},
|
|
47
|
+
preMergeFlow: [
|
|
48
|
+
"trekoon sync status",
|
|
49
|
+
"trekoon sync pull --from main",
|
|
50
|
+
"trekoon sync resolve <id> --use ours",
|
|
51
|
+
"trekoon sync status",
|
|
52
|
+
],
|
|
53
|
+
machineExamples: [
|
|
54
|
+
"trekoon quickstart --json",
|
|
55
|
+
"trekoon task show <task-id> --all --json",
|
|
56
|
+
"trekoon epic show <epic-id> --all --json",
|
|
57
|
+
"trekoon sync status --toon",
|
|
58
|
+
],
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
}
|