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/README.md +271 -0
- package/dist/aiClient.js +40 -0
- package/dist/completions.js +637 -0
- package/dist/config.js +39 -0
- package/dist/index.js +2074 -0
- package/dist/nostrRuntime.js +888 -0
- package/dist/onboarding.js +93 -0
- package/dist/render.js +207 -0
- package/dist/shared/agentDispatcher.js +595 -0
- package/dist/shared/agentIdempotency.js +50 -0
- package/dist/shared/agentRuntime.js +7 -0
- package/dist/shared/agentSecurity.js +161 -0
- package/dist/shared/boardUtils.js +441 -0
- package/dist/shared/dateUtils.js +123 -0
- package/dist/shared/nostr.js +70 -0
- package/dist/shared/settingsTypes.js +23 -0
- package/dist/shared/taskTypes.js +12 -0
- package/dist/shared/taskUtils.js +261 -0
- package/dist/taskCache.js +59 -0
- package/package.json +44 -0
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
|
+
}
|