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/.claude/skills/synap-assistant/SKILL.md +476 -0
- package/README.md +200 -0
- package/package.json +50 -0
- package/scripts/postinstall.js +58 -0
- package/src/cli.js +1734 -0
- package/src/deletion-log.js +105 -0
- package/src/preferences.js +175 -0
- package/src/skill-installer.js +175 -0
- package/src/storage.js +803 -0
- package/src/templates/user-preferences-template.md +16 -0
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
|
+
});
|