@hasna/knowledge 0.2.2 → 0.2.3

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/mcp.js DELETED
@@ -1,574 +0,0 @@
1
- #!/usr/bin/env bun
2
- import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
3
- import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
4
- import { z } from 'zod';
5
- import { defaultStorePath, loadStore, saveStore, makeId } from './store.ts';
6
- import { existsSync, readFileSync, writeFileSync } from 'node:fs';
7
-
8
- function createStoreSchema() {
9
- return z.object({
10
- store_path: z.string().optional().describe('Path to the store file (default: ~/.open-knowledge/db.json)'),
11
- });
12
- }
13
-
14
- function createItemSchema() {
15
- return z.object({
16
- store_path: z.string().optional().describe('Path to the store file'),
17
- });
18
- }
19
-
20
- function createAddSchema() {
21
- return z.object({
22
- title: z.string().describe('Item title'),
23
- content: z.string().describe('Item content/body'),
24
- tags: z.array(z.string()).optional().describe('Tags to attach'),
25
- metadata: z.record(z.string(), z.unknown()).optional().describe('Metadata key-value pairs'),
26
- store_path: z.string().optional().describe('Path to the store file'),
27
- });
28
- }
29
-
30
- function createIdSchema() {
31
- return z.object({
32
- id: z.string().describe('Item ID or short ID'),
33
- store_path: z.string().optional().describe('Path to the store file'),
34
- });
35
- }
36
-
37
- function createListSchema() {
38
- return z.object({
39
- search: z.string().optional().describe('Search text for title/content'),
40
- fuzzy: z.boolean().optional().describe('Use fuzzy matching for search'),
41
- tag: z.array(z.string()).optional().describe('Filter by tags (must match all)'),
42
- archived: z.boolean().optional().describe('Show only archived items'),
43
- include_archived: z.boolean().optional().describe('Include archived items in results'),
44
- page: z.number().optional().describe('Page number (default: 1)'),
45
- limit: z.number().optional().describe('Items per page (default: 20)'),
46
- sort: z.enum(['created', 'title']).optional().describe('Sort field'),
47
- desc: z.boolean().optional().describe('Sort descending'),
48
- after: z.string().optional().describe('Filter items created after ISO date'),
49
- before: z.string().optional().describe('Filter items created before ISO date'),
50
- store_path: z.string().optional().describe('Path to the store file'),
51
- });
52
- }
53
-
54
- function createUpdateSchema() {
55
- return z.object({
56
- id: z.string().describe('Item ID or short ID'),
57
- title: z.string().optional().describe('New title'),
58
- content: z.string().optional().describe('New content'),
59
- tags: z.array(z.string()).optional().describe('Tags to add'),
60
- metadata: z.record(z.string(), z.unknown()).optional().describe('Metadata to merge'),
61
- store_path: z.string().optional().describe('Path to the store file'),
62
- });
63
- }
64
-
65
- function createDeleteSchema() {
66
- return z.object({
67
- id: z.string().describe('Item ID or short ID'),
68
- confirm: z.boolean().describe('Must be true to confirm deletion'),
69
- store_path: z.string().optional().describe('Path to the store file'),
70
- });
71
- }
72
-
73
- function createUpsertSchema() {
74
- return z.object({
75
- id: z.string().describe('Item ID (used as id for new items)'),
76
- title: z.string().optional().describe('Item title'),
77
- content: z.string().optional().describe('Item content'),
78
- tags: z.array(z.string()).optional().describe('Tags'),
79
- metadata: z.record(z.string(), z.unknown()).optional().describe('Metadata'),
80
- store_path: z.string().optional().describe('Path to the store file'),
81
- });
82
- }
83
-
84
- function createBulkDeleteSchema() {
85
- return z.object({
86
- tag: z.array(z.string()).optional().describe('Delete items with these tags'),
87
- search: z.string().optional().describe('Delete items matching search in title/content'),
88
- confirm: z.boolean().describe('Must be true to confirm deletion'),
89
- store_path: z.string().optional().describe('Path to the store file'),
90
- });
91
- }
92
-
93
- function createExportSchema() {
94
- return z.object({
95
- file: z.string().optional().describe('Output file path (default: ./knowledge-export.json)'),
96
- store_path: z.string().optional().describe('Path to the store file'),
97
- });
98
- }
99
-
100
- function createImportSchema() {
101
- return z.object({
102
- file: z.string().describe('Path to exported JSON file'),
103
- store_path: z.string().optional().describe('Path to the store file'),
104
- });
105
- }
106
-
107
- function createStatsSchema() {
108
- return z.object({
109
- store_path: z.string().optional().describe('Path to the store file'),
110
- });
111
- }
112
-
113
- function createBatchSchema() {
114
- return z.object({
115
- items: z.array(z.object({
116
- id: z.string().optional(),
117
- title: z.string(),
118
- content: z.string(),
119
- tags: z.array(z.string()).optional(),
120
- metadata: z.record(z.string(), z.unknown()).optional(),
121
- created_at: z.string().optional(),
122
- updated_at: z.string().optional(),
123
- })).describe('Array of items to import'),
124
- store_path: z.string().optional().describe('Path to the store file'),
125
- });
126
- }
127
-
128
- function createUntagSchema() {
129
- return z.object({
130
- id: z.string().describe('Item ID or short ID'),
131
- tags: z.array(z.string()).describe('Tags to remove'),
132
- store_path: z.string().optional().describe('Path to the store file'),
133
- });
134
- }
135
-
136
- export function buildServer() {
137
- const server = new McpServer({
138
- name: 'open-knowledge',
139
- version: '0.1.0',
140
- });
141
-
142
- // Helper to resolve store path
143
- function resolveStore(path) {
144
- return path || defaultStorePath();
145
- }
146
-
147
- server.registerTool('ok_add', {
148
- title: 'Add a knowledge item',
149
- description: 'Add a new item to the knowledge store with title, content, optional tags and metadata',
150
- inputSchema: createAddSchema(),
151
- handler: async ({ title, content, tags, metadata, store_path }) => {
152
- const db = loadStore(resolveStore(store_path));
153
- const now = new Date().toISOString();
154
- const { id, shortId } = makeId();
155
- const item = {
156
- id,
157
- short_id: shortId,
158
- title,
159
- content,
160
- tags: tags ?? [],
161
- metadata: metadata ?? {},
162
- created_at: now,
163
- updated_at: now,
164
- };
165
- db.items.push(item);
166
- saveStore(resolveStore(store_path), db);
167
- return {
168
- content: [{ type: 'text', text: JSON.stringify({ ok: true, item, message: `Added ${item.id}` }, null, 2) }],
169
- };
170
- },
171
- });
172
-
173
- server.registerTool('ok_list', {
174
- title: 'List knowledge items',
175
- description: 'List items with pagination, search, tag filter, date filter, and sorting',
176
- inputSchema: createListSchema(),
177
- handler: async ({ search, fuzzy, tag, archived, include_archived, page, limit, sort, desc, after, before, store_path }) => {
178
- const db = loadStore(resolveStore(store_path));
179
- let items = db.items;
180
-
181
- if (search) {
182
- const q = search.toLowerCase();
183
- if (fuzzy) {
184
- const levenshtein = (a, b) => {
185
- const dp = Array.from({ length: a.length + 1 }, () => Array(b.length + 1).fill(0));
186
- for (let i = 0; i <= a.length; i += 1) dp[i][0] = i;
187
- for (let j = 0; j <= b.length; j += 1) dp[0][j] = j;
188
- for (let i = 1; i <= a.length; i += 1) {
189
- for (let j = 1; j <= b.length; j += 1) {
190
- const cost = a[i - 1] === b[j - 1] ? 0 : 1;
191
- dp[i][j] = Math.min(dp[i - 1][j] + 1, dp[i][j - 1] + 1, dp[i - 1][j - 1] + cost);
192
- }
193
- }
194
- return dp[a.length][b.length];
195
- };
196
- const scored = items.map((x) => {
197
- const titleScore = levenshtein(q, x.title.toLowerCase());
198
- const contentScore = Math.min(levenshtein(q, x.content.slice(0, 200).toLowerCase()), 20);
199
- return { ...x, _fuzzyScore: Math.min(titleScore, contentScore) };
200
- }).filter((x) => x._fuzzyScore <= 5);
201
- scored.sort((a, b) => a._fuzzyScore - b._fuzzyScore);
202
- items = scored;
203
- } else {
204
- items = items.filter((x) => x.title.toLowerCase().includes(q) || x.content.toLowerCase().includes(q));
205
- }
206
- }
207
-
208
- if (tag && tag.length > 0) {
209
- items = items.filter((x) => {
210
- const itemTags = (x.tags ?? []).map((t) => t.toLowerCase());
211
- return tag.every((t) => itemTags.includes(t.toLowerCase()));
212
- });
213
- }
214
-
215
- if (archived) {
216
- items = items.filter((x) => x.archived === true);
217
- } else if (!include_archived) {
218
- items = items.filter((x) => !x.archived);
219
- }
220
-
221
- if (after) {
222
- items = items.filter((x) => x.created_at > after);
223
- }
224
- if (before) {
225
- items = items.filter((x) => x.created_at < before);
226
- }
227
-
228
- const p = page ?? 1;
229
- const l = limit ?? 20;
230
- const start = (p - 1) * l;
231
- const totalPages = Math.max(1, Math.ceil(items.length / l));
232
- const rows = items.slice(start, start + l);
233
-
234
- return {
235
- content: [{ type: 'text', text: JSON.stringify({ page: p, limit: l, total: items.length, total_pages: totalPages, items: rows }, null, 2) }],
236
- };
237
- },
238
- });
239
-
240
- server.registerTool('ok_get', {
241
- title: 'Get a knowledge item',
242
- description: 'Retrieve a single item by its ID or short ID',
243
- inputSchema: createIdSchema(),
244
- handler: async ({ id, store_path }) => {
245
- const db = loadStore(resolveStore(store_path));
246
- const item = db.items.find((x) => x.id === id || x.short_id === id);
247
- if (!item) {
248
- return { content: [{ type: 'text', text: `Error: Item not found: ${id}` }] };
249
- }
250
- return { content: [{ type: 'text', text: JSON.stringify({ item }, null, 2) }] };
251
- },
252
- });
253
-
254
- server.registerTool('ok_update', {
255
- title: 'Update a knowledge item',
256
- description: 'Update title, content, tags, or metadata of an existing item',
257
- inputSchema: createUpdateSchema(),
258
- handler: async ({ id, title, content, tags, metadata, store_path }) => {
259
- const db = loadStore(resolveStore(store_path));
260
- const item = db.items.find((x) => x.id === id || x.short_id === id);
261
- if (!item) {
262
- return { content: [{ type: 'text', text: `Error: Item not found: ${id}` }] };
263
- }
264
- if (title) item.title = title;
265
- if (content) item.content = content;
266
- if (tags) {
267
- item.tags = [...new Set([...(item.tags ?? []), ...tags])];
268
- }
269
- if (metadata) {
270
- item.metadata = { ...(item.metadata ?? {}), ...metadata };
271
- }
272
- item.updated_at = new Date().toISOString();
273
- saveStore(resolveStore(store_path), db);
274
- return { content: [{ type: 'text', text: JSON.stringify({ ok: true, item }, null, 2) }] };
275
- },
276
- });
277
-
278
- server.registerTool('ok_delete', {
279
- title: 'Delete a knowledge item',
280
- description: 'Permanently delete an item by ID. Requires confirm=true to prevent accidental deletion.',
281
- inputSchema: createDeleteSchema(),
282
- handler: async ({ id, confirm, store_path }) => {
283
- if (!confirm) {
284
- return { content: [{ type: 'text', text: 'Error: Refusing delete without confirm=true. Re-run with confirm: true.' }] };
285
- }
286
- const db = loadStore(resolveStore(store_path));
287
- const before = db.items.length;
288
- db.items = db.items.filter((x) => x.id !== id && x.short_id !== id);
289
- if (db.items.length === before) {
290
- return { content: [{ type: 'text', text: `Error: Item not found: ${id}` }] };
291
- }
292
- saveStore(resolveStore(store_path), db);
293
- return { content: [{ type: 'text', text: JSON.stringify({ ok: true, deleted_id: id }, null, 2) }] };
294
- },
295
- });
296
-
297
- server.registerTool('ok_archive', {
298
- title: 'Archive a knowledge item',
299
- description: 'Soft-delete an item by setting its archived flag to true',
300
- inputSchema: createIdSchema(),
301
- handler: async ({ id, store_path }) => {
302
- const db = loadStore(resolveStore(store_path));
303
- const item = db.items.find((x) => x.id === id || x.short_id === id);
304
- if (!item) {
305
- return { content: [{ type: 'text', text: `Error: Item not found: ${id}` }] };
306
- }
307
- item.archived = true;
308
- item.updated_at = new Date().toISOString();
309
- saveStore(resolveStore(store_path), db);
310
- return { content: [{ type: 'text', text: JSON.stringify({ ok: true, item }, null, 2) }] };
311
- },
312
- });
313
-
314
- server.registerTool('ok_restore', {
315
- title: 'Restore a knowledge item',
316
- description: 'Un-archive an item by setting its archived flag back to false',
317
- inputSchema: createIdSchema(),
318
- handler: async ({ id, store_path }) => {
319
- const db = loadStore(resolveStore(store_path));
320
- const item = db.items.find((x) => x.id === id || x.short_id === id);
321
- if (!item) {
322
- return { content: [{ type: 'text', text: `Error: Item not found: ${id}` }] };
323
- }
324
- item.archived = false;
325
- item.updated_at = new Date().toISOString();
326
- saveStore(resolveStore(store_path), db);
327
- return { content: [{ type: 'text', text: JSON.stringify({ ok: true, item }, null, 2) }] };
328
- },
329
- });
330
-
331
- server.registerTool('ok_upsert', {
332
- title: 'Upsert a knowledge item',
333
- description: 'Create or update an item by ID. Creates new if ID does not exist, updates if it does.',
334
- inputSchema: createUpsertSchema(),
335
- handler: async ({ id, title, content, tags, metadata, store_path }) => {
336
- const db = loadStore(resolveStore(store_path));
337
- let item = db.items.find((x) => x.id === id || x.short_id === id);
338
- const now = new Date().toISOString();
339
- if (!item) {
340
- if (!title || !content) {
341
- return { content: [{ type: 'text', text: 'Error: New item requires both title and content.' }] };
342
- }
343
- const { shortId } = makeId();
344
- item = {
345
- id,
346
- short_id: shortId,
347
- title,
348
- content,
349
- tags: tags ?? [],
350
- metadata: metadata ?? {},
351
- created_at: now,
352
- updated_at: now,
353
- };
354
- db.items.push(item);
355
- } else {
356
- if (title) item.title = title;
357
- if (content) item.content = content;
358
- if (tags) {
359
- item.tags = [...new Set([...(item.tags ?? []), ...tags])];
360
- }
361
- if (metadata) {
362
- item.metadata = { ...(item.metadata ?? {}), ...metadata };
363
- }
364
- item.updated_at = now;
365
- }
366
- saveStore(resolveStore(store_path), db);
367
- return { content: [{ type: 'text', text: JSON.stringify({ ok: true, item }, null, 2) }] };
368
- },
369
- });
370
-
371
- server.registerTool('ok_untag', {
372
- title: 'Remove tags from a knowledge item',
373
- description: 'Remove specific tags from an item',
374
- inputSchema: createUntagSchema(),
375
- handler: async ({ id, tags, store_path }) => {
376
- const db = loadStore(resolveStore(store_path));
377
- const item = db.items.find((x) => x.id === id || x.short_id === id);
378
- if (!item) {
379
- return { content: [{ type: 'text', text: `Error: Item not found: ${id}` }] };
380
- }
381
- const removeTags = new Set(tags.map((t) => t.toLowerCase()));
382
- const before = (item.tags ?? []).length;
383
- item.tags = (item.tags ?? []).filter((t) => !removeTags.has(t.toLowerCase()));
384
- const removed = before - item.tags.length;
385
- item.updated_at = new Date().toISOString();
386
- saveStore(resolveStore(store_path), db);
387
- return { content: [{ type: 'text', text: JSON.stringify({ ok: true, item, removed }, null, 2) }] };
388
- },
389
- });
390
-
391
- server.registerTool('ok_bulk_delete', {
392
- title: 'Bulk delete knowledge items',
393
- description: 'Delete multiple items by tag or search pattern. Requires confirm=true.',
394
- inputSchema: createBulkDeleteSchema(),
395
- handler: async ({ tag, search, confirm, store_path }) => {
396
- if (!confirm) {
397
- return { content: [{ type: 'text', text: 'Error: Refusing bulk delete without confirm=true.' }] };
398
- }
399
- if (!tag && !search) {
400
- return { content: [{ type: 'text', text: 'Error: Missing filter. Use tag or search to specify items.' }] };
401
- }
402
- const db = loadStore(resolveStore(store_path));
403
- const before = db.items.length;
404
- let items = db.items;
405
-
406
- if (tag && tag.length > 0) {
407
- items = items.filter((x) => {
408
- const itemTags = (x.tags ?? []).map((t) => t.toLowerCase());
409
- return tag.some((t) => itemTags.includes(t.toLowerCase()));
410
- });
411
- }
412
-
413
- if (search) {
414
- const q = search.toLowerCase();
415
- items = items.filter((x) => x.title.toLowerCase().includes(q) || x.content.toLowerCase().includes(q));
416
- }
417
-
418
- const deleteIds = new Set(items.map((x) => x.id));
419
- db.items = db.items.filter((x) => !deleteIds.has(x.id));
420
- const deleted = before - db.items.length;
421
- saveStore(resolveStore(store_path), db);
422
- return { content: [{ type: 'text', text: JSON.stringify({ ok: true, deleted }, null, 2) }] };
423
- },
424
- });
425
-
426
- server.registerTool('ok_stats', {
427
- title: 'Knowledge store statistics',
428
- description: 'Get stats about the knowledge store: total items, tags, recent activity',
429
- inputSchema: createStatsSchema(),
430
- handler: async ({ store_path }) => {
431
- const db = loadStore(resolveStore(store_path));
432
- const items = db.items.filter((x) => !x.archived);
433
- const total = items.length;
434
- const tagCounts = {};
435
- for (const item of items) {
436
- for (const t of (item.tags ?? [])) {
437
- tagCounts[t] = (tagCounts[t] ?? 0) + 1;
438
- }
439
- }
440
- const now = new Date();
441
- const today = now.toISOString().slice(0, 10);
442
- const weekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000).toISOString();
443
- return {
444
- content: [{ type: 'text', text: JSON.stringify({
445
- total,
446
- created_today: items.filter((x) => x.created_at.slice(0, 10) === today).length,
447
- created_week: items.filter((x) => x.created_at > weekAgo).length,
448
- updated_week: items.filter((x) => x.updated_at && x.updated_at > weekAgo).length,
449
- tags: Object.fromEntries(Object.entries(tagCounts).sort((a, b) => b[1] - a[1])),
450
- }, null, 2) }],
451
- };
452
- },
453
- });
454
-
455
- server.registerTool('ok_export', {
456
- title: 'Export knowledge items',
457
- description: 'Export all items to a JSON file',
458
- inputSchema: createExportSchema(),
459
- handler: async ({ file, store_path }) => {
460
- const db = loadStore(resolveStore(store_path));
461
- const filePath = file || './knowledge-export.json';
462
- writeFileSync(filePath, JSON.stringify(db, null, 2));
463
- return { content: [{ type: 'text', text: JSON.stringify({ ok: true, file: filePath, count: db.items.length }, null, 2) }] };
464
- },
465
- });
466
-
467
- server.registerTool('ok_import', {
468
- title: 'Import knowledge items',
469
- description: 'Import items from an exported JSON file, skipping duplicates',
470
- inputSchema: createImportSchema(),
471
- handler: async ({ file, store_path }) => {
472
- if (!existsSync(file)) {
473
- return { content: [{ type: 'text', text: `Error: File not found: ${file}` }] };
474
- }
475
- const raw = readFileSync(file, 'utf8');
476
- const imported = JSON.parse(raw);
477
- if (!imported || !Array.isArray(imported.items)) {
478
- return { content: [{ type: 'text', text: 'Error: Invalid import file: expected {"items": [...]}' }] };
479
- }
480
- const db = loadStore(resolveStore(store_path));
481
- const existingIds = new Set(db.items.map((x) => x.id));
482
- let added = 0;
483
- for (const item of imported.items) {
484
- if (!existingIds.has(item.id)) {
485
- db.items.push(item);
486
- added += 1;
487
- }
488
- }
489
- saveStore(resolveStore(store_path), db);
490
- return { content: [{ type: 'text', text: JSON.stringify({ ok: true, added, skipped: imported.items.length - added }, null, 2) }] };
491
- },
492
- });
493
-
494
- server.registerTool('ok_batch', {
495
- title: 'Batch add knowledge items',
496
- description: 'Add multiple items at once from an array of item objects',
497
- inputSchema: createBatchSchema(),
498
- handler: async ({ items, store_path }) => {
499
- const db = loadStore(resolveStore(store_path));
500
- const now = new Date().toISOString();
501
- const existingIds = new Set(db.items.map((x) => x.id));
502
- let added = 0;
503
- let skipped = 0;
504
- for (const entry of items) {
505
- if (entry.id && existingIds.has(entry.id)) {
506
- skipped += 1;
507
- continue;
508
- }
509
- if (!entry.title || !entry.content) {
510
- skipped += 1;
511
- continue;
512
- }
513
- const ids = entry.id ? { id: entry.id, short_id: entry.short_id || null } : makeId();
514
- const item = {
515
- id: ids.id,
516
- short_id: ids.short_id,
517
- title: entry.title,
518
- content: entry.content,
519
- tags: entry.tags ?? [],
520
- metadata: entry.metadata ?? {},
521
- created_at: entry.created_at || now,
522
- updated_at: entry.updated_at || now,
523
- };
524
- db.items.push(item);
525
- added += 1;
526
- }
527
- saveStore(resolveStore(store_path), db);
528
- return { content: [{ type: 'text', text: JSON.stringify({ ok: true, added, skipped }, null, 2) }] };
529
- },
530
- });
531
-
532
- return server;
533
- }
534
-
535
- function printHelp() {
536
- console.error(`Usage: open-knowledge-mcp [options]
537
-
538
- Runs the @hasna/knowledge MCP server (stdio by default).
539
-
540
- Options:
541
- --http Serve MCP over Streamable HTTP (127.0.0.1)
542
- --port <number> HTTP port (default: 8819, env: MCP_HTTP_PORT)
543
- -h, --help Show this help text`);
544
- }
545
-
546
- async function main() {
547
- if (process.argv.includes('-h') || process.argv.includes('--help')) {
548
- printHelp();
549
- return;
550
- }
551
-
552
- const { isHttpMode, resolveMcpHttpPort, startMcpHttpServer } = await import('./mcp-http.js');
553
-
554
- if (isHttpMode()) {
555
- const handle = await startMcpHttpServer(buildServer, {
556
- port: resolveMcpHttpPort(),
557
- });
558
- process.on('SIGINT', () => void handle.close().finally(() => process.exit(0)));
559
- process.on('SIGTERM', () => void handle.close().finally(() => process.exit(0)));
560
- return;
561
- }
562
-
563
- const server = buildServer();
564
- const transport = new StdioServerTransport();
565
- await server.connect(transport);
566
- console.error('open-knowledge MCP server running on stdio');
567
- }
568
-
569
- if (import.meta.main) {
570
- main().catch((err) => {
571
- console.error('MCP server error:', err);
572
- process.exit(1);
573
- });
574
- }
package/src/schema.js DELETED
@@ -1,25 +0,0 @@
1
- import { z } from 'zod';
2
-
3
- export const itemSchema = z.object({
4
- id: z.string().min(1),
5
- short_id: z.string().nullable().optional(),
6
- title: z.string().min(1),
7
- content: z.string(),
8
- tags: z.array(z.string()).default([]),
9
- metadata: z.record(z.string(), z.unknown()).default({}),
10
- archived: z.boolean().default(false),
11
- created_at: z.string(),
12
- updated_at: z.string(),
13
- });
14
-
15
- export const storeSchema = z.object({
16
- items: z.array(itemSchema.passthrough()).default([]),
17
- });
18
-
19
- export function validateItem(data) {
20
- return itemSchema.parse(data);
21
- }
22
-
23
- export function validateStore(data) {
24
- return storeSchema.parse(data);
25
- }
package/tests/cli.test.ts DELETED
@@ -1,91 +0,0 @@
1
- /**
2
- * @hasna/knowledge
3
- * Copyright 2026 Hasna Inc.
4
- * Licensed under the Apache License, Version 2.0
5
- */
6
- import { describe, expect, test } from 'bun:test';
7
- import { mkdtempSync, readFileSync } from 'node:fs';
8
- import { tmpdir } from 'node:os';
9
- import { join, dirname } from 'node:path';
10
- import { fileURLToPath } from 'node:url';
11
-
12
- const __dirname = dirname(fileURLToPath(import.meta.url));
13
- const CLI = join(__dirname, '..', 'src', 'cli.ts');
14
-
15
- function runCli(args: string[]) {
16
- return Bun.spawnSync(['bun', CLI, ...args], {
17
- stdout: 'pipe',
18
- stderr: 'pipe'
19
- });
20
- }
21
-
22
- describe('open-knowledge cli', () => {
23
- test('help and subcommand help work', () => {
24
- const result = runCli(['--help']);
25
- expect(result.exitCode).toBe(0);
26
- const out = new TextDecoder().decode(result.stdout);
27
- expect(out).toContain('open-knowledge');
28
- expect(out).toContain('Commands:');
29
-
30
- const sub = runCli(['help', 'list']);
31
- expect(sub.exitCode).toBe(0);
32
- const subOut = new TextDecoder().decode(sub.stdout);
33
- expect(subOut).toContain('--sort created|title');
34
- });
35
-
36
- test('version flag works', () => {
37
- const pkg = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf8')) as { version: string };
38
- const result = runCli(['--version']);
39
- expect(result.exitCode).toBe(0);
40
- const out = new TextDecoder().decode(result.stdout);
41
- expect(out.trim()).toBe(pkg.version);
42
- });
43
-
44
- test('unknown command includes suggestion', () => {
45
- const result = runCli(['lits']);
46
- expect(result.exitCode).toBe(1);
47
- const err = new TextDecoder().decode(result.stderr);
48
- expect(err).toContain("Did you mean 'list'");
49
- });
50
-
51
- test('add/list/get/delete flow with json and confirmation', () => {
52
- const dir = mkdtempSync(join(tmpdir(), 'ok-cli-'));
53
- const store = join(dir, 'db.json');
54
-
55
- const addA = runCli(['add', 'TitleB', 'BodyA', '--store', store, '--json']);
56
- expect(addA.exitCode).toBe(0);
57
- const addAOut = JSON.parse(new TextDecoder().decode(addA.stdout));
58
-
59
- const addB = runCli(['add', 'TitleA', 'BodyB', '--store', store, '--json']);
60
- expect(addB.exitCode).toBe(0);
61
- const addBOut = JSON.parse(new TextDecoder().decode(addB.stdout));
62
-
63
- const list = runCli(['ls', '--store', store, '--json', '-p', '1', '-l', '10', '--sort', 'title']);
64
- expect(list.exitCode).toBe(0);
65
- const listOut = JSON.parse(new TextDecoder().decode(list.stdout));
66
- expect(listOut.total).toBe(2);
67
- expect(listOut.total_pages).toBe(1);
68
- expect(listOut.items[0].title).toBe('TitleA');
69
-
70
- const get = runCli(['get', '--id', addAOut.item.id, '--store', store, '--json']);
71
- expect(get.exitCode).toBe(0);
72
- const getOut = JSON.parse(new TextDecoder().decode(get.stdout));
73
- expect(getOut.item.content).toBe('BodyA');
74
-
75
- const delNoYes = runCli(['rm', '--id', addAOut.item.id, '--store', store, '--json']);
76
- expect(delNoYes.exitCode).toBe(1);
77
- const delErr = new TextDecoder().decode(delNoYes.stderr);
78
- expect(delErr).toContain('Refusing delete without --yes');
79
-
80
- const del = runCli(['delete', '--id', addAOut.item.id, '--store', store, '--json', '--yes']);
81
- expect(del.exitCode).toBe(0);
82
- const delOut = JSON.parse(new TextDecoder().decode(del.stdout));
83
- expect(delOut.ok).toBe(true);
84
-
85
- const del2 = runCli(['delete', '--id', addBOut.item.id, '--store', store, '--json', '--yes']);
86
- expect(del2.exitCode).toBe(0);
87
-
88
- const db = JSON.parse(readFileSync(store, 'utf8'));
89
- expect(db.items.length).toBe(0);
90
- });
91
- });