@hasna/knowledge 0.2.3 → 0.2.4

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.ts ADDED
@@ -0,0 +1,642 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * @hasna/knowledge
4
+ * Copyright 2026 Hasna Inc.
5
+ * Licensed under the Apache License, Version 2.0
6
+ */
7
+ import { defaultStorePath, loadStore, saveStore, withLock, makeId, makeShortId, ensureStore, type KnowledgeItem } from './store';
8
+ import { ensureKnowledgeWorkspace, readKnowledgeConfig, resolveScopedWorkspace } from './workspace';
9
+ import { getKnowledgeDbStats, migrateKnowledgeDb } from './knowledge-db';
10
+ import { createArtifactStore } from './artifact-store';
11
+ import { initializeWikiLayout } from './wiki-layout';
12
+ import pkg from '../package.json' with { type: 'json' };
13
+
14
+ type LogLevel = 'debug' | 'info' | 'warn' | 'error';
15
+ const LOG_LEVELS: Record<LogLevel, number> = { debug: 0, info: 1, warn: 2, error: 3 };
16
+ const currentLevel = (): LogLevel => {
17
+ if (process.env.DEBUG) return 'debug';
18
+ if (process.env.LOG_LEVEL === 'debug') return 'debug';
19
+ if (process.env.LOG_LEVEL === 'warn') return 'warn';
20
+ if (process.env.LOG_LEVEL === 'error') return 'error';
21
+ return 'info';
22
+ };
23
+ function log(level: LogLevel, msg: string, data?: Record<string, unknown>): void {
24
+ if (LOG_LEVELS[level] < LOG_LEVELS[currentLevel()]) return;
25
+ const prefix = { debug: '[DEBUG]', info: '[INFO]', warn: '[WARN]', error: '[ERROR]' }[level];
26
+ const entry = data ? `${prefix} ${msg} ${JSON.stringify(data)}` : `${prefix} ${msg}`;
27
+ if (level === 'error') console.error(entry);
28
+ else console.error(entry);
29
+ }
30
+
31
+ interface Flags {
32
+ json?: boolean;
33
+ yes?: boolean;
34
+ help?: boolean;
35
+ version?: boolean;
36
+ desc?: boolean;
37
+ page?: number;
38
+ limit?: number;
39
+ search?: string;
40
+ sort?: string;
41
+ id?: string;
42
+ store?: string;
43
+ title?: string;
44
+ content?: string;
45
+ url?: string;
46
+ tag?: string;
47
+ format?: string;
48
+ completions?: string;
49
+ noColor?: boolean;
50
+ scope?: string;
51
+ olderThan?: number;
52
+ empty?: boolean;
53
+ archived?: boolean;
54
+ includeArchived?: boolean;
55
+ }
56
+
57
+ interface ParseResult {
58
+ positional: string[];
59
+ flags: Flags;
60
+ }
61
+
62
+ const COMMANDS = ['add', 'list', 'get', 'delete', 'update', 'archive', 'restore', 'upsert', 'untag', 'export', 'prune', 'dedupe', 'stats', 'paths', 'db', 'wiki', 'help'];
63
+ const COMMAND_ALIASES: Record<string, string> = {
64
+ ls: 'list',
65
+ rm: 'delete',
66
+ edit: 'update',
67
+ unarchive: 'restore',
68
+ };
69
+
70
+ function parseArgs(argv: string[]): ParseResult {
71
+ const positional: string[] = [];
72
+ const flags: Flags = {};
73
+ for (let i = 0; i < argv.length; i += 1) {
74
+ const token = argv[i];
75
+ if (!token.startsWith('-')) {
76
+ positional.push(token);
77
+ continue;
78
+ }
79
+ switch (token) {
80
+ case '--json': flags.json = true; break;
81
+ case '--yes': case '-y': flags.yes = true; break;
82
+ case '--help': case '-h': flags.help = true; break;
83
+ case '--version': case '-v': flags.version = true; break;
84
+ case '--desc': flags.desc = true; break;
85
+ case '--page': case '-p': flags.page = Number(argv[i + 1]); i += 1; break;
86
+ case '--limit': case '-l': flags.limit = Number(argv[i + 1]); i += 1; break;
87
+ case '--search': case '-s': flags.search = argv[i + 1]; i += 1; break;
88
+ case '--sort': flags.sort = argv[i + 1]; i += 1; break;
89
+ case '--id': flags.id = argv[i + 1]; i += 1; break;
90
+ case '--store': flags.store = argv[i + 1]; i += 1; break;
91
+ case '--title': flags.title = argv[i + 1]; i += 1; break;
92
+ case '--content': flags.content = argv[i + 1]; i += 1; break;
93
+ case '--url': flags.url = argv[i + 1]; i += 1; break;
94
+ case '--tag': case '-t': flags.tag = argv[i + 1]; i += 1; break;
95
+ case '--format': flags.format = argv[i + 1]; i += 1; break;
96
+ case '--completions': flags.completions = argv[i + 1]; i += 1; break;
97
+ case '--no-color': flags.noColor = true; break;
98
+ case '--scope': flags.scope = argv[i + 1]; i += 1; break;
99
+ case '--older-than': flags.olderThan = Number(argv[i + 1]); i += 1; break;
100
+ case '--empty': flags.empty = true; break;
101
+ case '--archived': flags.archived = true; break;
102
+ case '--include-archived': flags.includeArchived = true; break;
103
+ default: throw new Error(`Unknown flag: ${token}. Run 'open-knowledge --help' for valid options.`);
104
+ }
105
+ }
106
+ return { positional, flags };
107
+ }
108
+
109
+ function resolveCommand(raw: string): string {
110
+ if (!raw) return '';
111
+ return COMMAND_ALIASES[raw] ?? raw;
112
+ }
113
+
114
+ function levenshtein(a: string, b: string): number {
115
+ const dp = Array.from({ length: a.length + 1 }, () => Array(b.length + 1).fill(0));
116
+ for (let i = 0; i <= a.length; i += 1) dp[i][0] = i;
117
+ for (let j = 0; j <= b.length; j += 1) dp[0][j] = j;
118
+ for (let i = 1; i <= a.length; i += 1) {
119
+ for (let j = 1; j <= b.length; j += 1) {
120
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
121
+ dp[i][j] = Math.min(dp[i - 1][j] + 1, dp[i][j - 1] + 1, dp[i - 1][j - 1] + cost);
122
+ }
123
+ }
124
+ return dp[a.length][b.length];
125
+ }
126
+
127
+ function suggestCommand(input: string): string {
128
+ if (!input) return '';
129
+ const all = [...COMMANDS, ...Object.keys(COMMAND_ALIASES)];
130
+ let best = '';
131
+ let bestScore = Number.POSITIVE_INFINITY;
132
+ for (const candidate of all) {
133
+ const score = levenshtein(input, candidate);
134
+ if (score < bestScore) {
135
+ bestScore = score;
136
+ best = candidate;
137
+ }
138
+ }
139
+ return bestScore <= 3 ? best : '';
140
+ }
141
+
142
+ function printGlobalHelp(): void {
143
+ console.log(`open-knowledge - local agent knowledge store
144
+
145
+ Usage:
146
+ open-knowledge <command> [options]
147
+
148
+ Commands:
149
+ add <title> <content> Add an item
150
+ list (alias: ls) List items (supports pagination/search/sort/tag)
151
+ get --id <id> Get one item
152
+ update --id <id> Update an item (--title, --content, --url, --tag)
153
+ archive --id <id> Archive an item
154
+ restore --id <id> Restore an archived item
155
+ upsert [title] [content] Create or update an item by --id
156
+ untag --id <id> -t <tag> Remove a tag from an item
157
+ delete (alias: rm) --id <id> Delete item (requires --yes)
158
+ export Export all items (--format jsonl)
159
+ prune Remove old/empty items (requires --yes)
160
+ dedupe Remove duplicate items by title+content (requires --yes)
161
+ stats Show knowledge base statistics
162
+ paths Show resolved workspace/store paths
163
+ db init|stats Initialize or inspect local knowledge.db
164
+ wiki init Initialize scalable wiki/schema/index/log artifacts
165
+ help [command] Show help
166
+
167
+ Global Options:
168
+ --json Output JSON
169
+ --store <path> Override store path
170
+ --scope local|global|project Store scope (default: global ~/.hasna/apps/knowledge/)
171
+ --no-color Disable color output
172
+ --completions <shell> Output completions for bash|zsh|fish
173
+ -v, --version Show version
174
+ -h, --help Show help
175
+
176
+ List Options:
177
+ --format table|json Output format (default: table if TTY, json otherwise)
178
+ -p, --page <n> Page number (default: 1)
179
+ -l, --limit <n> Items per page (default: 20)
180
+ -s, --search <text> Filter by title/content
181
+ -t, --tag <tag> Filter by tag
182
+ --sort <created|title> Sort field (default: created)
183
+ --desc Sort descending
184
+ --archived Show only archived items
185
+ --include-archived Include archived items
186
+
187
+ Add/Update Options:
188
+ --url <url> Attach source URL
189
+
190
+ Update Options:
191
+ --id <id> Item id
192
+ --title <title> New title
193
+ --content <content> New content
194
+ --url <url> New source URL
195
+ -t, --tag <tag> Add a tag
196
+
197
+ Delete Options:
198
+ --id <id> Item id
199
+ -y, --yes Confirm destructive action
200
+
201
+ Export Options:
202
+ --format jsonl Export as newline-delimited JSON (default: JSON array)
203
+
204
+ Prune Options:
205
+ --older-than <days> Remove items older than N days
206
+ --empty Remove items with empty content`);
207
+ }
208
+
209
+ function printCommandHelp(command: string): void {
210
+ if (command === 'add') { console.log('Usage: open-knowledge add <title> <content> [--url <url>] [-t <tag>] [--json]'); return; }
211
+ if (command === 'list' || command === 'ls') { console.log('Usage: open-knowledge list|ls [--format table|json] [-p <page>] [-l <limit>] [-s <search>] [-t <tag>] [--sort created|title] [--desc] [--json]'); return; }
212
+ if (command === 'get') { console.log('Usage: open-knowledge get --id <id> [--json]'); return; }
213
+ if (command === 'update' || command === 'edit') { console.log('Usage: open-knowledge update|edit --id <id> [--title <title>] [--content <content>] [--url <url>] [-t <tag>] [--json]'); return; }
214
+ if (command === 'archive') { console.log('Usage: open-knowledge archive --id <id> [--json]'); return; }
215
+ if (command === 'restore' || command === 'unarchive') { console.log('Usage: open-knowledge restore|unarchive --id <id> [--json]'); return; }
216
+ if (command === 'upsert') { console.log('Usage: open-knowledge upsert [title] [content] [--id <id>] [--title <title>] [--content <content>] [--url <url>] [-t <tag>] [--json]'); return; }
217
+ if (command === 'untag') { console.log('Usage: open-knowledge untag --id <id> -t <tag> [--json]'); return; }
218
+ if (command === 'delete' || command === 'rm') { console.log('Usage: open-knowledge delete|rm --id <id> -y [--json]'); return; }
219
+ if (command === 'export') { console.log('Usage: open-knowledge export [--format jsonl] [--json]'); return; }
220
+ if (command === 'prune') { console.log('Usage: open-knowledge prune --yes [--older-than <days>] [--empty] [--json]'); return; }
221
+ if (command === 'dedupe') { console.log('Usage: open-knowledge dedupe --yes [--json]'); return; }
222
+ if (command === 'stats') { console.log('Usage: open-knowledge stats [--json]'); return; }
223
+ if (command === 'paths') { console.log('Usage: open-knowledge paths [--scope local|global|project] [--json]'); return; }
224
+ if (command === 'db') { console.log('Usage: open-knowledge db init|stats [--scope local|global|project] [--json]'); return; }
225
+ if (command === 'wiki') { console.log('Usage: open-knowledge wiki init [--scope local|global|project] [--json]'); return; }
226
+ printGlobalHelp();
227
+ }
228
+
229
+ function useColor(flags: Flags): boolean {
230
+ if (flags.noColor || process.env.NO_COLOR) return false;
231
+ if (process.env.FORCE_COLOR) return true;
232
+ return process.stdout.isTTY === true;
233
+ }
234
+
235
+ function output(data: unknown, asJson?: boolean, _flags?: Flags): void {
236
+ if (asJson) { console.log(JSON.stringify(data, null, 2)); return; }
237
+ if (typeof data === 'string') { console.log(data); return; }
238
+ console.log((data as { message?: string }).message ?? JSON.stringify(data, null, 2));
239
+ }
240
+
241
+ function requireId(flags: Flags): asserts flags is Flags & { id: string } {
242
+ if (!flags.id) throw new Error('Missing required --id. Example: open-knowledge get --id <id>');
243
+ }
244
+
245
+ function sortItems(items: KnowledgeItem[], flags: Flags): { sorted: KnowledgeItem[]; sort: string; direction: string } {
246
+ const sort = flags.sort ?? 'created';
247
+ if (sort !== 'created' && sort !== 'title') {
248
+ throw new Error("Invalid --sort value. Use 'created' or 'title'.");
249
+ }
250
+ const sorted = [...items].sort((a, b) => {
251
+ if (sort === 'title') return a.title.localeCompare(b.title);
252
+ return a.created_at.localeCompare(b.created_at);
253
+ });
254
+ if (flags.desc) sorted.reverse();
255
+ return { sorted, sort, direction: flags.desc ? 'desc' : 'asc' };
256
+ }
257
+
258
+ async function run(argv: string[]): Promise<void> {
259
+ const { positional, flags } = parseArgs(argv);
260
+ log('debug', 'CLI invoked', { command: positional[0], flags: { json: flags.json, store: flags.store } });
261
+
262
+ if (flags.version) {
263
+ console.log(flags.json ? JSON.stringify({ name: pkg.name, version: pkg.version }, null, 2) : `${pkg.name} ${pkg.version}`);
264
+ return;
265
+ }
266
+
267
+ if (flags.completions) {
268
+ const shell = flags.completions;
269
+ if (shell === 'bash') {
270
+ console.log(`_open_knowledge() { local cur; cur="${"$"}{COMP_WORDS[COMP_CWORD]}"; COMPREPLY=($(compgen -W "add list get update archive restore upsert untag delete export prune dedupe stats paths db wiki help ls rm edit unarchive --json --yes --help --version --desc --page --limit --search --sort --id --store --title --content --url --tag --format --completions --no-color --scope --archived --include-archived" -- "$cur")); }; complete -F _open_knowledge open-knowledge`);
271
+ } else if (shell === 'zsh') {
272
+ console.log(`#compdef open-knowledge\n_open_knowledge() { _arguments -C "1: :(add list get update archive restore upsert untag delete export prune dedupe stats paths db wiki help ls rm edit unarchive)" "(--json)--json" "(--yes)-y" "(--help)--help" "(--version)--version" "(--desc)--desc" "(--archived)--archived" "(--include-archived)--include-archived" "(-p --page)"{-p,--page}"[page number]:number:" "(-l --limit)"{-l,--limit}"[items per page]:number:" "(-s --search)"{-s,--search}"[search text]:text:" "(--sort)--sort"\{created,title\}:" "(--id)--id[item id]:id:" "(--store)--store[store path]:path:" "(--title)--title[new title]:" "(--content)--content[new content]:" "(--url)--url[source url]:" "(-t --tag)"{-t,--tag}"[tag]:tag:" "(--format)--format[json|jsonl]:" "(--completions)--completions[output completions]:shell:(bash zsh fish):" "(--no-color)--no-color[disable color]" "(--scope)--scope"\{local,global,project\}:" }; _open_knowledge`);
273
+ } else if (shell === 'fish') {
274
+ console.log(`complete -c open-knowledge -f; complete -c open-knowledge -a "add list get update archive restore upsert untag delete export prune dedupe stats paths db wiki help ls rm edit unarchive"; complete -c open-knowledge -l json; complete -c open-knowledge -l yes -s y; complete -c open-knowledge -l help -s h; complete -c open-knowledge -l version -s v; complete -c open-knowledge -l desc; complete -c open-knowledge -l archived; complete -c open-knowledge -l include-archived; complete -c open-knowledge -s p -l page; complete -c open-knowledge -s l -l limit; complete -c open-knowledge -s s -l search; complete -c open-knowledge -l sort; complete -c open-knowledge -l id; complete -c open-knowledge -l store; complete -c open-knowledge -l title; complete -c open-knowledge -l content; complete -c open-knowledge -l url; complete -c open-knowledge -s t -l tag; complete -c open-knowledge -l format; complete -c open-knowledge -l completions; complete -c open-knowledge -l no-color; complete -c open-knowledge -l scope -a "local global project"`);
275
+ } else {
276
+ throw new Error("Invalid --completions value. Use 'bash', 'zsh', or 'fish'.");
277
+ }
278
+ return;
279
+ }
280
+
281
+ const command = resolveCommand(positional[0]);
282
+
283
+ if (!command || flags.help || command === 'help') { printCommandHelp(positional[1]); return; }
284
+
285
+ const workspace = resolveScopedWorkspace(flags.scope);
286
+ let storePath = flags.store;
287
+ if (!storePath) {
288
+ if (flags.scope === 'project' || flags.scope === 'local') {
289
+ storePath = ensureKnowledgeWorkspace(workspace.home).jsonStorePath;
290
+ } else {
291
+ storePath = defaultStorePath();
292
+ }
293
+ }
294
+
295
+ if (command === 'paths') {
296
+ const resolvedWorkspace = ensureKnowledgeWorkspace(workspace.home);
297
+ output({
298
+ ok: true,
299
+ scope: flags.scope ?? 'global',
300
+ home: resolvedWorkspace.home,
301
+ config_path: resolvedWorkspace.configPath,
302
+ json_store_path: resolvedWorkspace.jsonStorePath,
303
+ knowledge_db_path: resolvedWorkspace.knowledgeDbPath,
304
+ artifacts_dir: resolvedWorkspace.artifactsDir,
305
+ indexes_dir: resolvedWorkspace.indexesDir,
306
+ logs_dir: resolvedWorkspace.logsDir,
307
+ runs_dir: resolvedWorkspace.runsDir,
308
+ schemas_dir: resolvedWorkspace.schemasDir,
309
+ wiki_dir: resolvedWorkspace.wikiDir,
310
+ config: readKnowledgeConfig(resolvedWorkspace.configPath),
311
+ message: resolvedWorkspace.home,
312
+ }, flags.json);
313
+ return;
314
+ }
315
+
316
+ if (command === 'db') {
317
+ const action = positional[1] ?? 'init';
318
+ const resolvedWorkspace = ensureKnowledgeWorkspace(workspace.home);
319
+ if (action !== 'init' && action !== 'stats') {
320
+ throw new Error("Invalid db action. Use 'init' or 'stats'.");
321
+ }
322
+ if (action === 'init') {
323
+ const result = migrateKnowledgeDb(resolvedWorkspace.knowledgeDbPath);
324
+ output({ ok: true, ...result, message: `Initialized ${result.path}` }, flags.json);
325
+ return;
326
+ }
327
+ migrateKnowledgeDb(resolvedWorkspace.knowledgeDbPath);
328
+ const stats = getKnowledgeDbStats(resolvedWorkspace.knowledgeDbPath);
329
+ output({ ok: true, path: resolvedWorkspace.knowledgeDbPath, ...stats, message: `knowledge.db schema v${stats.schema_version}` }, flags.json);
330
+ return;
331
+ }
332
+
333
+ if (command === 'wiki') {
334
+ const action = positional[1] ?? 'init';
335
+ if (action !== 'init') throw new Error("Invalid wiki action. Use 'init'.");
336
+ const resolvedWorkspace = ensureKnowledgeWorkspace(workspace.home);
337
+ const config = readKnowledgeConfig(resolvedWorkspace.configPath);
338
+ const artifactStore = createArtifactStore(config, resolvedWorkspace);
339
+ const result = await initializeWikiLayout(artifactStore);
340
+ output({ ok: true, ...result, message: `Initialized wiki layout in ${resolvedWorkspace.home}` }, flags.json);
341
+ return;
342
+ }
343
+ ensureStore(storePath);
344
+
345
+ if (command === 'add') {
346
+ const title = positional[1];
347
+ const content = positional[2];
348
+ if (!title || !content) throw new Error('Usage: open-knowledge add <title> <content>');
349
+ withLock(storePath, () => {
350
+ const db = loadStore(storePath);
351
+ const item: KnowledgeItem = {
352
+ id: makeId(),
353
+ title,
354
+ content,
355
+ url: flags.url ?? null,
356
+ tags: flags.tag ? [flags.tag] : [],
357
+ created_at: new Date().toISOString(),
358
+ updated_at: new Date().toISOString(),
359
+ };
360
+ db.items.push(item);
361
+ saveStore(storePath, db);
362
+ log('info', 'Item added', { id: item.id, title: item.title });
363
+ output({ ok: true, item, message: `Added ${item.id}` }, flags.json);
364
+ });
365
+ return;
366
+ }
367
+
368
+ if (command === 'list') {
369
+ if (flags.format !== undefined && flags.format !== 'table' && flags.format !== 'json') {
370
+ throw new Error("Invalid --format value for list. Use 'table' or 'json'.");
371
+ }
372
+ withLock(storePath, () => {
373
+ const db = loadStore(storePath);
374
+ const page = Number.isFinite(flags.page) && (flags.page as number) > 0 ? flags.page as number : 1;
375
+ const limit = Number.isFinite(flags.limit) && (flags.limit as number) > 0 ? flags.limit as number : 20;
376
+ const search = flags.search ? String(flags.search).toLowerCase() : '';
377
+ const tag = flags.tag ? String(flags.tag).toLowerCase() : '';
378
+ const useTable = flags.format === 'table' || (!flags.json && !flags.format && useColor(flags));
379
+ const useJson = flags.json || flags.format === 'json';
380
+
381
+ let filtered = db.items;
382
+ if (flags.archived) filtered = filtered.filter((x) => x.archived === true);
383
+ else if (!flags.includeArchived) filtered = filtered.filter((x) => !x.archived);
384
+ if (search) filtered = filtered.filter((x) => x.title.toLowerCase().includes(search) || x.content.toLowerCase().includes(search));
385
+ if (tag) filtered = filtered.filter((x) => x.tags && x.tags.map((t) => t.toLowerCase()).includes(tag));
386
+
387
+ const { sorted, sort, direction } = sortItems(filtered, flags);
388
+ const start = (page - 1) * limit;
389
+ const rows = sorted.slice(start, start + limit);
390
+ const totalPages = Math.max(1, Math.ceil(sorted.length / limit));
391
+
392
+ if (useJson) { output({ ok: true, page, limit, total: sorted.length, total_pages: totalPages, sort, direction, items: rows }, true); return; }
393
+ if (rows.length === 0) { output(`No items found (search=${search || 'none'}, tag=${tag || 'none'})`, false); return; }
394
+ if (useTable) {
395
+ const col = (v: string) => v;
396
+ const header = `${col('ID')}\t${col('TITLE')}\t${col('CREATED')}\t${col('URL')}\t${col('TAGS')}`;
397
+ console.log(header);
398
+ for (const row of rows) {
399
+ console.log(`${row.id}\t${col(row.title)}\t${row.created_at}\t${row.url ? col(row.url) : ''}\t${row.tags?.length ? col(`[${row.tags.join(', ')}]`) : ''}`);
400
+ }
401
+ console.log(`Page ${page}/${totalPages} | showing ${rows.length} of ${sorted.length} | sort=${sort} ${direction} | search=${search || 'none'} | tag=${tag || 'none'}`);
402
+ } else {
403
+ for (const row of rows) {
404
+ console.log(`${row.id}\t${row.title}\t${row.created_at}${row.url ? `\t${row.url}` : ''}${row.tags?.length ? `\t[${row.tags.join(', ')}]` : ''}`);
405
+ }
406
+ console.log(`Page ${page}/${totalPages} | showing ${rows.length} of ${sorted.length} | sort=${sort} ${direction} | search=${search || 'none'} | tag=${tag || 'none'}`);
407
+ }
408
+ });
409
+ return;
410
+ }
411
+
412
+ if (command === 'get') {
413
+ requireId(flags);
414
+ withLock(storePath, () => {
415
+ const db = loadStore(storePath);
416
+ const item = db.items.find((x) => x.id === flags.id || x.short_id === flags.id);
417
+ if (!item) throw new Error(`Item not found: ${flags.id}`);
418
+ output({ ok: true, item, message: `${item.id}: ${item.title}` }, flags.json);
419
+ });
420
+ return;
421
+ }
422
+
423
+ if (command === 'update') {
424
+ requireId(flags);
425
+ withLock(storePath, () => {
426
+ const db = loadStore(storePath);
427
+ const idx = db.items.findIndex((x) => x.id === flags.id || x.short_id === flags.id);
428
+ if (idx === -1) throw new Error(`Item not found: ${flags.id}`);
429
+ const item = db.items[idx];
430
+ if (flags.title !== undefined) item.title = flags.title;
431
+ if (flags.content !== undefined) item.content = flags.content;
432
+ if (flags.url !== undefined) item.url = flags.url;
433
+ if (flags.tag !== undefined) {
434
+ item.tags = item.tags || [];
435
+ if (!item.tags.map((t) => t.toLowerCase()).includes(flags.tag!.toLowerCase())) {
436
+ item.tags.push(flags.tag!);
437
+ }
438
+ }
439
+ item.updated_at = new Date().toISOString();
440
+ db.items[idx] = item;
441
+ saveStore(storePath, db);
442
+ output({ ok: true, item, message: `Updated ${item.id}` }, flags.json);
443
+ });
444
+ return;
445
+ }
446
+
447
+ if (command === 'archive' || command === 'restore') {
448
+ requireId(flags);
449
+ withLock(storePath, () => {
450
+ const db = loadStore(storePath);
451
+ const idx = db.items.findIndex((x) => x.id === flags.id || x.short_id === flags.id);
452
+ if (idx === -1) throw new Error(`Item not found: ${flags.id}`);
453
+ const item = db.items[idx];
454
+ item.archived = command === 'archive';
455
+ item.updated_at = new Date().toISOString();
456
+ db.items[idx] = item;
457
+ saveStore(storePath, db);
458
+ output({ ok: true, item, message: `${command === 'archive' ? 'Archived' : 'Restored'} ${item.id}` }, flags.json);
459
+ });
460
+ return;
461
+ }
462
+
463
+ if (command === 'untag') {
464
+ requireId(flags);
465
+ if (!flags.tag) throw new Error('Missing required --tag. Example: open-knowledge untag --id <id> -t <tag>');
466
+ withLock(storePath, () => {
467
+ const db = loadStore(storePath);
468
+ const idx = db.items.findIndex((x) => x.id === flags.id || x.short_id === flags.id);
469
+ if (idx === -1) throw new Error(`Item not found: ${flags.id}`);
470
+ const item = db.items[idx];
471
+ const before = item.tags?.length ?? 0;
472
+ item.tags = (item.tags ?? []).filter((tag) => tag.toLowerCase() !== flags.tag!.toLowerCase());
473
+ item.updated_at = new Date().toISOString();
474
+ db.items[idx] = item;
475
+ saveStore(storePath, db);
476
+ output({ ok: true, item, removed: before - item.tags.length, message: `Removed tag from ${item.id}` }, flags.json);
477
+ });
478
+ return;
479
+ }
480
+
481
+ if (command === 'upsert') {
482
+ const title = flags.title ?? positional[1];
483
+ const content = flags.content ?? positional[2];
484
+ withLock(storePath, () => {
485
+ const db = loadStore(storePath);
486
+ const idx = flags.id ? db.items.findIndex((x) => x.id === flags.id || x.short_id === flags.id) : -1;
487
+ const now = new Date().toISOString();
488
+ if (idx === -1) {
489
+ if (!title || !content) throw new Error('New item requires title and content. Example: open-knowledge upsert <title> <content> [--id <id>]');
490
+ const id = flags.id ?? makeId();
491
+ const item: KnowledgeItem = {
492
+ id,
493
+ short_id: makeShortId(id),
494
+ title,
495
+ content,
496
+ url: flags.url ?? null,
497
+ tags: flags.tag ? [flags.tag] : [],
498
+ metadata: {},
499
+ archived: false,
500
+ created_at: now,
501
+ updated_at: now,
502
+ };
503
+ db.items.push(item);
504
+ saveStore(storePath, db);
505
+ output({ ok: true, created: true, item, message: `Upserted ${item.id}` }, flags.json);
506
+ return;
507
+ }
508
+ const item = db.items[idx];
509
+ if (title !== undefined) item.title = title;
510
+ if (content !== undefined) item.content = content;
511
+ if (flags.url !== undefined) item.url = flags.url;
512
+ if (flags.tag !== undefined) {
513
+ item.tags = item.tags || [];
514
+ if (!item.tags.map((tag) => tag.toLowerCase()).includes(flags.tag.toLowerCase())) item.tags.push(flags.tag);
515
+ }
516
+ item.updated_at = now;
517
+ db.items[idx] = item;
518
+ saveStore(storePath, db);
519
+ output({ ok: true, created: false, item, message: `Upserted ${item.id}` }, flags.json);
520
+ });
521
+ return;
522
+ }
523
+
524
+ if (command === 'delete') {
525
+ requireId(flags);
526
+ if (!flags.yes) throw new Error('Refusing delete without --yes. Re-run with: open-knowledge delete --id <id> --yes');
527
+ withLock(storePath, () => {
528
+ const db = loadStore(storePath);
529
+ const before = db.items.length;
530
+ db.items = db.items.filter((x) => x.id !== flags.id && x.short_id !== flags.id);
531
+ const deleted = before !== db.items.length;
532
+ saveStore(storePath, db);
533
+ if (!deleted) throw new Error(`Item not found: ${flags.id}`);
534
+ log('info', 'Item deleted', { id: flags.id });
535
+ output({ ok: true, deleted_id: flags.id, message: `Deleted ${flags.id}` }, flags.json);
536
+ });
537
+ return;
538
+ }
539
+
540
+ if (command === 'export') {
541
+ const format = flags.format ?? 'json';
542
+ if (format !== 'json' && format !== 'jsonl') throw new Error("Invalid --format. Use 'json' or 'jsonl'.");
543
+ withLock(storePath, () => {
544
+ const db = loadStore(storePath);
545
+ if (format === 'jsonl') {
546
+ for (const item of db.items) console.log(JSON.stringify(item));
547
+ } else {
548
+ output({ ok: true, items: db.items }, flags.json);
549
+ }
550
+ });
551
+ return;
552
+ }
553
+
554
+ if (command === 'prune') {
555
+ if (!flags.yes) throw new Error('Refusing prune without --yes. Re-run with: open-knowledge prune --yes [--older-than <days>] [--empty]');
556
+ withLock(storePath, () => {
557
+ const db = loadStore(storePath);
558
+ const before = db.items.length;
559
+ if (flags.olderThan !== undefined) {
560
+ const cutoff = new Date();
561
+ cutoff.setDate(cutoff.getDate() - flags.olderThan);
562
+ db.items = db.items.filter((x) => new Date(x.created_at) >= cutoff);
563
+ }
564
+ if (flags.empty) {
565
+ db.items = db.items.filter((x) => x.content.trim().length > 0);
566
+ }
567
+ const pruned = before - db.items.length;
568
+ saveStore(storePath, db);
569
+ log('info', 'Prune completed', { pruned, remaining: db.items.length });
570
+ output({ ok: true, pruned, remaining: db.items.length, message: `Pruned ${pruned} item(s)` }, flags.json);
571
+ });
572
+ return;
573
+ }
574
+
575
+ if (command === 'dedupe') {
576
+ if (!flags.yes) throw new Error('Refusing dedupe without --yes. Re-run with: open-knowledge dedupe --yes [--json]');
577
+ withLock(storePath, () => {
578
+ const db = loadStore(storePath);
579
+ const seen = new Set<string>();
580
+ const before = db.items.length;
581
+ db.items = db.items.filter((x) => {
582
+ const key = `${x.title}\u0000${x.content}`;
583
+ if (seen.has(key)) return false;
584
+ seen.add(key);
585
+ return true;
586
+ });
587
+ const removed = before - db.items.length;
588
+ saveStore(storePath, db);
589
+ log('info', 'Dedupe completed', { removed, remaining: db.items.length });
590
+ output({ ok: true, removed, remaining: db.items.length, message: `Dedupe removed ${removed} duplicate(s)` }, flags.json);
591
+ });
592
+ return;
593
+ }
594
+
595
+ if (command === 'stats') {
596
+ withLock(storePath, () => {
597
+ const db = loadStore(storePath);
598
+ const activeItems = db.items.filter((x) => !x.archived);
599
+ const total = activeItems.length;
600
+ const archived = db.items.length - total;
601
+ const withUrl = activeItems.filter((x) => x.url).length;
602
+ const withTags = activeItems.filter((x) => x.tags && x.tags.length > 0).length;
603
+ const oldest = total > 0 ? activeItems.map((x) => x.created_at).sort()[0] : null;
604
+ const newest = total > 0 ? activeItems.map((x) => x.created_at).sort()[total - 1] : null;
605
+ const tagCounts: Record<string, number> = {};
606
+ for (const item of activeItems) {
607
+ for (const tag of item.tags || []) {
608
+ tagCounts[tag] = (tagCounts[tag] || 0) + 1;
609
+ }
610
+ }
611
+ const topTags = Object.entries(tagCounts).sort((a, b) => b[1] - a[1]).slice(0, 5).map(([tag, count]) => ({ tag, count }));
612
+ output({
613
+ ok: true,
614
+ total,
615
+ archived,
616
+ with_url: withUrl,
617
+ with_tags: withTags,
618
+ oldest,
619
+ newest,
620
+ top_tags: topTags,
621
+ message: `${total} items | ${withUrl} with URL | ${withTags} with tags`,
622
+ }, flags.json);
623
+ });
624
+ return;
625
+ }
626
+
627
+ const suggestion = suggestCommand(positional[0]);
628
+ const hint = suggestion ? ` Did you mean '${suggestion}'?` : '';
629
+ log('warn', 'Unknown command', { input: positional[0], suggestion });
630
+ throw new Error(`Unknown command: ${positional[0]}.${hint} Run 'open-knowledge --help' for available commands.`);
631
+ }
632
+
633
+ if (import.meta.main) {
634
+ run(process.argv.slice(2)).catch((error) => {
635
+ const message = error instanceof Error ? error.message : String(error);
636
+ log('error', 'CLI error', { message, stack: error instanceof Error ? error.stack : undefined });
637
+ console.error(`Error: ${message}`);
638
+ process.exitCode = 1;
639
+ });
640
+ }
641
+
642
+ export { run, parseArgs, suggestCommand, sortItems };