@rahmantech/knl-view 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +483 -2
- package/bin/knl-view.js +1320 -0
- package/dist/assets/abnfDiagram-WEYOIA3C-hoSbsMOz.js +1 -0
- package/dist/assets/arc-g87Np6E5.js +1 -0
- package/dist/assets/architecture-WOLXFQ4H-DOIq-0IA.js +1 -0
- package/dist/assets/architectureDiagram-ZHVYKT53-Dxwe8n7m.js +36 -0
- package/dist/assets/array-BifhSqXX.js +1 -0
- package/dist/assets/blockDiagram-RJAE7QA3-BnGng59Z.js +140 -0
- package/dist/assets/c4Diagram-XDPPPE54-C5LjqIIo.js +38 -0
- package/dist/assets/channel-BVWkRFBw.js +1 -0
- package/dist/assets/chunk-2E7WHVMD-BJ1dyGLq.js +88 -0
- package/dist/assets/chunk-2Q5K7J3B-C1jixKkw.js +1 -0
- package/dist/assets/chunk-4EMNKR2T-DXF02Sgw.js +1 -0
- package/dist/assets/chunk-55XCU76K-CM1akAbu.js +1 -0
- package/dist/assets/chunk-5VM5RSS4-ZNzvKenW.js +15 -0
- package/dist/assets/chunk-7S6XSTFO-BOUjqldL.js +2 -0
- package/dist/assets/chunk-AQDTSKZB-C4isdNLA.js +72 -0
- package/dist/assets/chunk-DSNLSHJZ-CbOTkSVM.js +129 -0
- package/dist/assets/chunk-DUW6YSOI-Dgx8z5s3.js +1 -0
- package/dist/assets/chunk-HAGFOTED-CRfnketR.js +62 -0
- package/dist/assets/chunk-HNE4Q36P-rxlpGUU8.js +1 -0
- package/dist/assets/chunk-J5ZVWO5B-oDCd-gWO.js +1 -0
- package/dist/assets/chunk-JWPE2WC7-DVXcaiue.js +1 -0
- package/dist/assets/chunk-NGNAAXSQ-C2Wy9Xmz.js +136 -0
- package/dist/assets/chunk-NQOKRVIF-BOYCgTmV.js +213 -0
- package/dist/assets/chunk-UHW7PFBP-Cnogp1Fb.js +1 -0
- package/dist/assets/chunk-VQ5W62QI-Cu1WY5Uo.js +1 -0
- package/dist/assets/chunk-WQQL4CDK-BJc3FPE2.js +10 -0
- package/dist/assets/chunk-XXDRQBXY-BlPj4H7s.js +1 -0
- package/dist/assets/chunk-Y2CYZVJY-DsF7k-Jl.js +1 -0
- package/dist/assets/chunk-YZO67HFG-V85H31x4.js +1 -0
- package/dist/assets/chunk-ZAUX2LTX-C3XKXW09.js +1 -0
- package/dist/assets/chunk-ZCHMQSOR-B0mmpEgW.js +2 -0
- package/dist/assets/classDiagram-v2-4MYWR3UA-oMd7YaHP.js +217 -0
- package/dist/assets/cose-bilkent-JH36ORCC-J0kSVsDf.js +1 -0
- package/dist/assets/cynefin-EF2NZ3EQ-DK2TNhel.js +1 -0
- package/dist/assets/cynefinDiagram-WF24UDRP-DDvKgieA.js +62 -0
- package/dist/assets/cytoscape.esm-BegBNzOV.js +321 -0
- package/dist/assets/dagre-NK4GIZVX-Ca2Fe-i-.js +4 -0
- package/dist/assets/defaultLocale-BFoDCU3G.js +1 -0
- package/dist/assets/diagram-CBK3MWTC-L6dmeQ2V.js +24 -0
- package/dist/assets/diagram-I5XIIV2D-DhG8R-v5.js +24 -0
- package/dist/assets/diagram-ODIL2UCW-BpYYbYBT.js +30 -0
- package/dist/assets/diagram-PFBEJUXE-DKQpHSsC.js +3 -0
- package/dist/assets/diagram-RJA3I4VV-DP7Dsnya.js +41 -0
- package/dist/assets/diagram-TYUNFSNR-CfSFewUS.js +200 -0
- package/dist/assets/dist-CQNjnrld.js +1 -0
- package/dist/assets/ebnfDiagram-V4P3TTPT-DE3MZoJO.js +1 -0
- package/dist/assets/elk-QONTBCTQ-p_aAQPHU.js +27 -0
- package/dist/assets/erDiagram-KYQIGQAA-DZO3vQrk.js +99 -0
- package/dist/assets/eventmodeling-K75KTNOO-BII9EPl5.js +1 -0
- package/dist/assets/flowDiagram-IUGLLWBU-D9uPno39.js +1 -0
- package/dist/assets/ganttDiagram-R7GPNTJY-BKiH3v2r.js +292 -0
- package/dist/assets/gitGraph-VSP46ZUC-oHL6cr73.js +1 -0
- package/dist/assets/gitGraphDiagram-PDYL7XHY-CqPVIEeo.js +106 -0
- package/dist/assets/graphlib-DS17s2tU.js +1 -0
- package/dist/assets/index-CSpirV16.js +76 -0
- package/dist/assets/index-DLgMsUs2.css +1 -0
- package/dist/assets/info-OHQRW6UA-Drrbnhpd.js +1 -0
- package/dist/assets/infoDiagram-MVDRKAX5-DnQQFMAd.js +2 -0
- package/dist/assets/init-C-OQMol4.js +1 -0
- package/dist/assets/ishikawaDiagram-HPEHV2GE-DIpQlxRh.js +70 -0
- package/dist/assets/journeyDiagram-BOSHW7O7-EBR9I5h1.js +139 -0
- package/dist/assets/kanban-definition-FNSA4HC5-CdvNsjik.js +89 -0
- package/dist/assets/katex-B_JcIwqj.js +257 -0
- package/dist/assets/line-CDLzgxDc.js +1 -0
- package/dist/assets/linear-DfXKql9u.js +1 -0
- package/dist/assets/mermaid-parser.core-DnXGphT2.js +7 -0
- package/dist/assets/mermaid.core-h6fiCaSZ.js +44 -0
- package/dist/assets/mindmap-definition-6OY57UG3-BiNfio-j.js +98 -0
- package/dist/assets/ordinal-BDEzSJ7C.js +1 -0
- package/dist/assets/packet-JDAUHWVQ-D76byv1S.js +1 -0
- package/dist/assets/path-fybaL0A-.js +1 -0
- package/dist/assets/pegDiagram-ECIUJNBX-Drs8Pxdt.js +1 -0
- package/dist/assets/pie-XZMESJXO-D_83z1lM.js +1 -0
- package/dist/assets/pieDiagram-GNMPY74T-wvzIphSf.js +39 -0
- package/dist/assets/quadrantDiagram-D57K4MC3-B7EfMRWb.js +7 -0
- package/dist/assets/radar-ABXABTNO-Bs6dVdAR.js +1 -0
- package/dist/assets/railroad-I3PHUGI6-lKaN4miw.js +1 -0
- package/dist/assets/railroad-abnf-LNEFI6M7-Bhh16b7Z.js +1 -0
- package/dist/assets/railroad-ebnf-DWYD2UWJ-m6ybW-Y_.js +1 -0
- package/dist/assets/railroad-peg-JR7BVNR7-DNn72e7r.js +1 -0
- package/dist/assets/railroadDiagram-Y72NRBL7-DNeop1iZ.js +1 -0
- package/dist/assets/requirementDiagram-EQYSW5RA-BVYZ2XYJ.js +84 -0
- package/dist/assets/rough.esm-Dy-Kn_BL.js +1 -0
- package/dist/assets/sankeyDiagram-SY24JHO5-B2MP51on.js +40 -0
- package/dist/assets/sequenceDiagram-BLEHNHQU-BUvxstXG.js +169 -0
- package/dist/assets/sizeCapture-INFHLROL-3uHXrFqA.js +1 -0
- package/dist/assets/src-BrR0lqlG.js +1 -0
- package/dist/assets/stateDiagram-v2-A6IESKWZ-Ba84OGKE.js +291 -0
- package/dist/assets/swimlanes-B3ENRKFQ-BMSl2PNA.js +1 -0
- package/dist/assets/swimlanesDiagram-AYQRV4AK-M1dmvq7m.js +8 -0
- package/dist/assets/timeline-definition-JXYKGLGB-DOdW-dxq.js +120 -0
- package/dist/assets/treeView-D4JQ5SDB-DZ9KQyn5.js +1 -0
- package/dist/assets/treemap-SAJKECNS-WEC5wtDV.js +1 -0
- package/dist/assets/usecaseDiagram-WPOLBNNX-cldTztFL.js +328 -0
- package/dist/assets/vennDiagram-Q3Y3IDCW-c41KySoY.js +34 -0
- package/dist/assets/wardley-7MLQ67FV-Bf9_aSSZ.js +1 -0
- package/dist/assets/wardleyDiagram-IVAIDKWA-BeKEvEBG.js +78 -0
- package/dist/assets/xychartDiagram-4URNHDU4-tNkAxTxG.js +7 -0
- package/dist/index.html +22 -0
- package/package.json +50 -4
- package/server/config.js +389 -0
- package/server/consensus.js +180 -0
- package/server/graph/anchordb_client.js +253 -0
- package/server/graph/model.js +157 -0
- package/server/graph/parser.js +327 -0
- package/server/graph/sources.js +801 -0
- package/server/graph/syntax.js +437 -0
- package/server/index.js +742 -0
- package/server/install.js +437 -0
- package/server/map.js +126 -0
- package/server/models_registry.js +109 -0
- package/server/scaffold.js +466 -0
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import crypto from 'node:crypto'
|
|
4
|
+
import net from 'node:net'
|
|
5
|
+
import { globalConfigPath } from './config.js'
|
|
6
|
+
|
|
7
|
+
// AnchorDB installer for knl-view. Two paths:
|
|
8
|
+
// - Linux: binary from GitHub Releases (tar.gz per architecture, verified
|
|
9
|
+
// against the release's official SHA256SUMS.txt).
|
|
10
|
+
// - Non-Linux: AnchorDB needs Linux mmap, so the path is the
|
|
11
|
+
// ghcr.io/rachmanzz/anchordb:<version> container via docker/podman.
|
|
12
|
+
// Everything here is as pure/deterministic as possible; interactive
|
|
13
|
+
// orchestration (prompts, spawn) stays in bin/knl-view.js.
|
|
14
|
+
|
|
15
|
+
export const GHCR_IMAGE = 'ghcr.io/rachmanzz/anchordb'
|
|
16
|
+
export const HTTP_PORT_DEFAULT = 17447
|
|
17
|
+
export const WIRE_PORT_DEFAULT = 17448
|
|
18
|
+
export const CONTAINER_NAME = 'anchordb-knlview'
|
|
19
|
+
|
|
20
|
+
/** Check if a TCP port is currently free on a host. */
|
|
21
|
+
export async function isPortAvailable(port, host = '127.0.0.1') {
|
|
22
|
+
return new Promise((resolve) => {
|
|
23
|
+
const server = net.createServer()
|
|
24
|
+
server.once('error', () => resolve(false))
|
|
25
|
+
server.once('listening', () => {
|
|
26
|
+
server.close(() => resolve(true))
|
|
27
|
+
})
|
|
28
|
+
server.listen(port, host)
|
|
29
|
+
})
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Find the next available TCP port sequentially starting from startPort. */
|
|
33
|
+
export async function findNextAvailablePort(startPort, host = '127.0.0.1') {
|
|
34
|
+
let port = Number(startPort)
|
|
35
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
36
|
+
throw new RangeError(`Invalid start port: ${startPort}`)
|
|
37
|
+
}
|
|
38
|
+
while (!(await isPortAvailable(port, host))) {
|
|
39
|
+
port++
|
|
40
|
+
if (port > 65535) {
|
|
41
|
+
throw new Error(`No available port found up to 65535 starting from ${startPort}`)
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return port
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Check desired AnchorDB ports and suggest alternatives if any is taken. */
|
|
48
|
+
export async function resolveAnchorPorts({
|
|
49
|
+
desiredWirePort = WIRE_PORT_DEFAULT,
|
|
50
|
+
desiredHttpPort = HTTP_PORT_DEFAULT,
|
|
51
|
+
host = '127.0.0.1'
|
|
52
|
+
} = {}) {
|
|
53
|
+
const wireAvailable = await isPortAvailable(desiredWirePort, host)
|
|
54
|
+
const httpAvailable = desiredHttpPort !== desiredWirePort && (await isPortAvailable(desiredHttpPort, host))
|
|
55
|
+
|
|
56
|
+
const suggestedWirePort = wireAvailable ? desiredWirePort : await findNextAvailablePort(desiredWirePort + 1, host)
|
|
57
|
+
let candidateHttp = (httpAvailable && desiredHttpPort !== suggestedWirePort)
|
|
58
|
+
? desiredHttpPort
|
|
59
|
+
: await findNextAvailablePort(desiredHttpPort + 1, host)
|
|
60
|
+
if (candidateHttp === suggestedWirePort) {
|
|
61
|
+
candidateHttp = await findNextAvailablePort(candidateHttp + 1, host)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
wirePort: desiredWirePort,
|
|
66
|
+
httpPort: desiredHttpPort,
|
|
67
|
+
wireAvailable,
|
|
68
|
+
httpAvailable,
|
|
69
|
+
suggestedWirePort,
|
|
70
|
+
suggestedHttpPort: candidateHttp
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Release asset name for a platform/architecture, per the AnchorDB release pattern. */
|
|
75
|
+
export function releaseAssetName(version, platform = process.platform, arch = process.arch) {
|
|
76
|
+
const archName = arch === 'arm64' ? 'arm64' : 'amd64'
|
|
77
|
+
const platformName = platform === 'darwin' ? 'darwin' : 'linux'
|
|
78
|
+
return `anchordb_${version}_${platformName}_${archName}.tar.gz`
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Random token for ANCHORDB_AUTH_TOKEN: 32 bytes of hex. */
|
|
82
|
+
export function generateToken() {
|
|
83
|
+
return crypto.randomBytes(32).toString('hex')
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Fetch the latest release from the GitHub API. */
|
|
87
|
+
export async function latestRelease(repo = 'rachmanzz/anchordb') {
|
|
88
|
+
const res = await fetch(`https://api.github.com/repos/${repo}/releases/latest`)
|
|
89
|
+
if (!res.ok) throw new Error(`GitHub API ${res.status}: failed to read the latest release`)
|
|
90
|
+
const body = await res.json()
|
|
91
|
+
if (!body.tag_name) throw new Error('Latest release has no tag_name')
|
|
92
|
+
const version = String(body.tag_name).replace(/^v/, '')
|
|
93
|
+
return { tag: body.tag_name, version }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Download one file to disk. */
|
|
97
|
+
export async function downloadFile(url, dest) {
|
|
98
|
+
const res = await fetch(url, { redirect: 'follow' })
|
|
99
|
+
if (!res.ok) throw new Error(`Download failed (${res.status}): ${url}`)
|
|
100
|
+
const buf = Buffer.from(await res.arrayBuffer())
|
|
101
|
+
await fs.writeFile(dest, buf)
|
|
102
|
+
return buf.length
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Verify a file against SHA256SUMS.txt contents in the standard format:
|
|
107
|
+
* "<hash> <name>" per line. Throws an Error when the name is missing or
|
|
108
|
+
* the hash does not match.
|
|
109
|
+
*/
|
|
110
|
+
export async function verifySha256(filePath, sumsText) {
|
|
111
|
+
const fileName = path.basename(filePath)
|
|
112
|
+
const line = String(sumsText)
|
|
113
|
+
.split('\n')
|
|
114
|
+
.find((l) => l.trim().endsWith(fileName))
|
|
115
|
+
if (!line) throw new Error(`${fileName} is not in SHA256SUMS.txt`)
|
|
116
|
+
const expected = line.trim().split(/\s+/)[0].toLowerCase()
|
|
117
|
+
const actual = crypto.createHash('sha256').update(await fs.readFile(filePath)).digest('hex')
|
|
118
|
+
if (actual !== expected) {
|
|
119
|
+
throw new Error(`SHA256 mismatch for ${fileName}\n wanted ${expected}\n got ${actual}`)
|
|
120
|
+
}
|
|
121
|
+
return expected
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Merge the anchordb block into the global config (knl-config.json)
|
|
126
|
+
* without dropping other fields that may live there. Returns the config
|
|
127
|
+
* contents before the overwrite (as a backup, in case the caller shows it).
|
|
128
|
+
*/
|
|
129
|
+
export async function mergeGlobalAnchordb(home, anchordb, options = {}) {
|
|
130
|
+
const file = globalConfigPath(home, options)
|
|
131
|
+
let before = null
|
|
132
|
+
let raw = {}
|
|
133
|
+
try {
|
|
134
|
+
const text = await fs.readFile(file, 'utf8')
|
|
135
|
+
before = text
|
|
136
|
+
const parsed = JSON.parse(text)
|
|
137
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) raw = parsed
|
|
138
|
+
} catch {
|
|
139
|
+
// Missing/broken file: start from an empty object.
|
|
140
|
+
}
|
|
141
|
+
raw.anchordb = anchordb
|
|
142
|
+
await fs.mkdir(path.dirname(file), { recursive: true, mode: 0o700 })
|
|
143
|
+
await fs.writeFile(file, JSON.stringify(raw, null, 2) + '\n', { encoding: 'utf8', mode: 0o600 })
|
|
144
|
+
try { await fs.chmod(file, 0o600) } catch {}
|
|
145
|
+
return before
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** List all available container runtimes on the system. */
|
|
149
|
+
export async function availableContainerRuntimes(which) {
|
|
150
|
+
const found = []
|
|
151
|
+
for (const runtime of ['docker', 'podman']) {
|
|
152
|
+
if (await which(runtime)) found.push(runtime)
|
|
153
|
+
}
|
|
154
|
+
return found
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Pick an available container runtime: preferred first, or first detected. */
|
|
158
|
+
export async function detectContainerRuntime(which, preferred = null) {
|
|
159
|
+
if (preferred) {
|
|
160
|
+
if (await which(preferred)) return preferred
|
|
161
|
+
return null
|
|
162
|
+
}
|
|
163
|
+
const available = await availableContainerRuntimes(which)
|
|
164
|
+
return available[0] || null
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Inspect detailed container lifecycle status across available runtimes.
|
|
169
|
+
* Returns exists, runtime, running, valid, status, id, created, token, wire_port.
|
|
170
|
+
*/
|
|
171
|
+
export async function inspectContainerStatus(exec, name = CONTAINER_NAME) {
|
|
172
|
+
for (const runtime of ['podman', 'docker']) {
|
|
173
|
+
try {
|
|
174
|
+
const { stdout } = await exec(runtime, [
|
|
175
|
+
'inspect',
|
|
176
|
+
'--format',
|
|
177
|
+
'{{.Id}}|{{.Created}}|{{.State.Status}}|{{.State.Running}}|{{json .Config.Env}}',
|
|
178
|
+
name
|
|
179
|
+
])
|
|
180
|
+
const parts = stdout.trim().split('|')
|
|
181
|
+
if (parts.length >= 4 && parts[0]) {
|
|
182
|
+
const id = parts[0].trim()
|
|
183
|
+
const created = parts[1].trim()
|
|
184
|
+
const status = (parts[2] || '').trim().toLowerCase()
|
|
185
|
+
const running = (parts[3] || '').trim().toLowerCase() === 'true'
|
|
186
|
+
const valid = !['dead', 'removal', 'corrupt'].includes(status) && id.length >= 12
|
|
187
|
+
const envStr = parts.slice(4).join('|')
|
|
188
|
+
let token = null
|
|
189
|
+
let port = String(WIRE_PORT_DEFAULT)
|
|
190
|
+
try {
|
|
191
|
+
const envs = JSON.parse(envStr)
|
|
192
|
+
for (const e of envs) {
|
|
193
|
+
if (e.startsWith('ANCHORDB_AUTH_TOKEN=')) token = e.slice('ANCHORDB_AUTH_TOKEN='.length)
|
|
194
|
+
if (e.startsWith('ANCHORDB_WIRE_PORT=')) port = e.slice('ANCHORDB_WIRE_PORT='.length)
|
|
195
|
+
}
|
|
196
|
+
} catch {}
|
|
197
|
+
|
|
198
|
+
return {
|
|
199
|
+
exists: true,
|
|
200
|
+
runtime,
|
|
201
|
+
name,
|
|
202
|
+
id,
|
|
203
|
+
created,
|
|
204
|
+
status,
|
|
205
|
+
running,
|
|
206
|
+
valid,
|
|
207
|
+
token,
|
|
208
|
+
wire_port: port
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
} catch {
|
|
212
|
+
// not in this runtime
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return {
|
|
217
|
+
exists: false,
|
|
218
|
+
runtime: null,
|
|
219
|
+
name,
|
|
220
|
+
id: null,
|
|
221
|
+
created: null,
|
|
222
|
+
status: null,
|
|
223
|
+
running: false,
|
|
224
|
+
valid: false,
|
|
225
|
+
token: null,
|
|
226
|
+
wire_port: String(WIRE_PORT_DEFAULT)
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export async function startContainer(exec, runtime, name = CONTAINER_NAME) {
|
|
231
|
+
await exec(runtime, ['start', name])
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export async function removeContainer(exec, runtime, name = CONTAINER_NAME) {
|
|
235
|
+
await exec(runtime, ['rm', '-f', name])
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export async function detectGraphify(exec) {
|
|
239
|
+
try {
|
|
240
|
+
await exec('which', ['graphify'])
|
|
241
|
+
return true
|
|
242
|
+
} catch {
|
|
243
|
+
return false
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export const DECISIONSKILLS_REPO = 'rachmanzz/decisionskills'
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Check if the decision-history skill (from rachmanzz/decisionskills) is
|
|
251
|
+
* installed in project or global agent directories, and if an update is available.
|
|
252
|
+
*/
|
|
253
|
+
export async function checkDecisionSkillsStatus({ cwd = process.cwd(), home = process.env.HOME }) {
|
|
254
|
+
const projectSkill = path.join(cwd, '.agents', 'skills', 'decision-history', 'SKILL.md')
|
|
255
|
+
let hasProject = false
|
|
256
|
+
try {
|
|
257
|
+
await fs.stat(projectSkill)
|
|
258
|
+
hasProject = true
|
|
259
|
+
} catch {}
|
|
260
|
+
|
|
261
|
+
const globalPaths = [
|
|
262
|
+
path.join(home || '', '.codex', 'skills', 'decision-history', 'SKILL.md'),
|
|
263
|
+
path.join(home || '', '.claude', 'skills', 'decision-history', 'SKILL.md'),
|
|
264
|
+
path.join(home || '', '.config', 'opencode', 'skills', 'decision-history', 'SKILL.md'),
|
|
265
|
+
path.join(home || '', '.gemini', 'config', 'plugins', 'decision-history', 'skills', 'decision-history', 'SKILL.md'),
|
|
266
|
+
path.join(home || '', '.agents', 'skills', 'decision-history', 'SKILL.md')
|
|
267
|
+
]
|
|
268
|
+
|
|
269
|
+
let hasGlobal = false
|
|
270
|
+
for (const p of globalPaths) {
|
|
271
|
+
try {
|
|
272
|
+
await fs.stat(p)
|
|
273
|
+
hasGlobal = true
|
|
274
|
+
break
|
|
275
|
+
} catch {}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
let currentVersion = null
|
|
279
|
+
let latestVersion = null
|
|
280
|
+
let status = 'not_installed'
|
|
281
|
+
|
|
282
|
+
if (hasProject || hasGlobal) {
|
|
283
|
+
status = 'installed'
|
|
284
|
+
const versionCandidates = [
|
|
285
|
+
path.join(cwd, '.agents', 'skills', 'decision-history', 'VERSION'),
|
|
286
|
+
path.join(home || '', '.gemini', 'config', 'plugins', 'decision-history', 'VERSION')
|
|
287
|
+
]
|
|
288
|
+
for (const vFile of versionCandidates) {
|
|
289
|
+
try {
|
|
290
|
+
const text = await fs.readFile(vFile, 'utf8')
|
|
291
|
+
if (text.trim()) {
|
|
292
|
+
currentVersion = text.trim()
|
|
293
|
+
break
|
|
294
|
+
}
|
|
295
|
+
} catch {}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
try {
|
|
299
|
+
const res = await fetch('https://raw.githubusercontent.com/rachmanzz/decisionskills/main/VERSION', {
|
|
300
|
+
signal: AbortSignal.timeout(2000)
|
|
301
|
+
})
|
|
302
|
+
if (res.ok) {
|
|
303
|
+
latestVersion = (await res.text()).trim()
|
|
304
|
+
if (currentVersion && latestVersion && currentVersion !== latestVersion) {
|
|
305
|
+
status = 'needs_update'
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
} catch {}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
return {
|
|
312
|
+
hasProject,
|
|
313
|
+
hasGlobal,
|
|
314
|
+
status, // 'not_installed' | 'installed' | 'needs_update'
|
|
315
|
+
currentVersion,
|
|
316
|
+
latestVersion,
|
|
317
|
+
projectPath: projectSkill
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Install or update the decision-history skill from rachmanzz/decisionskills.
|
|
323
|
+
* Scope: 'project' | 'global' | 'both'.
|
|
324
|
+
*/
|
|
325
|
+
export async function installDecisionSkills({
|
|
326
|
+
scope = 'project',
|
|
327
|
+
cwd = process.cwd(),
|
|
328
|
+
home = process.env.HOME,
|
|
329
|
+
exec
|
|
330
|
+
}) {
|
|
331
|
+
const results = []
|
|
332
|
+
|
|
333
|
+
const copyDir = async (src, dest) => {
|
|
334
|
+
await fs.mkdir(dest, { recursive: true })
|
|
335
|
+
const entries = await fs.readdir(src, { withFileTypes: true })
|
|
336
|
+
for (const entry of entries) {
|
|
337
|
+
const srcPath = path.join(src, entry.name)
|
|
338
|
+
const destPath = path.join(dest, entry.name)
|
|
339
|
+
if (entry.isDirectory()) {
|
|
340
|
+
await copyDir(srcPath, destPath)
|
|
341
|
+
} else {
|
|
342
|
+
await fs.copyFile(srcPath, destPath)
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const doInstall = async (isGlobal) => {
|
|
348
|
+
let success = false
|
|
349
|
+
if (exec) {
|
|
350
|
+
try {
|
|
351
|
+
const args = ['--yes', 'skills', 'add', DECISIONSKILLS_REPO, '--skill', 'decision-history', '-y', '--copy']
|
|
352
|
+
if (isGlobal) args.push('-g')
|
|
353
|
+
await exec('npx', args, { cwd })
|
|
354
|
+
success = true
|
|
355
|
+
} catch {}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
if (!success) {
|
|
359
|
+
const fallbackSources = [
|
|
360
|
+
path.join(home || '', '.gemini', 'config', 'plugins', 'decision-history', 'skills', 'decision-history'),
|
|
361
|
+
path.join(home || '', '.agents', 'skills', 'decision-history'),
|
|
362
|
+
path.join(home || '', '.codex', 'skills', 'decision-history')
|
|
363
|
+
]
|
|
364
|
+
let srcDir = null
|
|
365
|
+
for (const candidate of fallbackSources) {
|
|
366
|
+
try {
|
|
367
|
+
await fs.stat(path.join(candidate, 'SKILL.md'))
|
|
368
|
+
srcDir = candidate
|
|
369
|
+
break
|
|
370
|
+
} catch {}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
if (srcDir) {
|
|
374
|
+
const dest = isGlobal
|
|
375
|
+
? path.join(home || '', '.agents', 'skills', 'decision-history')
|
|
376
|
+
: path.join(cwd, '.agents', 'skills', 'decision-history')
|
|
377
|
+
await copyDir(srcDir, dest)
|
|
378
|
+
success = true
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
if (isGlobal && exec) {
|
|
383
|
+
const agyDir = path.join(home || '', '.gemini', 'config', 'plugins', 'decision-history')
|
|
384
|
+
try {
|
|
385
|
+
await fs.stat(agyDir)
|
|
386
|
+
await exec('git', ['pull'], { cwd: agyDir })
|
|
387
|
+
} catch {
|
|
388
|
+
try {
|
|
389
|
+
await fs.mkdir(path.dirname(agyDir), { recursive: true })
|
|
390
|
+
await exec('git', ['clone', 'https://github.com/rachmanzz/decisionskills.git', agyDir])
|
|
391
|
+
} catch {}
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
return success
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
if (scope === 'project' || scope === 'both') {
|
|
399
|
+
const ok = await doInstall(false)
|
|
400
|
+
results.push({ scope: 'project', success: ok })
|
|
401
|
+
}
|
|
402
|
+
if (scope === 'global' || scope === 'both') {
|
|
403
|
+
const ok = await doInstall(true)
|
|
404
|
+
results.push({ scope: 'global', success: ok })
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
return results
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Detect a running AnchorDB daemon on the system (docker, podman, or binary).
|
|
412
|
+
* Inspects containers for CONTAINER_NAME or checks running processes.
|
|
413
|
+
*/
|
|
414
|
+
export async function detectRunningDaemon(exec) {
|
|
415
|
+
const container = await inspectContainerStatus(exec)
|
|
416
|
+
if (container.exists && container.running && container.valid) {
|
|
417
|
+
return {
|
|
418
|
+
daemon: container.runtime,
|
|
419
|
+
deamon: container.runtime,
|
|
420
|
+
container_name: container.name,
|
|
421
|
+
container_id: container.id,
|
|
422
|
+
container_created_on: container.created,
|
|
423
|
+
token: container.token,
|
|
424
|
+
wire_port: container.wire_port
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// Check if binary process is running
|
|
429
|
+
try {
|
|
430
|
+
const { stdout } = await exec('pgrep', ['-f', 'anchordb-server'])
|
|
431
|
+
if (stdout.trim()) {
|
|
432
|
+
return { daemon: 'binary', deamon: 'binary' }
|
|
433
|
+
}
|
|
434
|
+
} catch {}
|
|
435
|
+
|
|
436
|
+
return null
|
|
437
|
+
}
|
package/server/map.js
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
|
|
4
|
+
// The optional map (map.json): pure annotation on top of the disk structure.
|
|
5
|
+
// Principle: the disk is the truth, the map is an overlay. The map NEVER
|
|
6
|
+
// defines the existence of files/folders. A file missing from disk stays
|
|
7
|
+
// missing from the viewer even when the map still lists it (orphan entries
|
|
8
|
+
// are silently ignored). The map only decorates: title, order, metadata.
|
|
9
|
+
|
|
10
|
+
const MAP_FILE = 'map.json'
|
|
11
|
+
const MAX_MAP_BYTES = 1024 * 1024
|
|
12
|
+
|
|
13
|
+
export async function readMap(rootAbs) {
|
|
14
|
+
const abs = path.join(rootAbs, MAP_FILE)
|
|
15
|
+
const stat = await statOrNull(abs)
|
|
16
|
+
if (!stat || !stat.isFile() || stat.size > MAX_MAP_BYTES) return null
|
|
17
|
+
|
|
18
|
+
let parsed
|
|
19
|
+
try {
|
|
20
|
+
parsed = JSON.parse(await fs.readFile(abs, 'utf8'))
|
|
21
|
+
} catch {
|
|
22
|
+
// A broken map must never bring the viewer down: ignore it quietly.
|
|
23
|
+
return null
|
|
24
|
+
}
|
|
25
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null
|
|
26
|
+
return parsed
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Normalize shapes: the map may write the title in `title` (root/files),
|
|
30
|
+
// `sections`/`dirs` (folders), and `docs`/`files` (files). Only one clean
|
|
31
|
+
// representation matters for the overlay. Each entry may carry
|
|
32
|
+
// `ignore: true` to hide it (the disk still owns the file).
|
|
33
|
+
export function normalizeMap(map) {
|
|
34
|
+
if (!map) return { title: null, entries: new Map() }
|
|
35
|
+
const entries = new Map()
|
|
36
|
+
|
|
37
|
+
const dirs = { ...asObject(map.sections), ...asObject(map.dirs) }
|
|
38
|
+
for (const [key, value] of Object.entries(dirs)) {
|
|
39
|
+
const entry = asObject(value)
|
|
40
|
+
if (!entry) continue
|
|
41
|
+
mergeEntry(entries, cleanKey(key), entry)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// `docs`/`files` can be placed per folder:
|
|
45
|
+
// { "features/x": { "docs": { "a.md": {...} } } } or globally.
|
|
46
|
+
// Both are written into the same file entries.
|
|
47
|
+
const globalDocs = { ...asObject(map.docs), ...asObject(map.files) }
|
|
48
|
+
applyDocs(entries, '', globalDocs)
|
|
49
|
+
for (const [key, value] of Object.entries(dirs)) {
|
|
50
|
+
const docs = asObject(asObject(value)?.docs)
|
|
51
|
+
if (docs) applyDocs(entries, cleanKey(key), docs)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const ignoreList = Array.isArray(map.ignore) ? map.ignore : []
|
|
55
|
+
for (const path of ignoreList) {
|
|
56
|
+
if (typeof path !== 'string' || !path.trim()) continue
|
|
57
|
+
mergeEntry(entries, cleanKey(path), { ignore: true })
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
title: typeof map.title === 'string' ? map.title.trim() : null,
|
|
62
|
+
entries
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function applyDocs(entries, dirKey, docs) {
|
|
67
|
+
for (const [name, value] of Object.entries(docs)) {
|
|
68
|
+
const entry = asObject(value) || {}
|
|
69
|
+
const key = dirKey ? `${dirKey}/${name}` : name
|
|
70
|
+
mergeEntry(entries, cleanKey(key), entry)
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function mergeEntry(entries, key, entry) {
|
|
75
|
+
const prev = entries.get(key) || {}
|
|
76
|
+
entries.set(key, {
|
|
77
|
+
title: typeof entry.title === 'string' ? entry.title.trim() : prev.title ?? null,
|
|
78
|
+
order: toOrder(entry.order) ?? prev.order ?? null,
|
|
79
|
+
meta: typeof entry.meta === 'string' ? entry.meta.trim() : prev.meta ?? null,
|
|
80
|
+
ignore: entry.ignore === true || entry.hidden === true ? true : prev.ignore === true
|
|
81
|
+
})
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Apply the overlay to one node. Returns a new node (without corrupting the
|
|
85
|
+
// original object from disk), or the same node when the map does not touch it.
|
|
86
|
+
export function decorateNode(node, norm) {
|
|
87
|
+
const entry = norm?.entries?.get(node.path)
|
|
88
|
+
let next = node
|
|
89
|
+
|
|
90
|
+
const title = entry?.title
|
|
91
|
+
if (title) {
|
|
92
|
+
// A map title beats the heading title read from the file contents.
|
|
93
|
+
// `titleFromMap` marks that this label came from the map, so the view
|
|
94
|
+
// may use it directly as the label (not just as a subtitle).
|
|
95
|
+
next = { ...next, title, titleFromMap: true }
|
|
96
|
+
}
|
|
97
|
+
const order = entry?.order
|
|
98
|
+
if (order != null) next = { ...next, order }
|
|
99
|
+
const meta = entry?.meta
|
|
100
|
+
if (meta != null) next = { ...next, meta }
|
|
101
|
+
|
|
102
|
+
// Children are already decorated by buildTree while walking the disk,
|
|
103
|
+
// so here it is enough to touch this node only.
|
|
104
|
+
return next
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function asObject(value) {
|
|
108
|
+
return value && typeof value === 'object' && !Array.isArray(value) ? value : null
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function cleanKey(key) {
|
|
112
|
+
return String(key).replace(/^\.?\//, '').replace(/\/+$/, '')
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function toOrder(value) {
|
|
116
|
+
const n = Number(value)
|
|
117
|
+
return Number.isFinite(n) ? n : null
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function statOrNull(abs) {
|
|
121
|
+
try {
|
|
122
|
+
return await fs.stat(abs)
|
|
123
|
+
} catch {
|
|
124
|
+
return null
|
|
125
|
+
}
|
|
126
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import crypto from 'node:crypto'
|
|
4
|
+
import os from 'node:os'
|
|
5
|
+
import { knlGlobalDir } from './config.js'
|
|
6
|
+
|
|
7
|
+
// Global registry of AnchorDB model names:
|
|
8
|
+
// - %APPDATA%\knlview\models.json on Windows
|
|
9
|
+
// - ~/.knlview/models.json on Linux and macOS
|
|
10
|
+
//
|
|
11
|
+
// Model names may be random; what makes a random name meaningful is this
|
|
12
|
+
// registry: the mapping of name → project, role, and project location.
|
|
13
|
+
// knl-view code never guesses model names: it only knows roles (knowledges,
|
|
14
|
+
// consensus, etc.) and reads each role from the project map.
|
|
15
|
+
|
|
16
|
+
const REGISTRY_FILE = 'models.json'
|
|
17
|
+
|
|
18
|
+
export function modelRegistryPath(home = null, options = {}) {
|
|
19
|
+
return path.join(knlGlobalDir(home, options), REGISTRY_FILE)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Random name for a model: 8 Crockford base32 characters (no I, L, O, U
|
|
24
|
+
* so humans never misread/mistype them).
|
|
25
|
+
*/
|
|
26
|
+
export function generateModelName() {
|
|
27
|
+
const alphabet = '0123456789abcdefghjkmnpqrstvwxyz'
|
|
28
|
+
const bytes = crypto.randomBytes(8)
|
|
29
|
+
let name = ''
|
|
30
|
+
for (const byte of bytes) name += alphabet[byte % alphabet.length]
|
|
31
|
+
return name
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Read the registry. Missing/broken file = empty registry, not an error.
|
|
36
|
+
* @returns {Promise<{models: object, error: string|null}>}
|
|
37
|
+
*/
|
|
38
|
+
export async function readModelRegistry(home = null, options = {}) {
|
|
39
|
+
const primary = modelRegistryPath(home, options)
|
|
40
|
+
let content = null
|
|
41
|
+
try {
|
|
42
|
+
content = await fs.readFile(primary, 'utf8')
|
|
43
|
+
} catch (err) {
|
|
44
|
+
if (err?.code === 'ENOENT') {
|
|
45
|
+
const platform = options.platform || process.platform
|
|
46
|
+
const env = options.env || process.env
|
|
47
|
+
if (platform === 'win32' && !home) {
|
|
48
|
+
const winHome = env.USERPROFILE || env.HOME || (typeof os.homedir === 'function' ? os.homedir() : null)
|
|
49
|
+
if (winHome) {
|
|
50
|
+
try {
|
|
51
|
+
content = await fs.readFile(path.join(winHome, '.knlview', REGISTRY_FILE), 'utf8')
|
|
52
|
+
} catch {}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
if (!content) return { models: {}, error: null }
|
|
56
|
+
} else {
|
|
57
|
+
return { models: {}, error: err.message }
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
try {
|
|
62
|
+
const parsed = JSON.parse(content)
|
|
63
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
64
|
+
return { models: {}, error: 'registry contents are not an object' }
|
|
65
|
+
}
|
|
66
|
+
const models = {}
|
|
67
|
+
for (const [name, entry] of Object.entries(parsed)) {
|
|
68
|
+
if (entry && typeof entry === 'object' && typeof entry.role === 'string') {
|
|
69
|
+
models[name] = entry
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return { models, error: null }
|
|
73
|
+
} catch (err) {
|
|
74
|
+
return { models: {}, error: err.message }
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Register (or update) one model in the registry. Returns the previous owner
|
|
80
|
+
* when the name is already claimed by another project; the caller decides
|
|
81
|
+
* whether to warn.
|
|
82
|
+
* @returns {Promise<{previous: object|null, written: boolean, error: string|null}>}
|
|
83
|
+
*/
|
|
84
|
+
export async function registerModel(name, { role, project, cwd }, home = null, options = {}) {
|
|
85
|
+
const { models, error } = await readModelRegistry(home, options)
|
|
86
|
+
if (error) return { previous: null, written: false, error }
|
|
87
|
+
|
|
88
|
+
const previous = models[name] || null
|
|
89
|
+
const sameOwner = previous && path.resolve(previous.cwd || '') === path.resolve(cwd)
|
|
90
|
+
if (previous && !sameOwner) {
|
|
91
|
+
return { previous, written: false, error: null }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
models[name] = {
|
|
95
|
+
role,
|
|
96
|
+
project,
|
|
97
|
+
cwd: path.resolve(cwd),
|
|
98
|
+
registeredAt: new Date().toISOString()
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
try {
|
|
102
|
+
const file = modelRegistryPath(home, options)
|
|
103
|
+
await fs.mkdir(path.dirname(file), { recursive: true })
|
|
104
|
+
await fs.writeFile(file, JSON.stringify(models, null, 2) + '\n', 'utf8')
|
|
105
|
+
return { previous: null, written: true, error: null }
|
|
106
|
+
} catch (err) {
|
|
107
|
+
return { previous: null, written: false, error: err.message }
|
|
108
|
+
}
|
|
109
|
+
}
|