@nijaru/tk 0.0.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/LICENSE +21 -0
- package/README.md +229 -0
- package/package.json +47 -0
- package/src/cli.test.ts +636 -0
- package/src/cli.ts +871 -0
- package/src/db/storage.ts +777 -0
- package/src/lib/completions.ts +418 -0
- package/src/lib/format.test.ts +347 -0
- package/src/lib/format.ts +162 -0
- package/src/lib/priority.test.ts +105 -0
- package/src/lib/priority.ts +40 -0
- package/src/lib/root.ts +79 -0
- package/src/types.ts +130 -0
package/src/cli.ts
ADDED
|
@@ -0,0 +1,871 @@
|
|
|
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 { formatTaskList, formatTaskDetail, formatJson, formatConfig } from "./lib/format";
|
|
8
|
+
import { findRoot, setWorkingDir } from "./lib/root";
|
|
9
|
+
import { parseId } from "./types";
|
|
10
|
+
import type { Status } from "./types";
|
|
11
|
+
import { BASH_COMPLETION, ZSH_COMPLETION, FISH_COMPLETION } from "./lib/completions";
|
|
12
|
+
|
|
13
|
+
const VALID_STATUSES: Status[] = ["open", "active", "done"];
|
|
14
|
+
const PROJECT_PATTERN = /^[a-z][a-z0-9]*$/;
|
|
15
|
+
|
|
16
|
+
function validateProject(name: string): void {
|
|
17
|
+
if (!PROJECT_PATTERN.test(name)) {
|
|
18
|
+
throw new Error(
|
|
19
|
+
`Invalid project name: ${name}. Use lowercase letters and numbers, starting with a letter.`,
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function parseStatus(input: string | undefined): Status | undefined {
|
|
25
|
+
if (!input) return undefined;
|
|
26
|
+
if (!VALID_STATUSES.includes(input as Status)) {
|
|
27
|
+
throw new Error(`Invalid status: ${input}. Use: ${VALID_STATUSES.join(", ")}`);
|
|
28
|
+
}
|
|
29
|
+
return input as Status;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function parseLimit(input: string | undefined): number | undefined {
|
|
33
|
+
if (!input) return undefined;
|
|
34
|
+
const n = parseInt(input, 10);
|
|
35
|
+
if (isNaN(n) || n < 1) {
|
|
36
|
+
throw new Error(`Invalid limit: ${input}. Must be a positive number.`);
|
|
37
|
+
}
|
|
38
|
+
return n;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function parseEstimate(input: string | undefined): number | undefined {
|
|
42
|
+
if (!input) return undefined;
|
|
43
|
+
const n = parseInt(input, 10);
|
|
44
|
+
if (isNaN(n) || n < 0) {
|
|
45
|
+
throw new Error(`Invalid estimate: ${input}. Must be a non-negative number.`);
|
|
46
|
+
}
|
|
47
|
+
return n;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function parseDueDate(input: string | undefined): string | undefined {
|
|
51
|
+
if (!input) return undefined;
|
|
52
|
+
if (input === "-") return undefined; // clear
|
|
53
|
+
|
|
54
|
+
// Handle relative dates like +7d
|
|
55
|
+
if (input.startsWith("+")) {
|
|
56
|
+
const match = input.match(/^\+(\d+)([dwmh])$/);
|
|
57
|
+
if (match && match[1] && match[2]) {
|
|
58
|
+
const num = match[1];
|
|
59
|
+
const unit = match[2];
|
|
60
|
+
const n = parseInt(num, 10);
|
|
61
|
+
const now = new Date();
|
|
62
|
+
switch (unit) {
|
|
63
|
+
case "h":
|
|
64
|
+
now.setHours(now.getHours() + n);
|
|
65
|
+
break;
|
|
66
|
+
case "d":
|
|
67
|
+
now.setDate(now.getDate() + n);
|
|
68
|
+
break;
|
|
69
|
+
case "w":
|
|
70
|
+
now.setDate(now.getDate() + n * 7);
|
|
71
|
+
break;
|
|
72
|
+
case "m":
|
|
73
|
+
now.setMonth(now.getMonth() + n);
|
|
74
|
+
break;
|
|
75
|
+
}
|
|
76
|
+
return now.toISOString().split("T")[0];
|
|
77
|
+
}
|
|
78
|
+
throw new Error(`Invalid relative date: ${input}. Use format like +7d, +2w, +1m`);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Validate and normalize to YYYY-MM-DD
|
|
82
|
+
const date = new Date(input);
|
|
83
|
+
if (isNaN(date.getTime())) {
|
|
84
|
+
throw new Error(`Invalid date: ${input}. Use YYYY-MM-DD or +Nd format.`);
|
|
85
|
+
}
|
|
86
|
+
return date.toISOString().split("T")[0];
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function parseLabels(input: string | undefined): string[] | undefined {
|
|
90
|
+
if (!input) return undefined;
|
|
91
|
+
return input
|
|
92
|
+
.split(",")
|
|
93
|
+
.map((l) => l.trim())
|
|
94
|
+
.filter(Boolean);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function parseAssignees(input: string | undefined): string[] | undefined {
|
|
98
|
+
if (!input) return undefined;
|
|
99
|
+
// Expand @me to git user
|
|
100
|
+
const assignees = input
|
|
101
|
+
.split(",")
|
|
102
|
+
.map((a) => a.trim())
|
|
103
|
+
.filter(Boolean);
|
|
104
|
+
return assignees.map((a) => {
|
|
105
|
+
if (a === "@me") {
|
|
106
|
+
try {
|
|
107
|
+
const result = Bun.spawnSync(["git", "config", "user.name"]);
|
|
108
|
+
if (result.success) {
|
|
109
|
+
return result.stdout.toString().trim() || a;
|
|
110
|
+
}
|
|
111
|
+
} catch {
|
|
112
|
+
// Fall through to return @me
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return a;
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function resolveId(input: string | undefined, context: string): string {
|
|
120
|
+
if (!input) {
|
|
121
|
+
throw new Error(`ID required: tk ${context} <id>`);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Try to resolve ambiguous ID (just a number)
|
|
125
|
+
const resolved = storage.resolveId(input);
|
|
126
|
+
if (resolved) return resolved;
|
|
127
|
+
|
|
128
|
+
// Check if it's a valid full ID
|
|
129
|
+
if (parseId(input)) return input;
|
|
130
|
+
|
|
131
|
+
throw new Error(
|
|
132
|
+
`Invalid task ID: ${input}. Use format like tk-a1b2, or just the ref (a1b2) if unambiguous.`,
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const rawArgs = process.argv.slice(2);
|
|
137
|
+
|
|
138
|
+
// Global flags that can appear anywhere in the command
|
|
139
|
+
const GLOBAL_FLAGS = new Set(["--json", "--help", "-h", "--version", "-V"]);
|
|
140
|
+
|
|
141
|
+
function isFlag(arg: string): boolean {
|
|
142
|
+
return arg.startsWith("-");
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Extract -C <dir> flag (can appear anywhere)
|
|
146
|
+
let dirFlag: string | null = null;
|
|
147
|
+
const argsWithoutDir: string[] = [];
|
|
148
|
+
for (let i = 0; i < rawArgs.length; i++) {
|
|
149
|
+
const arg = rawArgs[i];
|
|
150
|
+
if (arg === "-C" && rawArgs[i + 1]) {
|
|
151
|
+
dirFlag = rawArgs[i + 1]!;
|
|
152
|
+
i++; // skip next arg
|
|
153
|
+
} else {
|
|
154
|
+
argsWithoutDir.push(arg!);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
if (dirFlag) {
|
|
158
|
+
setWorkingDir(dirFlag);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Extract global flags from anywhere in args
|
|
162
|
+
const jsonFlag = argsWithoutDir.includes("--json");
|
|
163
|
+
const helpFlag = argsWithoutDir.includes("--help") || argsWithoutDir.includes("-h");
|
|
164
|
+
const versionFlag = argsWithoutDir.includes("--version") || argsWithoutDir.includes("-V");
|
|
165
|
+
|
|
166
|
+
// Find command: first non-flag argument
|
|
167
|
+
const command = argsWithoutDir.find((arg) => !isFlag(arg));
|
|
168
|
+
|
|
169
|
+
// Get args for command: everything except the command itself and global flags
|
|
170
|
+
const args = argsWithoutDir.filter((arg) => arg !== command && !GLOBAL_FLAGS.has(arg));
|
|
171
|
+
|
|
172
|
+
function output(data: unknown, formatted: string) {
|
|
173
|
+
console.log(jsonFlag ? formatJson(data) : formatted);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function error(message: string): never {
|
|
177
|
+
console.error(`Error: ${message}`);
|
|
178
|
+
process.exit(1);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function showHelp() {
|
|
182
|
+
console.log(`tk v${version} - Task tracker for AI agents
|
|
183
|
+
|
|
184
|
+
USAGE:
|
|
185
|
+
tk <command> [options]
|
|
186
|
+
|
|
187
|
+
COMMANDS:
|
|
188
|
+
init Initialize .tasks/ directory
|
|
189
|
+
add Create task
|
|
190
|
+
ls, list List tasks
|
|
191
|
+
ready List ready tasks (open + unblocked)
|
|
192
|
+
show Show task details
|
|
193
|
+
start Start working (open -> active)
|
|
194
|
+
done Complete task
|
|
195
|
+
reopen Reopen task
|
|
196
|
+
edit Edit task
|
|
197
|
+
log Add log entry
|
|
198
|
+
block Add blocker
|
|
199
|
+
unblock Remove blocker
|
|
200
|
+
rm, remove Delete task
|
|
201
|
+
clean Remove old done tasks
|
|
202
|
+
config Show/set configuration
|
|
203
|
+
completions Output shell completions
|
|
204
|
+
|
|
205
|
+
GLOBAL OPTIONS:
|
|
206
|
+
-C <dir> Run in different directory
|
|
207
|
+
--json Output as JSON
|
|
208
|
+
-h, --help Show help
|
|
209
|
+
-V Show version
|
|
210
|
+
|
|
211
|
+
Run 'tk <command> --help' for command-specific options.
|
|
212
|
+
`);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function showCommandHelp(cmd: string) {
|
|
216
|
+
const helps: Record<string, string> = {
|
|
217
|
+
init: `tk init - Initialize .tasks/ directory
|
|
218
|
+
|
|
219
|
+
USAGE:
|
|
220
|
+
tk init [options]
|
|
221
|
+
|
|
222
|
+
OPTIONS:
|
|
223
|
+
-P, --project <name> Set default project name
|
|
224
|
+
`,
|
|
225
|
+
add: `tk add - Create a new task
|
|
226
|
+
|
|
227
|
+
USAGE:
|
|
228
|
+
tk add <title> [options]
|
|
229
|
+
|
|
230
|
+
OPTIONS:
|
|
231
|
+
-p, --priority <0-4> Priority (0=none, 1=urgent, 2=high, 3=medium, 4=low)
|
|
232
|
+
-P, --project <name> Project prefix for ID
|
|
233
|
+
-d, --description <text> Description
|
|
234
|
+
-l, --labels <csv> Labels (comma-separated)
|
|
235
|
+
-A, --assignees <csv> Assignees (comma-separated, @me for git user)
|
|
236
|
+
--parent <id> Parent task ID
|
|
237
|
+
--estimate <n> Estimate (user-defined units)
|
|
238
|
+
--due <date> Due date (YYYY-MM-DD or +Nh/+Nd/+Nw/+Nm)
|
|
239
|
+
|
|
240
|
+
EXAMPLES:
|
|
241
|
+
tk add "Fix login bug" -p 1
|
|
242
|
+
tk add "New feature" -P api -l bug,urgent
|
|
243
|
+
tk add "Sprint task" --due +7d
|
|
244
|
+
`,
|
|
245
|
+
ls: `tk ls - List tasks
|
|
246
|
+
|
|
247
|
+
USAGE:
|
|
248
|
+
tk ls [options]
|
|
249
|
+
|
|
250
|
+
OPTIONS:
|
|
251
|
+
-s, --status <status> Filter by status (open, active, done)
|
|
252
|
+
-p, --priority <0-4> Filter by priority
|
|
253
|
+
-P, --project <name> Filter by project
|
|
254
|
+
-l, --label <label> Filter by label
|
|
255
|
+
--assignee <name> Filter by assignee
|
|
256
|
+
--parent <id> Filter by parent
|
|
257
|
+
--roots Show only root tasks (no parent)
|
|
258
|
+
--overdue Show only overdue tasks
|
|
259
|
+
-n, --limit <n> Limit results (default: 20)
|
|
260
|
+
-a, --all Show all (no limit)
|
|
261
|
+
|
|
262
|
+
EXAMPLES:
|
|
263
|
+
tk ls -s open -p 1 # Urgent open tasks
|
|
264
|
+
tk ls --overdue # Overdue tasks
|
|
265
|
+
tk ls -P api --roots # Root tasks in api project
|
|
266
|
+
`,
|
|
267
|
+
list: `tk list - List tasks (alias for 'ls')
|
|
268
|
+
|
|
269
|
+
Run 'tk ls --help' for options.
|
|
270
|
+
`,
|
|
271
|
+
ready: `tk ready - List ready tasks (open + unblocked)
|
|
272
|
+
|
|
273
|
+
USAGE:
|
|
274
|
+
tk ready
|
|
275
|
+
|
|
276
|
+
Shows open tasks that are not blocked by any incomplete task.
|
|
277
|
+
`,
|
|
278
|
+
show: `tk show - Show task details
|
|
279
|
+
|
|
280
|
+
USAGE:
|
|
281
|
+
tk show <id>
|
|
282
|
+
|
|
283
|
+
EXAMPLES:
|
|
284
|
+
tk show tk-1
|
|
285
|
+
tk show 1 # If unambiguous
|
|
286
|
+
`,
|
|
287
|
+
start: `tk start - Start working on a task
|
|
288
|
+
|
|
289
|
+
USAGE:
|
|
290
|
+
tk start <id>
|
|
291
|
+
|
|
292
|
+
Changes status from open to active.
|
|
293
|
+
`,
|
|
294
|
+
done: `tk done - Complete a task
|
|
295
|
+
|
|
296
|
+
USAGE:
|
|
297
|
+
tk done <id>
|
|
298
|
+
|
|
299
|
+
Changes status to done.
|
|
300
|
+
`,
|
|
301
|
+
reopen: `tk reopen - Reopen a task
|
|
302
|
+
|
|
303
|
+
USAGE:
|
|
304
|
+
tk reopen <id>
|
|
305
|
+
|
|
306
|
+
Changes status back to open.
|
|
307
|
+
`,
|
|
308
|
+
edit: `tk edit - Edit a task
|
|
309
|
+
|
|
310
|
+
USAGE:
|
|
311
|
+
tk edit <id> [options]
|
|
312
|
+
|
|
313
|
+
OPTIONS:
|
|
314
|
+
-t, --title <text> New title
|
|
315
|
+
-d, --description <text> New description
|
|
316
|
+
-p, --priority <0-4> New priority
|
|
317
|
+
-l, --labels <csv> Replace labels (use +tag/-tag to add/remove)
|
|
318
|
+
-A, --assignees <csv> Replace assignees
|
|
319
|
+
--parent <id> Set parent (use - to clear)
|
|
320
|
+
--estimate <n> Set estimate (use - to clear)
|
|
321
|
+
--due <date> Set due date (use - to clear)
|
|
322
|
+
|
|
323
|
+
EXAMPLES:
|
|
324
|
+
tk edit tk-1 -t "New title"
|
|
325
|
+
tk edit tk-1 -l +urgent # Add label
|
|
326
|
+
tk edit tk-1 --due - # Clear due date
|
|
327
|
+
`,
|
|
328
|
+
log: `tk log - Add a log entry to a task
|
|
329
|
+
|
|
330
|
+
USAGE:
|
|
331
|
+
tk log <id> <message>
|
|
332
|
+
|
|
333
|
+
EXAMPLES:
|
|
334
|
+
tk log tk-1 "Started implementation"
|
|
335
|
+
tk log tk-1 "Blocked on API changes"
|
|
336
|
+
`,
|
|
337
|
+
block: `tk block - Add a blocker dependency
|
|
338
|
+
|
|
339
|
+
USAGE:
|
|
340
|
+
tk block <task> <blocker>
|
|
341
|
+
|
|
342
|
+
The first task becomes blocked by the second.
|
|
343
|
+
|
|
344
|
+
EXAMPLES:
|
|
345
|
+
tk block tk-2 tk-1 # tk-2 is blocked by tk-1
|
|
346
|
+
`,
|
|
347
|
+
unblock: `tk unblock - Remove a blocker dependency
|
|
348
|
+
|
|
349
|
+
USAGE:
|
|
350
|
+
tk unblock <task> <blocker>
|
|
351
|
+
|
|
352
|
+
EXAMPLES:
|
|
353
|
+
tk unblock tk-2 tk-1
|
|
354
|
+
`,
|
|
355
|
+
rm: `tk rm - Delete a task
|
|
356
|
+
|
|
357
|
+
USAGE:
|
|
358
|
+
tk rm <id>
|
|
359
|
+
|
|
360
|
+
EXAMPLES:
|
|
361
|
+
tk rm tk-1
|
|
362
|
+
`,
|
|
363
|
+
remove: `tk remove - Delete a task (alias for 'rm')
|
|
364
|
+
|
|
365
|
+
USAGE:
|
|
366
|
+
tk remove <id>
|
|
367
|
+
`,
|
|
368
|
+
clean: `tk clean - Remove old completed tasks
|
|
369
|
+
|
|
370
|
+
USAGE:
|
|
371
|
+
tk clean [options]
|
|
372
|
+
|
|
373
|
+
OPTIONS:
|
|
374
|
+
--older-than <duration> Age threshold (default: 7d)
|
|
375
|
+
-a, --all Remove all done tasks (ignore age)
|
|
376
|
+
|
|
377
|
+
DURATION FORMAT:
|
|
378
|
+
7d = 7 days, 2w = 2 weeks, 24h = 24 hours
|
|
379
|
+
|
|
380
|
+
EXAMPLES:
|
|
381
|
+
tk clean # Remove done tasks older than 7 days
|
|
382
|
+
tk clean --older-than 30d
|
|
383
|
+
tk clean -a # Remove all done tasks
|
|
384
|
+
`,
|
|
385
|
+
config: `tk config - Show or set configuration
|
|
386
|
+
|
|
387
|
+
USAGE:
|
|
388
|
+
tk config Show all config
|
|
389
|
+
tk config project Show default project
|
|
390
|
+
tk config project <name> Set default project
|
|
391
|
+
tk config project <new> --rename <old> Rename project (old-* → new-*)
|
|
392
|
+
tk config alias List aliases
|
|
393
|
+
tk config alias <name> <path> Add alias
|
|
394
|
+
tk config alias --rm <name> Remove alias
|
|
395
|
+
|
|
396
|
+
EXAMPLES:
|
|
397
|
+
tk config project api
|
|
398
|
+
tk config project lsmvec --rename cloudlsmvec
|
|
399
|
+
tk config alias web src/web
|
|
400
|
+
`,
|
|
401
|
+
completions: `tk completions - Output shell completions
|
|
402
|
+
|
|
403
|
+
USAGE:
|
|
404
|
+
tk completions <shell>
|
|
405
|
+
|
|
406
|
+
SHELLS:
|
|
407
|
+
bash Bash completion script
|
|
408
|
+
zsh Zsh completion script
|
|
409
|
+
fish Fish completion script
|
|
410
|
+
|
|
411
|
+
EXAMPLES:
|
|
412
|
+
eval "$(tk completions bash)" # Add to ~/.bashrc
|
|
413
|
+
eval "$(tk completions zsh)" # Add to ~/.zshrc
|
|
414
|
+
`,
|
|
415
|
+
};
|
|
416
|
+
|
|
417
|
+
const help = helps[cmd];
|
|
418
|
+
if (help) {
|
|
419
|
+
console.log(help);
|
|
420
|
+
} else {
|
|
421
|
+
console.error(`Unknown command: ${cmd}. Run 'tk --help' for usage.`);
|
|
422
|
+
process.exit(1);
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function main() {
|
|
427
|
+
if (versionFlag) {
|
|
428
|
+
console.log(`tk v${version}`);
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
if (!command) {
|
|
433
|
+
showHelp();
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// Handle 'tk help' and 'tk help <command>'
|
|
438
|
+
if (command === "help") {
|
|
439
|
+
const subcommand = args[0];
|
|
440
|
+
if (subcommand) {
|
|
441
|
+
showCommandHelp(subcommand);
|
|
442
|
+
} else {
|
|
443
|
+
showHelp();
|
|
444
|
+
}
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
if (helpFlag) {
|
|
449
|
+
showCommandHelp(command);
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
switch (command) {
|
|
454
|
+
case "init": {
|
|
455
|
+
const { values } = parseArgs({
|
|
456
|
+
args,
|
|
457
|
+
options: {
|
|
458
|
+
project: { type: "string", short: "P" },
|
|
459
|
+
},
|
|
460
|
+
allowPositionals: true,
|
|
461
|
+
});
|
|
462
|
+
if (values.project) {
|
|
463
|
+
validateProject(values.project);
|
|
464
|
+
}
|
|
465
|
+
const info = findRoot();
|
|
466
|
+
if (info.exists) {
|
|
467
|
+
output({ path: info.tasksDir, created: false }, `Already initialized: ${info.tasksDir}`);
|
|
468
|
+
} else {
|
|
469
|
+
const path = storage.initTasks(values.project);
|
|
470
|
+
output({ path, created: true }, `Initialized: ${path}`);
|
|
471
|
+
}
|
|
472
|
+
break;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
case "add": {
|
|
476
|
+
const { values, positionals } = parseArgs({
|
|
477
|
+
args,
|
|
478
|
+
options: {
|
|
479
|
+
description: { type: "string", short: "d" },
|
|
480
|
+
priority: { type: "string", short: "p" },
|
|
481
|
+
project: { type: "string", short: "P" },
|
|
482
|
+
labels: { type: "string", short: "l" },
|
|
483
|
+
assignees: { type: "string", short: "A" },
|
|
484
|
+
parent: { type: "string" },
|
|
485
|
+
estimate: { type: "string" },
|
|
486
|
+
due: { type: "string" },
|
|
487
|
+
},
|
|
488
|
+
allowPositionals: true,
|
|
489
|
+
});
|
|
490
|
+
const title = positionals[0]?.trim();
|
|
491
|
+
if (!title) error("Title required: tk add <title>");
|
|
492
|
+
|
|
493
|
+
// Validate project if provided
|
|
494
|
+
if (values.project) {
|
|
495
|
+
validateProject(values.project);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// Resolve and validate parent if provided
|
|
499
|
+
let parentId: string | undefined;
|
|
500
|
+
if (values.parent) {
|
|
501
|
+
const resolved = storage.resolveId(values.parent);
|
|
502
|
+
if (!resolved) error(`Parent task not found: ${values.parent}`);
|
|
503
|
+
parentId = resolved;
|
|
504
|
+
const parentResult = storage.validateParent(parentId);
|
|
505
|
+
if (!parentResult.ok) error(parentResult.error!);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
const task = storage.createTask({
|
|
509
|
+
title,
|
|
510
|
+
description: values.description,
|
|
511
|
+
priority: parsePriority(values.priority),
|
|
512
|
+
project: values.project,
|
|
513
|
+
labels: parseLabels(values.labels),
|
|
514
|
+
assignees: parseAssignees(values.assignees),
|
|
515
|
+
parent: parentId,
|
|
516
|
+
estimate: parseEstimate(values.estimate),
|
|
517
|
+
due_date: parseDueDate(values.due),
|
|
518
|
+
});
|
|
519
|
+
output(task, task.id);
|
|
520
|
+
break;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
case "ls":
|
|
524
|
+
case "list": {
|
|
525
|
+
const { values } = parseArgs({
|
|
526
|
+
args,
|
|
527
|
+
options: {
|
|
528
|
+
status: { type: "string", short: "s" },
|
|
529
|
+
priority: { type: "string", short: "p" },
|
|
530
|
+
project: { type: "string", short: "P" },
|
|
531
|
+
label: { type: "string", short: "l" },
|
|
532
|
+
assignee: { type: "string" },
|
|
533
|
+
parent: { type: "string" },
|
|
534
|
+
roots: { type: "boolean" },
|
|
535
|
+
overdue: { type: "boolean" },
|
|
536
|
+
limit: { type: "string", short: "n" },
|
|
537
|
+
all: { type: "boolean", short: "a" },
|
|
538
|
+
},
|
|
539
|
+
allowPositionals: true,
|
|
540
|
+
});
|
|
541
|
+
const status = parseStatus(values.status);
|
|
542
|
+
const priority = values.priority ? parsePriority(values.priority) : undefined;
|
|
543
|
+
const limit = values.all ? undefined : (parseLimit(values.limit) ?? 20);
|
|
544
|
+
|
|
545
|
+
// Resolve parent filter if provided
|
|
546
|
+
let parentFilter: string | undefined;
|
|
547
|
+
if (values.parent) {
|
|
548
|
+
const resolved = storage.resolveId(values.parent);
|
|
549
|
+
if (!resolved) error(`Parent task not found: ${values.parent}`);
|
|
550
|
+
parentFilter = resolved;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
const list = storage.listTasks({
|
|
554
|
+
status,
|
|
555
|
+
priority,
|
|
556
|
+
project: values.project,
|
|
557
|
+
label: values.label,
|
|
558
|
+
assignee: values.assignee,
|
|
559
|
+
parent: parentFilter,
|
|
560
|
+
roots: values.roots,
|
|
561
|
+
overdue: values.overdue,
|
|
562
|
+
limit,
|
|
563
|
+
});
|
|
564
|
+
output(list, formatTaskList(list));
|
|
565
|
+
break;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
case "ready": {
|
|
569
|
+
const list = storage.listReadyTasks();
|
|
570
|
+
output(list, formatTaskList(list));
|
|
571
|
+
break;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
case "show": {
|
|
575
|
+
const id = resolveId(args[0], "show");
|
|
576
|
+
const task = storage.getTaskWithMeta(id);
|
|
577
|
+
if (!task) error(`Task not found: ${id}`);
|
|
578
|
+
output(task, formatTaskDetail(task, task.logs));
|
|
579
|
+
break;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
case "start": {
|
|
583
|
+
const id = resolveId(args[0], "start");
|
|
584
|
+
const task = storage.getTask(id);
|
|
585
|
+
if (!task) error(`Task not found: ${id}`);
|
|
586
|
+
if (task.status !== "open") error(`Task is ${task.status}, not open`);
|
|
587
|
+
const updated = storage.updateTaskStatus(id, "active");
|
|
588
|
+
output(updated, `Started: ${id}`);
|
|
589
|
+
break;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
case "done": {
|
|
593
|
+
const id = resolveId(args[0], "done");
|
|
594
|
+
const task = storage.getTask(id);
|
|
595
|
+
if (!task) error(`Task not found: ${id}`);
|
|
596
|
+
const updated = storage.updateTaskStatus(id, "done");
|
|
597
|
+
output(updated, `Completed: ${id}`);
|
|
598
|
+
break;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
case "reopen": {
|
|
602
|
+
const id = resolveId(args[0], "reopen");
|
|
603
|
+
const task = storage.getTask(id);
|
|
604
|
+
if (!task) error(`Task not found: ${id}`);
|
|
605
|
+
const updated = storage.updateTaskStatus(id, "open");
|
|
606
|
+
output(updated, `Reopened: ${id}`);
|
|
607
|
+
break;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
case "edit": {
|
|
611
|
+
const { values, positionals } = parseArgs({
|
|
612
|
+
args,
|
|
613
|
+
options: {
|
|
614
|
+
title: { type: "string", short: "t" },
|
|
615
|
+
description: { type: "string", short: "d" },
|
|
616
|
+
priority: { type: "string", short: "p" },
|
|
617
|
+
labels: { type: "string", short: "l" },
|
|
618
|
+
assignees: { type: "string", short: "A" },
|
|
619
|
+
parent: { type: "string" },
|
|
620
|
+
estimate: { type: "string" },
|
|
621
|
+
due: { type: "string" },
|
|
622
|
+
},
|
|
623
|
+
allowPositionals: true,
|
|
624
|
+
});
|
|
625
|
+
const id = resolveId(positionals[0], "edit");
|
|
626
|
+
const task = storage.getTask(id);
|
|
627
|
+
if (!task) error(`Task not found: ${id}`);
|
|
628
|
+
|
|
629
|
+
// Handle label modifications (+tag, -tag)
|
|
630
|
+
let labels: string[] | undefined;
|
|
631
|
+
if (values.labels) {
|
|
632
|
+
if (values.labels.startsWith("+")) {
|
|
633
|
+
// Add label (avoid duplicates)
|
|
634
|
+
const newLabel = values.labels.slice(1);
|
|
635
|
+
labels = task.labels.includes(newLabel) ? task.labels : [...task.labels, newLabel];
|
|
636
|
+
} else if (values.labels.startsWith("-")) {
|
|
637
|
+
// Remove label
|
|
638
|
+
const removeLabel = values.labels.slice(1);
|
|
639
|
+
labels = task.labels.filter((l) => l !== removeLabel);
|
|
640
|
+
} else {
|
|
641
|
+
// Replace labels
|
|
642
|
+
labels = parseLabels(values.labels);
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
// Resolve and validate parent if provided (and not clearing)
|
|
647
|
+
let resolvedParent: string | null | undefined;
|
|
648
|
+
if (values.parent === "-") {
|
|
649
|
+
resolvedParent = null;
|
|
650
|
+
} else if (values.parent) {
|
|
651
|
+
const resolved = storage.resolveId(values.parent);
|
|
652
|
+
if (!resolved) error(`Parent task not found: ${values.parent}`);
|
|
653
|
+
resolvedParent = resolved;
|
|
654
|
+
const parentResult = storage.validateParent(resolvedParent, id);
|
|
655
|
+
if (!parentResult.ok) error(parentResult.error!);
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
const updated = storage.updateTask(id, {
|
|
659
|
+
title: values.title?.trim() || undefined,
|
|
660
|
+
description: values.description,
|
|
661
|
+
priority: values.priority ? parsePriority(values.priority) : undefined,
|
|
662
|
+
labels,
|
|
663
|
+
assignees: parseAssignees(values.assignees),
|
|
664
|
+
parent: resolvedParent,
|
|
665
|
+
estimate: values.estimate === "-" ? null : (parseEstimate(values.estimate) ?? undefined),
|
|
666
|
+
due_date: values.due === "-" ? null : (parseDueDate(values.due) ?? undefined),
|
|
667
|
+
});
|
|
668
|
+
output(updated, `Updated: ${id}`);
|
|
669
|
+
break;
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
case "log": {
|
|
673
|
+
const id = resolveId(args[0], "log");
|
|
674
|
+
const message = args.slice(1).join(" ").trim();
|
|
675
|
+
if (!message) error("Message required: tk log <id> <message>");
|
|
676
|
+
const task = storage.getTask(id);
|
|
677
|
+
if (!task) error(`Task not found: ${id}`);
|
|
678
|
+
const entry = storage.addLogEntry(id, message);
|
|
679
|
+
output(entry, `Logged: ${id}`);
|
|
680
|
+
break;
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
case "block": {
|
|
684
|
+
if (!args[0] || !args[1]) error("Usage: tk block <task> <blocker>");
|
|
685
|
+
const taskId = resolveId(args[0], "block");
|
|
686
|
+
const blockerId = resolveId(args[1], "block");
|
|
687
|
+
if (taskId === blockerId) error("Task cannot block itself");
|
|
688
|
+
const result = storage.addBlock(taskId, blockerId);
|
|
689
|
+
if (!result.ok) error(result.error!);
|
|
690
|
+
output({ task_id: taskId, blocked_by: blockerId }, `${taskId} blocked by ${blockerId}`);
|
|
691
|
+
break;
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
case "unblock": {
|
|
695
|
+
if (!args[0] || !args[1]) error("Usage: tk unblock <task> <blocker>");
|
|
696
|
+
const taskId = resolveId(args[0], "unblock");
|
|
697
|
+
const blockerId = resolveId(args[1], "unblock");
|
|
698
|
+
const success = storage.removeBlock(taskId, blockerId);
|
|
699
|
+
if (!success) error("Block not found");
|
|
700
|
+
output({ task_id: taskId, blocked_by: blockerId }, `${taskId} unblocked from ${blockerId}`);
|
|
701
|
+
break;
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
case "rm":
|
|
705
|
+
case "remove": {
|
|
706
|
+
const id = resolveId(args[0], "rm");
|
|
707
|
+
const success = storage.deleteTask(id);
|
|
708
|
+
if (!success) error(`Task not found: ${id}`);
|
|
709
|
+
output({ id, deleted: true }, `Deleted: ${id}`);
|
|
710
|
+
break;
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
case "clean": {
|
|
714
|
+
const { values } = parseArgs({
|
|
715
|
+
args,
|
|
716
|
+
options: {
|
|
717
|
+
"older-than": { type: "string", default: "7d" },
|
|
718
|
+
done: { type: "boolean" },
|
|
719
|
+
all: { type: "boolean", short: "a" },
|
|
720
|
+
},
|
|
721
|
+
allowPositionals: true,
|
|
722
|
+
});
|
|
723
|
+
const olderThan = values["older-than"] || "7d";
|
|
724
|
+
const ms = parseDuration(olderThan);
|
|
725
|
+
const status = values.done ? ("done" as Status) : undefined;
|
|
726
|
+
const count = storage.cleanTasks({
|
|
727
|
+
olderThanMs: ms,
|
|
728
|
+
status,
|
|
729
|
+
all: values.all,
|
|
730
|
+
});
|
|
731
|
+
output({ deleted: count }, `Cleaned ${count} tasks`);
|
|
732
|
+
break;
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
case "config": {
|
|
736
|
+
const subcommand = args[0];
|
|
737
|
+
const config = storage.getConfig();
|
|
738
|
+
|
|
739
|
+
if (!subcommand) {
|
|
740
|
+
// Show all config
|
|
741
|
+
output(config, formatConfig(config));
|
|
742
|
+
break;
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
switch (subcommand) {
|
|
746
|
+
case "project": {
|
|
747
|
+
const { values, positionals } = parseArgs({
|
|
748
|
+
args: args.slice(1),
|
|
749
|
+
options: {
|
|
750
|
+
rename: { type: "string" },
|
|
751
|
+
},
|
|
752
|
+
allowPositionals: true,
|
|
753
|
+
});
|
|
754
|
+
const newProject = positionals[0];
|
|
755
|
+
if (!newProject) {
|
|
756
|
+
output({ project: config.project }, config.project);
|
|
757
|
+
} else if (values.rename) {
|
|
758
|
+
validateProject(newProject);
|
|
759
|
+
const result = storage.renameProject(values.rename, newProject);
|
|
760
|
+
output(
|
|
761
|
+
result,
|
|
762
|
+
`Renamed ${result.renamed.length} tasks: ${values.rename}-* → ${newProject}-*` +
|
|
763
|
+
(result.referencesUpdated > 0
|
|
764
|
+
? `\nUpdated ${result.referencesUpdated} references`
|
|
765
|
+
: ""),
|
|
766
|
+
);
|
|
767
|
+
} else {
|
|
768
|
+
validateProject(newProject);
|
|
769
|
+
const updated = storage.setDefaultProject(newProject);
|
|
770
|
+
output(updated, `Default project: ${newProject}`);
|
|
771
|
+
}
|
|
772
|
+
break;
|
|
773
|
+
}
|
|
774
|
+
case "alias": {
|
|
775
|
+
const { values, positionals } = parseArgs({
|
|
776
|
+
args: args.slice(1),
|
|
777
|
+
options: {
|
|
778
|
+
rm: { type: "string" },
|
|
779
|
+
list: { type: "boolean" },
|
|
780
|
+
},
|
|
781
|
+
allowPositionals: true,
|
|
782
|
+
});
|
|
783
|
+
|
|
784
|
+
if (values.rm) {
|
|
785
|
+
const updated = storage.removeAlias(values.rm);
|
|
786
|
+
output(updated, `Removed alias: ${values.rm}`);
|
|
787
|
+
} else if (positionals.length >= 2) {
|
|
788
|
+
const alias = positionals[0];
|
|
789
|
+
const path = positionals[1];
|
|
790
|
+
if (!alias || !path || !alias.trim()) {
|
|
791
|
+
error("Alias name and path are required");
|
|
792
|
+
}
|
|
793
|
+
const updated = storage.setAlias(alias, path);
|
|
794
|
+
output(updated, `Added alias: ${alias} → ${path}`);
|
|
795
|
+
} else {
|
|
796
|
+
// List aliases
|
|
797
|
+
const aliases = config.aliases;
|
|
798
|
+
if (Object.keys(aliases).length === 0) {
|
|
799
|
+
output({ aliases: {} }, "No aliases defined.");
|
|
800
|
+
} else {
|
|
801
|
+
const lines = Object.entries(aliases)
|
|
802
|
+
.map(([a, p]) => `${a} → ${p}`)
|
|
803
|
+
.join("\n");
|
|
804
|
+
output({ aliases }, lines);
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
break;
|
|
808
|
+
}
|
|
809
|
+
default:
|
|
810
|
+
error(`Unknown config command: ${subcommand}`);
|
|
811
|
+
}
|
|
812
|
+
break;
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
case "completions": {
|
|
816
|
+
const shell = args[0];
|
|
817
|
+
switch (shell) {
|
|
818
|
+
case "bash":
|
|
819
|
+
console.log(BASH_COMPLETION);
|
|
820
|
+
break;
|
|
821
|
+
case "zsh":
|
|
822
|
+
console.log(ZSH_COMPLETION);
|
|
823
|
+
break;
|
|
824
|
+
case "fish":
|
|
825
|
+
console.log(FISH_COMPLETION);
|
|
826
|
+
break;
|
|
827
|
+
default:
|
|
828
|
+
error("Shell required: tk completions <bash|zsh|fish>");
|
|
829
|
+
}
|
|
830
|
+
break;
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
default:
|
|
834
|
+
error(`Unknown command: ${command}. Run 'tk --help' for usage.`);
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
function parseDuration(s: string): number {
|
|
839
|
+
const match = s.match(/^(\d+)(s|m|h|d|w)$/);
|
|
840
|
+
if (!match || !match[1] || !match[2]) {
|
|
841
|
+
throw new Error(`Invalid duration: ${s}. Use format like 7d, 24h, 30m, 90s, or 2w`);
|
|
842
|
+
}
|
|
843
|
+
const num = match[1];
|
|
844
|
+
const unit = match[2];
|
|
845
|
+
const n = parseInt(num);
|
|
846
|
+
switch (unit) {
|
|
847
|
+
case "s":
|
|
848
|
+
return n * 1000;
|
|
849
|
+
case "m":
|
|
850
|
+
return n * 60 * 1000;
|
|
851
|
+
case "h":
|
|
852
|
+
return n * 60 * 60 * 1000;
|
|
853
|
+
case "d":
|
|
854
|
+
return n * 24 * 60 * 60 * 1000;
|
|
855
|
+
case "w":
|
|
856
|
+
return n * 7 * 24 * 60 * 60 * 1000;
|
|
857
|
+
default:
|
|
858
|
+
throw new Error(`Invalid duration unit: ${unit}`);
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
try {
|
|
863
|
+
main();
|
|
864
|
+
} catch (e) {
|
|
865
|
+
if (e instanceof Error) {
|
|
866
|
+
console.error(`Error: ${e.message}`);
|
|
867
|
+
} else {
|
|
868
|
+
console.error("An unexpected error occurred");
|
|
869
|
+
}
|
|
870
|
+
process.exit(1);
|
|
871
|
+
}
|