@nijaru/tk 0.0.4 → 0.1.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.
- package/bin/tk.js +30 -0
- package/package.json +22 -45
- package/LICENSE +0 -21
- package/README.md +0 -242
- package/src/cli.test.ts +0 -976
- package/src/cli.ts +0 -695
- package/src/db/storage.ts +0 -1014
- package/src/lib/completions.ts +0 -425
- package/src/lib/format.test.ts +0 -361
- package/src/lib/format.ts +0 -188
- package/src/lib/help.ts +0 -249
- package/src/lib/priority.test.ts +0 -105
- package/src/lib/priority.ts +0 -40
- package/src/lib/root.ts +0 -79
- package/src/lib/time.ts +0 -115
- package/src/types.ts +0 -130
package/src/cli.ts
DELETED
|
@@ -1,695 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env bun
|
|
2
|
-
import { parseArgs } from "util";
|
|
3
|
-
import { version } from "../package.json";
|
|
4
|
-
|
|
5
|
-
import * as storage from "./db/storage";
|
|
6
|
-
import { parsePriority } from "./lib/priority";
|
|
7
|
-
import { parseDueDate, parseEstimate } from "./lib/time";
|
|
8
|
-
import {
|
|
9
|
-
formatTaskList,
|
|
10
|
-
formatTaskDetail,
|
|
11
|
-
formatJson,
|
|
12
|
-
formatConfig,
|
|
13
|
-
green,
|
|
14
|
-
red,
|
|
15
|
-
yellow,
|
|
16
|
-
dim,
|
|
17
|
-
} from "./lib/format";
|
|
18
|
-
import { findRoot, setWorkingDir } from "./lib/root";
|
|
19
|
-
import { parseId } from "./types";
|
|
20
|
-
import type { Status, TaskWithMeta } from "./types";
|
|
21
|
-
import { BASH_COMPLETION, ZSH_COMPLETION, FISH_COMPLETION } from "./lib/completions";
|
|
22
|
-
import { MAIN_HELP, COMMAND_HELP } from "./lib/help";
|
|
23
|
-
|
|
24
|
-
const VALID_STATUSES: Status[] = ["open", "active", "done"];
|
|
25
|
-
const PROJECT_PATTERN = /^[a-z][a-z0-9]*$/;
|
|
26
|
-
|
|
27
|
-
const COMMON_OPTIONS = {
|
|
28
|
-
project: { type: "string", short: "P" },
|
|
29
|
-
priority: { type: "string", short: "p" },
|
|
30
|
-
labels: { type: "string", short: "l" },
|
|
31
|
-
} as const;
|
|
32
|
-
|
|
33
|
-
const TASK_MUTATION_OPTIONS = {
|
|
34
|
-
...COMMON_OPTIONS,
|
|
35
|
-
description: { type: "string", short: "d" },
|
|
36
|
-
assignees: { type: "string", short: "A" },
|
|
37
|
-
parent: { type: "string" },
|
|
38
|
-
estimate: { type: "string" },
|
|
39
|
-
due: { type: "string" },
|
|
40
|
-
} as const;
|
|
41
|
-
|
|
42
|
-
function validateProject(name: string): void {
|
|
43
|
-
if (!PROJECT_PATTERN.test(name)) {
|
|
44
|
-
throw new Error(
|
|
45
|
-
`Invalid project name: ${name}. Use lowercase letters and numbers, starting with a letter (e.g., 'api', 'web2').`,
|
|
46
|
-
);
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
function parseStatus(input: string | undefined): Status | undefined {
|
|
51
|
-
if (!input) return undefined;
|
|
52
|
-
if (!VALID_STATUSES.includes(input as Status)) {
|
|
53
|
-
throw new Error(`Invalid status: ${input}. Use: ${VALID_STATUSES.join(", ")}`);
|
|
54
|
-
}
|
|
55
|
-
return input as Status;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
function parseLimit(input: string | undefined): number | undefined {
|
|
59
|
-
if (!input) return undefined;
|
|
60
|
-
if (!/^\d+$/.test(input)) {
|
|
61
|
-
throw new Error(`Invalid limit: ${input}. Must be a positive number.`);
|
|
62
|
-
}
|
|
63
|
-
const n = Number(input);
|
|
64
|
-
if (n < 1) {
|
|
65
|
-
throw new Error(`Invalid limit: ${input}. Must be a positive number.`);
|
|
66
|
-
}
|
|
67
|
-
return n;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
function parseLabels(input: string | undefined): string[] | undefined {
|
|
71
|
-
if (!input) return undefined;
|
|
72
|
-
return input
|
|
73
|
-
.split(",")
|
|
74
|
-
.map((l) => l.trim())
|
|
75
|
-
.filter(Boolean);
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
function parseAssignees(input: string | undefined): string[] | undefined {
|
|
79
|
-
if (!input) return undefined;
|
|
80
|
-
// Expand @me to git user
|
|
81
|
-
const assignees = input
|
|
82
|
-
.split(",")
|
|
83
|
-
.map((a) => a.trim())
|
|
84
|
-
.filter(Boolean);
|
|
85
|
-
return assignees.map((a) => {
|
|
86
|
-
if (a === "@me") {
|
|
87
|
-
try {
|
|
88
|
-
const result = Bun.spawnSync(["git", "config", "user.name"]);
|
|
89
|
-
if (result.success) {
|
|
90
|
-
return result.stdout.toString().trim() || a;
|
|
91
|
-
}
|
|
92
|
-
} catch {
|
|
93
|
-
// Fall through to return @me
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
return a;
|
|
97
|
-
});
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
function resolveId(input: string | undefined, context: string): string {
|
|
101
|
-
if (!input) {
|
|
102
|
-
throw new Error(`ID required: tk ${context} <id>`);
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
// Try to resolve partial ID
|
|
106
|
-
const resolved = storage.resolveId(input);
|
|
107
|
-
if (resolved) return resolved;
|
|
108
|
-
|
|
109
|
-
// Check for ambiguous matches
|
|
110
|
-
const matches = storage.findMatchingIds(input);
|
|
111
|
-
if (matches.length > 1) {
|
|
112
|
-
throw new Error(
|
|
113
|
-
`Ambiguous ID '${input}' matches ${matches.length} tasks: ${matches.join(", ")}. Use more characters to narrow it down.`,
|
|
114
|
-
);
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
// If it's a valid full ID format but doesn't exist, we still return it
|
|
118
|
-
// and let getTask handle the "not found" error consistently.
|
|
119
|
-
if (parseId(input)) return input;
|
|
120
|
-
|
|
121
|
-
throw new Error(`Task not found: ${input}. Run 'tk ls' to see available tasks.`);
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
/**
|
|
125
|
-
* Resolves an ID and fetches the task.
|
|
126
|
-
* Handles "Task not found" and cleanup output automatically.
|
|
127
|
-
*/
|
|
128
|
-
function resolveTask(input: string | undefined, context: string): TaskWithMeta {
|
|
129
|
-
const id = resolveId(input, context);
|
|
130
|
-
const result = storage.getTask(id);
|
|
131
|
-
if (!result) error(`Task not found: ${id}. Run 'tk ls' to see available tasks.`);
|
|
132
|
-
|
|
133
|
-
outputCleanup(id, result.cleanup);
|
|
134
|
-
return result.task;
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
const rawArgs = process.argv.slice(2);
|
|
138
|
-
|
|
139
|
-
function isFlag(arg: string): boolean {
|
|
140
|
-
return arg.startsWith("-");
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
// Extract -C <dir> flag (can appear anywhere)
|
|
144
|
-
let dirFlag: string | null = null;
|
|
145
|
-
const argsWithoutDir: string[] = [];
|
|
146
|
-
for (let i = 0; i < rawArgs.length; i++) {
|
|
147
|
-
const arg = rawArgs[i];
|
|
148
|
-
if (arg === "-C" && rawArgs[i + 1]) {
|
|
149
|
-
dirFlag = rawArgs[i + 1]!;
|
|
150
|
-
i++; // skip next arg
|
|
151
|
-
} else {
|
|
152
|
-
argsWithoutDir.push(arg!);
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
if (dirFlag) {
|
|
156
|
-
setWorkingDir(dirFlag);
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
// Find command: first non-flag argument
|
|
160
|
-
const commandIndex = argsWithoutDir.findIndex((arg) => !isFlag(arg));
|
|
161
|
-
const command = commandIndex >= 0 ? argsWithoutDir[commandIndex] : undefined;
|
|
162
|
-
|
|
163
|
-
// Get args for command (before stripping flags)
|
|
164
|
-
const postCommandArgs = commandIndex >= 0 ? argsWithoutDir.slice(commandIndex + 1) : [];
|
|
165
|
-
const preCommandArgs = commandIndex >= 0 ? argsWithoutDir.slice(0, commandIndex) : argsWithoutDir;
|
|
166
|
-
|
|
167
|
-
// Global flag detection:
|
|
168
|
-
// --json can appear anywhere and should be stripped from args
|
|
169
|
-
// --help/-h detected before command OR as first arg after command (not in message content)
|
|
170
|
-
// --version/-V only detected before command
|
|
171
|
-
const jsonFlag = argsWithoutDir.includes("--json");
|
|
172
|
-
const helpFlag =
|
|
173
|
-
preCommandArgs.includes("--help") ||
|
|
174
|
-
preCommandArgs.includes("-h") ||
|
|
175
|
-
postCommandArgs[0] === "--help" ||
|
|
176
|
-
postCommandArgs[0] === "-h";
|
|
177
|
-
const versionFlag = preCommandArgs.includes("--version") || preCommandArgs.includes("-V");
|
|
178
|
-
|
|
179
|
-
// Strip --json from args (works from any position)
|
|
180
|
-
// Strip --help/-h only if it's the first arg (to allow "tk ls --help")
|
|
181
|
-
const args = postCommandArgs
|
|
182
|
-
.filter((arg) => arg !== "--json")
|
|
183
|
-
.filter((arg, i) => i !== 0 || (arg !== "--help" && arg !== "-h"));
|
|
184
|
-
|
|
185
|
-
function output(data: unknown, formatted: string) {
|
|
186
|
-
console.log(jsonFlag ? formatJson(data) : formatted);
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
function outputCleanup(taskId: string, cleanup: storage.CleanupInfo | null) {
|
|
190
|
-
if (cleanup && !jsonFlag) {
|
|
191
|
-
console.error(storage.formatCleanupMessage(taskId, cleanup));
|
|
192
|
-
}
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
function error(message: string): never {
|
|
196
|
-
console.error(red(`Error: ${message}`));
|
|
197
|
-
process.exit(1);
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
function showHelp(): void {
|
|
201
|
-
console.log(MAIN_HELP);
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
function showCommandHelp(cmd: string): void {
|
|
205
|
-
const help = COMMAND_HELP[cmd];
|
|
206
|
-
if (help) {
|
|
207
|
-
console.log(help);
|
|
208
|
-
} else {
|
|
209
|
-
console.error(`Unknown command: ${cmd}. Run 'tk --help' for usage.`);
|
|
210
|
-
process.exit(1);
|
|
211
|
-
}
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
function main() {
|
|
215
|
-
if (versionFlag) {
|
|
216
|
-
console.log(`tk v${version}`);
|
|
217
|
-
return;
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
if (!command) {
|
|
221
|
-
showHelp();
|
|
222
|
-
return;
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
// Handle 'tk help' and 'tk help <command>'
|
|
226
|
-
if (command === "help") {
|
|
227
|
-
const subcommand = args[0];
|
|
228
|
-
if (subcommand) {
|
|
229
|
-
showCommandHelp(subcommand);
|
|
230
|
-
} else {
|
|
231
|
-
showHelp();
|
|
232
|
-
}
|
|
233
|
-
return;
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
if (helpFlag) {
|
|
237
|
-
showCommandHelp(command);
|
|
238
|
-
return;
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
switch (command) {
|
|
242
|
-
case "init": {
|
|
243
|
-
const { values } = parseArgs({
|
|
244
|
-
args,
|
|
245
|
-
options: {
|
|
246
|
-
project: { type: "string", short: "P" },
|
|
247
|
-
},
|
|
248
|
-
allowPositionals: true,
|
|
249
|
-
});
|
|
250
|
-
if (values.project) {
|
|
251
|
-
validateProject(values.project);
|
|
252
|
-
}
|
|
253
|
-
const info = findRoot();
|
|
254
|
-
if (info.exists) {
|
|
255
|
-
output(
|
|
256
|
-
{ path: info.tasksDir, created: false },
|
|
257
|
-
dim(`Already initialized: ${info.tasksDir}`),
|
|
258
|
-
);
|
|
259
|
-
} else {
|
|
260
|
-
const path = storage.initTasks(values.project);
|
|
261
|
-
output({ path, created: true }, green(`Initialized: ${path}`));
|
|
262
|
-
}
|
|
263
|
-
break;
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
case "add": {
|
|
267
|
-
const { values, positionals } = parseArgs({
|
|
268
|
-
args,
|
|
269
|
-
options: TASK_MUTATION_OPTIONS,
|
|
270
|
-
allowPositionals: true,
|
|
271
|
-
});
|
|
272
|
-
const title = positionals[0]?.trim();
|
|
273
|
-
if (!title) error("Title required: tk add <title>");
|
|
274
|
-
|
|
275
|
-
// Validate project if provided
|
|
276
|
-
if (values.project) {
|
|
277
|
-
validateProject(values.project);
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
// Resolve and validate parent if provided
|
|
281
|
-
let parentId: string | undefined;
|
|
282
|
-
if (values.parent) {
|
|
283
|
-
const resolved = storage.resolveId(values.parent);
|
|
284
|
-
if (!resolved)
|
|
285
|
-
error(`Parent task not found: ${values.parent}. Run 'tk ls' to see available tasks.`);
|
|
286
|
-
parentId = resolved;
|
|
287
|
-
const parentResult = storage.validateParent(parentId);
|
|
288
|
-
if (!parentResult.ok) error(parentResult.error!);
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
const task = storage.createTask({
|
|
292
|
-
title,
|
|
293
|
-
description: values.description,
|
|
294
|
-
priority: parsePriority(values.priority),
|
|
295
|
-
project: values.project,
|
|
296
|
-
labels: parseLabels(values.labels),
|
|
297
|
-
assignees: parseAssignees(values.assignees),
|
|
298
|
-
parent: parentId,
|
|
299
|
-
estimate: parseEstimate(values.estimate),
|
|
300
|
-
due_date: parseDueDate(values.due),
|
|
301
|
-
});
|
|
302
|
-
output(task, task.id);
|
|
303
|
-
break;
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
case "ls":
|
|
307
|
-
case "list": {
|
|
308
|
-
const { values } = parseArgs({
|
|
309
|
-
args,
|
|
310
|
-
options: {
|
|
311
|
-
...COMMON_OPTIONS,
|
|
312
|
-
label: COMMON_OPTIONS.labels, // alias for consistency
|
|
313
|
-
status: { type: "string", short: "s" },
|
|
314
|
-
assignee: { type: "string" },
|
|
315
|
-
parent: { type: "string" },
|
|
316
|
-
roots: { type: "boolean" },
|
|
317
|
-
overdue: { type: "boolean" },
|
|
318
|
-
limit: { type: "string", short: "n" },
|
|
319
|
-
all: { type: "boolean", short: "a" },
|
|
320
|
-
},
|
|
321
|
-
allowPositionals: true,
|
|
322
|
-
});
|
|
323
|
-
const status = parseStatus(values.status);
|
|
324
|
-
const priority = values.priority ? parsePriority(values.priority) : undefined;
|
|
325
|
-
const limit = values.all ? undefined : (parseLimit(values.limit) ?? 20);
|
|
326
|
-
|
|
327
|
-
// Resolve parent filter if provided
|
|
328
|
-
let parentFilter: string | undefined;
|
|
329
|
-
if (values.parent) {
|
|
330
|
-
const resolved = storage.resolveId(values.parent);
|
|
331
|
-
if (!resolved)
|
|
332
|
-
error(`Parent task not found: ${values.parent}. Run 'tk ls' to see available tasks.`);
|
|
333
|
-
parentFilter = resolved;
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
const list = storage.listTasks({
|
|
337
|
-
status,
|
|
338
|
-
priority,
|
|
339
|
-
project: values.project,
|
|
340
|
-
label: values.label ?? values.labels,
|
|
341
|
-
assignee: values.assignee,
|
|
342
|
-
parent: parentFilter,
|
|
343
|
-
roots: values.roots,
|
|
344
|
-
overdue: values.overdue,
|
|
345
|
-
limit,
|
|
346
|
-
});
|
|
347
|
-
output(list, formatTaskList(list));
|
|
348
|
-
break;
|
|
349
|
-
}
|
|
350
|
-
|
|
351
|
-
case "ready": {
|
|
352
|
-
const list = storage.listReadyTasks();
|
|
353
|
-
output(
|
|
354
|
-
list,
|
|
355
|
-
formatTaskList(list, undefined, "No ready tasks. All tasks are either done or blocked."),
|
|
356
|
-
);
|
|
357
|
-
break;
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
case "show": {
|
|
361
|
-
const task = resolveTask(args[0], "show");
|
|
362
|
-
output(task, formatTaskDetail(task, task.logs));
|
|
363
|
-
break;
|
|
364
|
-
}
|
|
365
|
-
|
|
366
|
-
case "start": {
|
|
367
|
-
const task = resolveTask(args[0], "start");
|
|
368
|
-
if (task.status === "active")
|
|
369
|
-
error(`Task already active. Use 'tk done ${task.id}' to complete it.`);
|
|
370
|
-
if (task.status === "done") error(`Task already done. Use 'tk reopen ${task.id}' first.`);
|
|
371
|
-
const updated = storage.updateTaskStatus(task.id, "active");
|
|
372
|
-
output(updated, green(`Started: ${task.id}`));
|
|
373
|
-
break;
|
|
374
|
-
}
|
|
375
|
-
|
|
376
|
-
case "done": {
|
|
377
|
-
const task = resolveTask(args[0], "done");
|
|
378
|
-
const updated = storage.updateTaskStatus(task.id, "done");
|
|
379
|
-
output(updated, green(`Completed: ${task.id}`));
|
|
380
|
-
break;
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
case "reopen": {
|
|
384
|
-
const task = resolveTask(args[0], "reopen");
|
|
385
|
-
const updated = storage.updateTaskStatus(task.id, "open");
|
|
386
|
-
output(updated, green(`Reopened: ${task.id}`));
|
|
387
|
-
break;
|
|
388
|
-
}
|
|
389
|
-
|
|
390
|
-
case "edit": {
|
|
391
|
-
const { values, positionals } = parseArgs({
|
|
392
|
-
args,
|
|
393
|
-
options: {
|
|
394
|
-
...TASK_MUTATION_OPTIONS,
|
|
395
|
-
title: { type: "string", short: "t" },
|
|
396
|
-
},
|
|
397
|
-
allowPositionals: true,
|
|
398
|
-
});
|
|
399
|
-
const task = resolveTask(positionals[0], "edit");
|
|
400
|
-
|
|
401
|
-
// Handle label modifications (+tag, -tag)
|
|
402
|
-
let labels: string[] | undefined;
|
|
403
|
-
if (values.labels) {
|
|
404
|
-
if (values.labels.startsWith("+")) {
|
|
405
|
-
// Add label (avoid duplicates)
|
|
406
|
-
const newLabel = values.labels.slice(1);
|
|
407
|
-
labels = task.labels.includes(newLabel) ? task.labels : [...task.labels, newLabel];
|
|
408
|
-
} else if (values.labels.startsWith("-")) {
|
|
409
|
-
// Remove label
|
|
410
|
-
const removeLabel = values.labels.slice(1);
|
|
411
|
-
labels = task.labels.filter((l: string) => l !== removeLabel);
|
|
412
|
-
} else {
|
|
413
|
-
// Replace labels
|
|
414
|
-
labels = parseLabels(values.labels);
|
|
415
|
-
}
|
|
416
|
-
}
|
|
417
|
-
|
|
418
|
-
// Resolve and validate parent if provided (and not clearing)
|
|
419
|
-
let resolvedParent: string | null | undefined;
|
|
420
|
-
if (values.parent === "-") {
|
|
421
|
-
resolvedParent = null;
|
|
422
|
-
} else if (values.parent) {
|
|
423
|
-
const resolved = storage.resolveId(values.parent);
|
|
424
|
-
if (!resolved)
|
|
425
|
-
error(`Parent task not found: ${values.parent}. Run 'tk ls' to see available tasks.`);
|
|
426
|
-
resolvedParent = resolved;
|
|
427
|
-
const parentResult = storage.validateParent(resolvedParent, task.id);
|
|
428
|
-
if (!parentResult.ok) error(parentResult.error!);
|
|
429
|
-
}
|
|
430
|
-
|
|
431
|
-
const updated = storage.updateTask(task.id, {
|
|
432
|
-
title: values.title?.trim() || undefined,
|
|
433
|
-
description: values.description,
|
|
434
|
-
priority: values.priority ? parsePriority(values.priority) : undefined,
|
|
435
|
-
labels,
|
|
436
|
-
assignees: parseAssignees(values.assignees),
|
|
437
|
-
parent: resolvedParent,
|
|
438
|
-
estimate: values.estimate === "-" ? null : (parseEstimate(values.estimate) ?? undefined),
|
|
439
|
-
due_date: values.due === "-" ? null : (parseDueDate(values.due) ?? undefined),
|
|
440
|
-
});
|
|
441
|
-
output(updated, green(`Updated: ${task.id}`));
|
|
442
|
-
break;
|
|
443
|
-
}
|
|
444
|
-
|
|
445
|
-
case "log": {
|
|
446
|
-
const task = resolveTask(args[0], "log");
|
|
447
|
-
const message = args[1]?.trim();
|
|
448
|
-
if (!message) error('Message required: tk log <id> "<message>"');
|
|
449
|
-
if (args.length > 2) {
|
|
450
|
-
error(
|
|
451
|
-
'Message must be quoted: tk log <id> "<message>"\n' +
|
|
452
|
-
` Got ${args.length - 1} arguments instead of 1`,
|
|
453
|
-
);
|
|
454
|
-
}
|
|
455
|
-
const entry = storage.addLogEntry(task.id, message);
|
|
456
|
-
output(entry, green(`Logged: ${task.id}`));
|
|
457
|
-
break;
|
|
458
|
-
}
|
|
459
|
-
|
|
460
|
-
case "block": {
|
|
461
|
-
if (!args[0] || !args[1])
|
|
462
|
-
error(
|
|
463
|
-
"Two IDs required: tk block <task> <blocker>. The first task becomes blocked by the second.",
|
|
464
|
-
);
|
|
465
|
-
const taskId = resolveId(args[0], "block");
|
|
466
|
-
const blockerId = resolveId(args[1], "block");
|
|
467
|
-
if (taskId === blockerId) error("Task cannot block itself.");
|
|
468
|
-
const result = storage.addBlock(taskId, blockerId);
|
|
469
|
-
if (!result.ok) error(result.error!);
|
|
470
|
-
output(
|
|
471
|
-
{ task_id: taskId, blocked_by: blockerId },
|
|
472
|
-
green(`${taskId} blocked by ${blockerId}`),
|
|
473
|
-
);
|
|
474
|
-
break;
|
|
475
|
-
}
|
|
476
|
-
|
|
477
|
-
case "unblock": {
|
|
478
|
-
if (!args[0] || !args[1]) error("Two IDs required: tk unblock <task> <blocker>.");
|
|
479
|
-
const taskId = resolveId(args[0], "unblock");
|
|
480
|
-
const blockerId = resolveId(args[1], "unblock");
|
|
481
|
-
const removed = storage.removeBlock(taskId, blockerId);
|
|
482
|
-
if (!removed) error(`${taskId} is not blocked by ${blockerId}`);
|
|
483
|
-
output(
|
|
484
|
-
{ task_id: taskId, blocked_by: blockerId },
|
|
485
|
-
green(`${taskId} unblocked from ${blockerId}`),
|
|
486
|
-
);
|
|
487
|
-
break;
|
|
488
|
-
}
|
|
489
|
-
|
|
490
|
-
case "rm":
|
|
491
|
-
case "remove": {
|
|
492
|
-
const id = resolveId(args[0], "rm");
|
|
493
|
-
const deleted = storage.deleteTask(id);
|
|
494
|
-
if (!deleted) error(`Task not found: ${id}. Run 'tk ls' to see available tasks.`);
|
|
495
|
-
output({ id, deleted: true }, green(`Deleted: ${id}`));
|
|
496
|
-
break;
|
|
497
|
-
}
|
|
498
|
-
|
|
499
|
-
case "clean": {
|
|
500
|
-
const config = storage.getConfig();
|
|
501
|
-
const { values } = parseArgs({
|
|
502
|
-
args,
|
|
503
|
-
options: {
|
|
504
|
-
"older-than": { type: "string" },
|
|
505
|
-
force: { type: "boolean", short: "f" },
|
|
506
|
-
},
|
|
507
|
-
allowPositionals: true,
|
|
508
|
-
});
|
|
509
|
-
|
|
510
|
-
// Get days from CLI or config
|
|
511
|
-
let days: number | false;
|
|
512
|
-
if (values["older-than"] !== undefined) {
|
|
513
|
-
if (!/^\d+$/.test(values["older-than"])) {
|
|
514
|
-
error(`Invalid --older-than: ${values["older-than"]}. Use a number of days.`);
|
|
515
|
-
}
|
|
516
|
-
days = Number(values["older-than"]);
|
|
517
|
-
} else {
|
|
518
|
-
days = config.clean_after;
|
|
519
|
-
// Validate config value at runtime
|
|
520
|
-
if (days !== false && (typeof days !== "number" || days < 0 || !Number.isFinite(days))) {
|
|
521
|
-
error(
|
|
522
|
-
`Invalid clean_after in config: ${JSON.stringify(days)}. Must be a number or false.`,
|
|
523
|
-
);
|
|
524
|
-
}
|
|
525
|
-
}
|
|
526
|
-
|
|
527
|
-
if (days === false && !values.force) {
|
|
528
|
-
error("Cleaning is disabled (clean_after: false). Use --force to override.");
|
|
529
|
-
}
|
|
530
|
-
|
|
531
|
-
const ms = days === false ? 0 : days * 24 * 60 * 60 * 1000;
|
|
532
|
-
const count = storage.cleanTasks({
|
|
533
|
-
olderThanMs: ms,
|
|
534
|
-
force: values.force,
|
|
535
|
-
});
|
|
536
|
-
output({ deleted: count }, green(`Cleaned ${count} tasks`));
|
|
537
|
-
break;
|
|
538
|
-
}
|
|
539
|
-
|
|
540
|
-
case "check": {
|
|
541
|
-
const checkResult = storage.checkTasks();
|
|
542
|
-
|
|
543
|
-
if (jsonFlag) {
|
|
544
|
-
output(checkResult, "");
|
|
545
|
-
} else {
|
|
546
|
-
// Report cleaned issues
|
|
547
|
-
if (checkResult.cleaned.length > 0) {
|
|
548
|
-
for (const { task, info } of checkResult.cleaned) {
|
|
549
|
-
console.log(storage.formatCleanupMessage(task, info));
|
|
550
|
-
}
|
|
551
|
-
}
|
|
552
|
-
|
|
553
|
-
// Report unfixable issues
|
|
554
|
-
if (checkResult.unfixable.length > 0) {
|
|
555
|
-
console.log(red("\nUnfixable issues (require manual intervention):"));
|
|
556
|
-
for (const { file, error: err } of checkResult.unfixable) {
|
|
557
|
-
console.log(red(` ${file}: ${err}`));
|
|
558
|
-
}
|
|
559
|
-
}
|
|
560
|
-
|
|
561
|
-
// Summary
|
|
562
|
-
if (checkResult.cleaned.length === 0 && checkResult.unfixable.length === 0) {
|
|
563
|
-
console.log(green(`All ${checkResult.totalTasks} tasks OK`));
|
|
564
|
-
} else {
|
|
565
|
-
const parts: string[] = [];
|
|
566
|
-
if (checkResult.cleaned.length > 0) {
|
|
567
|
-
parts.push(yellow(`${checkResult.cleaned.length} fixed`));
|
|
568
|
-
}
|
|
569
|
-
if (checkResult.unfixable.length > 0) {
|
|
570
|
-
parts.push(red(`${checkResult.unfixable.length} unfixable`));
|
|
571
|
-
}
|
|
572
|
-
console.log(`\nChecked ${checkResult.totalTasks} tasks: ${parts.join(", ")}`);
|
|
573
|
-
}
|
|
574
|
-
}
|
|
575
|
-
break;
|
|
576
|
-
}
|
|
577
|
-
|
|
578
|
-
case "config": {
|
|
579
|
-
const subcommand = args[0];
|
|
580
|
-
const config = storage.getConfig();
|
|
581
|
-
|
|
582
|
-
if (!subcommand) {
|
|
583
|
-
// Show all config
|
|
584
|
-
output(config, formatConfig(config));
|
|
585
|
-
break;
|
|
586
|
-
}
|
|
587
|
-
|
|
588
|
-
switch (subcommand) {
|
|
589
|
-
case "project": {
|
|
590
|
-
const { values, positionals } = parseArgs({
|
|
591
|
-
args: args.slice(1),
|
|
592
|
-
options: {
|
|
593
|
-
rename: { type: "string" },
|
|
594
|
-
},
|
|
595
|
-
allowPositionals: true,
|
|
596
|
-
});
|
|
597
|
-
const newProject = positionals[0];
|
|
598
|
-
if (!newProject) {
|
|
599
|
-
output({ project: config.project }, config.project);
|
|
600
|
-
} else if (values.rename) {
|
|
601
|
-
validateProject(newProject);
|
|
602
|
-
const result = storage.renameProject(values.rename, newProject);
|
|
603
|
-
output(
|
|
604
|
-
result,
|
|
605
|
-
green(
|
|
606
|
-
`Renamed ${result.renamed.length} tasks: ${values.rename}-* → ${newProject}-*` +
|
|
607
|
-
(result.referencesUpdated > 0
|
|
608
|
-
? `\nUpdated ${result.referencesUpdated} references`
|
|
609
|
-
: ""),
|
|
610
|
-
),
|
|
611
|
-
);
|
|
612
|
-
} else {
|
|
613
|
-
validateProject(newProject);
|
|
614
|
-
const updated = storage.setDefaultProject(newProject);
|
|
615
|
-
output(updated, green(`Default project: ${newProject}`));
|
|
616
|
-
}
|
|
617
|
-
break;
|
|
618
|
-
}
|
|
619
|
-
case "alias": {
|
|
620
|
-
const { values, positionals } = parseArgs({
|
|
621
|
-
args: args.slice(1),
|
|
622
|
-
options: {
|
|
623
|
-
rm: { type: "string" },
|
|
624
|
-
list: { type: "boolean" },
|
|
625
|
-
},
|
|
626
|
-
allowPositionals: true,
|
|
627
|
-
});
|
|
628
|
-
|
|
629
|
-
if (values.rm) {
|
|
630
|
-
const updated = storage.removeAlias(values.rm);
|
|
631
|
-
output(updated, green(`Removed alias: ${values.rm}`));
|
|
632
|
-
} else if (positionals.length >= 2) {
|
|
633
|
-
const alias = positionals[0];
|
|
634
|
-
const path = positionals[1];
|
|
635
|
-
if (!alias || !path || !alias.trim()) {
|
|
636
|
-
error("Alias name and path required: tk config alias <name> <path>");
|
|
637
|
-
}
|
|
638
|
-
const updated = storage.setAlias(alias, path);
|
|
639
|
-
output(updated, green(`Added alias: ${alias} → ${path}`));
|
|
640
|
-
} else {
|
|
641
|
-
// List aliases
|
|
642
|
-
const aliases = config.aliases;
|
|
643
|
-
if (Object.keys(aliases).length === 0) {
|
|
644
|
-
output(
|
|
645
|
-
{ aliases: {} },
|
|
646
|
-
"No aliases defined. Add one with: tk config alias <name> <path>",
|
|
647
|
-
);
|
|
648
|
-
} else {
|
|
649
|
-
const lines = Object.entries(aliases)
|
|
650
|
-
.map(([a, p]) => `${a} → ${p}`)
|
|
651
|
-
.join("\n");
|
|
652
|
-
output({ aliases }, lines);
|
|
653
|
-
}
|
|
654
|
-
}
|
|
655
|
-
break;
|
|
656
|
-
}
|
|
657
|
-
default:
|
|
658
|
-
error(`Unknown config command: ${subcommand}. Valid: project, alias.`);
|
|
659
|
-
}
|
|
660
|
-
break;
|
|
661
|
-
}
|
|
662
|
-
|
|
663
|
-
case "completions": {
|
|
664
|
-
const shell = args[0];
|
|
665
|
-
switch (shell) {
|
|
666
|
-
case "bash":
|
|
667
|
-
console.log(BASH_COMPLETION);
|
|
668
|
-
break;
|
|
669
|
-
case "zsh":
|
|
670
|
-
console.log(ZSH_COMPLETION);
|
|
671
|
-
break;
|
|
672
|
-
case "fish":
|
|
673
|
-
console.log(FISH_COMPLETION);
|
|
674
|
-
break;
|
|
675
|
-
default:
|
|
676
|
-
error("Shell required: tk completions <bash|zsh|fish>. Add to your shell's rc file.");
|
|
677
|
-
}
|
|
678
|
-
break;
|
|
679
|
-
}
|
|
680
|
-
|
|
681
|
-
default:
|
|
682
|
-
error(`Unknown command: ${command}. Run 'tk --help' for usage.`);
|
|
683
|
-
}
|
|
684
|
-
}
|
|
685
|
-
|
|
686
|
-
try {
|
|
687
|
-
main();
|
|
688
|
-
} catch (e) {
|
|
689
|
-
if (e instanceof Error) {
|
|
690
|
-
console.error(red(`Error: ${e.message}`));
|
|
691
|
-
} else {
|
|
692
|
-
console.error(red("An unexpected error occurred"));
|
|
693
|
-
}
|
|
694
|
-
process.exit(1);
|
|
695
|
-
}
|