@hasna/knowledge 0.1.0 → 0.2.1
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/.github/ISSUE_TEMPLATE/bug_report.yml +59 -0
- package/.github/ISSUE_TEMPLATE/feature_request.yml +34 -0
- package/.github/PULL_REQUEST_TEMPLATE.md +21 -0
- package/.github/workflows/ci.yml +49 -0
- package/CODE_OF_CONDUCT.md +31 -0
- package/CONTRIBUTING.md +83 -0
- package/FUNDING.yml +1 -0
- package/LICENSE +202 -0
- package/README.md +146 -0
- package/SECURITY.md +39 -0
- package/bin/open-knowledge.js +58 -0
- package/npmignore +9 -0
- package/package.json +23 -5
- package/src/cli.ts +486 -0
- package/src/store.ts +103 -0
- package/tests/cli.test.ts +9 -2
- package/tsconfig.json +16 -0
- package/src/cli.js +0 -279
- package/src/store.js +0 -32
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
// @bun
|
|
3
|
+
import{mkdirSync as c,readFileSync as L,writeFileSync as u,existsSync as k,renameSync as f,unlinkSync as b}from"fs";import{dirname as m}from"path";import{homedir as i}from"os";import{randomUUID as P}from"crypto";function v(){return`${i()}/.open-knowledge/db.json`}function x(B){if(!k(B))c(m(B),{recursive:!0}),u(B,JSON.stringify({items:[]},null,2))}function d(B){return`${B}.lock`}function g(B,W){let Q=Date.now();while(Date.now()-Q<5000){try{if(!k(B)){u(B,JSON.stringify({owner:W,ts:Date.now()}));return}let C=JSON.parse(L(B,"utf8"));if(Date.now()-C.ts>1e4)b(B)}catch{}let J=Date.now();while(Date.now()-J<50);}throw Error(`Could not acquire lock on ${B} after 5000ms`)}function l(B,W){try{if(k(B)){if(JSON.parse(L(B,"utf8")).owner===W)b(B)}}catch{}}function G(B){x(B);let W=L(B,"utf8"),z=JSON.parse(W);if(!z||!Array.isArray(z.items))return{items:[]};return z}function M(B,W){let z=`${B}.tmp.${P()}`;u(z,JSON.stringify(W,null,2)),f(z,B)}function O(B,W){let z=P(),K=d(B);g(K,z);try{return W()}finally{l(K,z)}}function p(){return`k_${Date.now().toString(36)}_${Math.random().toString(36).slice(2,8)}`}var A={name:"@hasna/knowledge",version:"0.2.1",description:"Agent-friendly local knowledge CLI with JSON output, pagination, and safe destructive actions",type:"module",bin:{"open-knowledge":"./src/cli.ts"},scripts:{test:"bun test","test:cli":"bun test tests/cli.test.ts",build:"bun build --target=bun --outfile=bin/open-knowledge.js --minify src/cli.ts",prepublishOnly:"bun run build",postinstall:"bun run build"},keywords:["knowledge","cli","agents","json","notes","local","store"],license:"Apache-2.0",repository:{type:"git",url:"https://github.com/hasna/knowledge"},bugs:{url:"https://github.com/hasna/knowledge/issues"},author:"Hasna Inc. <hasna@example.com>",engines:{bun:">=1.0",node:">=18"}};var h={debug:0,info:1,warn:2,error:3},s=()=>{if(process.env.DEBUG)return"debug";if(process.env.LOG_LEVEL==="debug")return"debug";if(process.env.LOG_LEVEL==="warn")return"warn";if(process.env.LOG_LEVEL==="error")return"error";return"info"};function D(B,W,z){if(h[B]<h[s()])return;let K={debug:"[DEBUG]",info:"[INFO]",warn:"[WARN]",error:"[ERROR]"}[B],Q=z?`${K} ${W} ${JSON.stringify(z)}`:`${K} ${W}`;if(B==="error")console.error(Q);else console.error(Q)}var e=["add","list","get","delete","update","export","prune","dedupe","stats","help"],n={ls:"list",rm:"delete",edit:"update"};function o(B){let W=[],z={};for(let K=0;K<B.length;K+=1){let Q=B[K];if(!Q.startsWith("-")){W.push(Q);continue}switch(Q){case"--json":z.json=!0;break;case"--yes":case"-y":z.yes=!0;break;case"--help":case"-h":z.help=!0;break;case"--version":case"-v":z.version=!0;break;case"--desc":z.desc=!0;break;case"--page":case"-p":z.page=Number(B[K+1]),K+=1;break;case"--limit":case"-l":z.limit=Number(B[K+1]),K+=1;break;case"--search":case"-s":z.search=B[K+1],K+=1;break;case"--sort":z.sort=B[K+1],K+=1;break;case"--id":z.id=B[K+1],K+=1;break;case"--store":z.store=B[K+1],K+=1;break;case"--title":z.title=B[K+1],K+=1;break;case"--content":z.content=B[K+1],K+=1;break;case"--url":z.url=B[K+1],K+=1;break;case"--tag":case"-t":z.tag=B[K+1],K+=1;break;case"--format":z.format=B[K+1],K+=1;break;case"--completions":z.completions=B[K+1],K+=1;break;case"--no-color":z.noColor=!0;break;case"--scope":z.scope=B[K+1],K+=1;break;case"--older-than":z.olderThan=Number(B[K+1]),K+=1;break;case"--empty":z.empty=!0;break;default:throw Error(`Unknown flag: ${Q}. Run 'open-knowledge --help' for valid options.`)}}return{positional:W,flags:z}}function t(B){if(!B)return"";return n[B]??B}function a(B,W){let z=Array.from({length:B.length+1},()=>Array(W.length+1).fill(0));for(let K=0;K<=B.length;K+=1)z[K][0]=K;for(let K=0;K<=W.length;K+=1)z[0][K]=K;for(let K=1;K<=B.length;K+=1)for(let Q=1;Q<=W.length;Q+=1){let J=B[K-1]===W[Q-1]?0:1;z[K][Q]=Math.min(z[K-1][Q]+1,z[K][Q-1]+1,z[K-1][Q-1]+J)}return z[B.length][W.length]}function z0(B){if(!B)return"";let W=[...e,...Object.keys(n)],z="",K=Number.POSITIVE_INFINITY;for(let Q of W){let J=a(B,Q);if(J<K)K=J,z=Q}return K<=3?z:""}function B0(){console.log(`open-knowledge - local agent knowledge store
|
|
4
|
+
|
|
5
|
+
Usage:
|
|
6
|
+
open-knowledge <command> [options]
|
|
7
|
+
|
|
8
|
+
Commands:
|
|
9
|
+
add <title> <content> Add an item
|
|
10
|
+
list (alias: ls) List items (supports pagination/search/sort/tag)
|
|
11
|
+
get --id <id> Get one item
|
|
12
|
+
update --id <id> Update an item (--title, --content, --url, --tag)
|
|
13
|
+
delete (alias: rm) --id <id> Delete item (requires --yes)
|
|
14
|
+
export Export all items (--format jsonl)
|
|
15
|
+
prune Remove old/empty items (requires --yes)
|
|
16
|
+
dedupe Remove duplicate items by title+content (requires --yes)
|
|
17
|
+
stats Show knowledge base statistics
|
|
18
|
+
help [command] Show help
|
|
19
|
+
|
|
20
|
+
Global Options:
|
|
21
|
+
--json Output JSON
|
|
22
|
+
--store <path> Override store path
|
|
23
|
+
--scope local|global|project Store scope (default: global ~/.open-knowledge/)
|
|
24
|
+
--no-color Disable color output
|
|
25
|
+
--completions <shell> Output completions for bash|zsh|fish
|
|
26
|
+
-v, --version Show version
|
|
27
|
+
-h, --help Show help
|
|
28
|
+
|
|
29
|
+
List Options:
|
|
30
|
+
--format table|json Output format (default: table if TTY, json otherwise)
|
|
31
|
+
-p, --page <n> Page number (default: 1)
|
|
32
|
+
-l, --limit <n> Items per page (default: 20)
|
|
33
|
+
-s, --search <text> Filter by title/content
|
|
34
|
+
-t, --tag <tag> Filter by tag
|
|
35
|
+
--sort <created|title> Sort field (default: created)
|
|
36
|
+
--desc Sort descending
|
|
37
|
+
|
|
38
|
+
Add/Update Options:
|
|
39
|
+
--url <url> Attach source URL
|
|
40
|
+
|
|
41
|
+
Update Options:
|
|
42
|
+
--id <id> Item id
|
|
43
|
+
--title <title> New title
|
|
44
|
+
--content <content> New content
|
|
45
|
+
--url <url> New source URL
|
|
46
|
+
-t, --tag <tag> Add a tag
|
|
47
|
+
|
|
48
|
+
Delete Options:
|
|
49
|
+
--id <id> Item id
|
|
50
|
+
-y, --yes Confirm destructive action
|
|
51
|
+
|
|
52
|
+
Export Options:
|
|
53
|
+
--format jsonl Export as newline-delimited JSON (default: JSON array)
|
|
54
|
+
|
|
55
|
+
Prune Options:
|
|
56
|
+
--older-than <days> Remove items older than N days
|
|
57
|
+
--empty Remove items with empty content`)}function K0(B){if(B==="add"){console.log("Usage: open-knowledge add <title> <content> [--url <url>] [-t <tag>] [--json]");return}if(B==="list"||B==="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}if(B==="get"){console.log("Usage: open-knowledge get --id <id> [--json]");return}if(B==="update"||B==="edit"){console.log("Usage: open-knowledge update|edit --id <id> [--title <title>] [--content <content>] [--url <url>] [-t <tag>] [--json]");return}if(B==="delete"||B==="rm"){console.log("Usage: open-knowledge delete|rm --id <id> -y [--json]");return}if(B==="export"){console.log("Usage: open-knowledge export [--format jsonl] [--json]");return}if(B==="prune"){console.log("Usage: open-knowledge prune --yes [--older-than <days>] [--empty] [--json]");return}if(B==="dedupe"){console.log("Usage: open-knowledge dedupe --yes [--json]");return}if(B==="stats"){console.log("Usage: open-knowledge stats [--json]");return}B0()}function Q0(B){if(B.noColor||process.env.NO_COLOR)return!1;if(process.env.FORCE_COLOR)return!0;return process.stdout.isTTY===!0}function q(B,W,z){if(W){console.log(JSON.stringify(B,null,2));return}if(typeof B==="string"){console.log(B);return}console.log(B.message??JSON.stringify(B,null,2))}function S(B){if(!B.id)throw Error("Missing required --id. Example: open-knowledge get --id <id>")}function R0(B,W){let z=W.sort??"created";if(z!=="created"&&z!=="title")throw Error("Invalid --sort value. Use 'created' or 'title'.");let K=[...B].sort((Q,J)=>{if(z==="title")return Q.title.localeCompare(J.title);return Q.created_at.localeCompare(J.created_at)});if(W.desc)K.reverse();return{sorted:K,sort:z,direction:W.desc?"desc":"asc"}}function W0(B){let{positional:W,flags:z}=o(B);if(D("debug","CLI invoked",{command:W[0],flags:{json:z.json,store:z.store}}),z.version){console.log(z.json?JSON.stringify({name:A.name,version:A.version},null,2):A.version);return}if(z.completions){let R=z.completions;if(R==="bash")console.log('_open_knowledge() { local cur; cur="${COMP_WORDS[COMP_CWORD]}"; COMPREPLY=($(compgen -W "add list get update delete export help ls rm edit --json --yes --help --version --desc --page --limit --search --sort --id --store --title --content --url --tag --format --completions --no-color --scope" -- "$cur")); }; complete -F _open_knowledge open-knowledge');else if(R==="zsh")console.log(`#compdef open-knowledge
|
|
58
|
+
_open_knowledge() { _arguments -C "1: :(add list get update delete export help ls rm edit)" "(--json)--json" "(--yes)-y" "(--help)--help" "(--version)--version" "(--desc)--desc" "(-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`);else if(R==="fish")console.log('complete -c open-knowledge -f; complete -c open-knowledge -a "add list get update delete export help ls rm edit"; 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 -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"');else throw Error("Invalid --completions value. Use 'bash', 'zsh', or 'fish'.");return}let K=t(W[0]);if(!K||z.help||K==="help"){K0(W[1]);return}let Q=z.store;if(!Q)if(z.scope==="project")Q="./.open-knowledge/db.json";else Q=v();if(x(Q),K==="add"){let R=W[1],Y=W[2];if(!R||!Y)throw Error("Usage: open-knowledge add <title> <content>");O(Q,()=>{let X=G(Q),Z={id:p(),title:R,content:Y,url:z.url??null,tags:z.tag?[z.tag]:[],created_at:new Date().toISOString(),updated_at:new Date().toISOString()};X.items.push(Z),M(Q,X),D("info","Item added",{id:Z.id,title:Z.title}),q({ok:!0,item:Z,message:`Added ${Z.id}`},z.json)});return}if(K==="list"){if(z.format!==void 0&&z.format!=="table"&&z.format!=="json")throw Error("Invalid --format value for list. Use 'table' or 'json'.");O(Q,()=>{let R=G(Q),Y=Number.isFinite(z.page)&&z.page>0?z.page:1,X=Number.isFinite(z.limit)&&z.limit>0?z.limit:20,Z=z.search?String(z.search).toLowerCase():"",N=z.tag?String(z.tag).toLowerCase():"",E=z.format==="table"||!z.json&&!z.format&&Q0(z),U=z.json||z.format==="json",_=R.items;if(Z)_=_.filter(($)=>$.title.toLowerCase().includes(Z)||$.content.toLowerCase().includes(Z));if(N)_=_.filter(($)=>$.tags&&$.tags.map((I)=>I.toLowerCase()).includes(N));let{sorted:H,sort:V,direction:j}=R0(_,z),w=(Y-1)*X,F=H.slice(w,w+X),y=Math.max(1,Math.ceil(H.length/X));if(U){q({ok:!0,page:Y,limit:X,total:H.length,total_pages:y,sort:V,direction:j,items:F},!0);return}if(F.length===0){q(`No items found (search=${Z||"none"}, tag=${N||"none"})`,!1);return}if(E){let $=(T)=>T,I=`${$("ID")} ${$("TITLE")} ${$("CREATED")} ${$("URL")} ${$("TAGS")}`;console.log(I);for(let T of F)console.log(`${T.id} ${$(T.title)} ${T.created_at} ${T.url?$(T.url):""} ${T.tags?.length?$(`[${T.tags.join(", ")}]`):""}`);console.log(`Page ${Y}/${y} | showing ${F.length} of ${H.length} | sort=${V} ${j} | search=${Z||"none"} | tag=${N||"none"}`)}else{for(let $ of F)console.log(`${$.id} ${$.title} ${$.created_at}${$.url?` ${$.url}`:""}${$.tags?.length?` [${$.tags.join(", ")}]`:""}`);console.log(`Page ${Y}/${y} | showing ${F.length} of ${H.length} | sort=${V} ${j} | search=${Z||"none"} | tag=${N||"none"}`)}});return}if(K==="get"){S(z),O(Q,()=>{let Y=G(Q).items.find((X)=>X.id===z.id);if(!Y)throw Error(`Item not found: ${z.id}`);q({ok:!0,item:Y,message:`${Y.id}: ${Y.title}`},z.json)});return}if(K==="update"){S(z),O(Q,()=>{let R=G(Q),Y=R.items.findIndex((Z)=>Z.id===z.id);if(Y===-1)throw Error(`Item not found: ${z.id}`);let X=R.items[Y];if(z.title!==void 0)X.title=z.title;if(z.content!==void 0)X.content=z.content;if(z.url!==void 0)X.url=z.url;if(z.tag!==void 0){if(X.tags=X.tags||[],!X.tags.map((Z)=>Z.toLowerCase()).includes(z.tag.toLowerCase()))X.tags.push(z.tag)}X.updated_at=new Date().toISOString(),R.items[Y]=X,M(Q,R),q({ok:!0,item:X,message:`Updated ${X.id}`},z.json)});return}if(K==="delete"){if(S(z),!z.yes)throw Error("Refusing delete without --yes. Re-run with: open-knowledge delete --id <id> --yes");O(Q,()=>{let R=G(Q),Y=R.items.length;R.items=R.items.filter((Z)=>Z.id!==z.id);let X=Y!==R.items.length;if(M(Q,R),!X)throw Error(`Item not found: ${z.id}`);D("info","Item deleted",{id:z.id}),q({ok:!0,deleted_id:z.id,message:`Deleted ${z.id}`},z.json)});return}if(K==="export"){let R=z.format??"json";if(R!=="json"&&R!=="jsonl")throw Error("Invalid --format. Use 'json' or 'jsonl'.");O(Q,()=>{let Y=G(Q);if(R==="jsonl")for(let X of Y.items)console.log(JSON.stringify(X));else q({ok:!0,items:Y.items},z.json)});return}if(K==="prune"){if(!z.yes)throw Error("Refusing prune without --yes. Re-run with: open-knowledge prune --yes [--older-than <days>] [--empty]");O(Q,()=>{let R=G(Q),Y=R.items.length;if(z.olderThan!==void 0){let Z=new Date;Z.setDate(Z.getDate()-z.olderThan),R.items=R.items.filter((N)=>new Date(N.created_at)>=Z)}if(z.empty)R.items=R.items.filter((Z)=>Z.content.trim().length>0);let X=Y-R.items.length;M(Q,R),D("info","Prune completed",{pruned:X,remaining:R.items.length}),q({ok:!0,pruned:X,remaining:R.items.length,message:`Pruned ${X} item(s)`},z.json)});return}if(K==="dedupe"){if(!z.yes)throw Error("Refusing dedupe without --yes. Re-run with: open-knowledge dedupe --yes [--json]");O(Q,()=>{let R=G(Q),Y=new Set,X=R.items.length;R.items=R.items.filter((N)=>{let E=`${N.title}\x00${N.content}`;if(Y.has(E))return!1;return Y.add(E),!0});let Z=X-R.items.length;M(Q,R),D("info","Dedupe completed",{removed:Z,remaining:R.items.length}),q({ok:!0,removed:Z,remaining:R.items.length,message:`Dedupe removed ${Z} duplicate(s)`},z.json)});return}if(K==="stats"){O(Q,()=>{let R=G(Q),Y=R.items.length,X=R.items.filter((H)=>H.url).length,Z=R.items.filter((H)=>H.tags&&H.tags.length>0).length,N=Y>0?R.items.map((H)=>H.created_at).sort()[0]:null,E=Y>0?R.items.map((H)=>H.created_at).sort()[Y-1]:null,U={};for(let H of R.items)for(let V of H.tags||[])U[V]=(U[V]||0)+1;let _=Object.entries(U).sort((H,V)=>V[1]-H[1]).slice(0,5).map(([H,V])=>({tag:H,count:V}));q({ok:!0,total:Y,with_url:X,with_tags:Z,oldest:N,newest:E,top_tags:_,message:`${Y} items | ${X} with URL | ${Z} with tags`},z.json)});return}let J=z0(W[0]),C=J?` Did you mean '${J}'?`:"";throw D("warn","Unknown command",{input:W[0],suggestion:J}),Error(`Unknown command: ${W[0]}.${C} Run 'open-knowledge --help' for available commands.`)}if(import.meta.main)try{W0(process.argv.slice(2))}catch(B){let W=B instanceof Error?B.message:String(B);D("error","CLI error",{message:W,stack:B instanceof Error?B.stack:void 0}),console.error(`Error: ${W}`),process.exitCode=1}export{z0 as suggestCommand,R0 as sortItems,W0 as run,o as parseArgs};
|
package/npmignore
ADDED
package/package.json
CHANGED
|
@@ -1,20 +1,38 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hasna/knowledge",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "Agent-friendly local knowledge CLI with JSON output, pagination, and safe destructive actions",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
|
-
"open-knowledge": "./src/cli.
|
|
7
|
+
"open-knowledge": "./src/cli.ts"
|
|
8
8
|
},
|
|
9
9
|
"scripts": {
|
|
10
10
|
"test": "bun test",
|
|
11
|
-
"test:cli": "bun test tests/cli.test.ts"
|
|
11
|
+
"test:cli": "bun test tests/cli.test.ts",
|
|
12
|
+
"build": "bun build --target=bun --outfile=bin/open-knowledge.js --minify src/cli.ts",
|
|
13
|
+
"prepublishOnly": "bun run build",
|
|
14
|
+
"postinstall": "bun run build"
|
|
12
15
|
},
|
|
13
16
|
"keywords": [
|
|
14
17
|
"knowledge",
|
|
15
18
|
"cli",
|
|
16
19
|
"agents",
|
|
17
|
-
"json"
|
|
20
|
+
"json",
|
|
21
|
+
"notes",
|
|
22
|
+
"local",
|
|
23
|
+
"store"
|
|
18
24
|
],
|
|
19
|
-
"license": "
|
|
25
|
+
"license": "Apache-2.0",
|
|
26
|
+
"repository": {
|
|
27
|
+
"type": "git",
|
|
28
|
+
"url": "https://github.com/hasna/knowledge"
|
|
29
|
+
},
|
|
30
|
+
"bugs": {
|
|
31
|
+
"url": "https://github.com/hasna/knowledge/issues"
|
|
32
|
+
},
|
|
33
|
+
"author": "Hasna Inc. <hasna@example.com>",
|
|
34
|
+
"engines": {
|
|
35
|
+
"bun": ">=1.0",
|
|
36
|
+
"node": ">=18"
|
|
37
|
+
}
|
|
20
38
|
}
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,486 @@
|
|
|
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, ensureStore, type KnowledgeItem } from './store';
|
|
8
|
+
import pkg from '../package.json' with { type: 'json' };
|
|
9
|
+
|
|
10
|
+
type LogLevel = 'debug' | 'info' | 'warn' | 'error';
|
|
11
|
+
const LOG_LEVELS: Record<LogLevel, number> = { debug: 0, info: 1, warn: 2, error: 3 };
|
|
12
|
+
const currentLevel = (): LogLevel => {
|
|
13
|
+
if (process.env.DEBUG) return 'debug';
|
|
14
|
+
if (process.env.LOG_LEVEL === 'debug') return 'debug';
|
|
15
|
+
if (process.env.LOG_LEVEL === 'warn') return 'warn';
|
|
16
|
+
if (process.env.LOG_LEVEL === 'error') return 'error';
|
|
17
|
+
return 'info';
|
|
18
|
+
};
|
|
19
|
+
function log(level: LogLevel, msg: string, data?: Record<string, unknown>): void {
|
|
20
|
+
if (LOG_LEVELS[level] < LOG_LEVELS[currentLevel()]) return;
|
|
21
|
+
const prefix = { debug: '[DEBUG]', info: '[INFO]', warn: '[WARN]', error: '[ERROR]' }[level];
|
|
22
|
+
const entry = data ? `${prefix} ${msg} ${JSON.stringify(data)}` : `${prefix} ${msg}`;
|
|
23
|
+
if (level === 'error') console.error(entry);
|
|
24
|
+
else console.error(entry);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface Flags {
|
|
28
|
+
json?: boolean;
|
|
29
|
+
yes?: boolean;
|
|
30
|
+
help?: boolean;
|
|
31
|
+
version?: boolean;
|
|
32
|
+
desc?: boolean;
|
|
33
|
+
page?: number;
|
|
34
|
+
limit?: number;
|
|
35
|
+
search?: string;
|
|
36
|
+
sort?: string;
|
|
37
|
+
id?: string;
|
|
38
|
+
store?: string;
|
|
39
|
+
title?: string;
|
|
40
|
+
content?: string;
|
|
41
|
+
url?: string;
|
|
42
|
+
tag?: string;
|
|
43
|
+
format?: string;
|
|
44
|
+
completions?: string;
|
|
45
|
+
noColor?: boolean;
|
|
46
|
+
scope?: string;
|
|
47
|
+
olderThan?: number;
|
|
48
|
+
empty?: boolean;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
interface ParseResult {
|
|
52
|
+
positional: string[];
|
|
53
|
+
flags: Flags;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const COMMANDS = ['add', 'list', 'get', 'delete', 'update', 'export', 'prune', 'dedupe', 'stats', 'help'];
|
|
57
|
+
const COMMAND_ALIASES: Record<string, string> = {
|
|
58
|
+
ls: 'list',
|
|
59
|
+
rm: 'delete',
|
|
60
|
+
edit: 'update',
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
function parseArgs(argv: string[]): ParseResult {
|
|
64
|
+
const positional: string[] = [];
|
|
65
|
+
const flags: Flags = {};
|
|
66
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
67
|
+
const token = argv[i];
|
|
68
|
+
if (!token.startsWith('-')) {
|
|
69
|
+
positional.push(token);
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
switch (token) {
|
|
73
|
+
case '--json': flags.json = true; break;
|
|
74
|
+
case '--yes': case '-y': flags.yes = true; break;
|
|
75
|
+
case '--help': case '-h': flags.help = true; break;
|
|
76
|
+
case '--version': case '-v': flags.version = true; break;
|
|
77
|
+
case '--desc': flags.desc = true; break;
|
|
78
|
+
case '--page': case '-p': flags.page = Number(argv[i + 1]); i += 1; break;
|
|
79
|
+
case '--limit': case '-l': flags.limit = Number(argv[i + 1]); i += 1; break;
|
|
80
|
+
case '--search': case '-s': flags.search = argv[i + 1]; i += 1; break;
|
|
81
|
+
case '--sort': flags.sort = argv[i + 1]; i += 1; break;
|
|
82
|
+
case '--id': flags.id = argv[i + 1]; i += 1; break;
|
|
83
|
+
case '--store': flags.store = argv[i + 1]; i += 1; break;
|
|
84
|
+
case '--title': flags.title = argv[i + 1]; i += 1; break;
|
|
85
|
+
case '--content': flags.content = argv[i + 1]; i += 1; break;
|
|
86
|
+
case '--url': flags.url = argv[i + 1]; i += 1; break;
|
|
87
|
+
case '--tag': case '-t': flags.tag = argv[i + 1]; i += 1; break;
|
|
88
|
+
case '--format': flags.format = argv[i + 1]; i += 1; break;
|
|
89
|
+
case '--completions': flags.completions = argv[i + 1]; i += 1; break;
|
|
90
|
+
case '--no-color': flags.noColor = true; break;
|
|
91
|
+
case '--scope': flags.scope = argv[i + 1]; i += 1; break;
|
|
92
|
+
case '--older-than': flags.olderThan = Number(argv[i + 1]); i += 1; break;
|
|
93
|
+
case '--empty': flags.empty = true; break;
|
|
94
|
+
default: throw new Error(`Unknown flag: ${token}. Run 'open-knowledge --help' for valid options.`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return { positional, flags };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function resolveCommand(raw: string): string {
|
|
101
|
+
if (!raw) return '';
|
|
102
|
+
return COMMAND_ALIASES[raw] ?? raw;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function levenshtein(a: string, b: string): number {
|
|
106
|
+
const dp = Array.from({ length: a.length + 1 }, () => Array(b.length + 1).fill(0));
|
|
107
|
+
for (let i = 0; i <= a.length; i += 1) dp[i][0] = i;
|
|
108
|
+
for (let j = 0; j <= b.length; j += 1) dp[0][j] = j;
|
|
109
|
+
for (let i = 1; i <= a.length; i += 1) {
|
|
110
|
+
for (let j = 1; j <= b.length; j += 1) {
|
|
111
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
112
|
+
dp[i][j] = Math.min(dp[i - 1][j] + 1, dp[i][j - 1] + 1, dp[i - 1][j - 1] + cost);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return dp[a.length][b.length];
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function suggestCommand(input: string): string {
|
|
119
|
+
if (!input) return '';
|
|
120
|
+
const all = [...COMMANDS, ...Object.keys(COMMAND_ALIASES)];
|
|
121
|
+
let best = '';
|
|
122
|
+
let bestScore = Number.POSITIVE_INFINITY;
|
|
123
|
+
for (const candidate of all) {
|
|
124
|
+
const score = levenshtein(input, candidate);
|
|
125
|
+
if (score < bestScore) {
|
|
126
|
+
bestScore = score;
|
|
127
|
+
best = candidate;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return bestScore <= 3 ? best : '';
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function printGlobalHelp(): void {
|
|
134
|
+
console.log(`open-knowledge - local agent knowledge store
|
|
135
|
+
|
|
136
|
+
Usage:
|
|
137
|
+
open-knowledge <command> [options]
|
|
138
|
+
|
|
139
|
+
Commands:
|
|
140
|
+
add <title> <content> Add an item
|
|
141
|
+
list (alias: ls) List items (supports pagination/search/sort/tag)
|
|
142
|
+
get --id <id> Get one item
|
|
143
|
+
update --id <id> Update an item (--title, --content, --url, --tag)
|
|
144
|
+
delete (alias: rm) --id <id> Delete item (requires --yes)
|
|
145
|
+
export Export all items (--format jsonl)
|
|
146
|
+
prune Remove old/empty items (requires --yes)
|
|
147
|
+
dedupe Remove duplicate items by title+content (requires --yes)
|
|
148
|
+
stats Show knowledge base statistics
|
|
149
|
+
help [command] Show help
|
|
150
|
+
|
|
151
|
+
Global Options:
|
|
152
|
+
--json Output JSON
|
|
153
|
+
--store <path> Override store path
|
|
154
|
+
--scope local|global|project Store scope (default: global ~/.open-knowledge/)
|
|
155
|
+
--no-color Disable color output
|
|
156
|
+
--completions <shell> Output completions for bash|zsh|fish
|
|
157
|
+
-v, --version Show version
|
|
158
|
+
-h, --help Show help
|
|
159
|
+
|
|
160
|
+
List Options:
|
|
161
|
+
--format table|json Output format (default: table if TTY, json otherwise)
|
|
162
|
+
-p, --page <n> Page number (default: 1)
|
|
163
|
+
-l, --limit <n> Items per page (default: 20)
|
|
164
|
+
-s, --search <text> Filter by title/content
|
|
165
|
+
-t, --tag <tag> Filter by tag
|
|
166
|
+
--sort <created|title> Sort field (default: created)
|
|
167
|
+
--desc Sort descending
|
|
168
|
+
|
|
169
|
+
Add/Update Options:
|
|
170
|
+
--url <url> Attach source URL
|
|
171
|
+
|
|
172
|
+
Update Options:
|
|
173
|
+
--id <id> Item id
|
|
174
|
+
--title <title> New title
|
|
175
|
+
--content <content> New content
|
|
176
|
+
--url <url> New source URL
|
|
177
|
+
-t, --tag <tag> Add a tag
|
|
178
|
+
|
|
179
|
+
Delete Options:
|
|
180
|
+
--id <id> Item id
|
|
181
|
+
-y, --yes Confirm destructive action
|
|
182
|
+
|
|
183
|
+
Export Options:
|
|
184
|
+
--format jsonl Export as newline-delimited JSON (default: JSON array)
|
|
185
|
+
|
|
186
|
+
Prune Options:
|
|
187
|
+
--older-than <days> Remove items older than N days
|
|
188
|
+
--empty Remove items with empty content`);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function printCommandHelp(command: string): void {
|
|
192
|
+
if (command === 'add') { console.log('Usage: open-knowledge add <title> <content> [--url <url>] [-t <tag>] [--json]'); return; }
|
|
193
|
+
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; }
|
|
194
|
+
if (command === 'get') { console.log('Usage: open-knowledge get --id <id> [--json]'); return; }
|
|
195
|
+
if (command === 'update' || command === 'edit') { console.log('Usage: open-knowledge update|edit --id <id> [--title <title>] [--content <content>] [--url <url>] [-t <tag>] [--json]'); return; }
|
|
196
|
+
if (command === 'delete' || command === 'rm') { console.log('Usage: open-knowledge delete|rm --id <id> -y [--json]'); return; }
|
|
197
|
+
if (command === 'export') { console.log('Usage: open-knowledge export [--format jsonl] [--json]'); return; }
|
|
198
|
+
if (command === 'prune') { console.log('Usage: open-knowledge prune --yes [--older-than <days>] [--empty] [--json]'); return; }
|
|
199
|
+
if (command === 'dedupe') { console.log('Usage: open-knowledge dedupe --yes [--json]'); return; }
|
|
200
|
+
if (command === 'stats') { console.log('Usage: open-knowledge stats [--json]'); return; }
|
|
201
|
+
printGlobalHelp();
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function useColor(flags: Flags): boolean {
|
|
205
|
+
if (flags.noColor || process.env.NO_COLOR) return false;
|
|
206
|
+
if (process.env.FORCE_COLOR) return true;
|
|
207
|
+
return process.stdout.isTTY === true;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function output(data: unknown, asJson?: boolean, _flags?: Flags): void {
|
|
211
|
+
if (asJson) { console.log(JSON.stringify(data, null, 2)); return; }
|
|
212
|
+
if (typeof data === 'string') { console.log(data); return; }
|
|
213
|
+
console.log((data as { message?: string }).message ?? JSON.stringify(data, null, 2));
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function requireId(flags: Flags): asserts flags is Flags & { id: string } {
|
|
217
|
+
if (!flags.id) throw new Error('Missing required --id. Example: open-knowledge get --id <id>');
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function sortItems(items: KnowledgeItem[], flags: Flags): { sorted: KnowledgeItem[]; sort: string; direction: string } {
|
|
221
|
+
const sort = flags.sort ?? 'created';
|
|
222
|
+
if (sort !== 'created' && sort !== 'title') {
|
|
223
|
+
throw new Error("Invalid --sort value. Use 'created' or 'title'.");
|
|
224
|
+
}
|
|
225
|
+
const sorted = [...items].sort((a, b) => {
|
|
226
|
+
if (sort === 'title') return a.title.localeCompare(b.title);
|
|
227
|
+
return a.created_at.localeCompare(b.created_at);
|
|
228
|
+
});
|
|
229
|
+
if (flags.desc) sorted.reverse();
|
|
230
|
+
return { sorted, sort, direction: flags.desc ? 'desc' : 'asc' };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function run(argv: string[]): void {
|
|
234
|
+
const { positional, flags } = parseArgs(argv);
|
|
235
|
+
log('debug', 'CLI invoked', { command: positional[0], flags: { json: flags.json, store: flags.store } });
|
|
236
|
+
|
|
237
|
+
if (flags.version) { console.log(flags.json ? JSON.stringify({ name: pkg.name, version: pkg.version }, null, 2) : pkg.version); return; }
|
|
238
|
+
|
|
239
|
+
if (flags.completions) {
|
|
240
|
+
const shell = flags.completions;
|
|
241
|
+
if (shell === 'bash') {
|
|
242
|
+
console.log(`_open_knowledge() { local cur; cur="${"$"}{COMP_WORDS[COMP_CWORD]}"; COMPREPLY=($(compgen -W "add list get update delete export help ls rm edit --json --yes --help --version --desc --page --limit --search --sort --id --store --title --content --url --tag --format --completions --no-color --scope" -- "$cur")); }; complete -F _open_knowledge open-knowledge`);
|
|
243
|
+
} else if (shell === 'zsh') {
|
|
244
|
+
console.log(`#compdef open-knowledge\n_open_knowledge() { _arguments -C "1: :(add list get update delete export help ls rm edit)" "(--json)--json" "(--yes)-y" "(--help)--help" "(--version)--version" "(--desc)--desc" "(-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`);
|
|
245
|
+
} else if (shell === 'fish') {
|
|
246
|
+
console.log(`complete -c open-knowledge -f; complete -c open-knowledge -a "add list get update delete export help ls rm edit"; 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 -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"`);
|
|
247
|
+
} else {
|
|
248
|
+
throw new Error("Invalid --completions value. Use 'bash', 'zsh', or 'fish'.");
|
|
249
|
+
}
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const command = resolveCommand(positional[0]);
|
|
254
|
+
|
|
255
|
+
if (!command || flags.help || command === 'help') { printCommandHelp(positional[1]); return; }
|
|
256
|
+
|
|
257
|
+
let storePath = flags.store;
|
|
258
|
+
if (!storePath) {
|
|
259
|
+
if (flags.scope === 'project') {
|
|
260
|
+
storePath = './.open-knowledge/db.json';
|
|
261
|
+
} else {
|
|
262
|
+
// local (default) and global both use the global store for now
|
|
263
|
+
// project scope uses a project-local store
|
|
264
|
+
storePath = defaultStorePath();
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
ensureStore(storePath);
|
|
268
|
+
|
|
269
|
+
if (command === 'add') {
|
|
270
|
+
const title = positional[1];
|
|
271
|
+
const content = positional[2];
|
|
272
|
+
if (!title || !content) throw new Error('Usage: open-knowledge add <title> <content>');
|
|
273
|
+
withLock(storePath, () => {
|
|
274
|
+
const db = loadStore(storePath);
|
|
275
|
+
const item: KnowledgeItem = {
|
|
276
|
+
id: makeId(),
|
|
277
|
+
title,
|
|
278
|
+
content,
|
|
279
|
+
url: flags.url ?? null,
|
|
280
|
+
tags: flags.tag ? [flags.tag] : [],
|
|
281
|
+
created_at: new Date().toISOString(),
|
|
282
|
+
updated_at: new Date().toISOString(),
|
|
283
|
+
};
|
|
284
|
+
db.items.push(item);
|
|
285
|
+
saveStore(storePath, db);
|
|
286
|
+
log('info', 'Item added', { id: item.id, title: item.title });
|
|
287
|
+
output({ ok: true, item, message: `Added ${item.id}` }, flags.json);
|
|
288
|
+
});
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
if (command === 'list') {
|
|
293
|
+
if (flags.format !== undefined && flags.format !== 'table' && flags.format !== 'json') {
|
|
294
|
+
throw new Error("Invalid --format value for list. Use 'table' or 'json'.");
|
|
295
|
+
}
|
|
296
|
+
withLock(storePath, () => {
|
|
297
|
+
const db = loadStore(storePath);
|
|
298
|
+
const page = Number.isFinite(flags.page) && (flags.page as number) > 0 ? flags.page as number : 1;
|
|
299
|
+
const limit = Number.isFinite(flags.limit) && (flags.limit as number) > 0 ? flags.limit as number : 20;
|
|
300
|
+
const search = flags.search ? String(flags.search).toLowerCase() : '';
|
|
301
|
+
const tag = flags.tag ? String(flags.tag).toLowerCase() : '';
|
|
302
|
+
const useTable = flags.format === 'table' || (!flags.json && !flags.format && useColor(flags));
|
|
303
|
+
const useJson = flags.json || flags.format === 'json';
|
|
304
|
+
|
|
305
|
+
let filtered = db.items;
|
|
306
|
+
if (search) filtered = filtered.filter((x) => x.title.toLowerCase().includes(search) || x.content.toLowerCase().includes(search));
|
|
307
|
+
if (tag) filtered = filtered.filter((x) => x.tags && x.tags.map((t) => t.toLowerCase()).includes(tag));
|
|
308
|
+
|
|
309
|
+
const { sorted, sort, direction } = sortItems(filtered, flags);
|
|
310
|
+
const start = (page - 1) * limit;
|
|
311
|
+
const rows = sorted.slice(start, start + limit);
|
|
312
|
+
const totalPages = Math.max(1, Math.ceil(sorted.length / limit));
|
|
313
|
+
|
|
314
|
+
if (useJson) { output({ ok: true, page, limit, total: sorted.length, total_pages: totalPages, sort, direction, items: rows }, true); return; }
|
|
315
|
+
if (rows.length === 0) { output(`No items found (search=${search || 'none'}, tag=${tag || 'none'})`, false); return; }
|
|
316
|
+
if (useTable) {
|
|
317
|
+
const col = (v: string) => v;
|
|
318
|
+
const header = `${col('ID')}\t${col('TITLE')}\t${col('CREATED')}\t${col('URL')}\t${col('TAGS')}`;
|
|
319
|
+
console.log(header);
|
|
320
|
+
for (const row of rows) {
|
|
321
|
+
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(', ')}]`) : ''}`);
|
|
322
|
+
}
|
|
323
|
+
console.log(`Page ${page}/${totalPages} | showing ${rows.length} of ${sorted.length} | sort=${sort} ${direction} | search=${search || 'none'} | tag=${tag || 'none'}`);
|
|
324
|
+
} else {
|
|
325
|
+
for (const row of rows) {
|
|
326
|
+
console.log(`${row.id}\t${row.title}\t${row.created_at}${row.url ? `\t${row.url}` : ''}${row.tags?.length ? `\t[${row.tags.join(', ')}]` : ''}`);
|
|
327
|
+
}
|
|
328
|
+
console.log(`Page ${page}/${totalPages} | showing ${rows.length} of ${sorted.length} | sort=${sort} ${direction} | search=${search || 'none'} | tag=${tag || 'none'}`);
|
|
329
|
+
}
|
|
330
|
+
});
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
if (command === 'get') {
|
|
335
|
+
requireId(flags);
|
|
336
|
+
withLock(storePath, () => {
|
|
337
|
+
const db = loadStore(storePath);
|
|
338
|
+
const item = db.items.find((x) => x.id === flags.id);
|
|
339
|
+
if (!item) throw new Error(`Item not found: ${flags.id}`);
|
|
340
|
+
output({ ok: true, item, message: `${item.id}: ${item.title}` }, flags.json);
|
|
341
|
+
});
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
if (command === 'update') {
|
|
346
|
+
requireId(flags);
|
|
347
|
+
withLock(storePath, () => {
|
|
348
|
+
const db = loadStore(storePath);
|
|
349
|
+
const idx = db.items.findIndex((x) => x.id === flags.id);
|
|
350
|
+
if (idx === -1) throw new Error(`Item not found: ${flags.id}`);
|
|
351
|
+
const item = db.items[idx];
|
|
352
|
+
if (flags.title !== undefined) item.title = flags.title;
|
|
353
|
+
if (flags.content !== undefined) item.content = flags.content;
|
|
354
|
+
if (flags.url !== undefined) item.url = flags.url;
|
|
355
|
+
if (flags.tag !== undefined) {
|
|
356
|
+
item.tags = item.tags || [];
|
|
357
|
+
if (!item.tags.map((t) => t.toLowerCase()).includes(flags.tag!.toLowerCase())) {
|
|
358
|
+
item.tags.push(flags.tag!);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
item.updated_at = new Date().toISOString();
|
|
362
|
+
db.items[idx] = item;
|
|
363
|
+
saveStore(storePath, db);
|
|
364
|
+
output({ ok: true, item, message: `Updated ${item.id}` }, flags.json);
|
|
365
|
+
});
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
if (command === 'delete') {
|
|
370
|
+
requireId(flags);
|
|
371
|
+
if (!flags.yes) throw new Error('Refusing delete without --yes. Re-run with: open-knowledge delete --id <id> --yes');
|
|
372
|
+
withLock(storePath, () => {
|
|
373
|
+
const db = loadStore(storePath);
|
|
374
|
+
const before = db.items.length;
|
|
375
|
+
db.items = db.items.filter((x) => x.id !== flags.id);
|
|
376
|
+
const deleted = before !== db.items.length;
|
|
377
|
+
saveStore(storePath, db);
|
|
378
|
+
if (!deleted) throw new Error(`Item not found: ${flags.id}`);
|
|
379
|
+
log('info', 'Item deleted', { id: flags.id });
|
|
380
|
+
output({ ok: true, deleted_id: flags.id, message: `Deleted ${flags.id}` }, flags.json);
|
|
381
|
+
});
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
if (command === 'export') {
|
|
386
|
+
const format = flags.format ?? 'json';
|
|
387
|
+
if (format !== 'json' && format !== 'jsonl') throw new Error("Invalid --format. Use 'json' or 'jsonl'.");
|
|
388
|
+
withLock(storePath, () => {
|
|
389
|
+
const db = loadStore(storePath);
|
|
390
|
+
if (format === 'jsonl') {
|
|
391
|
+
for (const item of db.items) console.log(JSON.stringify(item));
|
|
392
|
+
} else {
|
|
393
|
+
output({ ok: true, items: db.items }, flags.json);
|
|
394
|
+
}
|
|
395
|
+
});
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
if (command === 'prune') {
|
|
400
|
+
if (!flags.yes) throw new Error('Refusing prune without --yes. Re-run with: open-knowledge prune --yes [--older-than <days>] [--empty]');
|
|
401
|
+
withLock(storePath, () => {
|
|
402
|
+
const db = loadStore(storePath);
|
|
403
|
+
const before = db.items.length;
|
|
404
|
+
if (flags.olderThan !== undefined) {
|
|
405
|
+
const cutoff = new Date();
|
|
406
|
+
cutoff.setDate(cutoff.getDate() - flags.olderThan);
|
|
407
|
+
db.items = db.items.filter((x) => new Date(x.created_at) >= cutoff);
|
|
408
|
+
}
|
|
409
|
+
if (flags.empty) {
|
|
410
|
+
db.items = db.items.filter((x) => x.content.trim().length > 0);
|
|
411
|
+
}
|
|
412
|
+
const pruned = before - db.items.length;
|
|
413
|
+
saveStore(storePath, db);
|
|
414
|
+
log('info', 'Prune completed', { pruned, remaining: db.items.length });
|
|
415
|
+
output({ ok: true, pruned, remaining: db.items.length, message: `Pruned ${pruned} item(s)` }, flags.json);
|
|
416
|
+
});
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
if (command === 'dedupe') {
|
|
421
|
+
if (!flags.yes) throw new Error('Refusing dedupe without --yes. Re-run with: open-knowledge dedupe --yes [--json]');
|
|
422
|
+
withLock(storePath, () => {
|
|
423
|
+
const db = loadStore(storePath);
|
|
424
|
+
const seen = new Set<string>();
|
|
425
|
+
const before = db.items.length;
|
|
426
|
+
db.items = db.items.filter((x) => {
|
|
427
|
+
const key = `${x.title}\u0000${x.content}`;
|
|
428
|
+
if (seen.has(key)) return false;
|
|
429
|
+
seen.add(key);
|
|
430
|
+
return true;
|
|
431
|
+
});
|
|
432
|
+
const removed = before - db.items.length;
|
|
433
|
+
saveStore(storePath, db);
|
|
434
|
+
log('info', 'Dedupe completed', { removed, remaining: db.items.length });
|
|
435
|
+
output({ ok: true, removed, remaining: db.items.length, message: `Dedupe removed ${removed} duplicate(s)` }, flags.json);
|
|
436
|
+
});
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
if (command === 'stats') {
|
|
441
|
+
withLock(storePath, () => {
|
|
442
|
+
const db = loadStore(storePath);
|
|
443
|
+
const total = db.items.length;
|
|
444
|
+
const withUrl = db.items.filter((x) => x.url).length;
|
|
445
|
+
const withTags = db.items.filter((x) => x.tags && x.tags.length > 0).length;
|
|
446
|
+
const oldest = total > 0 ? db.items.map((x) => x.created_at).sort()[0] : null;
|
|
447
|
+
const newest = total > 0 ? db.items.map((x) => x.created_at).sort()[total - 1] : null;
|
|
448
|
+
const tagCounts: Record<string, number> = {};
|
|
449
|
+
for (const item of db.items) {
|
|
450
|
+
for (const tag of item.tags || []) {
|
|
451
|
+
tagCounts[tag] = (tagCounts[tag] || 0) + 1;
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
const topTags = Object.entries(tagCounts).sort((a, b) => b[1] - a[1]).slice(0, 5).map(([tag, count]) => ({ tag, count }));
|
|
455
|
+
output({
|
|
456
|
+
ok: true,
|
|
457
|
+
total,
|
|
458
|
+
with_url: withUrl,
|
|
459
|
+
with_tags: withTags,
|
|
460
|
+
oldest,
|
|
461
|
+
newest,
|
|
462
|
+
top_tags: topTags,
|
|
463
|
+
message: `${total} items | ${withUrl} with URL | ${withTags} with tags`,
|
|
464
|
+
}, flags.json);
|
|
465
|
+
});
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
const suggestion = suggestCommand(positional[0]);
|
|
470
|
+
const hint = suggestion ? ` Did you mean '${suggestion}'?` : '';
|
|
471
|
+
log('warn', 'Unknown command', { input: positional[0], suggestion });
|
|
472
|
+
throw new Error(`Unknown command: ${positional[0]}.${hint} Run 'open-knowledge --help' for available commands.`);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
if (import.meta.main) {
|
|
476
|
+
try {
|
|
477
|
+
run(process.argv.slice(2));
|
|
478
|
+
} catch (error) {
|
|
479
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
480
|
+
log('error', 'CLI error', { message, stack: error instanceof Error ? error.stack : undefined });
|
|
481
|
+
console.error(`Error: ${message}`);
|
|
482
|
+
process.exitCode = 1;
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
export { run, parseArgs, suggestCommand, sortItems };
|