synap 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/src/cli.js ADDED
@@ -0,0 +1,1734 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * synap CLI
5
+ * A CLI for externalizing your working memory
6
+ */
7
+
8
+ const { program } = require('commander');
9
+ const pkg = require('../package.json');
10
+
11
+ // Storage and utility modules
12
+ const storage = require('./storage');
13
+ const deletionLog = require('./deletion-log');
14
+ const preferences = require('./preferences');
15
+
16
+ async function main() {
17
+ // Dynamic imports for ESM-only packages
18
+ const chalk = (await import('chalk')).default;
19
+ const boxen = (await import('boxen')).default;
20
+
21
+ // Check for updates (non-blocking)
22
+ const updateNotifier = (await import('update-notifier')).default;
23
+ updateNotifier({ pkg }).notify();
24
+
25
+ program
26
+ .name('synap')
27
+ .description('A CLI for externalizing your working memory')
28
+ .version(pkg.version);
29
+
30
+ // Load configuration
31
+ const config = storage.loadConfig();
32
+
33
+ // Helper: Merge config tags with CLI tags
34
+ const mergeTags = (cliTagsString) => {
35
+ const cliTags = cliTagsString ? cliTagsString.split(',').map(t => t.trim()) : [];
36
+ return [...new Set([...(config.defaultTags || []), ...cliTags])];
37
+ };
38
+
39
+ // Helper: Format time based on config.dateFormat
40
+ const formatTime = (dateStr) => {
41
+ const date = new Date(dateStr);
42
+
43
+ if (config.dateFormat === 'relative') {
44
+ const now = new Date();
45
+ const diffMs = now - date;
46
+ const diffSec = Math.floor(diffMs / 1000);
47
+ const diffMin = Math.floor(diffSec / 60);
48
+ const diffHour = Math.floor(diffMin / 60);
49
+ const diffDay = Math.floor(diffHour / 24);
50
+
51
+ if (diffSec < 60) return 'just now';
52
+ if (diffMin < 60) return `${diffMin}m ago`;
53
+ if (diffHour < 24) return `${diffHour}h ago`;
54
+ if (diffDay < 7) return `${diffDay}d ago`;
55
+ return date.toLocaleDateString();
56
+ }
57
+
58
+ if (config.dateFormat === 'absolute') {
59
+ return date.toISOString();
60
+ }
61
+
62
+ // 'locale' or default
63
+ return date.toLocaleString();
64
+ };
65
+
66
+ // ============================================
67
+ // CAPTURE COMMANDS
68
+ // ============================================
69
+
70
+ program
71
+ .command('add <content...>')
72
+ .description(`Add a new entry (default: ${config.defaultType || 'idea'})`)
73
+ .option('-t, --type <type>', 'Entry type (idea, project, feature, todo, question, reference, note)', config.defaultType || 'idea')
74
+ .option('--title <title>', 'Short title for the entry')
75
+ .option('-p, --priority <priority>', 'Priority level (1=high, 2=medium, 3=low)')
76
+ .option('--tags <tags>', 'Comma-separated tags')
77
+ .option('--parent <id>', 'Parent entry ID')
78
+ .option('--json', 'Output as JSON')
79
+ .action(async (contentParts, options) => {
80
+ const content = contentParts.join(' ');
81
+ const tags = mergeTags(options.tags);
82
+
83
+ const entry = await storage.addEntry({
84
+ content,
85
+ title: options.title,
86
+ type: options.type,
87
+ priority: options.priority ? parseInt(options.priority, 10) : undefined,
88
+ tags,
89
+ parent: options.parent,
90
+ source: 'cli'
91
+ });
92
+
93
+ if (options.json) {
94
+ console.log(JSON.stringify({ success: true, entry }, null, 2));
95
+ } else {
96
+ const shortId = entry.id.slice(0, 8);
97
+ const priorityBadge = entry.priority ? chalk.yellow(`[P${entry.priority}] `) : '';
98
+ console.log(chalk.green(`Added ${entry.type} ${shortId}: `) + priorityBadge + `"${content.slice(0, 50)}${content.length > 50 ? '...' : ''}"`);
99
+ }
100
+ });
101
+
102
+ program
103
+ .command('todo <content...>')
104
+ .description('Add a new todo (shorthand for add --type todo)')
105
+ .option('-p, --priority <priority>', 'Priority level (1=high, 2=medium, 3=low)')
106
+ .option('--tags <tags>', 'Comma-separated tags')
107
+ .option('--parent <id>', 'Parent entry ID')
108
+ .option('--json', 'Output as JSON')
109
+ .action(async (contentParts, options) => {
110
+ const content = contentParts.join(' ');
111
+ const tags = mergeTags(options.tags);
112
+
113
+ const entry = await storage.addEntry({
114
+ content,
115
+ type: 'todo',
116
+ priority: options.priority ? parseInt(options.priority, 10) : undefined,
117
+ tags,
118
+ parent: options.parent,
119
+ source: 'cli'
120
+ });
121
+
122
+ if (options.json) {
123
+ console.log(JSON.stringify({ success: true, entry }, null, 2));
124
+ } else {
125
+ const shortId = entry.id.slice(0, 8);
126
+ const priorityBadge = entry.priority ? chalk.yellow(`[P${entry.priority}] `) : '';
127
+ console.log(chalk.green(`Added todo ${shortId}: `) + priorityBadge + `"${content.slice(0, 50)}${content.length > 50 ? '...' : ''}"`);
128
+ }
129
+ });
130
+
131
+ program
132
+ .command('question <content...>')
133
+ .description('Add a new question (shorthand for add --type question)')
134
+ .option('-p, --priority <priority>', 'Priority level (1=high, 2=medium, 3=low)')
135
+ .option('--tags <tags>', 'Comma-separated tags')
136
+ .option('--parent <id>', 'Parent entry ID')
137
+ .option('--json', 'Output as JSON')
138
+ .action(async (contentParts, options) => {
139
+ const content = contentParts.join(' ');
140
+ const tags = mergeTags(options.tags);
141
+
142
+ const entry = await storage.addEntry({
143
+ content,
144
+ type: 'question',
145
+ priority: options.priority ? parseInt(options.priority, 10) : undefined,
146
+ tags,
147
+ parent: options.parent,
148
+ source: 'cli'
149
+ });
150
+
151
+ if (options.json) {
152
+ console.log(JSON.stringify({ success: true, entry }, null, 2));
153
+ } else {
154
+ const shortId = entry.id.slice(0, 8);
155
+ const priorityBadge = entry.priority ? chalk.yellow(`[P${entry.priority}] `) : '';
156
+ console.log(chalk.green(`Added question ${shortId}: `) + priorityBadge + `"${content.slice(0, 50)}${content.length > 50 ? '...' : ''}"`);
157
+ }
158
+ });
159
+
160
+ // Helper function for type shorthand commands
161
+ const createTypeShorthand = (typeName, displayName = typeName) => {
162
+ return async (contentParts, options) => {
163
+ const content = contentParts.join(' ');
164
+ const tags = mergeTags(options.tags);
165
+
166
+ const entry = await storage.addEntry({
167
+ content,
168
+ type: typeName,
169
+ priority: options.priority ? parseInt(options.priority, 10) : undefined,
170
+ tags,
171
+ parent: options.parent,
172
+ source: 'cli'
173
+ });
174
+
175
+ if (options.json) {
176
+ console.log(JSON.stringify({ success: true, entry }, null, 2));
177
+ } else {
178
+ const shortId = entry.id.slice(0, 8);
179
+ const priorityBadge = entry.priority ? chalk.yellow(`[P${entry.priority}] `) : '';
180
+ console.log(chalk.green(`Added ${displayName} ${shortId}: `) + priorityBadge + `"${content.slice(0, 50)}${content.length > 50 ? '...' : ''}"`);
181
+ }
182
+ };
183
+ };
184
+
185
+ program
186
+ .command('idea <content...>')
187
+ .description('Add a new idea (shorthand for add --type idea)')
188
+ .option('-p, --priority <priority>', 'Priority level (1=high, 2=medium, 3=low)')
189
+ .option('--tags <tags>', 'Comma-separated tags')
190
+ .option('--parent <id>', 'Parent entry ID')
191
+ .option('--json', 'Output as JSON')
192
+ .action(createTypeShorthand('idea'));
193
+
194
+ program
195
+ .command('project <content...>')
196
+ .description('Add a new project (shorthand for add --type project)')
197
+ .option('-p, --priority <priority>', 'Priority level (1=high, 2=medium, 3=low)')
198
+ .option('--tags <tags>', 'Comma-separated tags')
199
+ .option('--parent <id>', 'Parent entry ID')
200
+ .option('--json', 'Output as JSON')
201
+ .action(createTypeShorthand('project'));
202
+
203
+ program
204
+ .command('feature <content...>')
205
+ .description('Add a new feature (shorthand for add --type feature)')
206
+ .option('-p, --priority <priority>', 'Priority level (1=high, 2=medium, 3=low)')
207
+ .option('--tags <tags>', 'Comma-separated tags')
208
+ .option('--parent <id>', 'Parent entry ID')
209
+ .option('--json', 'Output as JSON')
210
+ .action(createTypeShorthand('feature'));
211
+
212
+ program
213
+ .command('note <content...>')
214
+ .description('Add a new note (shorthand for add --type note)')
215
+ .option('-p, --priority <priority>', 'Priority level (1=high, 2=medium, 3=low)')
216
+ .option('--tags <tags>', 'Comma-separated tags')
217
+ .option('--parent <id>', 'Parent entry ID')
218
+ .option('--json', 'Output as JSON')
219
+ .action(createTypeShorthand('note'));
220
+
221
+ program
222
+ .command('ref <content...>')
223
+ .description('Add a new reference (shorthand for add --type reference)')
224
+ .option('-p, --priority <priority>', 'Priority level (1=high, 2=medium, 3=low)')
225
+ .option('--tags <tags>', 'Comma-separated tags')
226
+ .option('--parent <id>', 'Parent entry ID')
227
+ .option('--json', 'Output as JSON')
228
+ .action(createTypeShorthand('reference', 'reference'));
229
+
230
+ // ============================================
231
+ // QUERY COMMANDS
232
+ // ============================================
233
+
234
+ program
235
+ .command('list')
236
+ .description('List entries')
237
+ .option('-t, --type <type>', 'Filter by type')
238
+ .option('-s, --status <status>', 'Filter by status (default: raw,active)')
239
+ .option('--tags <tags>', 'Filter by tags (comma-separated, AND logic)')
240
+ .option('--any-tags <tags>', 'Filter by tags (comma-separated, OR logic)')
241
+ .option('--not-type <type>', 'Exclude entries of this type')
242
+ .option('--not-tags <tags>', 'Exclude entries with these tags')
243
+ .option('-p, --priority <priority>', 'Filter by priority')
244
+ .option('--parent <id>', 'Filter by parent')
245
+ .option('--orphans', 'Only entries without parent')
246
+ .option('--since <duration>', 'Created after (e.g., 7d, 24h)')
247
+ .option('--before <duration>', 'Created before (e.g., 7d, 24h)')
248
+ .option('--between <range>', 'Date range: start,end (e.g., 2025-01-01,2025-01-31)')
249
+ .option('-a, --all', 'Include all statuses except archived')
250
+ .option('--done', 'Include done entries')
251
+ .option('--archived', 'Show only archived entries')
252
+ .option('-n, --limit <n>', 'Max entries to return', '50')
253
+ .option('--sort <field>', 'Sort by: created, updated, priority', 'created')
254
+ .option('--reverse', 'Reverse sort order')
255
+ .option('--json', 'Output as JSON')
256
+ .action(async (options) => {
257
+ // Build query from options
258
+ const query = {
259
+ type: options.type,
260
+ status: options.archived ? 'archived' : (options.status || (options.all ? null : 'raw,active')),
261
+ tags: options.tags ? options.tags.split(',').map(t => t.trim()) : undefined,
262
+ anyTags: options.anyTags ? options.anyTags.split(',').map(t => t.trim()) : undefined,
263
+ notType: options.notType,
264
+ notTags: options.notTags ? options.notTags.split(',').map(t => t.trim()) : undefined,
265
+ priority: options.priority ? parseInt(options.priority, 10) : undefined,
266
+ parent: options.parent,
267
+ orphans: options.orphans,
268
+ since: options.since,
269
+ before: options.before,
270
+ between: options.between ? (() => {
271
+ const [start, end] = options.between.split(',');
272
+ return { start: start.trim(), end: end.trim() };
273
+ })() : undefined,
274
+ includeDone: options.done || options.all,
275
+ limit: parseInt(options.limit, 10),
276
+ sort: options.sort,
277
+ reverse: options.reverse
278
+ };
279
+
280
+ const result = await storage.listEntries(query);
281
+
282
+ if (options.json) {
283
+ console.log(JSON.stringify({
284
+ success: true,
285
+ entries: result.entries,
286
+ total: result.total,
287
+ returned: result.entries.length,
288
+ query
289
+ }, null, 2));
290
+ } else {
291
+ if (result.entries.length === 0) {
292
+ console.log(chalk.gray('No entries found.'));
293
+ if (!options.all && !options.archived) {
294
+ console.log(chalk.gray('Tip: Use --all to see all entries, or synap add "your thought" to create one.'));
295
+ }
296
+ return;
297
+ }
298
+
299
+ console.log(chalk.bold(`Entries (${result.entries.length} showing, ${result.total} total)\n`));
300
+
301
+ // Group by type
302
+ const grouped = {};
303
+ for (const entry of result.entries) {
304
+ if (!grouped[entry.type]) grouped[entry.type] = [];
305
+ grouped[entry.type].push(entry);
306
+ }
307
+
308
+ for (const [type, entries] of Object.entries(grouped)) {
309
+ console.log(chalk.bold.cyan(`${type.toUpperCase()}S (${entries.length})`));
310
+ for (const entry of entries) {
311
+ const shortId = entry.id.slice(0, 8);
312
+ const priorityBadge = entry.priority ? chalk.yellow(`[P${entry.priority}]`) : ' ';
313
+ const tags = entry.tags.length > 0 ? chalk.gray(' #' + entry.tags.join(' #')) : '';
314
+ const title = entry.title || entry.content.slice(0, 40);
315
+ const truncated = title.length > 40 ? '...' : '';
316
+ const timeStr = chalk.gray(formatTime(entry.createdAt));
317
+ console.log(` ${chalk.blue(shortId)} ${priorityBadge} ${title}${truncated}${tags} ${timeStr}`);
318
+ }
319
+ console.log('');
320
+ }
321
+
322
+ if (result.entries.length < result.total) {
323
+ console.log(chalk.gray(`Use --limit ${result.total} to see all entries`));
324
+ }
325
+ }
326
+ });
327
+
328
+ program
329
+ .command('show <id>')
330
+ .description('Show full entry details')
331
+ .option('--with-children', 'Include child entries')
332
+ .option('--with-related', 'Include related entries')
333
+ .option('--json', 'Output as JSON')
334
+ .action(async (id, options) => {
335
+ const entry = await storage.getEntry(id);
336
+
337
+ if (!entry) {
338
+ if (options.json) {
339
+ console.log(JSON.stringify({ success: false, error: `Entry not found: ${id}`, code: 'ENTRY_NOT_FOUND' }));
340
+ } else {
341
+ console.error(chalk.red(`Entry not found: ${id}`));
342
+ }
343
+ process.exit(1);
344
+ }
345
+
346
+ let children = [];
347
+ let related = [];
348
+
349
+ if (options.withChildren) {
350
+ children = await storage.getChildren(entry.id);
351
+ }
352
+ if (options.withRelated && entry.related) {
353
+ related = await storage.getEntriesByIds(entry.related);
354
+ }
355
+
356
+ if (options.json) {
357
+ console.log(JSON.stringify({ success: true, entry, children, related }, null, 2));
358
+ } else {
359
+ console.log(chalk.bold(`Entry ${entry.id}\n`));
360
+ console.log(` ${chalk.gray('Type:')} ${entry.type}`);
361
+ console.log(` ${chalk.gray('Status:')} ${entry.status}`);
362
+ if (entry.priority) {
363
+ console.log(` ${chalk.gray('Priority:')} ${chalk.yellow(`P${entry.priority}`)} (${entry.priority === 1 ? 'high' : entry.priority === 2 ? 'medium' : 'low'})`);
364
+ }
365
+ if (entry.tags.length > 0) {
366
+ console.log(` ${chalk.gray('Tags:')} ${entry.tags.map(t => chalk.cyan('#' + t)).join(' ')}`);
367
+ }
368
+ console.log('');
369
+ if (entry.title) {
370
+ console.log(` ${chalk.bold('Title:')} ${entry.title}\n`);
371
+ }
372
+ console.log(` ${chalk.bold('Content:')}`);
373
+ console.log(` ${entry.content.split('\n').join('\n ')}\n`);
374
+
375
+ console.log(` ${chalk.gray('Created:')} ${formatTime(entry.createdAt)}`);
376
+ console.log(` ${chalk.gray('Updated:')} ${formatTime(entry.updatedAt)}`);
377
+ if (entry.source) {
378
+ console.log(` ${chalk.gray('Source:')} ${entry.source}`);
379
+ }
380
+ if (entry.parent) {
381
+ console.log(` ${chalk.gray('Parent:')} ${entry.parent.slice(0, 8)}`);
382
+ }
383
+
384
+ if (children.length > 0) {
385
+ console.log(`\n ${chalk.bold('Children:')}`);
386
+ for (const child of children) {
387
+ console.log(` ${child.id.slice(0, 8)} (${child.type}): ${child.title || child.content.slice(0, 30)}...`);
388
+ }
389
+ }
390
+
391
+ if (related.length > 0) {
392
+ console.log(`\n ${chalk.bold('Related:')}`);
393
+ for (const rel of related) {
394
+ console.log(` ${rel.id.slice(0, 8)} (${rel.type}): ${rel.title || rel.content.slice(0, 30)}...`);
395
+ }
396
+ }
397
+ }
398
+ });
399
+
400
+ program
401
+ .command('search <query...>')
402
+ .description('Full-text search across entries')
403
+ .option('-t, --type <type>', 'Filter by type')
404
+ .option('-s, --status <status>', 'Filter by status')
405
+ .option('--not-type <type>', 'Exclude entries of this type')
406
+ .option('--since <duration>', 'Only search recent entries')
407
+ .option('-n, --limit <n>', 'Max results', '20')
408
+ .option('--json', 'Output as JSON')
409
+ .action(async (queryParts, options) => {
410
+ const query = queryParts.join(' ');
411
+ const result = await storage.searchEntries(query, {
412
+ type: options.type,
413
+ status: options.status,
414
+ notType: options.notType,
415
+ since: options.since,
416
+ limit: parseInt(options.limit, 10)
417
+ });
418
+
419
+ if (options.json) {
420
+ console.log(JSON.stringify({
421
+ success: true,
422
+ query,
423
+ entries: result.entries,
424
+ total: result.total
425
+ }, null, 2));
426
+ } else {
427
+ if (result.entries.length === 0) {
428
+ console.log(chalk.gray(`No entries found matching "${query}"`));
429
+ return;
430
+ }
431
+
432
+ console.log(chalk.bold(`Search results for "${query}" (${result.entries.length} found)\n`));
433
+
434
+ for (const entry of result.entries) {
435
+ const shortId = entry.id.slice(0, 8);
436
+ const priorityBadge = entry.priority ? chalk.yellow(`[P${entry.priority}]`) : ' ';
437
+ const tags = entry.tags.length > 0 ? chalk.gray(' #' + entry.tags.join(' #')) : '';
438
+ const title = entry.title || entry.content.slice(0, 40);
439
+ console.log(` ${chalk.blue(shortId)} ${chalk.cyan(entry.type.padEnd(10))} ${priorityBadge} ${title}${tags}`);
440
+ }
441
+ }
442
+ });
443
+
444
+ // ============================================
445
+ // MODIFY COMMANDS
446
+ // ============================================
447
+
448
+ program
449
+ .command('edit <id>')
450
+ .description('Edit entry content')
451
+ .option('--content <text>', 'Replace content (non-interactive)')
452
+ .option('--title <title>', 'Update title')
453
+ .option('--append <text>', 'Append to content')
454
+ .option('--json', 'Output as JSON')
455
+ .action(async (id, options) => {
456
+ const entry = await storage.getEntry(id);
457
+
458
+ if (!entry) {
459
+ if (options.json) {
460
+ console.log(JSON.stringify({ success: false, error: `Entry not found: ${id}`, code: 'ENTRY_NOT_FOUND' }));
461
+ } else {
462
+ console.error(chalk.red(`Entry not found: ${id}`));
463
+ }
464
+ process.exit(1);
465
+ }
466
+
467
+ const updates = {};
468
+ if (options.content) {
469
+ updates.content = options.content;
470
+ }
471
+ if (options.title) {
472
+ updates.title = options.title;
473
+ }
474
+ if (options.append) {
475
+ updates.content = entry.content + '\n' + options.append;
476
+ }
477
+
478
+ if (Object.keys(updates).length === 0) {
479
+ // Interactive edit - open $EDITOR
480
+ const { execSync } = require('child_process');
481
+ const fs = require('fs');
482
+ const os = require('os');
483
+ const path = require('path');
484
+
485
+ const tmpFile = path.join(os.tmpdir(), `synap-${entry.id.slice(0, 8)}.txt`);
486
+ fs.writeFileSync(tmpFile, entry.content);
487
+
488
+ const editor = config.editor || process.env.EDITOR || 'vi';
489
+ try {
490
+ execSync(`${editor} "${tmpFile}"`, { stdio: 'inherit' });
491
+ updates.content = fs.readFileSync(tmpFile, 'utf8');
492
+ fs.unlinkSync(tmpFile);
493
+ } catch (err) {
494
+ console.error(chalk.red('Editor failed or was cancelled'));
495
+ process.exit(1);
496
+ }
497
+ }
498
+
499
+ const updated = await storage.updateEntry(entry.id, updates);
500
+
501
+ if (options.json) {
502
+ console.log(JSON.stringify({ success: true, entry: updated }, null, 2));
503
+ } else {
504
+ console.log(chalk.green(`Updated entry ${entry.id.slice(0, 8)}`));
505
+ }
506
+ });
507
+
508
+ program
509
+ .command('set <id>')
510
+ .description('Update entry metadata')
511
+ .option('-t, --type <type>', 'Change type')
512
+ .option('-s, --status <status>', 'Change status')
513
+ .option('-p, --priority <priority>', 'Set priority')
514
+ .option('--clear-priority', 'Remove priority')
515
+ .option('--tags <tags>', 'Replace all tags')
516
+ .option('--add-tags <tags>', 'Add tags')
517
+ .option('--remove-tags <tags>', 'Remove tags')
518
+ .option('--parent <id>', 'Set parent')
519
+ .option('--clear-parent', 'Remove parent')
520
+ .option('--json', 'Output as JSON')
521
+ .action(async (id, options) => {
522
+ const entry = await storage.getEntry(id);
523
+
524
+ if (!entry) {
525
+ if (options.json) {
526
+ console.log(JSON.stringify({ success: false, error: `Entry not found: ${id}`, code: 'ENTRY_NOT_FOUND' }));
527
+ } else {
528
+ console.error(chalk.red(`Entry not found: ${id}`));
529
+ }
530
+ process.exit(1);
531
+ }
532
+
533
+ const updates = {};
534
+ if (options.type) updates.type = options.type;
535
+ if (options.status) updates.status = options.status;
536
+ if (options.priority) updates.priority = parseInt(options.priority, 10);
537
+ if (options.clearPriority) updates.priority = null;
538
+ if (options.tags) updates.tags = options.tags.split(',').map(t => t.trim());
539
+ if (options.addTags) {
540
+ const newTags = options.addTags.split(',').map(t => t.trim());
541
+ updates.tags = [...new Set([...entry.tags, ...newTags])];
542
+ }
543
+ if (options.removeTags) {
544
+ const removeTags = options.removeTags.split(',').map(t => t.trim());
545
+ updates.tags = entry.tags.filter(t => !removeTags.includes(t));
546
+ }
547
+ if (options.parent) updates.parent = options.parent;
548
+ if (options.clearParent) updates.parent = null;
549
+
550
+ const updated = await storage.updateEntry(entry.id, updates);
551
+
552
+ if (options.json) {
553
+ console.log(JSON.stringify({ success: true, entry: updated }, null, 2));
554
+ } else {
555
+ console.log(chalk.green(`Updated entry ${entry.id.slice(0, 8)}`));
556
+ }
557
+ });
558
+
559
+ program
560
+ .command('link <id1> <id2>')
561
+ .description('Create relationship between entries')
562
+ .option('--as-parent', 'Set id2 as parent of id1')
563
+ .option('--as-child', 'Set id2 as child of id1')
564
+ .option('--unlink', 'Remove relationship')
565
+ .option('--json', 'Output as JSON')
566
+ .action(async (id1, id2, options) => {
567
+ const entry1 = await storage.getEntry(id1);
568
+ const entry2 = await storage.getEntry(id2);
569
+
570
+ if (!entry1 || !entry2) {
571
+ const missing = !entry1 ? id1 : id2;
572
+ if (options.json) {
573
+ console.log(JSON.stringify({ success: false, error: `Entry not found: ${missing}`, code: 'ENTRY_NOT_FOUND' }));
574
+ } else {
575
+ console.error(chalk.red(`Entry not found: ${missing}`));
576
+ }
577
+ process.exit(1);
578
+ }
579
+
580
+ if (options.asParent) {
581
+ await storage.updateEntry(entry1.id, { parent: entry2.id });
582
+ } else if (options.asChild) {
583
+ await storage.updateEntry(entry2.id, { parent: entry1.id });
584
+ } else if (options.unlink) {
585
+ // Remove from related
586
+ const newRelated = (entry1.related || []).filter(r => !r.startsWith(id2));
587
+ await storage.updateEntry(entry1.id, { related: newRelated });
588
+ } else {
589
+ // Add to related
590
+ const newRelated = [...new Set([...(entry1.related || []), entry2.id])];
591
+ await storage.updateEntry(entry1.id, { related: newRelated });
592
+ }
593
+
594
+ if (options.json) {
595
+ console.log(JSON.stringify({ success: true }, null, 2));
596
+ } else {
597
+ if (options.asParent) {
598
+ console.log(chalk.green(`Set ${id2.slice(0, 8)} as parent of ${id1.slice(0, 8)}`));
599
+ } else if (options.asChild) {
600
+ console.log(chalk.green(`Set ${id2.slice(0, 8)} as child of ${id1.slice(0, 8)}`));
601
+ } else if (options.unlink) {
602
+ console.log(chalk.green(`Unlinked ${id1.slice(0, 8)} and ${id2.slice(0, 8)}`));
603
+ } else {
604
+ console.log(chalk.green(`Linked ${id1.slice(0, 8)} and ${id2.slice(0, 8)}`));
605
+ }
606
+ }
607
+ });
608
+
609
+ // ============================================
610
+ // BULK COMMANDS
611
+ // ============================================
612
+
613
+ program
614
+ .command('done [ids...]')
615
+ .description('Mark entries as done')
616
+ .option('-t, --type <type>', 'Filter by type')
617
+ .option('--tags <tags>', 'Filter by tags')
618
+ .option('--dry-run', 'Show what would be marked done')
619
+ .option('--confirm', 'Skip confirmation prompt')
620
+ .option('--json', 'Output as JSON')
621
+ .action(async (ids, options) => {
622
+ let entries;
623
+
624
+ if (ids.length > 0) {
625
+ entries = await storage.getEntriesByIds(ids);
626
+ } else if (options.type || options.tags) {
627
+ const result = await storage.listEntries({
628
+ type: options.type,
629
+ tags: options.tags ? options.tags.split(',').map(t => t.trim()) : undefined,
630
+ status: 'raw,active',
631
+ limit: 1000
632
+ });
633
+ entries = result.entries;
634
+ } else {
635
+ console.error(chalk.red('Please provide entry IDs or filter options (--type, --tags)'));
636
+ process.exit(1);
637
+ }
638
+
639
+ if (entries.length === 0) {
640
+ if (options.json) {
641
+ console.log(JSON.stringify({ success: true, count: 0, entries: [] }));
642
+ } else {
643
+ console.log(chalk.gray('No entries matched'));
644
+ }
645
+ return;
646
+ }
647
+
648
+ if (options.dryRun) {
649
+ if (options.json) {
650
+ console.log(JSON.stringify({ success: true, dryRun: true, count: entries.length, entries }));
651
+ } else {
652
+ console.log(chalk.yellow(`Would mark ${entries.length} entries as done:`));
653
+ for (const entry of entries) {
654
+ console.log(` ${entry.id.slice(0, 8)} (${entry.type}): ${entry.title || entry.content.slice(0, 40)}...`);
655
+ }
656
+ }
657
+ return;
658
+ }
659
+
660
+ // Mark done
661
+ for (const entry of entries) {
662
+ await storage.updateEntry(entry.id, { status: 'done' });
663
+ }
664
+
665
+ if (options.json) {
666
+ console.log(JSON.stringify({ success: true, count: entries.length }));
667
+ } else {
668
+ console.log(chalk.green(`Marked ${entries.length} entries as done`));
669
+ }
670
+ });
671
+
672
+ program
673
+ .command('archive [ids...]')
674
+ .description('Archive entries')
675
+ .option('-t, --type <type>', 'Filter by type')
676
+ .option('--tags <tags>', 'Filter by tags')
677
+ .option('-s, --status <status>', 'Filter by status')
678
+ .option('--since <duration>', 'Filter by age')
679
+ .option('--dry-run', 'Show what would be archived')
680
+ .option('--confirm', 'Skip confirmation prompt')
681
+ .option('--json', 'Output as JSON')
682
+ .action(async (ids, options) => {
683
+ let entries;
684
+
685
+ if (ids.length > 0) {
686
+ entries = await storage.getEntriesByIds(ids);
687
+ } else if (options.type || options.tags || options.status || options.since) {
688
+ const result = await storage.listEntries({
689
+ type: options.type,
690
+ tags: options.tags ? options.tags.split(',').map(t => t.trim()) : undefined,
691
+ status: options.status,
692
+ since: options.since,
693
+ limit: 1000
694
+ });
695
+ entries = result.entries;
696
+ } else {
697
+ console.error(chalk.red('Please provide entry IDs or filter options'));
698
+ process.exit(1);
699
+ }
700
+
701
+ if (entries.length === 0) {
702
+ if (options.json) {
703
+ console.log(JSON.stringify({ success: true, count: 0, entries: [] }));
704
+ } else {
705
+ console.log(chalk.gray('No entries matched'));
706
+ }
707
+ return;
708
+ }
709
+
710
+ if (options.dryRun) {
711
+ if (options.json) {
712
+ console.log(JSON.stringify({ success: true, dryRun: true, count: entries.length, entries }));
713
+ } else {
714
+ console.log(chalk.yellow(`Would archive ${entries.length} entries:`));
715
+ for (const entry of entries) {
716
+ console.log(` ${entry.id.slice(0, 8)} (${entry.type}): ${entry.title || entry.content.slice(0, 40)}...`);
717
+ }
718
+ }
719
+ return;
720
+ }
721
+
722
+ // Archive entries
723
+ await storage.archiveEntries(entries.map(e => e.id));
724
+
725
+ if (options.json) {
726
+ console.log(JSON.stringify({ success: true, count: entries.length }));
727
+ } else {
728
+ console.log(chalk.green(`Archived ${entries.length} entries`));
729
+ }
730
+ });
731
+
732
+ program
733
+ .command('delete [ids...]')
734
+ .description('Delete entries (logged for undo)')
735
+ .option('-t, --type <type>', 'Filter by type')
736
+ .option('--tags <tags>', 'Filter by tags')
737
+ .option('-s, --status <status>', 'Filter by status')
738
+ .option('--since <duration>', 'Filter by age')
739
+ .option('--dry-run', 'Show what would be deleted')
740
+ .option('--confirm', 'Skip confirmation prompt')
741
+ .option('--force', 'Override safety warnings')
742
+ .option('--json', 'Output as JSON')
743
+ .action(async (ids, options) => {
744
+ let entries;
745
+
746
+ if (ids.length > 0) {
747
+ entries = await storage.getEntriesByIds(ids);
748
+ } else if (options.type || options.tags || options.status || options.since) {
749
+ const result = await storage.listEntries({
750
+ type: options.type,
751
+ tags: options.tags ? options.tags.split(',').map(t => t.trim()) : undefined,
752
+ status: options.status,
753
+ since: options.since,
754
+ limit: 1000
755
+ });
756
+ entries = result.entries;
757
+ } else {
758
+ console.error(chalk.red('Please provide entry IDs or filter options'));
759
+ process.exit(1);
760
+ }
761
+
762
+ if (entries.length === 0) {
763
+ if (options.json) {
764
+ console.log(JSON.stringify({ success: true, count: 0, entries: [] }));
765
+ } else {
766
+ console.log(chalk.gray('No entries matched'));
767
+ }
768
+ return;
769
+ }
770
+
771
+ // Safety check
772
+ if (entries.length > 10 && !options.confirm && !options.force) {
773
+ console.error(chalk.red(`Refusing to delete ${entries.length} entries without --confirm or --force`));
774
+ process.exit(1);
775
+ }
776
+
777
+ // Check for entries with children
778
+ const entriesWithChildren = [];
779
+ for (const entry of entries) {
780
+ const children = await storage.getChildren(entry.id);
781
+ if (children.length > 0) {
782
+ entriesWithChildren.push({ entry, childCount: children.length });
783
+ }
784
+ }
785
+
786
+ if (entriesWithChildren.length > 0 && !options.force) {
787
+ console.error(chalk.red(`Cannot delete entries with children without --force:`));
788
+ for (const { entry, childCount } of entriesWithChildren) {
789
+ console.error(` ${entry.id.slice(0, 8)} has ${childCount} children`);
790
+ }
791
+ process.exit(1);
792
+ }
793
+
794
+ if (options.dryRun) {
795
+ if (options.json) {
796
+ console.log(JSON.stringify({ success: true, dryRun: true, count: entries.length, entries }));
797
+ } else {
798
+ console.log(chalk.yellow(`Would delete ${entries.length} entries:`));
799
+ for (const entry of entries) {
800
+ console.log(` ${entry.id.slice(0, 8)} (${entry.type}): ${entry.title || entry.content.slice(0, 40)}...`);
801
+ }
802
+ }
803
+ return;
804
+ }
805
+
806
+ // Log before delete
807
+ await deletionLog.logDeletions(entries);
808
+
809
+ // Delete entries
810
+ await storage.deleteEntries(entries.map(e => e.id));
811
+
812
+ if (options.json) {
813
+ console.log(JSON.stringify({ success: true, count: entries.length }));
814
+ } else {
815
+ console.log(chalk.green(`Deleted ${entries.length} entries (use "synap restore --last ${entries.length}" to undo)`));
816
+ }
817
+ });
818
+
819
+ program
820
+ .command('restore')
821
+ .description('Restore deleted entries')
822
+ .option('--last <n>', 'Restore last N deletions')
823
+ .option('--ids <ids>', 'Restore specific entry IDs')
824
+ .option('--list', 'Show deletion log')
825
+ .option('--json', 'Output as JSON')
826
+ .action(async (options) => {
827
+ if (options.list) {
828
+ const log = await deletionLog.getLog();
829
+ if (options.json) {
830
+ console.log(JSON.stringify({ success: true, deletions: log }));
831
+ } else {
832
+ if (log.length === 0) {
833
+ console.log(chalk.gray('Deletion log is empty'));
834
+ return;
835
+ }
836
+ console.log(chalk.bold('Deletion Log:\n'));
837
+ for (const entry of log.slice(0, 20)) {
838
+ const date = new Date(entry.deletedAt).toLocaleString();
839
+ console.log(` ${chalk.gray(date)} ${entry.id.slice(0, 8)} (${entry.type}): ${entry.title || entry.content.slice(0, 30)}...`);
840
+ }
841
+ if (log.length > 20) {
842
+ console.log(chalk.gray(`\n ... and ${log.length - 20} more`));
843
+ }
844
+ }
845
+ return;
846
+ }
847
+
848
+ let toRestore = [];
849
+
850
+ if (options.last) {
851
+ const n = parseInt(options.last, 10);
852
+ const log = await deletionLog.getLog();
853
+ toRestore = log.slice(0, n);
854
+ } else if (options.ids) {
855
+ const ids = options.ids.split(',').map(id => id.trim());
856
+ const log = await deletionLog.getLog();
857
+ toRestore = log.filter(e => ids.some(id => e.id.startsWith(id)));
858
+ } else {
859
+ console.error(chalk.red('Please provide --last <n> or --ids <ids>'));
860
+ process.exit(1);
861
+ }
862
+
863
+ if (toRestore.length === 0) {
864
+ if (options.json) {
865
+ console.log(JSON.stringify({ success: true, count: 0 }));
866
+ } else {
867
+ console.log(chalk.gray('No entries to restore'));
868
+ }
869
+ return;
870
+ }
871
+
872
+ // Restore entries
873
+ await storage.restoreEntries(toRestore);
874
+ await deletionLog.removeFromLog(toRestore.map(e => e.id));
875
+
876
+ if (options.json) {
877
+ console.log(JSON.stringify({ success: true, count: toRestore.length, entries: toRestore }));
878
+ } else {
879
+ console.log(chalk.green(`Restored ${toRestore.length} entries`));
880
+ }
881
+ });
882
+
883
+ // ============================================
884
+ // MAINTENANCE COMMANDS
885
+ // ============================================
886
+
887
+ program
888
+ .command('stats')
889
+ .description('Show entry statistics')
890
+ .option('--json', 'Output as JSON')
891
+ .action(async (options) => {
892
+ const stats = await storage.getStats();
893
+
894
+ if (options.json) {
895
+ console.log(JSON.stringify({ success: true, ...stats }, null, 2));
896
+ } else {
897
+ console.log(chalk.bold('synap Statistics\n'));
898
+ console.log(` Total entries: ${stats.total}`);
899
+ console.log(` Active: ${stats.byStatus.active || 0}`);
900
+ console.log(` Raw (need triage): ${stats.byStatus.raw || 0}`);
901
+ console.log(` Done: ${stats.byStatus.done || 0}`);
902
+ console.log(` Archived: ${stats.byStatus.archived || 0}`);
903
+ console.log('');
904
+ console.log(' By Type:');
905
+ for (const [type, count] of Object.entries(stats.byType)) {
906
+ console.log(` ${type.padEnd(12)} ${count}`);
907
+ }
908
+ console.log('');
909
+ console.log(` High Priority (P1): ${stats.highPriority}`);
910
+ console.log(` Created this week: ${stats.createdThisWeek}`);
911
+ console.log(` Updated today: ${stats.updatedToday}`);
912
+
913
+ if (stats.byStatus.raw > 0) {
914
+ console.log(chalk.gray('\nTip: Run "synap list --status raw" to triage unprocessed entries'));
915
+ }
916
+ }
917
+ });
918
+
919
+ program
920
+ .command('export')
921
+ .description('Export entries')
922
+ .option('--file <path>', 'Export to file')
923
+ .option('-t, --type <type>', 'Filter by type')
924
+ .option('-s, --status <status>', 'Filter by status')
925
+ .option('--format <format>', 'Format: json or csv', 'json')
926
+ .action(async (options) => {
927
+ const data = await storage.exportEntries({
928
+ type: options.type,
929
+ status: options.status
930
+ });
931
+
932
+ let output;
933
+ if (options.format === 'csv') {
934
+ // Simple CSV export
935
+ const headers = ['id', 'type', 'status', 'priority', 'title', 'content', 'tags', 'createdAt', 'updatedAt'];
936
+ const rows = data.entries.map(e => headers.map(h => {
937
+ const val = h === 'tags' ? e[h].join(';') : e[h];
938
+ return `"${String(val || '').replace(/"/g, '""')}"`;
939
+ }).join(','));
940
+ output = [headers.join(','), ...rows].join('\n');
941
+ } else {
942
+ output = JSON.stringify(data, null, 2);
943
+ }
944
+
945
+ if (options.file) {
946
+ const fs = require('fs');
947
+ fs.writeFileSync(options.file, output);
948
+ console.log(chalk.green(`Exported ${data.entries.length} entries to ${options.file}`));
949
+ } else {
950
+ console.log(output);
951
+ }
952
+ });
953
+
954
+ program
955
+ .command('import <file>')
956
+ .description('Import entries from file')
957
+ .option('--dry-run', 'Preview what would be imported')
958
+ .option('--merge', 'Update existing, add new')
959
+ .option('--skip-existing', 'Only add new entries')
960
+ .option('--json', 'Output as JSON')
961
+ .action(async (file, options) => {
962
+ const fs = require('fs');
963
+
964
+ if (!fs.existsSync(file)) {
965
+ console.error(chalk.red(`File not found: ${file}`));
966
+ process.exit(1);
967
+ }
968
+
969
+ const data = JSON.parse(fs.readFileSync(file, 'utf8'));
970
+ const entries = data.entries || data;
971
+
972
+ if (options.dryRun) {
973
+ if (options.json) {
974
+ console.log(JSON.stringify({ success: true, dryRun: true, count: entries.length }));
975
+ } else {
976
+ console.log(chalk.yellow(`Would import ${entries.length} entries`));
977
+ }
978
+ return;
979
+ }
980
+
981
+ const result = await storage.importEntries(entries, {
982
+ merge: options.merge,
983
+ skipExisting: options.skipExisting
984
+ });
985
+
986
+ if (options.json) {
987
+ console.log(JSON.stringify({ success: true, ...result }));
988
+ } else {
989
+ console.log(chalk.green(`Imported ${result.added} new entries, updated ${result.updated} existing`));
990
+ }
991
+ });
992
+
993
+ // ============================================
994
+ // WORKFLOW COMMANDS
995
+ // ============================================
996
+
997
+ program
998
+ .command('tree [id]')
999
+ .description('Hierarchical view of entries')
1000
+ .option('--depth <n>', 'Max depth to display', '10')
1001
+ .option('--json', 'Output as JSON')
1002
+ .action(async (id, options) => {
1003
+ const maxDepth = parseInt(options.depth, 10);
1004
+ const rootIds = id ? [id] : null;
1005
+
1006
+ const tree = await storage.buildEntryTree(rootIds, maxDepth);
1007
+
1008
+ if (tree.length === 0 && id) {
1009
+ if (options.json) {
1010
+ console.log(JSON.stringify({ success: false, error: `Entry not found: ${id}`, code: 'ENTRY_NOT_FOUND' }));
1011
+ } else {
1012
+ console.error(chalk.red(`Entry not found: ${id}`));
1013
+ }
1014
+ process.exit(1);
1015
+ }
1016
+
1017
+ if (options.json) {
1018
+ console.log(JSON.stringify({ success: true, tree }, null, 2));
1019
+ } else {
1020
+ if (tree.length === 0) {
1021
+ console.log(chalk.gray('No entries found.'));
1022
+ return;
1023
+ }
1024
+
1025
+ // Render ASCII tree
1026
+ function renderNode(node, prefix = '', isLast = true) {
1027
+ const connector = isLast ? '└── ' : '├── ';
1028
+ const shortId = node.id.slice(0, 8);
1029
+ const typeBadge = chalk.cyan(`[${node.type}]`);
1030
+ const title = node.title || node.content.slice(0, 40);
1031
+ const priorityBadge = node.priority ? chalk.yellow(` [P${node.priority}]`) : '';
1032
+ console.log(`${prefix}${connector}${chalk.blue(shortId)} ${typeBadge}${priorityBadge} ${title}`);
1033
+
1034
+ const childPrefix = prefix + (isLast ? ' ' : '│ ');
1035
+ node.children.forEach((child, i) => {
1036
+ renderNode(child, childPrefix, i === node.children.length - 1);
1037
+ });
1038
+ }
1039
+
1040
+ tree.forEach((root, i) => renderNode(root, '', i === tree.length - 1));
1041
+ }
1042
+ });
1043
+
1044
+ program
1045
+ .command('focus')
1046
+ .description('Show what to work on now: P1 todos + active projects')
1047
+ .option('--json', 'Output as JSON')
1048
+ .action(async (options) => {
1049
+ // Get P1 todos
1050
+ const p1Todos = await storage.listEntries({
1051
+ type: 'todo',
1052
+ priority: 1,
1053
+ status: 'raw,active',
1054
+ limit: 100
1055
+ });
1056
+
1057
+ // Get active projects with progress
1058
+ const projects = await storage.listEntries({
1059
+ type: 'project',
1060
+ status: 'active',
1061
+ limit: 50
1062
+ });
1063
+
1064
+ const projectsWithProgress = await Promise.all(
1065
+ projects.entries.map(async (project) => {
1066
+ const children = await storage.getChildren(project.id);
1067
+ const total = children.length;
1068
+ const done = children.filter(c => c.status === 'done').length;
1069
+ const percent = total > 0 ? Math.round(done / total * 100) : 0;
1070
+ return { ...project, progress: { total, done, percent } };
1071
+ })
1072
+ );
1073
+
1074
+ if (options.json) {
1075
+ console.log(JSON.stringify({
1076
+ success: true,
1077
+ p1Todos: p1Todos.entries,
1078
+ activeProjects: projectsWithProgress
1079
+ }, null, 2));
1080
+ } else {
1081
+ console.log(chalk.bold('Focus: What to work on now\n'));
1082
+
1083
+ // P1 Todos
1084
+ if (p1Todos.entries.length > 0) {
1085
+ console.log(chalk.yellow.bold('P1 Todos:'));
1086
+ for (const todo of p1Todos.entries) {
1087
+ const shortId = todo.id.slice(0, 8);
1088
+ const title = todo.title || todo.content.slice(0, 50);
1089
+ console.log(` ${chalk.blue(shortId)} ${title}`);
1090
+ }
1091
+ console.log();
1092
+ } else {
1093
+ console.log(chalk.gray('No P1 todos.\n'));
1094
+ }
1095
+
1096
+ // Active Projects
1097
+ if (projectsWithProgress.length > 0) {
1098
+ console.log(chalk.green.bold('Active Projects:'));
1099
+ for (const project of projectsWithProgress) {
1100
+ const shortId = project.id.slice(0, 8);
1101
+ const title = project.title || project.content.slice(0, 40);
1102
+ const progressBar = project.progress.total > 0
1103
+ ? ` [${project.progress.done}/${project.progress.total}] ${project.progress.percent}%`
1104
+ : '';
1105
+ console.log(` ${chalk.blue(shortId)} ${title}${chalk.gray(progressBar)}`);
1106
+ }
1107
+ } else {
1108
+ console.log(chalk.gray('No active projects.'));
1109
+ }
1110
+ }
1111
+ });
1112
+
1113
+ program
1114
+ .command('review [scope]')
1115
+ .description('Guided review session (daily or weekly)')
1116
+ .option('--json', 'Output as JSON')
1117
+ .action(async (scope = 'daily', options) => {
1118
+ const stats = await storage.getStats();
1119
+
1120
+ if (scope === 'daily') {
1121
+ // Daily review: stats + raw entries + P1 items + stale
1122
+ const rawEntries = await storage.listEntries({ status: 'raw', limit: 100 });
1123
+ const p1Items = await storage.listEntries({ priority: 1, status: 'raw,active', limit: 100 });
1124
+
1125
+ // Stale items: not updated in 7 days
1126
+ const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
1127
+ const allActive = await storage.listEntries({ status: 'active', limit: 500 });
1128
+ const staleItems = allActive.entries.filter(e => new Date(e.updatedAt) < sevenDaysAgo);
1129
+
1130
+ if (options.json) {
1131
+ console.log(JSON.stringify({
1132
+ success: true,
1133
+ scope: 'daily',
1134
+ stats,
1135
+ rawCount: rawEntries.entries.length,
1136
+ p1Items: p1Items.entries,
1137
+ staleItems
1138
+ }, null, 2));
1139
+ } else {
1140
+ console.log(chalk.bold('Daily Review\n'));
1141
+ console.log(`Total entries: ${stats.total}`);
1142
+ console.log(` Raw: ${stats.byStatus.raw || 0}`);
1143
+ console.log(` Active: ${stats.byStatus.active || 0}`);
1144
+ console.log(` Done: ${stats.byStatus.done || 0}`);
1145
+ console.log();
1146
+
1147
+ if (rawEntries.entries.length > 0) {
1148
+ console.log(chalk.yellow(`${rawEntries.entries.length} entries need triage (synap triage)`));
1149
+ }
1150
+
1151
+ if (p1Items.entries.length > 0) {
1152
+ console.log(chalk.red.bold(`\n${p1Items.entries.length} P1 items:`));
1153
+ for (const item of p1Items.entries.slice(0, 5)) {
1154
+ console.log(` ${chalk.blue(item.id.slice(0, 8))} ${item.title || item.content.slice(0, 50)}`);
1155
+ }
1156
+ }
1157
+
1158
+ if (staleItems.length > 0) {
1159
+ console.log(chalk.gray(`\n${staleItems.length} stale items (not updated in 7 days)`));
1160
+ }
1161
+ }
1162
+ } else if (scope === 'weekly') {
1163
+ // Weekly review: completed this week + project progress
1164
+ const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
1165
+ const allDone = await storage.listEntries({ status: 'done', includeDone: true, limit: 500 });
1166
+ const completedThisWeek = allDone.entries.filter(e => new Date(e.updatedAt) >= sevenDaysAgo);
1167
+
1168
+ const projects = await storage.listEntries({ type: 'project', status: 'active', limit: 50 });
1169
+ const projectsWithProgress = await Promise.all(
1170
+ projects.entries.map(async (project) => {
1171
+ const children = await storage.getChildren(project.id);
1172
+ const total = children.length;
1173
+ const done = children.filter(c => c.status === 'done').length;
1174
+ return { ...project, progress: { total, done, percent: total > 0 ? Math.round(done / total * 100) : 0 } };
1175
+ })
1176
+ );
1177
+
1178
+ if (options.json) {
1179
+ console.log(JSON.stringify({
1180
+ success: true,
1181
+ scope: 'weekly',
1182
+ completedThisWeek,
1183
+ projectProgress: projectsWithProgress
1184
+ }, null, 2));
1185
+ } else {
1186
+ console.log(chalk.bold('Weekly Review\n'));
1187
+
1188
+ console.log(chalk.green.bold(`Completed this week: ${completedThisWeek.length} items`));
1189
+ for (const item of completedThisWeek.slice(0, 10)) {
1190
+ console.log(` ✓ ${item.title || item.content.slice(0, 50)}`);
1191
+ }
1192
+
1193
+ if (projectsWithProgress.length > 0) {
1194
+ console.log(chalk.blue.bold('\nProject Progress:'));
1195
+ for (const project of projectsWithProgress) {
1196
+ const title = project.title || project.content.slice(0, 40);
1197
+ const bar = project.progress.total > 0
1198
+ ? ` [${project.progress.done}/${project.progress.total}] ${project.progress.percent}%`
1199
+ : ' (no children)';
1200
+ console.log(` ${title}${chalk.gray(bar)}`);
1201
+ }
1202
+ }
1203
+ }
1204
+ } else {
1205
+ console.error(chalk.red(`Unknown scope: ${scope}. Use 'daily' or 'weekly'.`));
1206
+ process.exit(1);
1207
+ }
1208
+ });
1209
+
1210
+ program
1211
+ .command('triage')
1212
+ .description('Interactive processing of raw entries')
1213
+ .option('--auto', 'Non-interactive mode (just list raw entries)')
1214
+ .option('--json', 'Output as JSON')
1215
+ .action(async (options) => {
1216
+ const rawEntries = await storage.listEntries({ status: 'raw', limit: 100 });
1217
+
1218
+ if (rawEntries.entries.length === 0) {
1219
+ if (options.json) {
1220
+ console.log(JSON.stringify({ success: true, count: 0, message: 'No raw entries to triage' }));
1221
+ } else {
1222
+ console.log(chalk.green('No raw entries to triage!'));
1223
+ }
1224
+ return;
1225
+ }
1226
+
1227
+ // Non-interactive modes
1228
+ if (options.auto || options.json) {
1229
+ if (options.json) {
1230
+ console.log(JSON.stringify({ success: true, rawEntries: rawEntries.entries }, null, 2));
1231
+ } else {
1232
+ console.log(chalk.bold(`${rawEntries.entries.length} raw entries to triage:\n`));
1233
+ for (const entry of rawEntries.entries) {
1234
+ const shortId = entry.id.slice(0, 8);
1235
+ console.log(` ${chalk.blue(shortId)} [${entry.type}] ${entry.content.slice(0, 60)}`);
1236
+ }
1237
+ }
1238
+ return;
1239
+ }
1240
+
1241
+ // Interactive mode
1242
+ const { select, input } = await import('@inquirer/prompts');
1243
+
1244
+ console.log(chalk.bold(`\nTriaging ${rawEntries.entries.length} raw entries...\n`));
1245
+
1246
+ let processed = 0;
1247
+ for (const entry of rawEntries.entries) {
1248
+ console.log(boxen(entry.content, { padding: 1, title: `${entry.id.slice(0, 8)} [${entry.type}]`, borderColor: 'cyan' }));
1249
+
1250
+ const action = await select({
1251
+ message: 'Action?',
1252
+ choices: [
1253
+ { value: 'classify', name: 'Classify (set type, priority, tags)' },
1254
+ { value: 'skip', name: 'Skip for now' },
1255
+ { value: 'done', name: 'Mark as done' },
1256
+ { value: 'quit', name: 'Quit triage' }
1257
+ ]
1258
+ });
1259
+
1260
+ if (action === 'quit') {
1261
+ console.log(chalk.yellow(`\nProcessed ${processed} entries. ${rawEntries.entries.length - processed} remaining.`));
1262
+ break;
1263
+ }
1264
+
1265
+ if (action === 'skip') {
1266
+ continue;
1267
+ }
1268
+
1269
+ if (action === 'done') {
1270
+ await storage.updateEntry(entry.id, { status: 'done' });
1271
+ console.log(chalk.green(`Marked as done.`));
1272
+ processed++;
1273
+ continue;
1274
+ }
1275
+
1276
+ // Classify
1277
+ const type = await select({
1278
+ message: 'Type?',
1279
+ choices: storage.VALID_TYPES.map(t => ({ value: t, name: t })),
1280
+ default: entry.type
1281
+ });
1282
+
1283
+ const priorityChoice = await select({
1284
+ message: 'Priority?',
1285
+ choices: [
1286
+ { value: 'none', name: 'None' },
1287
+ { value: '1', name: 'P1 (High)' },
1288
+ { value: '2', name: 'P2 (Medium)' },
1289
+ { value: '3', name: 'P3 (Low)' }
1290
+ ]
1291
+ });
1292
+ const priority = priorityChoice === 'none' ? undefined : parseInt(priorityChoice, 10);
1293
+
1294
+ const tagsInput = await input({
1295
+ message: 'Tags (comma-separated)?',
1296
+ default: entry.tags.join(', ')
1297
+ });
1298
+ const tags = tagsInput ? tagsInput.split(',').map(t => t.trim()).filter(Boolean) : [];
1299
+
1300
+ await storage.updateEntry(entry.id, {
1301
+ type,
1302
+ priority,
1303
+ tags,
1304
+ status: 'active'
1305
+ });
1306
+
1307
+ console.log(chalk.green(`Updated ${entry.id.slice(0, 8)} → ${type}, P${priority || '-'}, tags: ${tags.join(', ') || 'none'}\n`));
1308
+ processed++;
1309
+ }
1310
+
1311
+ console.log(chalk.green.bold(`\nTriage complete! Processed ${processed} entries.`));
1312
+ });
1313
+
1314
+ // ============================================
1315
+ // PREFERENCES COMMANDS
1316
+ // ============================================
1317
+
1318
+ program
1319
+ .command('preferences')
1320
+ .description('View or update user preferences')
1321
+ .option('--edit', 'Open preferences in $EDITOR')
1322
+ .option('--reset', 'Reset preferences to the default template')
1323
+ .option('--confirm', 'Skip confirmation prompt when resetting')
1324
+ .option('--append <values...>', 'Append text to a section')
1325
+ .option('--json', 'Output as JSON')
1326
+ .action(async (options) => {
1327
+ const hasAppend = Array.isArray(options.append);
1328
+ const activeFlags = [options.edit, options.reset, hasAppend].filter(Boolean).length;
1329
+
1330
+ const respondError = (message, code = 'INVALID_ARGS') => {
1331
+ if (options.json) {
1332
+ console.log(JSON.stringify({ success: false, error: message, code }));
1333
+ } else {
1334
+ console.error(chalk.red(message));
1335
+ }
1336
+ process.exit(1);
1337
+ };
1338
+
1339
+ if (activeFlags > 1) {
1340
+ respondError('Use only one of --edit, --reset, or --append');
1341
+ }
1342
+ try {
1343
+ if (options.edit) {
1344
+ if (options.json) {
1345
+ respondError('Cannot use --json with --edit', 'INVALID_MODE');
1346
+ }
1347
+
1348
+ preferences.loadPreferences();
1349
+ const { execSync } = require('child_process');
1350
+ const fs = require('fs');
1351
+
1352
+ const editor = config.editor || process.env.EDITOR || 'vi';
1353
+ const preferencesPath = preferences.getPreferencesPath();
1354
+ try {
1355
+ execSync(`${editor} "${preferencesPath}"`, { stdio: 'inherit' });
1356
+ } catch {
1357
+ console.error(chalk.red('Editor failed or was cancelled'));
1358
+ process.exit(1);
1359
+ }
1360
+
1361
+ const updated = fs.readFileSync(preferencesPath, 'utf8');
1362
+ const validation = preferences.validatePreferences(updated);
1363
+ if (!validation.valid) {
1364
+ console.error(chalk.red(`Invalid preferences: ${validation.error}`));
1365
+ process.exit(1);
1366
+ }
1367
+
1368
+ console.log(chalk.green('Preferences updated'));
1369
+ return;
1370
+ }
1371
+
1372
+ if (options.reset) {
1373
+ if (!options.confirm && !options.json) {
1374
+ const { confirm } = await import('@inquirer/prompts');
1375
+ const confirmed = await confirm({
1376
+ message: 'Reset preferences to the default template?',
1377
+ default: false
1378
+ });
1379
+ if (!confirmed) {
1380
+ console.log(chalk.gray('Cancelled'));
1381
+ return;
1382
+ }
1383
+ }
1384
+
1385
+ const content = preferences.resetPreferences();
1386
+ if (options.json) {
1387
+ console.log(JSON.stringify({
1388
+ success: true,
1389
+ path: preferences.getPreferencesPath(),
1390
+ content
1391
+ }, null, 2));
1392
+ } else {
1393
+ console.log(chalk.green('Preferences reset to template'));
1394
+ }
1395
+ return;
1396
+ }
1397
+
1398
+ if (hasAppend) {
1399
+ const values = options.append || [];
1400
+ if (values.length < 2) {
1401
+ respondError('Usage: synap preferences --append "## Section" "Text to append"');
1402
+ }
1403
+
1404
+ const [section, ...textParts] = values;
1405
+ const text = textParts.join(' ');
1406
+ const content = preferences.appendToSection(section, text);
1407
+
1408
+ if (options.json) {
1409
+ console.log(JSON.stringify({
1410
+ success: true,
1411
+ path: preferences.getPreferencesPath(),
1412
+ section,
1413
+ text,
1414
+ content
1415
+ }, null, 2));
1416
+ } else {
1417
+ console.log(chalk.green(`Appended to ${section}`));
1418
+ }
1419
+ return;
1420
+ }
1421
+
1422
+ const content = preferences.loadPreferences();
1423
+ if (options.json) {
1424
+ console.log(JSON.stringify({
1425
+ success: true,
1426
+ path: preferences.getPreferencesPath(),
1427
+ lineCount: content.split(/\r?\n/).length,
1428
+ content
1429
+ }, null, 2));
1430
+ } else {
1431
+ console.log(content);
1432
+ }
1433
+ } catch (err) {
1434
+ respondError(err.message || 'Failed to update preferences', 'PREFERENCES_ERROR');
1435
+ }
1436
+ });
1437
+
1438
+ // ============================================
1439
+ // SETUP COMMANDS
1440
+ // ============================================
1441
+
1442
+ program
1443
+ .command('setup')
1444
+ .description('Run the first-run setup wizard')
1445
+ .option('--json', 'Output as JSON')
1446
+ .action(async (options) => {
1447
+ const fs = require('fs');
1448
+
1449
+ let hasEntries = false;
1450
+ if (fs.existsSync(storage.ENTRIES_FILE)) {
1451
+ try {
1452
+ const data = JSON.parse(fs.readFileSync(storage.ENTRIES_FILE, 'utf8'));
1453
+ hasEntries = Array.isArray(data.entries) && data.entries.length > 0;
1454
+ } catch {
1455
+ hasEntries = false;
1456
+ }
1457
+ }
1458
+
1459
+ let createdEntry = null;
1460
+ if (!hasEntries) {
1461
+ createdEntry = await storage.addEntry({
1462
+ content: 'My first thought',
1463
+ type: config.defaultType || 'idea',
1464
+ source: 'setup'
1465
+ });
1466
+ }
1467
+
1468
+ let skillResult = { prompted: false };
1469
+ if (!options.json) {
1470
+ console.log('');
1471
+ console.log(chalk.bold('Welcome to synap!\n'));
1472
+ console.log('synap helps you externalize your working memory.');
1473
+ console.log('Capture ideas, todos, projects, and questions.\n');
1474
+
1475
+ console.log('Let\'s get you set up:\n');
1476
+
1477
+ console.log('[1/3] Quick capture test...');
1478
+ if (createdEntry) {
1479
+ console.log(` synap add "My first thought"`);
1480
+ console.log(` ${chalk.green('✓')} Created entry ${createdEntry.id.slice(0, 8)}`);
1481
+ } else {
1482
+ console.log(` ${chalk.gray('Existing entries detected, skipping.')}`);
1483
+ }
1484
+
1485
+ console.log('\n[2/3] Configuration...');
1486
+ console.log(` Default type: ${chalk.cyan(config.defaultType || 'idea')}`);
1487
+ console.log(` Change with: ${chalk.cyan('synap config defaultType todo')}`);
1488
+
1489
+ console.log('\n[3/3] Claude Code Integration');
1490
+ const { confirm } = await import('@inquirer/prompts');
1491
+ const shouldInstall = await confirm({
1492
+ message: 'Install Claude skill for AI assistance?',
1493
+ default: true
1494
+ });
1495
+
1496
+ skillResult.prompted = true;
1497
+ if (shouldInstall) {
1498
+ const skillInstaller = require('./skill-installer');
1499
+ try {
1500
+ skillResult = { ...skillResult, ...(await skillInstaller.install()) };
1501
+ if (skillResult.installed) {
1502
+ console.log(` ${chalk.green('✓')} Skill installed at ~/.claude/skills/synap-assistant/`);
1503
+ } else if (skillResult.skipped) {
1504
+ console.log(` ${chalk.yellow('•')} Skill already up to date`);
1505
+ } else if (skillResult.needsForce) {
1506
+ console.log(` ${chalk.yellow('•')} Skill modified. Use ${chalk.cyan('synap install-skill --force')}`);
1507
+ }
1508
+ } catch (err) {
1509
+ console.log(` ${chalk.red('✗')} Skill install failed: ${err.message}`);
1510
+ skillResult = { ...skillResult, error: err.message };
1511
+ }
1512
+ } else {
1513
+ console.log(` ${chalk.gray('Skipped skill install')}`);
1514
+ }
1515
+
1516
+ console.log('\nYou\'re ready! Try these commands:');
1517
+ console.log(` ${chalk.cyan('synap todo "Something to do"')}`);
1518
+ console.log(` ${chalk.cyan('synap focus')}`);
1519
+ console.log(` ${chalk.cyan('synap review daily')}`);
1520
+ console.log('');
1521
+ return;
1522
+ }
1523
+
1524
+ console.log(JSON.stringify({
1525
+ success: true,
1526
+ mode: hasEntries ? 'existing' : 'first-run',
1527
+ entry: createdEntry,
1528
+ config: { defaultType: config.defaultType || 'idea' },
1529
+ skill: {
1530
+ prompted: false,
1531
+ installed: false,
1532
+ message: 'Run synap install-skill to enable Claude Code integration'
1533
+ }
1534
+ }, null, 2));
1535
+ });
1536
+
1537
+ // ============================================
1538
+ // CONFIGURATION COMMANDS
1539
+ // ============================================
1540
+
1541
+ program
1542
+ .command('config [key] [value]')
1543
+ .description('View or update configuration')
1544
+ .option('--reset', 'Reset to defaults')
1545
+ .option('--json', 'Output as JSON')
1546
+ .action(async (key, value, options) => {
1547
+ const currentConfig = storage.loadConfig();
1548
+ const defaults = storage.getDefaultConfig();
1549
+
1550
+ // Reset to defaults
1551
+ if (options.reset) {
1552
+ storage.saveConfig(defaults);
1553
+ if (options.json) {
1554
+ console.log(JSON.stringify({ success: true, config: defaults, message: 'Config reset to defaults' }));
1555
+ } else {
1556
+ console.log(chalk.green('Config reset to defaults'));
1557
+ }
1558
+ return;
1559
+ }
1560
+
1561
+ // No key: show all config
1562
+ if (!key) {
1563
+ if (options.json) {
1564
+ console.log(JSON.stringify({ success: true, config: currentConfig, defaults }, null, 2));
1565
+ } else {
1566
+ console.log(chalk.bold('Configuration:\n'));
1567
+ for (const [k, v] of Object.entries(currentConfig)) {
1568
+ const isDefault = JSON.stringify(v) === JSON.stringify(defaults[k]);
1569
+ const defaultNote = isDefault ? chalk.gray(' (default)') : '';
1570
+ const displayValue = Array.isArray(v) ? v.join(', ') || '(none)' : (v === null ? '(null)' : v);
1571
+ console.log(` ${chalk.cyan(k)}: ${displayValue}${defaultNote}`);
1572
+ }
1573
+ console.log(chalk.gray('\nUse: synap config <key> <value> to set a value'));
1574
+ }
1575
+ return;
1576
+ }
1577
+
1578
+ // Key only: get specific value
1579
+ if (value === undefined) {
1580
+ if (!(key in defaults)) {
1581
+ if (options.json) {
1582
+ console.log(JSON.stringify({ success: false, error: `Unknown config key: ${key}`, code: 'INVALID_KEY' }));
1583
+ } else {
1584
+ console.error(chalk.red(`Unknown config key: ${key}`));
1585
+ console.error(chalk.gray(`Valid keys: ${Object.keys(defaults).join(', ')}`));
1586
+ }
1587
+ process.exit(1);
1588
+ }
1589
+ if (options.json) {
1590
+ console.log(JSON.stringify({ success: true, key, value: currentConfig[key] }));
1591
+ } else {
1592
+ const v = currentConfig[key];
1593
+ const displayValue = Array.isArray(v) ? v.join(', ') || '(none)' : (v === null ? '(null)' : v);
1594
+ console.log(displayValue);
1595
+ }
1596
+ return;
1597
+ }
1598
+
1599
+ // Key + value: set value
1600
+ const validation = storage.validateConfigValue(key, value);
1601
+ if (!validation.valid) {
1602
+ if (options.json) {
1603
+ console.log(JSON.stringify({ success: false, error: validation.error, code: 'VALIDATION_ERROR' }));
1604
+ } else {
1605
+ console.error(chalk.red(validation.error));
1606
+ }
1607
+ process.exit(1);
1608
+ }
1609
+
1610
+ // Parse value appropriately
1611
+ let parsedValue = value;
1612
+ if (key === 'defaultTags') {
1613
+ parsedValue = value.split(',').map(t => t.trim()).filter(Boolean);
1614
+ } else if (key === 'editor' && (value === 'null' || value === '')) {
1615
+ parsedValue = null;
1616
+ }
1617
+
1618
+ currentConfig[key] = parsedValue;
1619
+ storage.saveConfig(currentConfig);
1620
+
1621
+ if (options.json) {
1622
+ console.log(JSON.stringify({ success: true, key, value: parsedValue }));
1623
+ } else {
1624
+ const displayValue = Array.isArray(parsedValue) ? parsedValue.join(', ') : parsedValue;
1625
+ console.log(chalk.green(`Set ${key} = ${displayValue}`));
1626
+ }
1627
+ });
1628
+
1629
+ program
1630
+ .command('tags [action] [args...]')
1631
+ .description('Tag management (list, rename)')
1632
+ .option('--unused', 'Show tags only in deletion log (orphaned)')
1633
+ .option('--json', 'Output as JSON')
1634
+ .action(async (action, args, options) => {
1635
+ // Handle rename action
1636
+ if (action === 'rename') {
1637
+ if (args.length < 2) {
1638
+ if (options.json) {
1639
+ console.log(JSON.stringify({ success: false, error: 'Usage: synap tags rename <old> <new>', code: 'INVALID_ARGS' }));
1640
+ } else {
1641
+ console.error(chalk.red('Usage: synap tags rename <old> <new>'));
1642
+ }
1643
+ process.exit(1);
1644
+ }
1645
+ const [oldTag, newTag] = args;
1646
+ const result = await storage.renameTag(oldTag, newTag);
1647
+
1648
+ if (options.json) {
1649
+ console.log(JSON.stringify({ success: true, ...result }));
1650
+ } else {
1651
+ console.log(chalk.green(`Renamed "${oldTag}" to "${newTag}" in ${result.entriesUpdated} entries`));
1652
+ }
1653
+ return;
1654
+ }
1655
+
1656
+ // Handle --unused flag
1657
+ if (options.unused) {
1658
+ const currentTags = await storage.getAllTags();
1659
+ const currentTagSet = new Set(currentTags.map(t => t.tag));
1660
+ const log = await deletionLog.getLog();
1661
+
1662
+ const deletedTags = new Set();
1663
+ for (const entry of log) {
1664
+ for (const tag of entry.tags || []) {
1665
+ deletedTags.add(tag);
1666
+ }
1667
+ }
1668
+
1669
+ const unusedTags = [...deletedTags].filter(t => !currentTagSet.has(t));
1670
+
1671
+ if (options.json) {
1672
+ console.log(JSON.stringify({ success: true, unusedTags }));
1673
+ } else {
1674
+ if (unusedTags.length === 0) {
1675
+ console.log(chalk.gray('No unused tags found.'));
1676
+ } else {
1677
+ console.log(chalk.bold('Unused tags (from deleted entries):\n'));
1678
+ for (const tag of unusedTags) {
1679
+ console.log(` ${chalk.gray('#')}${tag}`);
1680
+ }
1681
+ }
1682
+ }
1683
+ return;
1684
+ }
1685
+
1686
+ // Default: list all tags
1687
+ const tags = await storage.getAllTags();
1688
+
1689
+ if (options.json) {
1690
+ console.log(JSON.stringify({ success: true, tags }, null, 2));
1691
+ } else {
1692
+ if (tags.length === 0) {
1693
+ console.log(chalk.gray('No tags found.'));
1694
+ return;
1695
+ }
1696
+ console.log(chalk.bold('Tags:\n'));
1697
+ for (const { tag, count } of tags) {
1698
+ const countStr = count === 1 ? '1 entry' : `${count} entries`;
1699
+ console.log(` ${chalk.cyan('#' + tag.padEnd(20))} ${countStr}`);
1700
+ }
1701
+ }
1702
+ });
1703
+
1704
+ program
1705
+ .command('install-skill')
1706
+ .description('Install Claude Code skill')
1707
+ .option('--uninstall', 'Remove the skill')
1708
+ .option('--force', 'Override ownership check')
1709
+ .action(async (options) => {
1710
+ const skillInstaller = require('./skill-installer');
1711
+
1712
+ if (options.uninstall) {
1713
+ await skillInstaller.uninstall();
1714
+ console.log(chalk.green('Skill uninstalled'));
1715
+ } else {
1716
+ const result = await skillInstaller.install({ force: options.force });
1717
+ if (result.installed) {
1718
+ console.log(chalk.green('Skill installed to ~/.claude/skills/synap-assistant/'));
1719
+ } else if (result.skipped) {
1720
+ console.log(chalk.yellow('Skill already up to date'));
1721
+ } else if (result.needsForce) {
1722
+ console.log(chalk.yellow('Skill was modified by user. Use --force to overwrite.'));
1723
+ }
1724
+ }
1725
+ });
1726
+
1727
+ // Parse and execute
1728
+ await program.parseAsync(process.argv);
1729
+ }
1730
+
1731
+ main().catch(err => {
1732
+ console.error(err);
1733
+ process.exit(1);
1734
+ });