taskify-nostr 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/dist/index.js ADDED
@@ -0,0 +1,2074 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from "commander";
3
+ import chalk from "chalk";
4
+ import { readFile, writeFile } from "fs/promises";
5
+ import { createInterface } from "readline";
6
+ import { nip19 } from "nostr-tools";
7
+ import { loadConfig, saveConfig } from "./config.js";
8
+ import { createNostrRuntime } from "./nostrRuntime.js";
9
+ import { renderTable, renderTaskCard, renderJson } from "./render.js";
10
+ import { zshCompletion, bashCompletion, fishCompletion } from "./completions.js";
11
+ import { readCache, clearCache, CACHE_TTL_MS } from "./taskCache.js";
12
+ import { runOnboarding } from "./onboarding.js";
13
+ const program = new Command();
14
+ program
15
+ .name("taskify")
16
+ .version("0.1.0")
17
+ .description("Taskify CLI — manage tasks over Nostr");
18
+ // ---- Validation helpers ----
19
+ function validateDue(due) {
20
+ if (!due)
21
+ return;
22
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(due)) {
23
+ console.error(chalk.red(`Invalid --due format: "${due}". Expected YYYY-MM-DD.`));
24
+ process.exit(1);
25
+ }
26
+ }
27
+ function validatePriority(pri) {
28
+ if (!pri)
29
+ return;
30
+ if (!["1", "2", "3"].includes(pri)) {
31
+ console.error(chalk.red(`Invalid --priority: "${pri}". Must be 1, 2, or 3.`));
32
+ process.exit(1);
33
+ }
34
+ }
35
+ function warnShortTaskId(taskId) {
36
+ if (taskId.length < 8) {
37
+ console.warn(chalk.yellow(`Warning: taskId "${taskId}" is suspiciously short (< 8 chars). Attempting anyway.`));
38
+ }
39
+ }
40
+ const VALID_REMINDER_PRESETS = new Set(["0h", "5m", "15m", "30m", "1h", "1d", "1w"]);
41
+ function initRuntime(config) {
42
+ try {
43
+ return createNostrRuntime(config);
44
+ }
45
+ catch (err) {
46
+ console.error(chalk.red(String(err)));
47
+ process.exit(1);
48
+ }
49
+ }
50
+ /**
51
+ * Resolve a boardId for commands that need it.
52
+ * - If --board given: look it up in config.boards by UUID or name; error if not found.
53
+ * - If no --board and exactly one board configured: use it automatically.
54
+ * - If no --board and multiple boards: print list and error.
55
+ */
56
+ async function resolveBoardId(boardOpt, config) {
57
+ if (boardOpt) {
58
+ const entry = config.boards.find((b) => b.id === boardOpt) ??
59
+ config.boards.find((b) => b.name.toLowerCase() === boardOpt.toLowerCase());
60
+ if (!entry) {
61
+ console.error(chalk.red(`Board not found: "${boardOpt}". Known boards:`));
62
+ for (const b of config.boards) {
63
+ console.error(` ${b.name} (${b.id})`);
64
+ }
65
+ process.exit(1);
66
+ }
67
+ return entry.id;
68
+ }
69
+ if (config.boards.length === 1) {
70
+ return config.boards[0].id;
71
+ }
72
+ if (config.boards.length === 0) {
73
+ console.error(chalk.red("No boards configured. Use: taskify board join <id> --name <name>"));
74
+ process.exit(1);
75
+ }
76
+ console.error(chalk.red("Multiple boards configured. Specify one with --board <id|name>:"));
77
+ for (const b of config.boards) {
78
+ console.error(` ${b.name} (${b.id})`);
79
+ }
80
+ process.exit(1);
81
+ }
82
+ // ---- board command group ----
83
+ const boardCmd = program
84
+ .command("board")
85
+ .description("Manage boards");
86
+ boardCmd
87
+ .command("list")
88
+ .description("List all configured boards")
89
+ .action(async () => {
90
+ const config = await loadConfig();
91
+ if (config.boards.length === 0) {
92
+ console.log(chalk.dim("No boards configured. Use: taskify board join <id> --name <name>"));
93
+ }
94
+ else {
95
+ for (const b of config.boards) {
96
+ const relays = b.relays?.length ? ` [${b.relays.join(", ")}]` : "";
97
+ console.log(` ${chalk.bold(b.name.padEnd(16))} ${chalk.dim(b.id)}${relays}`);
98
+ }
99
+ }
100
+ process.exit(0);
101
+ });
102
+ boardCmd
103
+ .command("join <boardId>")
104
+ .description("Join a board by its UUID")
105
+ .option("--name <name>", "Human-readable name for this board")
106
+ .option("--relay <url>", "Additional relay URL for this board")
107
+ .action(async (boardId, opts) => {
108
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
109
+ if (!UUID_RE.test(boardId)) {
110
+ console.warn(chalk.yellow(`Warning: "${boardId}" does not look like a UUID.`));
111
+ }
112
+ const config = await loadConfig();
113
+ const existing = config.boards.find((b) => b.id === boardId);
114
+ if (existing) {
115
+ console.log(chalk.dim(`Already on board ${existing.name} (${boardId})`));
116
+ process.exit(0);
117
+ }
118
+ const name = opts.name ?? boardId.slice(0, 8);
119
+ const entry = { id: boardId, name };
120
+ if (opts.relay) {
121
+ entry.relays = [opts.relay];
122
+ }
123
+ config.boards.push(entry);
124
+ await saveConfig(config);
125
+ console.log(chalk.green(`✓ Joined board ${name} (${boardId})`));
126
+ // Auto-sync board metadata immediately after joining
127
+ try {
128
+ const runtime = initRuntime(config);
129
+ const meta = await runtime.syncBoard(boardId);
130
+ if (meta.kind || (meta.columns && meta.columns.length > 0)) {
131
+ const colCount = meta.columns?.length ?? 0;
132
+ console.log(chalk.dim(` Synced: kind=${meta.kind ?? "?"}, columns=${colCount}`));
133
+ }
134
+ await runtime.disconnect();
135
+ }
136
+ catch { /* non-fatal if sync fails on join */ }
137
+ process.exit(0);
138
+ });
139
+ boardCmd
140
+ .command("sync [boardId]")
141
+ .description("Sync board metadata (kind, columns) from Nostr")
142
+ .action(async (boardId) => {
143
+ const config = await loadConfig();
144
+ if (config.boards.length === 0) {
145
+ console.error(chalk.red("No boards configured."));
146
+ process.exit(1);
147
+ }
148
+ const toSync = boardId
149
+ ? (() => {
150
+ const entry = config.boards.find((b) => b.id === boardId) ??
151
+ config.boards.find((b) => b.name.toLowerCase() === boardId.toLowerCase());
152
+ if (!entry) {
153
+ console.error(chalk.red(`Board not found: "${boardId}"`));
154
+ process.exit(1);
155
+ }
156
+ return [entry];
157
+ })()
158
+ : config.boards;
159
+ const runtime = initRuntime(config);
160
+ let exitCode = 0;
161
+ try {
162
+ for (const entry of toSync) {
163
+ try {
164
+ const meta = await runtime.syncBoard(entry.id);
165
+ const colCount = meta.columns?.length ?? 0;
166
+ const kindStr = meta.kind ?? "unknown";
167
+ const reloadedEntry = (await loadConfig()).boards.find((b) => b.id === entry.id);
168
+ const childrenCount = reloadedEntry?.children?.length ?? 0;
169
+ const childrenStr = kindStr === "compound" ? `, children: ${childrenCount}` : "";
170
+ console.log(chalk.green(`✓ Synced: ${entry.name} (kind: ${kindStr}, columns: ${colCount}${childrenStr})`));
171
+ }
172
+ catch (err) {
173
+ console.error(chalk.red(` ✗ Failed to sync ${entry.name}: ${String(err)}`));
174
+ exitCode = 1;
175
+ }
176
+ }
177
+ }
178
+ finally {
179
+ await runtime.disconnect();
180
+ process.exit(exitCode);
181
+ }
182
+ });
183
+ boardCmd
184
+ .command("leave <boardId>")
185
+ .description("Remove a board from config")
186
+ .action(async (boardId) => {
187
+ const config = await loadConfig();
188
+ const before = config.boards.length;
189
+ config.boards = config.boards.filter((b) => b.id !== boardId);
190
+ if (config.boards.length === before) {
191
+ console.error(chalk.red(`Board not found: ${boardId}`));
192
+ process.exit(1);
193
+ }
194
+ await saveConfig(config);
195
+ console.log(chalk.green(`✓ Left board ${boardId}`));
196
+ process.exit(0);
197
+ });
198
+ boardCmd
199
+ .command("columns")
200
+ .description("Show cached columns for all configured boards")
201
+ .action(async () => {
202
+ const config = await loadConfig();
203
+ if (config.boards.length === 0) {
204
+ console.log(chalk.dim("No boards configured. Use: taskify board join <id> --name <name>"));
205
+ process.exit(0);
206
+ }
207
+ for (const b of config.boards) {
208
+ const kindStr = b.kind ? ` (${b.kind})` : "";
209
+ console.log(chalk.bold(`${b.name}${kindStr}:`));
210
+ if (!b.columns || b.columns.length === 0) {
211
+ console.log(chalk.dim(` — no columns cached (run: taskify board sync)`));
212
+ }
213
+ else {
214
+ for (const col of b.columns) {
215
+ console.log(` [${chalk.cyan(col.id)}] ${col.name}`);
216
+ }
217
+ }
218
+ }
219
+ process.exit(0);
220
+ });
221
+ boardCmd
222
+ .command("children <board>")
223
+ .description("List children of a compound board")
224
+ .action(async (boardArg) => {
225
+ const config = await loadConfig();
226
+ const entry = config.boards.find((b) => b.id === boardArg) ??
227
+ config.boards.find((b) => b.name.toLowerCase() === boardArg.toLowerCase());
228
+ if (!entry) {
229
+ console.error(chalk.red(`Board not found: "${boardArg}"`));
230
+ process.exit(1);
231
+ }
232
+ if (entry.kind !== "compound") {
233
+ console.log(chalk.dim(`Board is not a compound board (kind: ${entry.kind ?? "unknown"})`));
234
+ process.exit(0);
235
+ }
236
+ if (!entry.children || entry.children.length === 0) {
237
+ console.log(chalk.dim("No children cached — run: taskify board sync"));
238
+ process.exit(0);
239
+ }
240
+ console.log(chalk.bold(`Children of ${entry.name}:`));
241
+ for (const childId of entry.children) {
242
+ const childEntry = config.boards.find((b) => b.id === childId);
243
+ if (childEntry) {
244
+ console.log(` ${chalk.cyan(childEntry.name.padEnd(16))} ${chalk.dim(childId)}`);
245
+ }
246
+ else {
247
+ console.log(` ${chalk.dim(childId)} ${chalk.yellow("(not in local config)")}`);
248
+ }
249
+ }
250
+ process.exit(0);
251
+ });
252
+ // ---- boards (alias for board list) ----
253
+ program
254
+ .command("boards")
255
+ .description("List configured boards (alias for: board list)")
256
+ .action(async () => {
257
+ const config = await loadConfig();
258
+ if (config.boards.length === 0) {
259
+ console.log(chalk.dim("No boards configured. Use: taskify board join <id> --name <name>"));
260
+ }
261
+ else {
262
+ for (const b of config.boards) {
263
+ console.log(` ${chalk.bold(b.name.padEnd(16))} ${chalk.dim(b.id)}`);
264
+ }
265
+ }
266
+ process.exit(0);
267
+ });
268
+ const WEEK_DAY_MAP = {
269
+ mon: 0, tue: 1, wed: 2, thu: 3, fri: 4, sat: 5, sun: 6,
270
+ };
271
+ /** Resolve a week-board day name to the ISO date for that day in the current week (Mon-based). */
272
+ function resolveWeekDayToISO(dayKey) {
273
+ const offset = WEEK_DAY_MAP[dayKey];
274
+ if (offset === undefined)
275
+ return dayKey;
276
+ const today = new Date();
277
+ // JavaScript: 0=Sun, 1=Mon … 6=Sat
278
+ const jsDay = today.getDay();
279
+ // Offset from Monday: Mon=0 … Sun=6
280
+ const mondayShift = jsDay === 0 ? -6 : 1 - jsDay;
281
+ const monday = new Date(today);
282
+ monday.setDate(today.getDate() + mondayShift);
283
+ monday.setHours(0, 0, 0, 0);
284
+ const target = new Date(monday);
285
+ target.setDate(monday.getDate() + offset);
286
+ const y = target.getFullYear();
287
+ const m = String(target.getMonth() + 1).padStart(2, "0");
288
+ const d = String(target.getDate()).padStart(2, "0");
289
+ return `${y}-${m}-${d}`;
290
+ }
291
+ // Resolve --column value to { id, name } given a board entry.
292
+ function resolveColumn(entry, columnArg) {
293
+ const dayKey = columnArg.toLowerCase();
294
+ // Week board: day name → ISO date
295
+ if (dayKey in WEEK_DAY_MAP && entry.kind === "week") {
296
+ const isoDate = resolveWeekDayToISO(dayKey);
297
+ return { id: isoDate, name: columnArg };
298
+ }
299
+ if (!entry.columns || entry.columns.length === 0)
300
+ return null;
301
+ // Exact id match
302
+ const byId = entry.columns.find((c) => c.id === columnArg);
303
+ if (byId)
304
+ return byId;
305
+ // Case-insensitive name substring
306
+ const lower = columnArg.toLowerCase();
307
+ const byName = entry.columns.find((c) => c.name.toLowerCase().includes(lower));
308
+ return byName ?? null;
309
+ }
310
+ // ---- list ----
311
+ program
312
+ .command("list")
313
+ .description("List tasks")
314
+ .option("--board <id|name>", "Filter by board (UUID or name)")
315
+ .option("--status <status>", "Filter: open (default), done, or any", "open")
316
+ .option("--column <id|name>", "Filter by column id or name (use day names for week boards)")
317
+ .option("--refresh", "Bypass cache and fetch live from relay")
318
+ .option("--no-cache", "Do not fall back to stale cache if relay returns empty")
319
+ .option("--json", "Output as JSON")
320
+ .action(async (opts) => {
321
+ const config = await loadConfig();
322
+ const runtime = initRuntime(config);
323
+ let exitCode = 0;
324
+ try {
325
+ let columnId;
326
+ let columnName;
327
+ if (opts.column) {
328
+ // Column requires a single board to be resolvable
329
+ const singleBoardId = opts.board
330
+ ? await resolveBoardId(opts.board, config)
331
+ : config.boards.length === 1
332
+ ? config.boards[0].id
333
+ : undefined;
334
+ if (!singleBoardId) {
335
+ console.error(chalk.red("--column requires --board when multiple boards are configured"));
336
+ process.exit(1);
337
+ }
338
+ const boardEntry = config.boards.find((b) => b.id === singleBoardId);
339
+ const resolved = resolveColumn(boardEntry, opts.column);
340
+ if (!resolved) {
341
+ console.error(chalk.red(`Unknown column: ${opts.column}. Run: taskify board sync`));
342
+ process.exit(1);
343
+ }
344
+ columnId = resolved.id;
345
+ columnName = resolved.name;
346
+ }
347
+ const tasks = await runtime.listTasks({
348
+ boardId: opts.board,
349
+ status: opts.status,
350
+ columnId,
351
+ refresh: !!opts.refresh,
352
+ noCache: !opts.cache,
353
+ });
354
+ if (opts.json) {
355
+ renderJson(tasks);
356
+ }
357
+ else {
358
+ if (tasks.length === 0) {
359
+ console.log(chalk.dim("No tasks found."));
360
+ }
361
+ else {
362
+ renderTable(tasks, config.trustedNpubs, columnName);
363
+ }
364
+ }
365
+ }
366
+ catch (err) {
367
+ console.error(chalk.red(String(err)));
368
+ exitCode = 1;
369
+ }
370
+ finally {
371
+ await runtime.disconnect();
372
+ process.exit(exitCode);
373
+ }
374
+ });
375
+ // ---- show ----
376
+ program
377
+ .command("show <taskId>")
378
+ .description("Show full task details (accepts 8-char prefix or full UUID)")
379
+ .option("--board <id|name>", "Board to search in (optional; scans all if omitted)")
380
+ .option("--json", "Output raw task fields as JSON")
381
+ .action(async (taskId, opts) => {
382
+ warnShortTaskId(taskId);
383
+ const config = await loadConfig();
384
+ const runtime = initRuntime(config);
385
+ let exitCode = 0;
386
+ try {
387
+ const task = await runtime.getTask(taskId, opts.board);
388
+ if (!task) {
389
+ console.error(chalk.red(`Task not found: ${taskId}`));
390
+ exitCode = 1;
391
+ }
392
+ else if (opts.json) {
393
+ renderJson(task);
394
+ }
395
+ else {
396
+ const localReminders = runtime.getLocalReminders(task.id);
397
+ renderTaskCard(task, config.trustedNpubs, localReminders);
398
+ }
399
+ }
400
+ catch (err) {
401
+ console.error(chalk.red(String(err)));
402
+ exitCode = 1;
403
+ }
404
+ finally {
405
+ await runtime.disconnect();
406
+ process.exit(exitCode);
407
+ }
408
+ });
409
+ // ---- search ----
410
+ program
411
+ .command("search <query>")
412
+ .description("Full-text search tasks by title or note across all configured boards")
413
+ .option("--board <id|name>", "Limit to a specific board")
414
+ .option("--json", "Output as JSON")
415
+ .action(async (query, opts) => {
416
+ const config = await loadConfig();
417
+ const runtime = initRuntime(config);
418
+ let exitCode = 0;
419
+ try {
420
+ const allTasks = await runtime.listTasks({
421
+ boardId: opts.board,
422
+ status: "any",
423
+ });
424
+ const q = query.toLowerCase();
425
+ const matched = allTasks.filter((t) => {
426
+ const inTitle = t.title.toLowerCase().includes(q);
427
+ const inNote = t.note ? t.note.toLowerCase().includes(q) : false;
428
+ return inTitle || inNote;
429
+ });
430
+ if (opts.json) {
431
+ renderJson(matched);
432
+ }
433
+ else {
434
+ if (matched.length === 0) {
435
+ console.log(chalk.dim(`No tasks matching "${query}".`));
436
+ }
437
+ else {
438
+ renderTable(matched, config.trustedNpubs);
439
+ }
440
+ }
441
+ }
442
+ catch (err) {
443
+ console.error(chalk.red(String(err)));
444
+ exitCode = 1;
445
+ }
446
+ finally {
447
+ await runtime.disconnect();
448
+ process.exit(exitCode);
449
+ }
450
+ });
451
+ // ---- remind ----
452
+ program
453
+ .command("remind <taskId> <presets...>")
454
+ .description("Set device-local reminders on a task. Presets: 0h, 5m, 15m, 30m, 1h, 1d, 1w")
455
+ .option("--board <id|name>", "Board the task belongs to")
456
+ .action(async (taskId, presets, opts) => {
457
+ warnShortTaskId(taskId);
458
+ const invalid = presets.filter((p) => !VALID_REMINDER_PRESETS.has(p));
459
+ if (invalid.length > 0) {
460
+ console.error(chalk.red(`Invalid reminder preset(s): ${invalid.join(", ")}. Valid: ${[...VALID_REMINDER_PRESETS].join(", ")}`));
461
+ process.exit(1);
462
+ }
463
+ const config = await loadConfig();
464
+ const runtime = initRuntime(config);
465
+ let exitCode = 0;
466
+ try {
467
+ // Try to fetch task title for a nicer success message
468
+ let title = taskId.slice(0, 8);
469
+ try {
470
+ const hasSingleOrSpecifiedBoard = opts.board || config.boards.length === 1;
471
+ if (hasSingleOrSpecifiedBoard) {
472
+ const boardId = await resolveBoardId(opts.board, config);
473
+ const task = await runtime.getTask(taskId, boardId);
474
+ if (task?.title)
475
+ title = task.title;
476
+ }
477
+ }
478
+ catch { /* title lookup is best-effort */ }
479
+ await runtime.remindTask(taskId, presets);
480
+ console.log(chalk.green(`✓ Reminders set for ${title}: ${presets.join(", ")} (device-local only, will not sync)`));
481
+ }
482
+ catch (err) {
483
+ console.error(chalk.red(String(err)));
484
+ exitCode = 1;
485
+ }
486
+ finally {
487
+ await runtime.disconnect();
488
+ process.exit(exitCode);
489
+ }
490
+ });
491
+ // ---- add ----
492
+ program
493
+ .command("add <title>")
494
+ .description("Create a new task")
495
+ .option("--board <id|name>", "Board to add to (required if multiple boards configured)")
496
+ .option("--due <YYYY-MM-DD>", "Due date")
497
+ .option("--priority <1|2|3>", "Priority (1=low, 3=high)")
498
+ .option("--note <text>", "Note")
499
+ .option("--subtask <text>", "Add a subtask (repeatable)", (val, arr) => [...arr, val], [])
500
+ .option("--column <id|name>", "Column to place task in")
501
+ .option("--json", "Output created task as JSON")
502
+ .action(async (title, opts) => {
503
+ validateDue(opts.due);
504
+ validatePriority(opts.priority);
505
+ const config = await loadConfig();
506
+ const boardId = await resolveBoardId(opts.board, config);
507
+ const boardEntry = config.boards.find((b) => b.id === boardId);
508
+ // Block add on compound boards
509
+ if (boardEntry.kind === "compound") {
510
+ const childNames = (boardEntry.children ?? []).map((cid) => {
511
+ const ce = config.boards.find((b) => b.id === cid);
512
+ return ce ? ` ${ce.name} (${cid})` : ` ${cid}`;
513
+ }).join("\n");
514
+ console.error(chalk.red("Cannot add tasks directly to a compound board. Use one of its child boards:"));
515
+ if (childNames)
516
+ console.error(childNames);
517
+ process.exit(1);
518
+ }
519
+ // Resolve --column
520
+ let resolvedColumnId;
521
+ let resolvedColumnName;
522
+ if (opts.column) {
523
+ const col = resolveColumn(boardEntry, opts.column);
524
+ if (!col) {
525
+ process.stderr.write(`⚠ Column not found in board config — run: taskify board sync\n`);
526
+ }
527
+ else {
528
+ resolvedColumnId = col.id;
529
+ resolvedColumnName = col.name;
530
+ }
531
+ }
532
+ const runtime = initRuntime(config);
533
+ let exitCode = 0;
534
+ try {
535
+ const subtasks = opts.subtask.map((text) => ({
536
+ id: crypto.randomUUID(),
537
+ title: text,
538
+ completed: false,
539
+ }));
540
+ const task = await runtime.createTaskFull({
541
+ title,
542
+ note: opts.note ?? "",
543
+ boardId,
544
+ dueISO: opts.due,
545
+ priority: opts.priority ? parseInt(opts.priority, 10) : undefined,
546
+ subtasks: subtasks.length > 0 ? subtasks : undefined,
547
+ columnId: resolvedColumnId,
548
+ });
549
+ if (opts.json) {
550
+ renderJson(task);
551
+ }
552
+ else {
553
+ const colStr = task.column
554
+ ? chalk.dim(` [col: ${resolvedColumnName ?? task.column}]`)
555
+ : "";
556
+ console.log(chalk.green(`✓ Created: ${task.title}`) + colStr);
557
+ if (subtasks.length > 0) {
558
+ console.log(chalk.dim(` Subtasks: ${subtasks.map((s) => s.title).join(", ")}`));
559
+ }
560
+ }
561
+ }
562
+ catch (err) {
563
+ console.error(chalk.red(String(err)));
564
+ exitCode = 1;
565
+ }
566
+ finally {
567
+ await runtime.disconnect();
568
+ process.exit(exitCode);
569
+ }
570
+ });
571
+ // ---- done ----
572
+ program
573
+ .command("done <taskId>")
574
+ .description("Mark a task as done (accepts 8-char prefix or full UUID)")
575
+ .option("--board <id|name>", "Board the task belongs to")
576
+ .option("--json", "Output updated task as JSON")
577
+ .action(async (taskId, opts) => {
578
+ warnShortTaskId(taskId);
579
+ const config = await loadConfig();
580
+ const boardId = await resolveBoardId(opts.board, config);
581
+ const runtime = initRuntime(config);
582
+ let exitCode = 0;
583
+ try {
584
+ const task = await runtime.setTaskStatus(taskId, "done", boardId);
585
+ if (!task) {
586
+ console.error(chalk.red(`Task not found: ${taskId}`));
587
+ exitCode = 1;
588
+ }
589
+ else if (opts.json) {
590
+ renderJson(task);
591
+ }
592
+ else {
593
+ console.log(chalk.green(`✓ Marked done: ${task.title}`));
594
+ }
595
+ }
596
+ catch (err) {
597
+ console.error(chalk.red(String(err)));
598
+ exitCode = 1;
599
+ }
600
+ finally {
601
+ await runtime.disconnect();
602
+ process.exit(exitCode);
603
+ }
604
+ });
605
+ // ---- reopen ----
606
+ program
607
+ .command("reopen <taskId>")
608
+ .description("Reopen a completed task (accepts 8-char prefix or full UUID)")
609
+ .option("--board <id|name>", "Board the task belongs to")
610
+ .option("--json", "Output updated task as JSON")
611
+ .action(async (taskId, opts) => {
612
+ warnShortTaskId(taskId);
613
+ const config = await loadConfig();
614
+ const boardId = await resolveBoardId(opts.board, config);
615
+ const runtime = initRuntime(config);
616
+ let exitCode = 0;
617
+ try {
618
+ const task = await runtime.setTaskStatus(taskId, "open", boardId);
619
+ if (!task) {
620
+ console.error(chalk.red(`Task not found: ${taskId}`));
621
+ exitCode = 1;
622
+ }
623
+ else if (opts.json) {
624
+ renderJson(task);
625
+ }
626
+ else {
627
+ console.log(chalk.green(`✓ Reopened: ${task.title}`));
628
+ }
629
+ }
630
+ catch (err) {
631
+ console.error(chalk.red(String(err)));
632
+ exitCode = 1;
633
+ }
634
+ finally {
635
+ await runtime.disconnect();
636
+ process.exit(exitCode);
637
+ }
638
+ });
639
+ // ---- delete ----
640
+ program
641
+ .command("delete <taskId>")
642
+ .description("Delete a task (publishes status=deleted to Nostr; accepts 8-char prefix or full UUID)")
643
+ .option("--board <id|name>", "Board the task belongs to")
644
+ .option("--force", "Skip confirmation prompt")
645
+ .option("--json", "Output deleted task as JSON")
646
+ .action(async (taskId, opts) => {
647
+ warnShortTaskId(taskId);
648
+ const config = await loadConfig();
649
+ const boardId = await resolveBoardId(opts.board, config);
650
+ const runtime = initRuntime(config);
651
+ let exitCode = 0;
652
+ try {
653
+ // Fetch task first so we can show the title in the prompt
654
+ const task = await runtime.getTask(taskId, boardId);
655
+ if (!task) {
656
+ console.error(chalk.red(`Task not found: ${taskId}`));
657
+ exitCode = 1;
658
+ }
659
+ else {
660
+ if (!opts.force) {
661
+ const { createInterface } = await import("readline");
662
+ const confirmed = await new Promise((resolve) => {
663
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
664
+ rl.question(`Delete task: ${task.title} (${task.id.slice(0, 8)})? [y/N] `, (ans) => {
665
+ rl.close();
666
+ resolve(ans === "y" || ans === "Y");
667
+ });
668
+ });
669
+ if (!confirmed) {
670
+ console.log("Aborted.");
671
+ await runtime.disconnect();
672
+ process.exit(0);
673
+ }
674
+ }
675
+ const deleted = await runtime.deleteTask(taskId, boardId);
676
+ if (!deleted) {
677
+ console.error(chalk.red(`Task not found: ${taskId}`));
678
+ exitCode = 1;
679
+ }
680
+ else if (opts.json) {
681
+ renderJson(deleted);
682
+ }
683
+ else {
684
+ console.log(chalk.green(`✓ Deleted: ${deleted.title}`));
685
+ }
686
+ }
687
+ }
688
+ catch (err) {
689
+ console.error(chalk.red(String(err)));
690
+ exitCode = 1;
691
+ }
692
+ finally {
693
+ await runtime.disconnect();
694
+ process.exit(exitCode);
695
+ }
696
+ });
697
+ // ---- subtask ----
698
+ program
699
+ .command("subtask <taskId> <subtaskRef>")
700
+ .description("Toggle a subtask done/incomplete. subtaskRef can be a 1-based index or partial title match.")
701
+ .option("--board <id|name>", "Board the task belongs to")
702
+ .option("--done", "Mark subtask completed")
703
+ .option("--reopen", "Mark subtask incomplete")
704
+ .option("--json", "Output updated full task as JSON")
705
+ .action(async (taskId, subtaskRef, opts) => {
706
+ if (!opts.done && !opts.reopen) {
707
+ console.error(chalk.red("Specify --done or --reopen."));
708
+ process.exit(1);
709
+ }
710
+ if (opts.done && opts.reopen) {
711
+ console.error(chalk.red("Specify only one of --done or --reopen."));
712
+ process.exit(1);
713
+ }
714
+ warnShortTaskId(taskId);
715
+ const config = await loadConfig();
716
+ const boardId = await resolveBoardId(opts.board, config);
717
+ const runtime = initRuntime(config);
718
+ let exitCode = 0;
719
+ try {
720
+ const completed = !!opts.done;
721
+ const task = await runtime.toggleSubtask(taskId, boardId, subtaskRef, completed);
722
+ if (!task) {
723
+ console.error(chalk.red(`Task not found: ${taskId}`));
724
+ exitCode = 1;
725
+ }
726
+ else if (opts.json) {
727
+ renderJson(task);
728
+ }
729
+ else {
730
+ // Find the subtask that was toggled (by ref) to display its title
731
+ const subtasks = task.subtasks ?? [];
732
+ const indexNum = parseInt(subtaskRef, 10);
733
+ let found;
734
+ if (!isNaN(indexNum) && indexNum >= 1 && indexNum <= subtasks.length) {
735
+ found = subtasks[indexNum - 1];
736
+ }
737
+ else {
738
+ const lower = subtaskRef.toLowerCase();
739
+ found = subtasks.find((s) => s.title.toLowerCase().includes(lower));
740
+ }
741
+ const check = completed ? "x" : " ";
742
+ const stitle = found?.title ?? subtaskRef;
743
+ console.log(chalk.green(`✓ Subtask [${check}] ${stitle} (task: ${task.title})`));
744
+ }
745
+ }
746
+ catch (err) {
747
+ console.error(chalk.red(String(err)));
748
+ exitCode = 1;
749
+ }
750
+ finally {
751
+ await runtime.disconnect();
752
+ process.exit(exitCode);
753
+ }
754
+ });
755
+ // ---- update ----
756
+ program
757
+ .command("update <taskId>")
758
+ .description("Update task fields (accepts 8-char prefix or full UUID)")
759
+ .option("--board <id|name>", "Board the task belongs to")
760
+ .option("--title <t>", "New title")
761
+ .option("--due <d>", "New due date")
762
+ .option("--priority <p>", "New priority")
763
+ .option("--note <n>", "New note")
764
+ .option("--column <id|name>", "Move task to a different column")
765
+ .option("--json", "Output updated task as JSON")
766
+ .action(async (taskId, opts) => {
767
+ warnShortTaskId(taskId);
768
+ validateDue(opts.due);
769
+ validatePriority(opts.priority);
770
+ const config = await loadConfig();
771
+ const boardId = await resolveBoardId(opts.board, config);
772
+ const runtime = initRuntime(config);
773
+ let exitCode = 0;
774
+ try {
775
+ const patch = {};
776
+ if (opts.title !== undefined)
777
+ patch.title = opts.title;
778
+ if (opts.due !== undefined)
779
+ patch.dueISO = opts.due;
780
+ if (opts.priority !== undefined)
781
+ patch.priority = parseInt(opts.priority, 10);
782
+ if (opts.note !== undefined)
783
+ patch.note = opts.note;
784
+ if (opts.column !== undefined) {
785
+ const bEntry = config.boards.find((b) => b.id === boardId);
786
+ if (bEntry) {
787
+ const col = resolveColumn(bEntry, opts.column);
788
+ if (col) {
789
+ patch.columnId = col.id;
790
+ }
791
+ else {
792
+ process.stderr.write(`⚠ Column not found in board config — run: taskify board sync\n`);
793
+ }
794
+ }
795
+ }
796
+ const task = await runtime.updateTask(taskId, boardId, patch);
797
+ if (!task) {
798
+ console.error(chalk.red(`Task not found: ${taskId}`));
799
+ exitCode = 1;
800
+ }
801
+ else if (opts.json) {
802
+ renderJson(task);
803
+ }
804
+ else {
805
+ console.log(chalk.green(`✓ Updated: ${task.id.slice(0, 8)} ${task.title} ${task.boardId}`));
806
+ }
807
+ }
808
+ catch (err) {
809
+ console.error(chalk.red(String(err)));
810
+ exitCode = 1;
811
+ }
812
+ finally {
813
+ await runtime.disconnect();
814
+ process.exit(exitCode);
815
+ }
816
+ });
817
+ // ---- trust ----
818
+ const trust = program.command("trust").description("Manage trusted npubs");
819
+ trust
820
+ .command("add <npub>")
821
+ .description("Add a trusted npub")
822
+ .action(async (npub) => {
823
+ const config = await loadConfig();
824
+ if (!config.trustedNpubs.includes(npub)) {
825
+ config.trustedNpubs.push(npub);
826
+ }
827
+ await saveConfig(config);
828
+ console.log(chalk.green("✓ Added"));
829
+ process.exit(0);
830
+ });
831
+ trust
832
+ .command("remove <npub>")
833
+ .description("Remove a trusted npub")
834
+ .action(async (npub) => {
835
+ const config = await loadConfig();
836
+ config.trustedNpubs = config.trustedNpubs.filter((n) => n !== npub);
837
+ await saveConfig(config);
838
+ console.log(chalk.green("✓ Removed"));
839
+ process.exit(0);
840
+ });
841
+ trust
842
+ .command("list")
843
+ .description("List trusted npubs")
844
+ .action(async () => {
845
+ const config = await loadConfig();
846
+ if (config.trustedNpubs.length === 0) {
847
+ console.log(chalk.dim("No trusted npubs."));
848
+ }
849
+ else {
850
+ for (const npub of config.trustedNpubs) {
851
+ console.log(npub);
852
+ }
853
+ }
854
+ process.exit(0);
855
+ });
856
+ // ---- relay command group ----
857
+ const relayCmd = program.command("relay").description("Manage relay connections");
858
+ relayCmd
859
+ .command("status")
860
+ .description("Show connection status of relays in the NDK pool")
861
+ .action(async () => {
862
+ const config = await loadConfig();
863
+ const runtime = initRuntime(config);
864
+ let exitCode = 0;
865
+ try {
866
+ const statuses = await runtime.getRelayStatus();
867
+ if (statuses.length === 0) {
868
+ console.log(chalk.dim("No relays configured."));
869
+ }
870
+ else {
871
+ for (const { url, connected } of statuses) {
872
+ if (connected) {
873
+ console.log(chalk.green(`✓ ${url}`) + chalk.dim(" connected"));
874
+ }
875
+ else {
876
+ console.log(chalk.red(`✗ ${url}`) + chalk.dim(" disconnected"));
877
+ }
878
+ }
879
+ }
880
+ }
881
+ catch (err) {
882
+ console.error(chalk.red(String(err)));
883
+ exitCode = 1;
884
+ }
885
+ finally {
886
+ await runtime.disconnect();
887
+ process.exit(exitCode);
888
+ }
889
+ });
890
+ relayCmd
891
+ .command("list")
892
+ .description("Show configured relays with live connection check")
893
+ .action(async () => {
894
+ const config = await loadConfig();
895
+ if (config.relays.length === 0) {
896
+ console.log(chalk.dim("No relays configured."));
897
+ process.exit(0);
898
+ }
899
+ console.log(chalk.dim(`Checking ${config.relays.length} relay(s)...`));
900
+ for (const relay of config.relays) {
901
+ const ok = await checkRelay(relay);
902
+ if (ok) {
903
+ console.log(chalk.green(`✓ ${relay}`) + chalk.dim(" connected"));
904
+ }
905
+ else {
906
+ console.log(chalk.red(`✗ ${relay}`) + chalk.dim(" disconnected"));
907
+ }
908
+ }
909
+ process.exit(0);
910
+ });
911
+ relayCmd
912
+ .command("add <url>")
913
+ .description("Add a relay URL to config")
914
+ .action(async (url) => {
915
+ const config = await loadConfig();
916
+ if (!config.relays.includes(url)) {
917
+ config.relays.push(url);
918
+ await saveConfig(config);
919
+ console.log(chalk.green(`✓ Relay added: ${url}`));
920
+ }
921
+ else {
922
+ console.log(chalk.dim(`Relay already configured: ${url}`));
923
+ }
924
+ process.exit(0);
925
+ });
926
+ relayCmd
927
+ .command("remove <url>")
928
+ .description("Remove a relay URL from config")
929
+ .action(async (url) => {
930
+ const config = await loadConfig();
931
+ const before = config.relays.length;
932
+ config.relays = config.relays.filter((r) => r !== url);
933
+ if (config.relays.length === before) {
934
+ console.error(chalk.red(`Relay not found in config: ${url}`));
935
+ process.exit(1);
936
+ }
937
+ await saveConfig(config);
938
+ console.log(chalk.green(`✓ Relay removed: ${url}`));
939
+ process.exit(0);
940
+ });
941
+ // ---- cache command group ----
942
+ const cacheCmd = program.command("cache").description("Manage task cache");
943
+ cacheCmd
944
+ .command("clear")
945
+ .description("Delete the task cache file")
946
+ .action(() => {
947
+ clearCache();
948
+ console.log(chalk.green("✓ Cache cleared"));
949
+ process.exit(0);
950
+ });
951
+ cacheCmd
952
+ .command("status")
953
+ .description("Show per-board cache age and task count")
954
+ .action(async () => {
955
+ const config = await loadConfig();
956
+ const cache = readCache();
957
+ const now = Date.now();
958
+ if (Object.keys(cache.boards).length === 0) {
959
+ console.log(chalk.dim("No cache."));
960
+ process.exit(0);
961
+ }
962
+ for (const board of config.boards) {
963
+ const bc = cache.boards[board.id];
964
+ if (!bc) {
965
+ console.log(`${board.name}: ${chalk.dim("No cache")}`);
966
+ continue;
967
+ }
968
+ const ageMs = now - bc.fetchedAt;
969
+ const ageSec = Math.floor(ageMs / 1000);
970
+ let ageStr;
971
+ if (ageSec < 60) {
972
+ ageStr = `${ageSec}s ago`;
973
+ }
974
+ else if (ageSec < 3600) {
975
+ ageStr = `${Math.floor(ageSec / 60)}m ago`;
976
+ }
977
+ else {
978
+ ageStr = `${Math.floor(ageSec / 3600)}h ago`;
979
+ }
980
+ const stale = ageMs > CACHE_TTL_MS ? chalk.yellow(" (stale)") : "";
981
+ const openCount = bc.tasks.filter((t) => t.status === "open").length;
982
+ console.log(`${chalk.bold(board.name)}: ${bc.tasks.length} tasks (${openCount} open), cached ${ageStr}${stale}`);
983
+ }
984
+ // Show boards in cache that aren't in config
985
+ for (const [boardId, bc] of Object.entries(cache.boards)) {
986
+ if (!config.boards.find((b) => b.id === boardId)) {
987
+ console.log(chalk.dim(` [orphan ${boardId.slice(0, 8)}]: ${bc.tasks.length} tasks`));
988
+ }
989
+ }
990
+ process.exit(0);
991
+ });
992
+ // ---- config ----
993
+ const configCmd = program.command("config").description("Manage CLI config");
994
+ const configSet = configCmd.command("set").description("Set config values");
995
+ configSet
996
+ .command("nsec <nsec>")
997
+ .description("Set your nsec private key")
998
+ .action(async (nsec) => {
999
+ if (!nsec.startsWith("nsec1")) {
1000
+ console.error(chalk.red(`Invalid nsec: must start with "nsec1".`));
1001
+ process.exit(1);
1002
+ }
1003
+ const config = await loadConfig();
1004
+ config.nsec = nsec;
1005
+ await saveConfig(config);
1006
+ console.log(chalk.green("✓ nsec saved"));
1007
+ process.exit(0);
1008
+ });
1009
+ configSet
1010
+ .command("relay <url>")
1011
+ .description("Add a relay URL")
1012
+ .action(async (url) => {
1013
+ const config = await loadConfig();
1014
+ if (!config.relays.includes(url)) {
1015
+ config.relays.push(url);
1016
+ }
1017
+ await saveConfig(config);
1018
+ console.log(chalk.green("✓ Relay added"));
1019
+ process.exit(0);
1020
+ });
1021
+ async function checkRelay(url, timeoutMs = 5000) {
1022
+ return new Promise((resolve) => {
1023
+ let settled = false;
1024
+ const done = (ok) => {
1025
+ if (!settled) {
1026
+ settled = true;
1027
+ resolve(ok);
1028
+ }
1029
+ };
1030
+ const timer = setTimeout(() => {
1031
+ ws.close();
1032
+ done(false);
1033
+ }, timeoutMs);
1034
+ let ws;
1035
+ try {
1036
+ ws = new WebSocket(url);
1037
+ ws.onopen = () => {
1038
+ clearTimeout(timer);
1039
+ ws.close();
1040
+ done(true);
1041
+ };
1042
+ ws.onerror = () => {
1043
+ clearTimeout(timer);
1044
+ done(false);
1045
+ };
1046
+ }
1047
+ catch {
1048
+ clearTimeout(timer);
1049
+ done(false);
1050
+ }
1051
+ });
1052
+ }
1053
+ configCmd
1054
+ .command("show")
1055
+ .description("Show current config")
1056
+ .action(async () => {
1057
+ const config = await loadConfig();
1058
+ const display = {
1059
+ ...config,
1060
+ nsec: config.nsec ? "nsec1****" : undefined,
1061
+ };
1062
+ console.log(JSON.stringify(display, null, 2));
1063
+ console.log("\nChecking relays...");
1064
+ for (const relay of config.relays) {
1065
+ const ok = await checkRelay(relay);
1066
+ if (ok) {
1067
+ console.log(chalk.green(`✓ ${relay}`) + chalk.dim(" (connected)"));
1068
+ }
1069
+ else {
1070
+ console.log(chalk.red(`✗ ${relay}`) + chalk.dim(" (timeout)"));
1071
+ }
1072
+ }
1073
+ process.exit(0);
1074
+ });
1075
+ // ---- completions ----
1076
+ program
1077
+ .command("completions")
1078
+ .description("Generate shell completion scripts")
1079
+ .option("--shell <zsh|bash|fish>", "Shell type (defaults to current shell)")
1080
+ .action((opts) => {
1081
+ let shell = opts.shell;
1082
+ if (!shell) {
1083
+ const envShell = process.env.SHELL ?? "";
1084
+ if (envShell.includes("zsh"))
1085
+ shell = "zsh";
1086
+ else if (envShell.includes("bash"))
1087
+ shell = "bash";
1088
+ else {
1089
+ // Print all three if shell cannot be determined
1090
+ process.stdout.write(zshCompletion());
1091
+ process.stdout.write("\n");
1092
+ process.stdout.write(bashCompletion());
1093
+ process.stdout.write("\n");
1094
+ process.stdout.write(fishCompletion());
1095
+ process.exit(0);
1096
+ }
1097
+ }
1098
+ switch (shell) {
1099
+ case "zsh":
1100
+ process.stdout.write(zshCompletion());
1101
+ break;
1102
+ case "bash":
1103
+ process.stdout.write(bashCompletion());
1104
+ break;
1105
+ case "fish":
1106
+ process.stdout.write(fishCompletion());
1107
+ break;
1108
+ default:
1109
+ console.error(chalk.red(`Unknown shell: "${shell}". Use: zsh, bash, or fish`));
1110
+ process.exit(1);
1111
+ }
1112
+ process.exit(0);
1113
+ });
1114
+ // ---- agent command group ----
1115
+ const agentCmd = program
1116
+ .command("agent")
1117
+ .description("AI-powered task commands");
1118
+ const agentConfigCmd = agentCmd
1119
+ .command("config")
1120
+ .description("Manage agent AI configuration");
1121
+ agentConfigCmd
1122
+ .command("set-key <key>")
1123
+ .description("Set the AI API key")
1124
+ .action(async (key) => {
1125
+ const config = await loadConfig();
1126
+ if (!config.agent)
1127
+ config.agent = {};
1128
+ config.agent.apiKey = key;
1129
+ await saveConfig(config);
1130
+ console.log(chalk.green("✓ Agent API key saved"));
1131
+ process.exit(0);
1132
+ });
1133
+ agentConfigCmd
1134
+ .command("set-model <model>")
1135
+ .description("Set the AI model")
1136
+ .action(async (model) => {
1137
+ const config = await loadConfig();
1138
+ if (!config.agent)
1139
+ config.agent = {};
1140
+ config.agent.model = model;
1141
+ await saveConfig(config);
1142
+ console.log(chalk.green(`✓ Agent model set to: ${model}`));
1143
+ process.exit(0);
1144
+ });
1145
+ agentConfigCmd
1146
+ .command("set-url <url>")
1147
+ .description("Set the AI base URL (OpenAI-compatible)")
1148
+ .action(async (url) => {
1149
+ const config = await loadConfig();
1150
+ if (!config.agent)
1151
+ config.agent = {};
1152
+ config.agent.baseUrl = url;
1153
+ await saveConfig(config);
1154
+ console.log(chalk.green(`✓ Agent base URL set to: ${url}`));
1155
+ process.exit(0);
1156
+ });
1157
+ agentConfigCmd
1158
+ .command("show")
1159
+ .description("Show current agent config (masks API key)")
1160
+ .action(async () => {
1161
+ const config = await loadConfig();
1162
+ const ag = config.agent ?? {};
1163
+ const rawKey = ag.apiKey ?? process.env.TASKIFY_AGENT_API_KEY ?? "";
1164
+ let maskedKey = "(not set)";
1165
+ if (rawKey.length > 7) {
1166
+ maskedKey = rawKey.slice(0, 3) + "..." + rawKey.slice(-3);
1167
+ }
1168
+ else if (rawKey.length > 0) {
1169
+ maskedKey = "***";
1170
+ }
1171
+ console.log(` apiKey: ${maskedKey}`);
1172
+ console.log(` baseUrl: ${ag.baseUrl ?? "https://api.openai.com/v1"}`);
1173
+ console.log(` model: ${ag.model ?? "gpt-4o-mini"}`);
1174
+ console.log(` defaultBoardId: ${ag.defaultBoardId ?? "(not set)"}`);
1175
+ process.exit(0);
1176
+ });
1177
+ agentCmd
1178
+ .command("add <description>")
1179
+ .description("AI-powered task creation from natural language")
1180
+ .option("--board <id|name>", "Target board")
1181
+ .option("--yes", "Skip confirmation prompt")
1182
+ .option("--dry-run", "Show extracted fields without creating")
1183
+ .option("--json", "Output created task as JSON")
1184
+ .action(async (description, opts) => {
1185
+ const config = await loadConfig();
1186
+ const apiKey = config.agent?.apiKey ?? process.env.TASKIFY_AGENT_API_KEY ?? "";
1187
+ if (!apiKey) {
1188
+ console.error(chalk.red("No AI API key configured. Run: taskify agent config set-key <key>"));
1189
+ console.error(chalk.dim(" or set TASKIFY_AGENT_API_KEY environment variable"));
1190
+ process.exit(1);
1191
+ }
1192
+ const baseUrl = config.agent?.baseUrl ?? "https://api.openai.com/v1";
1193
+ const model = config.agent?.model ?? "gpt-4o-mini";
1194
+ const boardId = await resolveBoardId(opts.board ?? config.agent?.defaultBoardId, config);
1195
+ const boardEntry = config.boards.find((b) => b.id === boardId);
1196
+ if (boardEntry.kind === "compound") {
1197
+ const childNames = (boardEntry.children ?? []).map((cid) => {
1198
+ const ce = config.boards.find((b) => b.id === cid);
1199
+ return ce ? ` ${ce.name} (${cid})` : ` ${cid}`;
1200
+ }).join("\n");
1201
+ console.error(chalk.red("Cannot add tasks directly to a compound board. Use one of its child boards:"));
1202
+ if (childNames)
1203
+ console.error(childNames);
1204
+ process.exit(1);
1205
+ }
1206
+ const today = new Date().toISOString().slice(0, 10);
1207
+ const { callAI } = await import("./aiClient.js");
1208
+ const SYSTEM_PROMPT = `You are a task extraction assistant. Extract fields from the description.
1209
+ Return ONLY valid JSON (no markdown, no explanation):
1210
+ {
1211
+ "title": "concise task title (max 80 chars)",
1212
+ "note": "additional detail or empty string",
1213
+ "priority": 1|2|3|null,
1214
+ "dueISO": "YYYY-MM-DD"|null,
1215
+ "column": "column name/id hint or null",
1216
+ "subtasks": ["subtask 1", "subtask 2"] or []
1217
+ }
1218
+ Today is ${today}.`;
1219
+ let extracted;
1220
+ console.log(chalk.dim("Calling AI..."));
1221
+ try {
1222
+ const raw = await callAI({ apiKey, baseUrl, model, systemPrompt: SYSTEM_PROMPT, userMessage: description });
1223
+ // Strip markdown code fences if present
1224
+ const cleaned = raw.replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/i, "").trim();
1225
+ extracted = JSON.parse(cleaned);
1226
+ }
1227
+ catch (err) {
1228
+ console.error(chalk.red(`AI extraction failed: ${String(err)}`));
1229
+ process.exit(1);
1230
+ }
1231
+ // Resolve column hint
1232
+ let resolvedColumnId;
1233
+ let resolvedColumnName;
1234
+ if (extracted.column) {
1235
+ const col = resolveColumn(boardEntry, extracted.column);
1236
+ if (col) {
1237
+ resolvedColumnId = col.id;
1238
+ resolvedColumnName = col.name;
1239
+ }
1240
+ }
1241
+ // Print extracted fields
1242
+ console.log(chalk.bold("\nExtracted task:"));
1243
+ console.log(` title: ${extracted.title}`);
1244
+ if (extracted.note)
1245
+ console.log(` note: ${extracted.note}`);
1246
+ if (extracted.priority)
1247
+ console.log(` priority: ${extracted.priority}`);
1248
+ if (extracted.dueISO)
1249
+ console.log(` due: ${extracted.dueISO}`);
1250
+ if (resolvedColumnName)
1251
+ console.log(` column: ${resolvedColumnName}`);
1252
+ if (extracted.subtasks?.length > 0) {
1253
+ console.log(` subtasks: ${extracted.subtasks.join(", ")}`);
1254
+ }
1255
+ if (opts.dryRun) {
1256
+ console.log(chalk.dim("\n[dry-run] No task created."));
1257
+ process.exit(0);
1258
+ }
1259
+ if (!opts.yes) {
1260
+ const { createInterface } = await import("readline");
1261
+ const confirmed = await new Promise((resolve) => {
1262
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
1263
+ rl.question("\nCreate this task? [Y/n] ", (ans) => {
1264
+ rl.close();
1265
+ resolve(ans === "" || ans.toLowerCase() === "y");
1266
+ });
1267
+ });
1268
+ if (!confirmed) {
1269
+ console.log("Aborted.");
1270
+ process.exit(0);
1271
+ }
1272
+ }
1273
+ const runtime = initRuntime(config);
1274
+ try {
1275
+ const subtasks = (extracted.subtasks ?? []).map((text) => ({
1276
+ id: crypto.randomUUID(),
1277
+ title: text,
1278
+ completed: false,
1279
+ }));
1280
+ const task = await runtime.createTaskFull({
1281
+ title: extracted.title,
1282
+ note: extracted.note ?? "",
1283
+ boardId,
1284
+ dueISO: extracted.dueISO ?? undefined,
1285
+ priority: extracted.priority ?? undefined,
1286
+ columnId: resolvedColumnId,
1287
+ subtasks: subtasks.length > 0 ? subtasks : undefined,
1288
+ });
1289
+ if (opts.json) {
1290
+ renderJson(task);
1291
+ }
1292
+ else {
1293
+ const colStr = task.column ? chalk.dim(` [col: ${resolvedColumnName ?? task.column}]`) : "";
1294
+ console.log(chalk.green(`✓ Created: ${task.title}`) + colStr);
1295
+ }
1296
+ }
1297
+ catch (err) {
1298
+ console.error(chalk.red(String(err)));
1299
+ process.exit(1);
1300
+ }
1301
+ finally {
1302
+ await runtime.disconnect();
1303
+ }
1304
+ process.exit(0);
1305
+ });
1306
+ agentCmd
1307
+ .command("triage")
1308
+ .description("AI-powered task prioritization suggestions")
1309
+ .option("--board <id|name>", "Target board")
1310
+ .option("--yes", "Apply changes without confirmation")
1311
+ .option("--dry-run", "Show suggestions without applying")
1312
+ .option("--json", "Output suggestions as JSON")
1313
+ .action(async (opts) => {
1314
+ const config = await loadConfig();
1315
+ const apiKey = config.agent?.apiKey ?? process.env.TASKIFY_AGENT_API_KEY ?? "";
1316
+ if (!apiKey) {
1317
+ console.error(chalk.red("No AI API key configured. Run: taskify agent config set-key <key>"));
1318
+ process.exit(1);
1319
+ }
1320
+ const baseUrl = config.agent?.baseUrl ?? "https://api.openai.com/v1";
1321
+ const model = config.agent?.model ?? "gpt-4o-mini";
1322
+ const boardId = await resolveBoardId(opts.board ?? config.agent?.defaultBoardId, config);
1323
+ const runtime = initRuntime(config);
1324
+ let exitCode = 0;
1325
+ try {
1326
+ const tasks = await runtime.listTasks({ boardId, status: "open" });
1327
+ if (tasks.length === 0) {
1328
+ console.log(chalk.dim("No open tasks to triage."));
1329
+ process.exit(0);
1330
+ }
1331
+ const { callAI } = await import("./aiClient.js");
1332
+ const SYSTEM_PROMPT = `You are a task prioritization assistant. Given open tasks, suggest priority (1=low, 2=medium, 3=high) for each.
1333
+ Return ONLY a valid JSON array (no markdown):
1334
+ [{"id":"<taskId>","priority":1|2|3,"reason":"one sentence"}]`;
1335
+ const taskList = tasks.map((t) => ({
1336
+ id: t.id,
1337
+ title: t.title,
1338
+ note: t.note || undefined,
1339
+ dueISO: t.dueISO || undefined,
1340
+ currentPriority: t.priority,
1341
+ }));
1342
+ console.log(chalk.dim(`Analyzing ${tasks.length} tasks...`));
1343
+ let suggestions;
1344
+ try {
1345
+ const raw = await callAI({
1346
+ apiKey, baseUrl, model,
1347
+ systemPrompt: SYSTEM_PROMPT,
1348
+ userMessage: `Tasks: ${JSON.stringify(taskList)}`,
1349
+ });
1350
+ const cleaned = raw.replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/i, "").trim();
1351
+ suggestions = JSON.parse(cleaned);
1352
+ }
1353
+ catch (err) {
1354
+ console.error(chalk.red(`AI triage failed: ${String(err)}`));
1355
+ process.exit(1);
1356
+ }
1357
+ // Filter to only changes
1358
+ const changes = suggestions.filter((s) => {
1359
+ const task = tasks.find((t) => t.id === s.id);
1360
+ return task && task.priority !== s.priority;
1361
+ });
1362
+ if (opts.json) {
1363
+ renderJson(suggestions);
1364
+ process.exit(0);
1365
+ }
1366
+ if (changes.length === 0) {
1367
+ console.log(chalk.dim("No priority changes suggested."));
1368
+ process.exit(0);
1369
+ }
1370
+ console.log(chalk.bold("\nSuggested priority changes:"));
1371
+ const PRIO_LABELS = { 1: "low", 2: "medium", 3: "high" };
1372
+ for (const s of changes) {
1373
+ const task = tasks.find((t) => t.id === s.id);
1374
+ const oldPrio = task?.priority ? PRIO_LABELS[task.priority] : "none";
1375
+ const newPrio = PRIO_LABELS[s.priority] ?? String(s.priority);
1376
+ console.log(` ${s.id.slice(0, 8)} ${(task?.title ?? "").slice(0, 40).padEnd(40)} ${oldPrio} → ${newPrio}`);
1377
+ console.log(chalk.dim(` ${s.reason}`));
1378
+ }
1379
+ if (opts.dryRun) {
1380
+ console.log(chalk.dim("\n[dry-run] No changes applied."));
1381
+ process.exit(0);
1382
+ }
1383
+ if (!opts.yes) {
1384
+ const { createInterface } = await import("readline");
1385
+ const confirmed = await new Promise((resolve) => {
1386
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
1387
+ rl.question("\nApply these priority changes? [Y/n] ", (ans) => {
1388
+ rl.close();
1389
+ resolve(ans === "" || ans.toLowerCase() === "y");
1390
+ });
1391
+ });
1392
+ if (!confirmed) {
1393
+ console.log("Aborted.");
1394
+ process.exit(0);
1395
+ }
1396
+ }
1397
+ for (const s of changes) {
1398
+ await runtime.updateTask(s.id, boardId, { priority: s.priority });
1399
+ }
1400
+ console.log(chalk.green(`✓ Applied ${changes.length} priority update(s)`));
1401
+ }
1402
+ catch (err) {
1403
+ console.error(chalk.red(String(err)));
1404
+ exitCode = 1;
1405
+ }
1406
+ finally {
1407
+ await runtime.disconnect();
1408
+ process.exit(exitCode);
1409
+ }
1410
+ });
1411
+ // ---- CSV helpers ----
1412
+ function csvEscape(val) {
1413
+ if (!val)
1414
+ return "";
1415
+ if (val.includes(",") || val.includes('"') || val.includes("\n")) {
1416
+ return '"' + val.replace(/"/g, '""') + '"';
1417
+ }
1418
+ return val;
1419
+ }
1420
+ function parseCSVLine(line) {
1421
+ const fields = [];
1422
+ let i = 0;
1423
+ while (i <= line.length) {
1424
+ if (i === line.length) {
1425
+ fields.push("");
1426
+ break;
1427
+ }
1428
+ if (line[i] === '"') {
1429
+ let field = "";
1430
+ i++;
1431
+ while (i < line.length) {
1432
+ if (line[i] === '"' && line[i + 1] === '"') {
1433
+ field += '"';
1434
+ i += 2;
1435
+ }
1436
+ else if (line[i] === '"') {
1437
+ i++;
1438
+ break;
1439
+ }
1440
+ else {
1441
+ field += line[i++];
1442
+ }
1443
+ }
1444
+ fields.push(field);
1445
+ if (line[i] === ",")
1446
+ i++;
1447
+ }
1448
+ else {
1449
+ const end = line.indexOf(",", i);
1450
+ if (end === -1) {
1451
+ fields.push(line.slice(i));
1452
+ break;
1453
+ }
1454
+ else {
1455
+ fields.push(line.slice(i, end));
1456
+ i = end + 1;
1457
+ }
1458
+ }
1459
+ }
1460
+ return fields;
1461
+ }
1462
+ function parseCSV(text) {
1463
+ const lines = text.split(/\r?\n/).filter((l) => l.trim() !== "");
1464
+ if (lines.length < 2)
1465
+ return [];
1466
+ const headers = parseCSVLine(lines[0]);
1467
+ return lines.slice(1).map((line) => {
1468
+ const values = parseCSVLine(line);
1469
+ const row = {};
1470
+ headers.forEach((h, idx) => { row[h.trim()] = (values[idx] ?? "").trim(); });
1471
+ return row;
1472
+ });
1473
+ }
1474
+ function npubOrHexToHex(val) {
1475
+ if (val.startsWith("npub1")) {
1476
+ try {
1477
+ const decoded = nip19.decode(val);
1478
+ if (decoded.type === "npub")
1479
+ return decoded.data;
1480
+ }
1481
+ catch { /* fall through */ }
1482
+ }
1483
+ return val;
1484
+ }
1485
+ // ---- export ----
1486
+ program
1487
+ .command("export")
1488
+ .description("Export tasks to JSON, CSV, or Markdown")
1489
+ .option("--board <id|name>", "Board to export from")
1490
+ .option("--format <json|csv|md>", "Output format (default: json)", "json")
1491
+ .option("--status <open|done|any>", "Status filter (default: open)", "open")
1492
+ .option("--output <file>", "Write to file instead of stdout")
1493
+ .action(async (opts) => {
1494
+ const config = await loadConfig();
1495
+ const boardId = await resolveBoardId(opts.board, config);
1496
+ const runtime = initRuntime(config);
1497
+ let exitCode = 0;
1498
+ try {
1499
+ const tasks = await runtime.listTasks({
1500
+ boardId,
1501
+ status: opts.status,
1502
+ refresh: false,
1503
+ });
1504
+ const boardEntry = config.boards.find((b) => b.id === boardId);
1505
+ let output = "";
1506
+ if (opts.format === "json") {
1507
+ output = JSON.stringify(tasks, null, 2);
1508
+ }
1509
+ else if (opts.format === "csv") {
1510
+ const CSV_HEADER = "id,title,status,priority,dueISO,column,boardName,note,subtasks,createdAt";
1511
+ const rows = tasks.map((t) => {
1512
+ const subtaskStr = (t.subtasks ?? []).map((s) => s.title).join("|");
1513
+ return [
1514
+ csvEscape(t.id),
1515
+ csvEscape(t.title),
1516
+ csvEscape(t.completed ? "done" : "open"),
1517
+ csvEscape(t.priority ? String(t.priority) : ""),
1518
+ csvEscape(t.dueISO ? t.dueISO.slice(0, 10) : ""),
1519
+ csvEscape(t.column ?? ""),
1520
+ csvEscape(t.boardName ?? ""),
1521
+ csvEscape(t.note ?? ""),
1522
+ csvEscape(subtaskStr),
1523
+ csvEscape(t.createdAt ? String(t.createdAt) : ""),
1524
+ ].join(",");
1525
+ });
1526
+ output = [CSV_HEADER, ...rows].join("\n") + "\n";
1527
+ }
1528
+ else if (opts.format === "md") {
1529
+ const boardName = boardEntry?.name ?? boardId.slice(0, 8);
1530
+ const statusLabel = opts.status === "done" ? "Done Tasks" : opts.status === "any" ? "All Tasks" : "Open Tasks";
1531
+ const lines = [`## ${statusLabel} — ${boardName}`, ""];
1532
+ // Group by column
1533
+ const byColumn = new Map();
1534
+ for (const t of tasks) {
1535
+ const colId = t.column ?? "";
1536
+ const group = byColumn.get(colId) ?? [];
1537
+ group.push(t);
1538
+ byColumn.set(colId, group);
1539
+ }
1540
+ for (const [colId, colTasks] of byColumn) {
1541
+ let colName = colId;
1542
+ if (boardEntry?.columns) {
1543
+ const col = boardEntry.columns.find((c) => c.id === colId);
1544
+ if (col)
1545
+ colName = col.name;
1546
+ }
1547
+ if (!colId)
1548
+ colName = "No Column";
1549
+ lines.push(`### ${colName}`, "");
1550
+ for (const t of colTasks) {
1551
+ const check = t.completed ? "x" : " ";
1552
+ const meta = [];
1553
+ if (t.priority)
1554
+ meta.push(`priority: ${t.priority === 3 ? "high" : t.priority === 2 ? "medium" : "low"}`);
1555
+ if (t.dueISO)
1556
+ meta.push(`due: ${t.dueISO.slice(0, 10)}`);
1557
+ const metaStr = meta.length > 0 ? ` *(${meta.join(", ")})*` : "";
1558
+ lines.push(`- [${check}] ${t.title}${metaStr}`);
1559
+ for (const s of t.subtasks ?? []) {
1560
+ const sc = s.completed ? "x" : " ";
1561
+ lines.push(` - [${sc}] ${s.title}`);
1562
+ }
1563
+ }
1564
+ lines.push("");
1565
+ }
1566
+ output = lines.join("\n");
1567
+ }
1568
+ else {
1569
+ console.error(chalk.red(`Unknown format: "${opts.format}". Use: json, csv, md`));
1570
+ exitCode = 1;
1571
+ }
1572
+ if (exitCode === 0) {
1573
+ if (opts.output) {
1574
+ await writeFile(opts.output, output, "utf-8");
1575
+ process.stderr.write(`✓ Exported ${tasks.length} tasks → ${opts.output}\n`);
1576
+ }
1577
+ else {
1578
+ process.stdout.write(output);
1579
+ }
1580
+ }
1581
+ }
1582
+ catch (err) {
1583
+ console.error(chalk.red(String(err)));
1584
+ exitCode = 1;
1585
+ }
1586
+ finally {
1587
+ await runtime.disconnect();
1588
+ process.exit(exitCode);
1589
+ }
1590
+ });
1591
+ // ---- import ----
1592
+ program
1593
+ .command("import <file>")
1594
+ .description("Import tasks from a JSON or CSV file")
1595
+ .option("--board <id|name>", "Board to import into")
1596
+ .option("--dry-run", "Print preview but do not create tasks")
1597
+ .option("--yes", "Skip confirmation prompt")
1598
+ .action(async (file, opts) => {
1599
+ const config = await loadConfig();
1600
+ const boardId = await resolveBoardId(opts.board, config);
1601
+ let raw;
1602
+ try {
1603
+ raw = await readFile(file, "utf-8");
1604
+ }
1605
+ catch {
1606
+ console.error(chalk.red(`Cannot read file: ${file}`));
1607
+ process.exit(1);
1608
+ }
1609
+ let rows = [];
1610
+ const ext = file.split(".").pop()?.toLowerCase();
1611
+ if (ext === "json") {
1612
+ let parsed;
1613
+ try {
1614
+ parsed = JSON.parse(raw);
1615
+ }
1616
+ catch {
1617
+ console.error(chalk.red("Invalid JSON file"));
1618
+ process.exit(1);
1619
+ }
1620
+ if (!Array.isArray(parsed)) {
1621
+ console.error(chalk.red("JSON file must be an array of objects"));
1622
+ process.exit(1);
1623
+ }
1624
+ rows = parsed.map((obj) => ({
1625
+ title: String(obj.title ?? ""),
1626
+ note: obj.note ? String(obj.note) : undefined,
1627
+ priority: [1, 2, 3].includes(Number(obj.priority)) ? Number(obj.priority) : undefined,
1628
+ dueISO: obj.dueISO ? String(obj.dueISO) : undefined,
1629
+ column: obj.column ? String(obj.column) : undefined,
1630
+ subtasks: Array.isArray(obj.subtasks)
1631
+ ? obj.subtasks.map((s) => typeof s === "string" ? s : s.title ? String(s.title) : "").filter(Boolean)
1632
+ : undefined,
1633
+ }));
1634
+ }
1635
+ else if (ext === "csv") {
1636
+ const csvRows = parseCSV(raw);
1637
+ rows = csvRows.map((r) => ({
1638
+ title: r.title ?? "",
1639
+ note: r.note || undefined,
1640
+ priority: [1, 2, 3].includes(Number(r.priority)) ? Number(r.priority) : undefined,
1641
+ dueISO: r.dueISO || undefined,
1642
+ column: r.column || undefined,
1643
+ subtasks: r.subtasks ? r.subtasks.split("|").map((s) => s.trim()).filter(Boolean) : undefined,
1644
+ }));
1645
+ }
1646
+ else {
1647
+ console.error(chalk.red(`Unsupported file extension: .${ext}. Use .json or .csv`));
1648
+ process.exit(1);
1649
+ }
1650
+ // Validate: check for missing titles
1651
+ const invalid = rows.map((r, i) => ({ i, r })).filter(({ r }) => !r.title.trim());
1652
+ if (invalid.length > 0) {
1653
+ console.error(chalk.red(`Invalid rows (missing title): ${invalid.map(({ i }) => i + 1).join(", ")}`));
1654
+ process.exit(1);
1655
+ }
1656
+ if (rows.length === 0) {
1657
+ console.log(chalk.dim("No rows to import."));
1658
+ process.exit(0);
1659
+ }
1660
+ // Print preview table
1661
+ console.log(chalk.bold(`\nImport preview (${rows.length} tasks):`));
1662
+ console.log(chalk.dim(` ${"TITLE".padEnd(36)} ${"PRI".padEnd(4)} ${"DUE".padEnd(12)} COLUMN`));
1663
+ for (const r of rows) {
1664
+ const t = (r.title.length > 36 ? r.title.slice(0, 35) + "…" : r.title).padEnd(36);
1665
+ const p = (r.priority ? String(r.priority) : "-").padEnd(4);
1666
+ const d = (r.dueISO ? r.dueISO.slice(0, 10) : "").padEnd(12);
1667
+ const c = r.column ?? "";
1668
+ console.log(` ${t} ${p} ${d} ${c}`);
1669
+ }
1670
+ if (opts.dryRun) {
1671
+ console.log(chalk.dim("\n[dry-run] No tasks created."));
1672
+ process.exit(0);
1673
+ }
1674
+ if (!opts.yes) {
1675
+ const { createInterface } = await import("readline");
1676
+ const confirmed = await new Promise((resolve) => {
1677
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
1678
+ rl.question("\nProceed? [Y/n] ", (ans) => {
1679
+ rl.close();
1680
+ resolve(ans === "" || ans.toLowerCase() === "y");
1681
+ });
1682
+ });
1683
+ if (!confirmed) {
1684
+ console.log("Aborted.");
1685
+ process.exit(0);
1686
+ }
1687
+ }
1688
+ const runtime = initRuntime(config);
1689
+ const boardEntry = config.boards.find((b) => b.id === boardId);
1690
+ let exitCode = 0;
1691
+ try {
1692
+ // Check existing tasks to detect duplicates
1693
+ const existing = await runtime.listTasks({ boardId, status: "any" });
1694
+ const existingTitles = new Set(existing.map((t) => t.title.toLowerCase()));
1695
+ let created = 0;
1696
+ for (let i = 0; i < rows.length; i++) {
1697
+ const r = rows[i];
1698
+ if (existingTitles.has(r.title.toLowerCase())) {
1699
+ console.log(chalk.yellow(`⚠ Skipping duplicate: ${r.title}`));
1700
+ continue;
1701
+ }
1702
+ // Resolve column
1703
+ let colId;
1704
+ if (r.column) {
1705
+ const col = resolveColumn(boardEntry, r.column);
1706
+ if (col)
1707
+ colId = col.id;
1708
+ }
1709
+ const subtasks = (r.subtasks ?? []).map((text) => ({
1710
+ id: crypto.randomUUID(),
1711
+ title: text,
1712
+ completed: false,
1713
+ }));
1714
+ await runtime.createTaskFull({
1715
+ title: r.title,
1716
+ note: r.note ?? "",
1717
+ boardId,
1718
+ dueISO: r.dueISO,
1719
+ priority: r.priority,
1720
+ columnId: colId,
1721
+ subtasks: subtasks.length > 0 ? subtasks : undefined,
1722
+ });
1723
+ created++;
1724
+ console.log(chalk.green(` [${created}/${rows.length}] ✓ ${r.title}`));
1725
+ }
1726
+ console.log(chalk.green(`✓ Imported ${created}/${rows.length} tasks`));
1727
+ }
1728
+ catch (err) {
1729
+ console.error(chalk.red(String(err)));
1730
+ exitCode = 1;
1731
+ }
1732
+ finally {
1733
+ await runtime.disconnect();
1734
+ process.exit(exitCode);
1735
+ }
1736
+ });
1737
+ // ---- inbox ----
1738
+ const inboxCmd = program
1739
+ .command("inbox")
1740
+ .description("Manage inbox tasks (quick capture and triage)");
1741
+ inboxCmd
1742
+ .command("list")
1743
+ .description("List inbox tasks (inboxItem: true)")
1744
+ .option("--board <id|name>", "Board to list from")
1745
+ .action(async (opts) => {
1746
+ const config = await loadConfig();
1747
+ const boardId = await resolveBoardId(opts.board, config);
1748
+ const runtime = initRuntime(config);
1749
+ let exitCode = 0;
1750
+ try {
1751
+ const tasks = await runtime.listTasks({ boardId, status: "open" });
1752
+ const inboxTasks = tasks.filter((t) => t.inboxItem === true);
1753
+ if (inboxTasks.length === 0) {
1754
+ console.log(chalk.dim("No inbox tasks."));
1755
+ }
1756
+ else {
1757
+ renderTable(inboxTasks, config.trustedNpubs);
1758
+ }
1759
+ }
1760
+ catch (err) {
1761
+ console.error(chalk.red(String(err)));
1762
+ exitCode = 1;
1763
+ }
1764
+ finally {
1765
+ await runtime.disconnect();
1766
+ process.exit(exitCode);
1767
+ }
1768
+ });
1769
+ inboxCmd
1770
+ .command("add <title>")
1771
+ .description("Capture a task to inbox (inboxItem: true)")
1772
+ .option("--board <id|name>", "Board to add to")
1773
+ .action(async (title, opts) => {
1774
+ const config = await loadConfig();
1775
+ const boardId = await resolveBoardId(opts.board, config);
1776
+ const boardEntry = config.boards.find((b) => b.id === boardId);
1777
+ if (boardEntry.kind === "compound") {
1778
+ console.error(chalk.red("Cannot add tasks to a compound board."));
1779
+ process.exit(1);
1780
+ }
1781
+ const runtime = initRuntime(config);
1782
+ let exitCode = 0;
1783
+ try {
1784
+ await runtime.createTaskFull({
1785
+ title,
1786
+ note: "",
1787
+ boardId,
1788
+ inboxItem: true,
1789
+ });
1790
+ console.log(chalk.green(`✓ Inbox: ${title}`));
1791
+ }
1792
+ catch (err) {
1793
+ console.error(chalk.red(String(err)));
1794
+ exitCode = 1;
1795
+ }
1796
+ finally {
1797
+ await runtime.disconnect();
1798
+ process.exit(exitCode);
1799
+ }
1800
+ });
1801
+ inboxCmd
1802
+ .command("triage <taskId>")
1803
+ .description("Triage an inbox task: assign column, priority, due date")
1804
+ .option("--board <id|name>", "Board the task belongs to")
1805
+ .option("--column <id|name>", "Column to assign")
1806
+ .option("--priority <1|2|3>", "Priority")
1807
+ .option("--due <YYYY-MM-DD>", "Due date")
1808
+ .option("--yes", "Apply flags directly without prompting")
1809
+ .action(async (taskId, opts) => {
1810
+ validateDue(opts.due);
1811
+ validatePriority(opts.priority);
1812
+ warnShortTaskId(taskId);
1813
+ const config = await loadConfig();
1814
+ const boardId = await resolveBoardId(opts.board, config);
1815
+ const boardEntry = config.boards.find((b) => b.id === boardId);
1816
+ const runtime = initRuntime(config);
1817
+ let exitCode = 0;
1818
+ try {
1819
+ const task = await runtime.getTask(taskId, boardId);
1820
+ if (!task) {
1821
+ console.error(chalk.red(`Task not found: ${taskId}`));
1822
+ exitCode = 1;
1823
+ }
1824
+ else {
1825
+ // Show task details
1826
+ console.log(chalk.bold(`\nTask: ${task.title}`));
1827
+ if (task.note)
1828
+ console.log(` Note: ${task.note}`);
1829
+ if (task.priority)
1830
+ console.log(` Priority: ${task.priority}`);
1831
+ if (task.dueISO)
1832
+ console.log(` Due: ${task.dueISO.slice(0, 10)}`);
1833
+ console.log();
1834
+ let colId = null;
1835
+ let colName = null;
1836
+ let priority = null;
1837
+ let dueISO = null;
1838
+ if (opts.yes) {
1839
+ // Apply flags directly
1840
+ if (opts.column) {
1841
+ const col = resolveColumn(boardEntry, opts.column);
1842
+ if (col) {
1843
+ colId = col.id;
1844
+ colName = col.name;
1845
+ }
1846
+ }
1847
+ if (opts.priority)
1848
+ priority = parseInt(opts.priority, 10);
1849
+ if (opts.due)
1850
+ dueISO = opts.due;
1851
+ }
1852
+ else {
1853
+ const { createInterface } = await import("readline");
1854
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
1855
+ const ask = (q) => new Promise((resolve) => rl.question(q, (ans) => resolve(ans.trim())));
1856
+ const currentCol = task.column
1857
+ ? (boardEntry.columns?.find((c) => c.id === task.column)?.name ?? task.column)
1858
+ : "none";
1859
+ const colAns = await ask(`Column [${currentCol}]: `);
1860
+ if (colAns) {
1861
+ const col = resolveColumn(boardEntry, colAns);
1862
+ if (col) {
1863
+ colId = col.id;
1864
+ colName = col.name;
1865
+ }
1866
+ else
1867
+ process.stderr.write(`⚠ Column not found — skipping column change\n`);
1868
+ }
1869
+ const priAns = await ask(`Priority [${task.priority ?? "none"}]: `);
1870
+ if (priAns && ["1", "2", "3"].includes(priAns)) {
1871
+ priority = parseInt(priAns, 10);
1872
+ }
1873
+ const dueAns = await ask(`Due date [${task.dueISO ? task.dueISO.slice(0, 10) : "none"}]: `);
1874
+ if (dueAns && /^\d{4}-\d{2}-\d{2}$/.test(dueAns)) {
1875
+ dueISO = dueAns;
1876
+ }
1877
+ else if (dueAns) {
1878
+ process.stderr.write(`⚠ Invalid due date format — skipping\n`);
1879
+ }
1880
+ rl.close();
1881
+ }
1882
+ const patch = { inboxItem: false };
1883
+ if (colId !== null)
1884
+ patch.columnId = colId;
1885
+ if (priority !== null)
1886
+ patch.priority = priority;
1887
+ if (dueISO !== null)
1888
+ patch.dueISO = dueISO;
1889
+ const updated = await runtime.updateTask(taskId, boardId, patch);
1890
+ if (!updated) {
1891
+ console.error(chalk.red("Failed to update task"));
1892
+ exitCode = 1;
1893
+ }
1894
+ else {
1895
+ const parts = [];
1896
+ if (colName)
1897
+ parts.push(`column: ${colName}`);
1898
+ if (priority)
1899
+ parts.push(`priority: ${priority}`);
1900
+ if (dueISO)
1901
+ parts.push(`due: ${dueISO}`);
1902
+ const detail = parts.length > 0 ? ` → ${parts.join(", ")}` : "";
1903
+ console.log(chalk.green(`✓ Triaged: ${updated.title}${detail}`));
1904
+ }
1905
+ }
1906
+ }
1907
+ catch (err) {
1908
+ console.error(chalk.red(String(err)));
1909
+ exitCode = 1;
1910
+ }
1911
+ finally {
1912
+ await runtime.disconnect();
1913
+ process.exit(exitCode);
1914
+ }
1915
+ });
1916
+ // ---- board create ----
1917
+ boardCmd
1918
+ .command("create <name>")
1919
+ .description("Create and publish a new board")
1920
+ .option("--kind <lists|week>", "Board kind (default: lists)", "lists")
1921
+ .option("--relay <url>", "Relay URL hint (informational)")
1922
+ .action(async (name, opts) => {
1923
+ if (!["lists", "week"].includes(opts.kind)) {
1924
+ console.error(chalk.red(`Invalid --kind: "${opts.kind}". Use: lists or week`));
1925
+ process.exit(1);
1926
+ }
1927
+ const kind = opts.kind;
1928
+ const config = await loadConfig();
1929
+ const runtime = initRuntime(config);
1930
+ let exitCode = 0;
1931
+ try {
1932
+ let columns = [];
1933
+ if (kind === "lists") {
1934
+ const { createInterface } = await import("readline");
1935
+ const answer = await new Promise((resolve) => {
1936
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
1937
+ rl.question("Column names (comma-separated, or blank for none): ", (ans) => {
1938
+ rl.close();
1939
+ resolve(ans.trim());
1940
+ });
1941
+ });
1942
+ if (answer) {
1943
+ columns = answer.split(",").map((n) => n.trim()).filter(Boolean).map((n) => ({
1944
+ id: crypto.randomUUID(),
1945
+ name: n,
1946
+ }));
1947
+ }
1948
+ }
1949
+ const { boardId } = await runtime.createBoard({ name, kind, columns });
1950
+ console.log(chalk.green(`✓ Created board: ${name} [id: ${boardId}] [kind: ${kind}]`));
1951
+ console.log(chalk.dim(" Joined automatically. Run: taskify board sync to confirm."));
1952
+ }
1953
+ catch (err) {
1954
+ console.error(chalk.red(String(err)));
1955
+ exitCode = 1;
1956
+ }
1957
+ finally {
1958
+ await runtime.disconnect();
1959
+ process.exit(exitCode);
1960
+ }
1961
+ });
1962
+ // ---- assign ----
1963
+ program
1964
+ .command("assign <taskId> <npubOrHex>")
1965
+ .description("Assign a task to a user (npub or hex pubkey)")
1966
+ .option("--board <id|name>", "Board the task belongs to")
1967
+ .action(async (taskId, npubOrHex, opts) => {
1968
+ warnShortTaskId(taskId);
1969
+ const hex = npubOrHexToHex(npubOrHex);
1970
+ const config = await loadConfig();
1971
+ const boardId = await resolveBoardId(opts.board, config);
1972
+ const runtime = initRuntime(config);
1973
+ let exitCode = 0;
1974
+ try {
1975
+ const task = await runtime.getTask(taskId, boardId);
1976
+ if (!task) {
1977
+ console.error(chalk.red(`Task not found: ${taskId}`));
1978
+ exitCode = 1;
1979
+ }
1980
+ else {
1981
+ const existing = task.assignees ?? [];
1982
+ if (existing.includes(hex)) {
1983
+ console.log(chalk.dim(`Already assigned: ${npubOrHex}`));
1984
+ }
1985
+ else {
1986
+ const updated = await runtime.updateTask(taskId, boardId, {
1987
+ assignees: [...existing, hex],
1988
+ });
1989
+ if (!updated) {
1990
+ console.error(chalk.red("Failed to update task"));
1991
+ exitCode = 1;
1992
+ }
1993
+ else {
1994
+ console.log(chalk.green(`✓ Assigned to: ${updated.title}`));
1995
+ }
1996
+ }
1997
+ }
1998
+ }
1999
+ catch (err) {
2000
+ console.error(chalk.red(String(err)));
2001
+ exitCode = 1;
2002
+ }
2003
+ finally {
2004
+ await runtime.disconnect();
2005
+ process.exit(exitCode);
2006
+ }
2007
+ });
2008
+ // ---- unassign ----
2009
+ program
2010
+ .command("unassign <taskId> <npubOrHex>")
2011
+ .description("Remove an assignee from a task")
2012
+ .option("--board <id|name>", "Board the task belongs to")
2013
+ .action(async (taskId, npubOrHex, opts) => {
2014
+ warnShortTaskId(taskId);
2015
+ const hex = npubOrHexToHex(npubOrHex);
2016
+ const config = await loadConfig();
2017
+ const boardId = await resolveBoardId(opts.board, config);
2018
+ const runtime = initRuntime(config);
2019
+ let exitCode = 0;
2020
+ try {
2021
+ const task = await runtime.getTask(taskId, boardId);
2022
+ if (!task) {
2023
+ console.error(chalk.red(`Task not found: ${taskId}`));
2024
+ exitCode = 1;
2025
+ }
2026
+ else {
2027
+ const filtered = (task.assignees ?? []).filter((a) => a !== hex);
2028
+ const updated = await runtime.updateTask(taskId, boardId, {
2029
+ assignees: filtered,
2030
+ });
2031
+ if (!updated) {
2032
+ console.error(chalk.red("Failed to update task"));
2033
+ exitCode = 1;
2034
+ }
2035
+ else {
2036
+ console.log(chalk.green(`✓ Unassigned from: ${updated.title}`));
2037
+ }
2038
+ }
2039
+ }
2040
+ catch (err) {
2041
+ console.error(chalk.red(String(err)));
2042
+ exitCode = 1;
2043
+ }
2044
+ finally {
2045
+ await runtime.disconnect();
2046
+ process.exit(exitCode);
2047
+ }
2048
+ });
2049
+ // ---- setup ----
2050
+ program
2051
+ .command("setup")
2052
+ .description("Run the first-run onboarding wizard (re-configure or add a new key)")
2053
+ .action(async () => {
2054
+ const existing = await loadConfig();
2055
+ if (existing.nsec) {
2056
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
2057
+ const ans = await new Promise((resolve) => {
2058
+ rl.question("⚠ You already have a private key configured. This will replace it.\nContinue? [Y/n] ", resolve);
2059
+ });
2060
+ rl.close();
2061
+ if (ans.trim().toLowerCase() === "n") {
2062
+ process.exit(0);
2063
+ }
2064
+ }
2065
+ await runOnboarding();
2066
+ });
2067
+ // ---- auto-onboarding trigger + parse ----
2068
+ const cfg = await loadConfig();
2069
+ if (!cfg.nsec && process.argv.length <= 2) {
2070
+ await runOnboarding();
2071
+ }
2072
+ else {
2073
+ program.parse(process.argv);
2074
+ }