@rahmantech/knl-view 0.1.0 → 0.3.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.
Files changed (98) hide show
  1. package/README.md +133 -32
  2. package/bin/knl-view.js +591 -58
  3. package/dist/assets/{abnfDiagram-WEYOIA3C-B1gaBaSI.js → abnfDiagram-WEYOIA3C-hoSbsMOz.js} +1 -1
  4. package/dist/assets/{arc-NZ9uRaCl.js → arc-g87Np6E5.js} +1 -1
  5. package/dist/assets/{architecture-WOLXFQ4H-8MalLwTd.js → architecture-WOLXFQ4H-DOIq-0IA.js} +1 -1
  6. package/dist/assets/{architectureDiagram-ZHVYKT53-eZ8i5cl4.js → architectureDiagram-ZHVYKT53-Dxwe8n7m.js} +1 -1
  7. package/dist/assets/{blockDiagram-RJAE7QA3-BavOE8BM.js → blockDiagram-RJAE7QA3-BnGng59Z.js} +1 -1
  8. package/dist/assets/{c4Diagram-XDPPPE54-DqMaTAzx.js → c4Diagram-XDPPPE54-C5LjqIIo.js} +1 -1
  9. package/dist/assets/channel-BVWkRFBw.js +1 -0
  10. package/dist/assets/{chunk-2E7WHVMD-B1Hj2BjJ.js → chunk-2E7WHVMD-BJ1dyGLq.js} +1 -1
  11. package/dist/assets/{chunk-4EMNKR2T-HDscrCiq.js → chunk-4EMNKR2T-DXF02Sgw.js} +1 -1
  12. package/dist/assets/{chunk-55XCU76K-QR_ARj8I.js → chunk-55XCU76K-CM1akAbu.js} +1 -1
  13. package/dist/assets/{chunk-7S6XSTFO-B0p3o547.js → chunk-7S6XSTFO-BOUjqldL.js} +1 -1
  14. package/dist/assets/{chunk-AQDTSKZB-CCxwNyyA.js → chunk-AQDTSKZB-C4isdNLA.js} +1 -1
  15. package/dist/assets/{chunk-DSNLSHJZ-BM5PBFmz.js → chunk-DSNLSHJZ-CbOTkSVM.js} +1 -1
  16. package/dist/assets/{chunk-HAGFOTED-UCDVFuN_.js → chunk-HAGFOTED-CRfnketR.js} +1 -1
  17. package/dist/assets/{chunk-HNE4Q36P-C_1sakp2.js → chunk-HNE4Q36P-rxlpGUU8.js} +1 -1
  18. package/dist/assets/{chunk-NQOKRVIF-b2D1Z4oK.js → chunk-NQOKRVIF-BOYCgTmV.js} +1 -1
  19. package/dist/assets/{chunk-UHW7PFBP-CJs0VwBY.js → chunk-UHW7PFBP-Cnogp1Fb.js} +1 -1
  20. package/dist/assets/{chunk-VQ5W62QI-DQxjgE4_.js → chunk-VQ5W62QI-Cu1WY5Uo.js} +1 -1
  21. package/dist/assets/{chunk-WQQL4CDK-DcjPeAfQ.js → chunk-WQQL4CDK-BJc3FPE2.js} +1 -1
  22. package/dist/assets/{chunk-XXDRQBXY-glA0kB5s.js → chunk-XXDRQBXY-BlPj4H7s.js} +1 -1
  23. package/dist/assets/{chunk-YZO67HFG-DJqACL5V.js → chunk-YZO67HFG-V85H31x4.js} +1 -1
  24. package/dist/assets/{chunk-ZAUX2LTX-CWfaYtwb.js → chunk-ZAUX2LTX-C3XKXW09.js} +1 -1
  25. package/dist/assets/{chunk-ZCHMQSOR-BGX73Hho.js → chunk-ZCHMQSOR-B0mmpEgW.js} +1 -1
  26. package/dist/assets/{classDiagram-v2-4MYWR3UA-BpYXPz-e.js → classDiagram-v2-4MYWR3UA-oMd7YaHP.js} +1 -1
  27. package/dist/assets/{cose-bilkent-JH36ORCC-e1quKm0Q.js → cose-bilkent-JH36ORCC-J0kSVsDf.js} +1 -1
  28. package/dist/assets/{cynefin-EF2NZ3EQ-hb3oUmFO.js → cynefin-EF2NZ3EQ-DK2TNhel.js} +1 -1
  29. package/dist/assets/{cynefinDiagram-WF24UDRP-CnMQvLqi.js → cynefinDiagram-WF24UDRP-DDvKgieA.js} +1 -1
  30. package/dist/assets/{dagre-NK4GIZVX-BH_Y8y3F.js → dagre-NK4GIZVX-Ca2Fe-i-.js} +1 -1
  31. package/dist/assets/{diagram-CBK3MWTC-Dh79VetZ.js → diagram-CBK3MWTC-L6dmeQ2V.js} +1 -1
  32. package/dist/assets/{diagram-I5XIIV2D-D3u-o1RV.js → diagram-I5XIIV2D-DhG8R-v5.js} +1 -1
  33. package/dist/assets/{diagram-ODIL2UCW-Bfuo-OKS.js → diagram-ODIL2UCW-BpYYbYBT.js} +1 -1
  34. package/dist/assets/{diagram-PFBEJUXE-CwHasIbs.js → diagram-PFBEJUXE-DKQpHSsC.js} +1 -1
  35. package/dist/assets/{diagram-RJA3I4VV-D8ttmuE8.js → diagram-RJA3I4VV-DP7Dsnya.js} +1 -1
  36. package/dist/assets/{diagram-TYUNFSNR-CzC-8RZ0.js → diagram-TYUNFSNR-CfSFewUS.js} +1 -1
  37. package/dist/assets/{dist-CUJYGnnI.js → dist-CQNjnrld.js} +1 -1
  38. package/dist/assets/{ebnfDiagram-V4P3TTPT-9AAhU7In.js → ebnfDiagram-V4P3TTPT-DE3MZoJO.js} +1 -1
  39. package/dist/assets/{elk-QONTBCTQ-BgvnD1v_.js → elk-QONTBCTQ-p_aAQPHU.js} +1 -1
  40. package/dist/assets/{erDiagram-KYQIGQAA-RLtbiLlP.js → erDiagram-KYQIGQAA-DZO3vQrk.js} +1 -1
  41. package/dist/assets/{eventmodeling-K75KTNOO-DVY22vNZ.js → eventmodeling-K75KTNOO-BII9EPl5.js} +1 -1
  42. package/dist/assets/flowDiagram-IUGLLWBU-D9uPno39.js +1 -0
  43. package/dist/assets/{ganttDiagram-R7GPNTJY-hHiBGa9W.js → ganttDiagram-R7GPNTJY-BKiH3v2r.js} +1 -1
  44. package/dist/assets/{gitGraph-VSP46ZUC-Cvxay1wh.js → gitGraph-VSP46ZUC-oHL6cr73.js} +1 -1
  45. package/dist/assets/{gitGraphDiagram-PDYL7XHY-AOhlpSqg.js → gitGraphDiagram-PDYL7XHY-CqPVIEeo.js} +1 -1
  46. package/dist/assets/{index-CBLNQvK3.js → index-CSpirV16.js} +3 -3
  47. package/dist/assets/{info-OHQRW6UA-CJ8EUO1m.js → info-OHQRW6UA-Drrbnhpd.js} +1 -1
  48. package/dist/assets/{infoDiagram-MVDRKAX5-DOQu7oYI.js → infoDiagram-MVDRKAX5-DnQQFMAd.js} +1 -1
  49. package/dist/assets/{ishikawaDiagram-HPEHV2GE-DcVMvGmx.js → ishikawaDiagram-HPEHV2GE-DIpQlxRh.js} +1 -1
  50. package/dist/assets/{journeyDiagram-BOSHW7O7-luMeqEqz.js → journeyDiagram-BOSHW7O7-EBR9I5h1.js} +1 -1
  51. package/dist/assets/{kanban-definition-FNSA4HC5-KTZbmrW2.js → kanban-definition-FNSA4HC5-CdvNsjik.js} +1 -1
  52. package/dist/assets/{line-CmPHiOVm.js → line-CDLzgxDc.js} +1 -1
  53. package/dist/assets/{linear-DHYlKXBA.js → linear-DfXKql9u.js} +1 -1
  54. package/dist/assets/{mermaid-parser.core-BRDI-WhC.js → mermaid-parser.core-DnXGphT2.js} +3 -3
  55. package/dist/assets/{mermaid.core-DPTRaYLy.js → mermaid.core-h6fiCaSZ.js} +4 -4
  56. package/dist/assets/{mindmap-definition-6OY57UG3-70EN5Kw_.js → mindmap-definition-6OY57UG3-BiNfio-j.js} +1 -1
  57. package/dist/assets/{packet-JDAUHWVQ-CXWz83oS.js → packet-JDAUHWVQ-D76byv1S.js} +1 -1
  58. package/dist/assets/{pegDiagram-ECIUJNBX-z5UXKmp4.js → pegDiagram-ECIUJNBX-Drs8Pxdt.js} +1 -1
  59. package/dist/assets/{pie-XZMESJXO-rWI1HIQ7.js → pie-XZMESJXO-D_83z1lM.js} +1 -1
  60. package/dist/assets/{pieDiagram-GNMPY74T-Bh4Kv3NQ.js → pieDiagram-GNMPY74T-wvzIphSf.js} +1 -1
  61. package/dist/assets/{quadrantDiagram-D57K4MC3-wGM4Ckig.js → quadrantDiagram-D57K4MC3-B7EfMRWb.js} +1 -1
  62. package/dist/assets/{radar-ABXABTNO-CIJ92p5N.js → radar-ABXABTNO-Bs6dVdAR.js} +1 -1
  63. package/dist/assets/{railroad-I3PHUGI6-8bxua-Hf.js → railroad-I3PHUGI6-lKaN4miw.js} +1 -1
  64. package/dist/assets/{railroad-abnf-LNEFI6M7-B9KVI10m.js → railroad-abnf-LNEFI6M7-Bhh16b7Z.js} +1 -1
  65. package/dist/assets/{railroad-ebnf-DWYD2UWJ-Bt73rnyJ.js → railroad-ebnf-DWYD2UWJ-m6ybW-Y_.js} +1 -1
  66. package/dist/assets/{railroad-peg-JR7BVNR7-Cie82UPT.js → railroad-peg-JR7BVNR7-DNn72e7r.js} +1 -1
  67. package/dist/assets/{railroadDiagram-Y72NRBL7-C1nmi_tl.js → railroadDiagram-Y72NRBL7-DNeop1iZ.js} +1 -1
  68. package/dist/assets/{requirementDiagram-EQYSW5RA-BSianW40.js → requirementDiagram-EQYSW5RA-BVYZ2XYJ.js} +1 -1
  69. package/dist/assets/{sankeyDiagram-SY24JHO5-DIa11-h7.js → sankeyDiagram-SY24JHO5-B2MP51on.js} +1 -1
  70. package/dist/assets/{sequenceDiagram-BLEHNHQU-Df5rZKz2.js → sequenceDiagram-BLEHNHQU-BUvxstXG.js} +1 -1
  71. package/dist/assets/{src-dClg85lX.js → src-BrR0lqlG.js} +1 -1
  72. package/dist/assets/{stateDiagram-v2-A6IESKWZ-B15PTSvX.js → stateDiagram-v2-A6IESKWZ-Ba84OGKE.js} +1 -1
  73. package/dist/assets/{swimlanes-B3ENRKFQ-C9mgRCMi.js → swimlanes-B3ENRKFQ-BMSl2PNA.js} +1 -1
  74. package/dist/assets/swimlanesDiagram-AYQRV4AK-M1dmvq7m.js +8 -0
  75. package/dist/assets/{timeline-definition-JXYKGLGB-BDVf_mIv.js → timeline-definition-JXYKGLGB-DOdW-dxq.js} +1 -1
  76. package/dist/assets/{treeView-D4JQ5SDB-BbBK-25J.js → treeView-D4JQ5SDB-DZ9KQyn5.js} +1 -1
  77. package/dist/assets/{treemap-SAJKECNS-CEDX8apk.js → treemap-SAJKECNS-WEC5wtDV.js} +1 -1
  78. package/dist/assets/{usecaseDiagram-WPOLBNNX-D1QkwFF8.js → usecaseDiagram-WPOLBNNX-cldTztFL.js} +1 -1
  79. package/dist/assets/{vennDiagram-Q3Y3IDCW-5PeGZvEO.js → vennDiagram-Q3Y3IDCW-c41KySoY.js} +1 -1
  80. package/dist/assets/{wardley-7MLQ67FV-BatN94pO.js → wardley-7MLQ67FV-Bf9_aSSZ.js} +1 -1
  81. package/dist/assets/{wardleyDiagram-IVAIDKWA-DyCcDS3Y.js → wardleyDiagram-IVAIDKWA-BeKEvEBG.js} +1 -1
  82. package/dist/assets/{xychartDiagram-4URNHDU4-CbCXcRlg.js → xychartDiagram-4URNHDU4-tNkAxTxG.js} +1 -1
  83. package/dist/index.html +1 -1
  84. package/package.json +5 -1
  85. package/server/config.js +140 -14
  86. package/server/consensus.js +18 -13
  87. package/server/graph/modules_builder.js +290 -0
  88. package/server/graph/modules_catalog.js +288 -0
  89. package/server/graph/parser.js +3 -2
  90. package/server/graph/sources.js +165 -24
  91. package/server/graph/syntax.js +245 -28
  92. package/server/index.js +124 -61
  93. package/server/install.js +350 -10
  94. package/server/models_registry.js +34 -9
  95. package/server/scaffold.js +109 -9
  96. package/dist/assets/channel-BZ2_TTTJ.js +0 -1
  97. package/dist/assets/flowDiagram-IUGLLWBU-l7L5DTb5.js +0 -1
  98. package/dist/assets/swimlanesDiagram-AYQRV4AK-BGFq4t7O.js +0 -8
package/bin/knl-view.js CHANGED
@@ -1,17 +1,15 @@
1
1
  #!/usr/bin/env node
2
- import { parseArgs } from 'node:util'
2
+ import { parseArgs, promisify } from 'node:util'
3
3
  import path from 'node:path'
4
4
  import fs from 'node:fs'
5
5
  import readline from 'node:readline/promises'
6
6
  import { execFile, spawn } from 'node:child_process'
7
- import { promisify } from 'node:util'
8
7
  import os from 'node:os'
9
8
  import { fileURLToPath } from 'node:url'
10
9
  import { createKnowledgeServer, attachAnchorDB } from '../server/index.js'
11
- import { scaffoldBase, seedSkill, ensureGitignore } from '../server/scaffold.js'
10
+ import { scaffoldBase, seedSkill, ensureGitignore, seedAgentInstructions } from '../server/scaffold.js'
12
11
  import { buildGraphFromRoot } from '../server/graph/parser.js'
13
12
  import {
14
- claimId,
15
13
  deriveStatus,
16
14
  recordClaim,
17
15
  readClaimsIndex,
@@ -21,9 +19,20 @@ import {
21
19
  outboundConsensus,
22
20
  CONSENSUS_RELATIONS
23
21
  } from '../server/consensus.js'
24
- import { readProjectConfig, readGlobalConfig, projectSlug, anchordbClientOptions, resolveModel, resolveSettings, DEFAULT_MODEL } from '../server/config.js'
22
+ import { readProjectConfig, readGlobalConfig, projectSlug, anchordbClientOptions, resolveModel, resolveSettings, knlGlobalDir, globalConfigPath } from '../server/config.js'
25
23
  import { createAnchorWireClient, mirrorStillValid } from '../server/graph/anchordb_client.js'
26
24
  import { syncSourcesToAnchorDB, traceSources, purgeArchivedRecords } from '../server/graph/sources.js'
25
+ import {
26
+ parseModuleSpecifier,
27
+ buildModuleGraph,
28
+ syncModuleToAnchorDB,
29
+ traceModuleSymbol
30
+ } from '../server/graph/modules_builder.js'
31
+ import {
32
+ readModulesCatalog,
33
+ defaultEcosystemModel,
34
+ detectProjectManifests
35
+ } from '../server/graph/modules_catalog.js'
27
36
  import { readModelRegistry, registerModel, generateModelName, modelRegistryPath } from '../server/models_registry.js'
28
37
  import {
29
38
  latestRelease,
@@ -32,7 +41,15 @@ import {
32
41
  downloadFile,
33
42
  verifySha256,
34
43
  mergeGlobalAnchordb,
35
- detectContainerRuntime,
44
+ availableContainerRuntimes,
45
+ detectRunningDaemon,
46
+ inspectContainerStatus,
47
+ startContainer,
48
+ removeContainer,
49
+ detectGraphify,
50
+ checkDecisionSkillsStatus,
51
+ installDecisionSkills,
52
+ resolveAnchorPorts,
36
53
  GHCR_IMAGE,
37
54
  HTTP_PORT_DEFAULT,
38
55
  WIRE_PORT_DEFAULT,
@@ -91,6 +108,12 @@ Commands:
91
108
  "sources" model via the REST collector
92
109
  sources trace --target=<path> File lineage over the sources graph
93
110
  (--hops, --direction in|out|both)
111
+ modules build-graph <target> Build & sync external module graph into global
112
+ AnchorDB model (modules-<eco>) on-demand
113
+ Target: <eco>::<module_name> (e.g. npm::express)
114
+ modules trace <target> [sym] Trace calls/definitions for an external module/symbol
115
+ modules list [ecosystem] List locally indexed modules from ~/.knlview/modules
116
+ setup Bootstrap project in one command (scaffold base, skill, and models)
94
117
 
95
118
  Options:
96
119
  --port, -p <number> HTTP port (default: 8085, or knl-config.json)
@@ -99,11 +122,13 @@ Options:
99
122
  --anchordb <url> AnchorWire URL for this session, overrides the
100
123
  "anchordb" block in knl-config.json
101
124
  --method <method> Method for "install anchordb": binary | container
125
+ --runtime <engine> Container runtime for "install anchordb": docker | podman
102
126
  --token <text> Auth token for "install anchordb" (default: random)
103
127
  --direction <dir> Lineage direction for "graph trace": in | out | both
104
128
  (default: both; see README "Two-way lineage")
105
129
  --target <id> Target node id for the subgraph query
106
130
  --hops <number> Graph trace hop radius (default: 2)
131
+ --force Rebuild external module graph even if already indexed
107
132
  --init Scaffold a new base via the wizard (does not start the server)
108
133
  --structure <layout> Layout for non-interactive --init:
109
134
  standard | empty | custom (default: standard)
@@ -133,10 +158,12 @@ try {
133
158
  host: { type: 'string' },
134
159
  anchordb: { type: 'string' },
135
160
  method: { type: 'string' },
161
+ runtime: { type: 'string' },
136
162
  token: { type: 'string' },
137
163
  target: { type: 'string' },
138
164
  direction: { type: 'string' },
139
165
  hops: { type: 'string', default: '2' },
166
+ force: { type: 'boolean', default: false },
140
167
  init: { type: 'boolean', default: false },
141
168
  structure: { type: 'string', default: 'standard' },
142
169
  folders: { type: 'string', default: '' },
@@ -191,6 +218,12 @@ if (values.anchordb !== undefined && !settings.anchordb) {
191
218
  fail('--anchordb must be a wire:// or tcp:// URL, example: --anchordb=wire://127.0.0.1:7448?token=xxx')
192
219
  }
193
220
 
221
+ // The "setup" command: one-shot bootstrap for the project
222
+ if (positionals?.[0] === 'setup') {
223
+ await runSetupCommand({ values, settings, projectConfig, globalConfig })
224
+ process.exit(0)
225
+ }
226
+
194
227
  // The "model" command: manage the role → AnchorDB model name map. No other
195
228
  // code ever guesses names; everything resolves through the role in this map.
196
229
  const modelCommand = positionals?.[0] === 'model' ? positionals?.[1] : null
@@ -230,6 +263,18 @@ if (sourcesSub) {
230
263
  process.exit(0)
231
264
  }
232
265
 
266
+ // The "modules" command: on-demand external boundary graph in global AnchorDB
267
+ // modules models (modules-npm, modules-go, modules-cargo, modules-pypi).
268
+ const modulesSub = positionals?.[0] === 'modules' ? positionals?.[1] : null
269
+ if (modulesSub) {
270
+ try {
271
+ await runModulesCommand(modulesSub, positionals?.slice(2), { values, settings })
272
+ } catch (err) {
273
+ fail(`Modules command failed: ${err.message}`)
274
+ }
275
+ process.exit(0)
276
+ }
277
+
233
278
  // Register the project's whole models map to the global registry (updating
234
279
  // path/timestamp when this project owns them) and warn about names claimed
235
280
  // by other projects. This local registration needs no server connection.
@@ -280,6 +325,10 @@ if (values.init || values['seed-skill'] !== undefined) {
280
325
  process.exit(0)
281
326
  }
282
327
 
328
+ if (positionals && positionals.length > 0) {
329
+ fail(`Unknown command "${positionals[0]}".\n\nRun "knl-view --help" for available commands.`)
330
+ }
331
+
283
332
  const port = Number(settings.port)
284
333
  if (!Number.isInteger(port) || port < 1 || port > 65535) {
285
334
  fail(`--port must be a number 1-65535, got "${settings.port}".`)
@@ -305,7 +354,9 @@ const server = createKnowledgeServer({
305
354
  port,
306
355
  anchordb: settings.anchordb,
307
356
  consensusModel: resolveModel(settings.models, 'consensus'),
308
- sourcesModel: resolveModel(settings.models, 'sources')
357
+ sourcesModel: resolveModel(settings.models, 'sources'),
358
+ include: settings.include,
359
+ exclude: settings.exclude
309
360
  })
310
361
 
311
362
  server.on('error', (err) => {
@@ -359,6 +410,8 @@ server.listen(port, settings.host, () => {
359
410
  } else {
360
411
  process.stdout.write(` anchordb failed: ${result.reason} (viewer runs local only)\n`)
361
412
  }
413
+ }).catch((err) => {
414
+ process.stdout.write(` anchordb error: ${err.message} (viewer runs local only)\n`)
362
415
  })
363
416
  }
364
417
  }
@@ -428,6 +481,271 @@ async function runSetup() {
428
481
  }
429
482
  }
430
483
 
484
+ async function runSetupCommand({ values, settings, projectConfig, globalConfig }) {
485
+ const root = path.resolve(process.cwd(), values.path || settings.path || '.knowledges')
486
+ const interactive = Boolean(process.stdin.isTTY) && !values.yes
487
+ const rl = interactive ? readline.createInterface({ input: process.stdin, output: process.stdout }) : null
488
+
489
+ try {
490
+ process.stdout.write('knl-view setup: bootstrapping project ...\n')
491
+
492
+ // Scaffold base
493
+ const actions = await scaffoldBase({
494
+ root,
495
+ structure: values.structure || 'standard',
496
+ folders: values.folders ? values.folders.split(',').map((s) => s.trim()).filter(Boolean) : []
497
+ })
498
+ const createdCount = actions.filter((a) => a.status === 'created').length
499
+ const relRoot = path.relative(process.cwd(), root) || root
500
+ process.stdout.write(` [1/5] knowledge base: ${relRoot} (${createdCount > 0 ? 'created' : 'already present'})\n`)
501
+
502
+ // Audit and seed skill
503
+ const skillPath = path.join(process.cwd(), '.agents', 'skills', 'knl-view', 'SKILL.md')
504
+ let skillPresent = false
505
+ try {
506
+ await fs.promises.stat(skillPath)
507
+ skillPresent = true
508
+ } catch {}
509
+
510
+ if (skillPresent) {
511
+ process.stdout.write(` [2/5] agent skill: ${skillPath} (already present)\n`)
512
+ } else {
513
+ let wantSkill = true
514
+ if (interactive) {
515
+ wantSkill = await askYesNo(rl, 'Install knl-view agent skill to .agents/skills/knl-view?', true)
516
+ }
517
+ if (wantSkill) {
518
+ const skillResult = await seedSkill({ scope: values.dir || 'project', cwd: process.cwd() })
519
+ process.stdout.write(` [2/5] agent skill: ${skillResult.path} (${skillResult.status})\n`)
520
+ } else {
521
+ process.stdout.write(` [2/5] agent skill: skipped by user\n`)
522
+ }
523
+ }
524
+
525
+ // Audit and seed decision-history skill
526
+ const home = process.env.HOME || process.cwd()
527
+ const decisionStatus = await checkDecisionSkillsStatus({ cwd: process.cwd(), home })
528
+ const statusLabel =
529
+ decisionStatus.status === 'not_installed'
530
+ ? 'not installed'
531
+ : decisionStatus.status === 'needs_update'
532
+ ? `update available (current: ${decisionStatus.currentVersion || 'unknown'}, latest: ${decisionStatus.latestVersion})`
533
+ : 'already installed'
534
+
535
+ process.stdout.write(` [skill] decision-history: ${statusLabel}\n`)
536
+
537
+ if (decisionStatus.status === 'not_installed' || decisionStatus.status === 'needs_update') {
538
+ let wantDecisionSkill = true
539
+ if (interactive) {
540
+ wantDecisionSkill = await askYesNo(
541
+ rl,
542
+ 'Would you like to install/update the decision-history skill (rachmanzz/decisionskills)?',
543
+ true
544
+ )
545
+ }
546
+ if (wantDecisionSkill) {
547
+ let scope = 'project'
548
+ if (interactive) {
549
+ scope = await askChoice(
550
+ rl,
551
+ 'Scope for decision-history skill?',
552
+ [
553
+ ['project', 'per-project (.agents/skills/decision-history)'],
554
+ ['global', 'global (all agent terminals: agy, codex, opencode, claude)']
555
+ ],
556
+ 'project'
557
+ )
558
+ }
559
+ process.stdout.write(`Installing decision-history skill (${scope}) ...\n`)
560
+ await installDecisionSkills({ scope, cwd: process.cwd(), home, exec })
561
+ process.stdout.write(` [skill] decision-history: installed (${scope})\n`)
562
+ } else {
563
+ process.stdout.write(` [skill] decision-history: skipped by user\n`)
564
+ }
565
+ }
566
+
567
+ // Ensure gitignore
568
+ const giResult = await ensureGitignore({ cwd: process.cwd() })
569
+ process.stdout.write(` [3/5] gitignore: ${giResult.path} (${giResult.status})\n`)
570
+
571
+ // Daemon and container lifecycle
572
+ let currentGlobalConfig = globalConfig
573
+ let containerStatus = await inspectContainerStatus(exec)
574
+ let daemonMeta = await detectRunningDaemon(exec)
575
+
576
+ if (containerStatus.exists && !containerStatus.valid) {
577
+ let wantRecreate = true
578
+ if (interactive) {
579
+ wantRecreate = await askYesNo(
580
+ rl,
581
+ `AnchorDB container "${containerStatus.name}" is in an invalid state (${containerStatus.status}). Would you like to recreate it?`,
582
+ true
583
+ )
584
+ }
585
+ if (wantRecreate) {
586
+ process.stdout.write(`Removing invalid container "${containerStatus.name}" ...\n`)
587
+ try {
588
+ await removeContainer(exec, containerStatus.runtime, containerStatus.name)
589
+ } catch {}
590
+ await runInstallCommand('anchordb', {
591
+ values: { ...values, method: 'container' },
592
+ rl
593
+ })
594
+ const freshGlobal = await readGlobalConfig()
595
+ currentGlobalConfig = freshGlobal.config || currentGlobalConfig
596
+ daemonMeta = await detectRunningDaemon(exec)
597
+ }
598
+ } else if (containerStatus.exists && !containerStatus.running && containerStatus.valid) {
599
+ let wantStart = true
600
+ if (interactive) {
601
+ wantStart = await askYesNo(
602
+ rl,
603
+ `AnchorDB container "${containerStatus.name}" is stopped. Would you like to start it now?`,
604
+ true
605
+ )
606
+ }
607
+ if (wantStart) {
608
+ process.stdout.write(`Starting container "${containerStatus.name}" ...\n`)
609
+ await startContainer(exec, containerStatus.runtime, containerStatus.name)
610
+ await waitAnchorHealth()
611
+ daemonMeta = await detectRunningDaemon(exec)
612
+ if (daemonMeta) {
613
+ const updatedAnchor = {
614
+ ...(currentGlobalConfig?.anchordb || {}),
615
+ url: `wire://127.0.0.1:${daemonMeta.wire_port || WIRE_PORT_DEFAULT}`,
616
+ token: daemonMeta.token || currentGlobalConfig?.anchordb?.token || '',
617
+ sync: currentGlobalConfig?.anchordb?.sync !== false,
618
+ daemon: daemonMeta.daemon || daemonMeta.deamon,
619
+ deamon: daemonMeta.daemon || daemonMeta.deamon,
620
+ container_name: daemonMeta.container_name,
621
+ container_id: daemonMeta.container_id,
622
+ container_created_on: daemonMeta.container_created_on
623
+ }
624
+ await mergeGlobalAnchordb(home, updatedAnchor)
625
+ currentGlobalConfig = { ...currentGlobalConfig, anchordb: updatedAnchor }
626
+ process.stdout.write(` [info] global config updated with ${daemonMeta.daemon || daemonMeta.deamon} daemon metadata\n`)
627
+ }
628
+ }
629
+ } else if (!daemonMeta && !containerStatus.exists) {
630
+ const hasConfigured = Boolean(currentGlobalConfig?.anchordb?.url)
631
+ let wantInstall = Boolean(values.runtime)
632
+ if (!wantInstall && interactive && !hasConfigured) {
633
+ wantInstall = await askYesNo(
634
+ rl,
635
+ 'AnchorDB is not running. Would you like to create an AnchorDB container now?',
636
+ false
637
+ )
638
+ }
639
+ if (wantInstall) {
640
+ process.stdout.write('\nProvisioning AnchorDB container ...\n')
641
+ await runInstallCommand('anchordb', {
642
+ values: { ...values, method: 'container' },
643
+ rl
644
+ })
645
+ const freshGlobal = await readGlobalConfig()
646
+ currentGlobalConfig = freshGlobal.config || currentGlobalConfig
647
+ daemonMeta = await detectRunningDaemon(exec)
648
+ }
649
+ }
650
+
651
+ if (daemonMeta && currentGlobalConfig?.anchordb) {
652
+ const existing = currentGlobalConfig.anchordb
653
+ const daemonType = daemonMeta.daemon || daemonMeta.deamon || existing.daemon || existing.deamon
654
+ const updated = {
655
+ ...existing,
656
+ daemon: daemonType,
657
+ deamon: daemonType
658
+ }
659
+ if (daemonMeta.container_name) updated.container_name = daemonMeta.container_name
660
+ if (daemonMeta.container_id) updated.container_id = daemonMeta.container_id
661
+ if (daemonMeta.container_created_on) updated.container_created_on = daemonMeta.container_created_on
662
+
663
+ if (JSON.stringify(existing) !== JSON.stringify(updated)) {
664
+ await mergeGlobalAnchordb(home, updated)
665
+ currentGlobalConfig = { ...currentGlobalConfig, anchordb: updated }
666
+ }
667
+ }
668
+
669
+ let currentConfig = projectConfig || {}
670
+ const hasGlobalAnchor = Boolean(currentGlobalConfig?.anchordb)
671
+ const hasLocalAnchor = Boolean(projectConfig?.anchordb)
672
+ const hasAnchor = hasGlobalAnchor || hasLocalAnchor
673
+
674
+ if (hasAnchor) {
675
+ const slug = projectSlug(process.cwd())
676
+ const neededRoles = ['knowledges', 'sources']
677
+ let modelsUpdated = false
678
+ const nextModels = { ...(currentConfig.models || {}) }
679
+
680
+ for (const role of neededRoles) {
681
+ if (!nextModels[role]) {
682
+ const name = generateModelName()
683
+ await registerModel(name, { role, project: slug, cwd: process.cwd() })
684
+ nextModels[role] = name
685
+ modelsUpdated = true
686
+ }
687
+ }
688
+
689
+ if (modelsUpdated) {
690
+ currentConfig = { ...currentConfig, models: nextModels }
691
+ delete currentConfig.model
692
+ fs.writeFileSync(path.join(process.cwd(), 'knl-config.json'), JSON.stringify(currentConfig, null, 2) + '\n', 'utf8')
693
+ process.stdout.write(` [4/5] anchordb models: mapped knowledges (${nextModels.knowledges}) and sources (${nextModels.sources}) in knl-config.json\n`)
694
+ } else {
695
+ process.stdout.write(` [4/5] anchordb models: already mapped in knl-config.json\n`)
696
+ }
697
+
698
+ const targetAnchordb = {
699
+ ...(settings.anchordb || currentGlobalConfig?.anchordb || currentConfig?.anchordb),
700
+ model: nextModels.sources
701
+ }
702
+ const opts = anchordbClientOptions(targetAnchordb)
703
+ if (opts) {
704
+ try {
705
+ process.stdout.write(' syncing project sources to AnchorDB ...\n')
706
+ const client = await createAnchorWireClient(opts)
707
+ const result = await syncSourcesToAnchorDB(client, process.cwd(), {
708
+ labelDir: settings.labelDir,
709
+ include: settings.include,
710
+ exclude: settings.exclude
711
+ })
712
+ client.close()
713
+ process.stdout.write(` sources sync complete: ${result.files} files, ${result.edges} edges\n`)
714
+ } catch (err) {
715
+ process.stdout.write(` (sources sync deferred: ${err.message})\n`)
716
+ }
717
+ }
718
+ } else {
719
+ process.stdout.write(' [4/5] anchordb: inactive (run "knl-view install anchordb" to enable)\n')
720
+ }
721
+
722
+ // Seed agent rules
723
+ let wantAgentRules = true
724
+ if (interactive) {
725
+ wantAgentRules = await askYesNo(
726
+ rl,
727
+ 'Would you like to configure KNL-View as your primary code intelligence tool in your agent rule files (AGENTS.md, etc.)?',
728
+ true
729
+ )
730
+ }
731
+ if (wantAgentRules) {
732
+ const hasGraphify = await detectGraphify(exec)
733
+ const ruleActions = await seedAgentInstructions({ cwd: process.cwd(), home, hasGraphify })
734
+ for (const action of ruleActions) {
735
+ const rel = path.relative(process.cwd(), action.path)
736
+ const display = rel.startsWith('..') ? action.path : rel
737
+ process.stdout.write(` [5/5] agent rules: ${display} (${action.status}${hasGraphify ? ', graphify fallback enabled' : ''})\n`)
738
+ }
739
+ } else {
740
+ process.stdout.write(' [5/5] agent rules: skipped by user\n')
741
+ }
742
+
743
+ process.stdout.write('\nSetup complete! You can now run:\n knl-view # start the viewer\n knl-view graph trace --target=README.md\n')
744
+ } finally {
745
+ if (rl) rl.close()
746
+ }
747
+ }
748
+
431
749
  function report(actions) {
432
750
  for (const action of actions) {
433
751
  const label = action.kind === 'dir' ? 'folder ' : 'file '
@@ -479,13 +797,14 @@ async function askYesNo(rl, question, fallback) {
479
797
  // prefers it): ghcr.io container via docker/podman. After install, the
480
798
  // "anchordb" block is written to the global config so every project can
481
799
  // connect right away.
482
- async function runInstallCommand(target, { values }) {
800
+ async function runInstallCommand(target, { values, rl: externalRl = null }) {
483
801
  if (target !== 'anchordb') {
484
802
  fail(`Unknown install target: ${target || '(none)'}\nAvailable: knl-view install anchordb`)
485
803
  }
486
804
  const nativeOk = process.platform === 'linux' || process.platform === 'darwin'
487
805
  const interactive = Boolean(process.stdin.isTTY) && !values.yes
488
- const rl = interactive ? readline.createInterface({ input: process.stdin, output: process.stdout }) : null
806
+ const ownsRl = !externalRl && interactive
807
+ const rl = externalRl || (interactive ? readline.createInterface({ input: process.stdin, output: process.stdout }) : null)
489
808
 
490
809
  let method = values.method
491
810
  if (!method) {
@@ -510,6 +829,36 @@ async function runInstallCommand(target, { values }) {
510
829
  // fs in this module is the callback API; installer steps need the promise version.
511
830
  const fsp = fs.promises
512
831
  let started = false
832
+ let daemonType = method === 'binary' ? 'binary' : null
833
+ let containerMetadata = null
834
+
835
+ const portInfo = await resolveAnchorPorts({
836
+ desiredWirePort: WIRE_PORT_DEFAULT,
837
+ desiredHttpPort: HTTP_PORT_DEFAULT
838
+ })
839
+
840
+ let wirePort = portInfo.wirePort
841
+ let httpPort = portInfo.httpPort
842
+
843
+ if (!portInfo.wireAvailable || !portInfo.httpAvailable) {
844
+ const conflictPort = !portInfo.wireAvailable ? wirePort : httpPort
845
+ const suggested = !portInfo.wireAvailable ? portInfo.suggestedWirePort : portInfo.suggestedHttpPort
846
+ let useSuggested = true
847
+ if (interactive) {
848
+ useSuggested = await askYesNo(
849
+ rl,
850
+ `Port ${conflictPort} is already in use. Would you like to use port ${suggested} instead?`,
851
+ true
852
+ )
853
+ }
854
+ if (useSuggested) {
855
+ wirePort = portInfo.suggestedWirePort
856
+ httpPort = portInfo.suggestedHttpPort
857
+ process.stdout.write(` [port] selected wire port ${wirePort} and HTTP port ${httpPort}\n`)
858
+ } else {
859
+ fail(`Port ${conflictPort} is in use and alternative port was declined.`)
860
+ }
861
+ }
513
862
 
514
863
  try {
515
864
  if (method === 'binary') {
@@ -517,60 +866,82 @@ async function runInstallCommand(target, { values }) {
517
866
  fail('The binary method needs Linux or macOS.\nUse --method=container.')
518
867
  }
519
868
  const { version } = await latestRelease()
869
+ const knlViewDir = knlGlobalDir(home)
520
870
  const asset = releaseAssetName(version)
521
871
  const workdir = path.join(os.tmpdir(), `knlview-install-${Date.now()}`)
522
872
  await fsp.mkdir(workdir, { recursive: true })
523
- const binDir = path.join(home, '.knlview', 'bin')
524
-
525
- process.stdout.write(`Downloading ${asset} (v${version}) ...\n`)
526
- const archive = path.join(workdir, asset)
527
- await downloadFile(`https://github.com/rachmanzz/anchordb/releases/download/v${version}/${asset}`, archive)
528
- const sums = path.join(workdir, 'SHA256SUMS.txt')
529
- await downloadFile(`https://github.com/rachmanzz/anchordb/releases/download/v${version}/SHA256SUMS.txt`, sums)
530
- await verifySha256(archive, await fsp.readFile(sums, 'utf8'))
531
- process.stdout.write(' SHA256 matches SHA256SUMS.txt\n')
532
-
533
- process.stdout.write('Extracting ...\n')
534
- await exec('tar', ['-xzf', archive, '-C', workdir])
535
- await fsp.mkdir(binDir, { recursive: true })
536
- for (const bin of ['anchordb-server', 'anchordb']) {
537
- const target2 = path.join(binDir, bin)
538
- await fsp.copyFile(path.join(workdir, bin), target2)
539
- await fsp.chmod(target2, 0o755)
540
- }
541
- process.stdout.write(` installed to ${binDir}\n`)
873
+ try {
874
+ const binDir = path.join(knlViewDir, 'bin')
875
+
876
+ process.stdout.write(`Downloading ${asset} (v${version}) ...\n`)
877
+ const archive = path.join(workdir, asset)
878
+ await downloadFile(`https://github.com/rachmanzz/anchordb/releases/download/v${version}/${asset}`, archive)
879
+ const sums = path.join(workdir, 'SHA256SUMS.txt')
880
+ await downloadFile(`https://github.com/rachmanzz/anchordb/releases/download/v${version}/SHA256SUMS.txt`, sums)
881
+ await verifySha256(archive, await fsp.readFile(sums, 'utf8'))
882
+ process.stdout.write(' SHA256 matches SHA256SUMS.txt\n')
883
+
884
+ process.stdout.write('Extracting ...\n')
885
+ await exec('tar', ['-xzf', archive, '-C', workdir])
886
+ await fsp.mkdir(binDir, { recursive: true, mode: 0o700 })
887
+ for (const bin of ['anchordb-server', 'anchordb']) {
888
+ const target2 = path.join(binDir, bin)
889
+ await fsp.copyFile(path.join(workdir, bin), target2)
890
+ await fsp.chmod(target2, 0o755)
891
+ }
892
+ process.stdout.write(` installed to ${binDir}\n`)
893
+ } finally {
894
+ await fsp.rm(workdir, { recursive: true, force: true }).catch(() => {})
895
+ }
542
896
 
543
897
  const runNow = interactive
544
898
  ? await askYesNo(rl, 'Start the AnchorDB server now?', true)
545
899
  : true
546
900
  if (runNow) {
547
- const dataDir = path.join(home, '.knlview', 'anchordb-data')
901
+ const dataDir = path.join(knlViewDir, 'anchordb-data')
548
902
  await fsp.mkdir(dataDir, { recursive: true })
549
- const logFd = await fsp.open(path.join(home, '.knlview', 'anchordb.log'), 'a')
903
+ const logFd = await fsp.open(path.join(knlViewDir, 'anchordb.log'), 'a')
550
904
  const child = spawn(path.join(binDir, 'anchordb-server'), [], {
551
905
  env: {
552
906
  ...process.env,
553
907
  ANCHORDB_AUTH_TOKEN: token,
554
908
  ANCHORDB_DATA_DIR: dataDir,
555
- ANCHORDB_PORT: String(HTTP_PORT_DEFAULT),
556
- ANCHORDB_WIRE_PORT: String(WIRE_PORT_DEFAULT)
909
+ ANCHORDB_PORT: String(httpPort),
910
+ ANCHORDB_WIRE_PORT: String(wirePort)
557
911
  },
558
912
  detached: true,
559
913
  stdio: ['ignore', logFd.fd, logFd.fd]
560
914
  })
561
915
  child.unref()
562
916
  logFd.close()
563
- await waitAnchorHealth()
917
+ await waitAnchorHealth(httpPort)
564
918
  started = true
565
- process.stdout.write(` server running (log: ${path.join(home, '.knlview', 'anchordb.log')})\n`)
919
+ process.stdout.write(` server running (log: ${path.join(knlViewDir, 'anchordb.log')})\n`)
566
920
  }
567
921
  } else {
568
- const runtime = await detectContainerRuntime(async (cmd) => {
922
+ const preferredRuntime = values.runtime
923
+ if (preferredRuntime && !['docker', 'podman'].includes(preferredRuntime)) {
924
+ fail(`--runtime must be docker or podman, got "${preferredRuntime}".`)
925
+ }
926
+ const which = async (cmd) => {
569
927
  try { await exec(cmd, ['--version']); return true } catch { return false }
570
- })
571
- if (!runtime) {
928
+ }
929
+ const available = await availableContainerRuntimes(which)
930
+ if (!available.length) {
572
931
  fail('docker/podman not found.\nInstall one first: Docker Desktop (macOS/Windows) or podman.')
573
932
  }
933
+ let runtime = preferredRuntime
934
+ if (!runtime) {
935
+ if (available.length === 1) {
936
+ runtime = available[0]
937
+ } else {
938
+ runtime = interactive
939
+ ? await askChoice(rl, 'Container runtime to use?', available.map((r) => [r, r]), available[0])
940
+ : available[0]
941
+ }
942
+ } else if (!available.includes(runtime)) {
943
+ fail(`Runtime "${runtime}" was requested via --runtime, but it is not installed or available on this system.`)
944
+ }
574
945
  const { version } = await latestRelease()
575
946
  const image = `${GHCR_IMAGE}:${version}`
576
947
  process.stdout.write(`Pulling ${image} via ${runtime} ...\n`)
@@ -583,14 +954,19 @@ async function runInstallCommand(target, { values }) {
583
954
  fail(`Container "${CONTAINER_NAME}" already exists.\nRemove it first if you really want to reinstall: ${runtime} rm -f ${CONTAINER_NAME}`)
584
955
  }
585
956
 
586
- const dataDir = path.join(home, '.knlview', 'anchordb-data')
587
- await fsp.mkdir(dataDir, { recursive: true })
588
- await fsp.chmod(dataDir, 0o777)
957
+ const knlViewDir = knlGlobalDir(home)
958
+ await fsp.mkdir(knlViewDir, { recursive: true, mode: 0o700 })
959
+ try { await fsp.chmod(knlViewDir, 0o700) } catch {}
960
+ const dataDir = path.join(knlViewDir, 'anchordb-data')
961
+ await fsp.mkdir(dataDir, { recursive: true, mode: 0o770 })
962
+ try { await fsp.chmod(dataDir, 0o770) } catch {}
589
963
  const baseArgs = ['run', '-d', '--name', CONTAINER_NAME,
590
- '-p', `${HTTP_PORT_DEFAULT}:${HTTP_PORT_DEFAULT}`,
591
- '-p', `${WIRE_PORT_DEFAULT}:${WIRE_PORT_DEFAULT}`,
964
+ '-p', `${httpPort}:${httpPort}`,
965
+ '-p', `${wirePort}:${wirePort}`,
592
966
  '-e', `ANCHORDB_AUTH_TOKEN=${token}`,
593
- '-e', 'ANCHORDB_DATA_DIR=/data']
967
+ '-e', 'ANCHORDB_DATA_DIR=/data',
968
+ '-e', `ANCHORDB_PORT=${httpPort}`,
969
+ '-e', `ANCHORDB_WIRE_PORT=${wirePort}`]
594
970
  // The image runs as user "anchordb" (not root). Rootless podman maps
595
971
  // that uid to a different host uid, so data files owned by the host
596
972
  // user become unreadable ("permission denied"). keep-id runs the
@@ -598,27 +974,53 @@ async function runInstallCommand(target, { values }) {
598
974
  // container can share the folder. Docker (macOS/Windows) runs in a VM
599
975
  // and its bind mounts are unaffected, hence no flag here.
600
976
  if (runtime === 'podman') baseArgs.push('--userns=keep-id')
977
+ daemonType = runtime
978
+ let containerId = null
979
+ let containerCreatedOn = new Date().toISOString()
601
980
  process.stdout.write(`Starting container ...\n`)
602
981
  try {
603
- await exec(runtime, [...baseArgs, '-v', `${dataDir}:/data:Z`, image])
982
+ const { stdout } = await exec(runtime, [...baseArgs, '-v', `${dataDir}:/data:Z`, image])
983
+ containerId = stdout.trim().split('\n')[0]
604
984
  } catch {
605
985
  // Rootless/sandbox setups sometimes reject volume binds; without a
606
986
  // volume the data dies with the container, so we say so plainly.
607
987
  process.stdout.write(' volume could not be mounted; data is ephemeral inside the container\n')
608
- await exec(runtime, [...baseArgs, image])
988
+ const { stdout } = await exec(runtime, [...baseArgs, image])
989
+ containerId = stdout.trim().split('\n')[0]
609
990
  }
610
- await waitAnchorHealth()
991
+ try {
992
+ const { stdout: idOut } = await exec(runtime, ['inspect', '--format', '{{.Id}}', CONTAINER_NAME])
993
+ if (idOut.trim()) containerId = idOut.trim()
994
+ const { stdout: createdOut } = await exec(runtime, ['inspect', '--format', '{{.Created}}', CONTAINER_NAME])
995
+ if (createdOut.trim()) containerCreatedOn = createdOut.trim()
996
+ } catch {
997
+ // fallback to captured containerId and timestamp
998
+ }
999
+ containerMetadata = {
1000
+ container_created_on: containerCreatedOn,
1001
+ container_name: CONTAINER_NAME,
1002
+ container_id: containerId
1003
+ }
1004
+ await waitAnchorHealth(httpPort)
611
1005
  started = true
612
1006
  }
613
1007
  } catch (err) {
614
1008
  fail(`Install failed: ${err.message || err}`)
615
1009
  }
616
1010
 
617
- const before = await mergeGlobalAnchordb(home, {
618
- url: `wire://127.0.0.1:${WIRE_PORT_DEFAULT}`,
1011
+ const anchordbBlock = {
1012
+ url: `wire://127.0.0.1:${wirePort}`,
619
1013
  token,
620
- sync: true
621
- })
1014
+ sync: true,
1015
+ deamon: daemonType
1016
+ }
1017
+ if (containerMetadata) {
1018
+ anchordbBlock.container_created_on = containerMetadata.container_created_on
1019
+ anchordbBlock.container_name = containerMetadata.container_name
1020
+ anchordbBlock.container_id = containerMetadata.container_id
1021
+ }
1022
+
1023
+ const before = await mergeGlobalAnchordb(home, anchordbBlock)
622
1024
  if (before) {
623
1025
  process.stdout.write(' (old anchordb block in the global config overwritten; new token applied)\n')
624
1026
  }
@@ -626,10 +1028,10 @@ async function runInstallCommand(target, { values }) {
626
1028
  process.stdout.write(
627
1029
  `\nAnchorDB ready (optional layer: projects without an anchordb block\n` +
628
1030
  ` in the global config still run fully local).\n` +
629
- ` HTTP http://127.0.0.1:${HTTP_PORT_DEFAULT}/health${started ? ' (already responding)' : ''}\n` +
630
- ` wire wire://127.0.0.1:${WIRE_PORT_DEFAULT}\n` +
631
- ` token ${token.slice(0, 8)}... (full value in ~/.knlview/knl-config.json)\n` +
632
- ` config ${home}/.knlview/knl-config.json\n\n` +
1031
+ ` HTTP http://127.0.0.1:${httpPort}/health${started ? ' (already responding)' : ''}\n` +
1032
+ ` wire wire://127.0.0.1:${wirePort}\n` +
1033
+ ` token ${token.slice(0, 8)}... (full value in ${globalConfigPath(home)})\n` +
1034
+ ` config ${globalConfigPath(home)}\n\n` +
633
1035
  `Next steps (if this project should use sync):\n` +
634
1036
  ` 1. In your project: knl-view model new knowledges\n` +
635
1037
  ` 2. knl-view, the "anchordb connected" line appears once the graph is in sync\n`
@@ -640,19 +1042,20 @@ async function runInstallCommand(target, { values }) {
640
1042
  ` ~/.knlview/bin/anchordb-server (env: ANCHORDB_AUTH_TOKEN, ANCHORDB_DATA_DIR)\n`
641
1043
  )
642
1044
  }
1045
+ if (ownsRl && rl) rl.close()
643
1046
  }
644
1047
 
645
- async function waitAnchorHealth(attempts = 30) {
1048
+ async function waitAnchorHealth(httpPort = HTTP_PORT_DEFAULT, attempts = 30) {
646
1049
  for (let i = 0; i < attempts; i++) {
647
1050
  try {
648
- const res = await fetch(`http://127.0.0.1:${HTTP_PORT_DEFAULT}/health`)
1051
+ const res = await fetch(`http://127.0.0.1:${httpPort}/health`, { signal: AbortSignal.timeout(2000) })
649
1052
  if (res.ok) return
650
1053
  } catch {
651
1054
  // not ready yet, retry
652
1055
  }
653
1056
  await new Promise((resolve) => setTimeout(resolve, 500))
654
1057
  }
655
- throw new Error(`server not responding at http://127.0.0.1:${HTTP_PORT_DEFAULT}/health within ${attempts / 2} seconds, check the anchordb log`)
1058
+ throw new Error(`server not responding at http://127.0.0.1:${httpPort}/health within ${attempts / 2} seconds, check the anchordb log`)
656
1059
  }
657
1060
 
658
1061
  async function containerExists(runtime) {
@@ -793,7 +1196,11 @@ async function runSourcesCommand(sub, values, { settings }) {
793
1196
  const projectRoot = process.cwd()
794
1197
 
795
1198
  if (sub === 'sync') {
796
- const result = await syncSourcesToAnchorDB(client, projectRoot, { labelDir: settings.labelDir })
1199
+ const result = await syncSourcesToAnchorDB(client, projectRoot, {
1200
+ labelDir: settings.labelDir,
1201
+ include: settings.include,
1202
+ exclude: settings.exclude
1203
+ })
797
1204
  const parts = [
798
1205
  `${result.files} files`,
799
1206
  `${result.symbols} symbols`,
@@ -870,6 +1277,132 @@ async function runSourcesCollector(base, model, { quiet }) {
870
1277
  }
871
1278
  }
872
1279
 
1280
+ // The "modules" subcommands: on-demand external boundary graph in global
1281
+ // AnchorDB modules models (modules-npm, modules-go, modules-cargo, modules-pypi).
1282
+ async function runModulesCommand(sub, args, { values, settings }) {
1283
+ if (sub === 'list') {
1284
+ const ecoFilter = args?.[0]?.toLowerCase()
1285
+ const ecosystems = ecoFilter ? [ecoFilter] : ['npm', 'go', 'cargo', 'pypi']
1286
+ const all = {}
1287
+ for (const eco of ecosystems) {
1288
+ all[eco] = await readModulesCatalog(eco)
1289
+ }
1290
+ process.stdout.write(JSON.stringify(all, null, 2) + '\n')
1291
+ return
1292
+ }
1293
+
1294
+ if (sub === 'build-graph' || sub === 'build') {
1295
+ const target = args?.[0] || values.target
1296
+ if (!target) {
1297
+ fail('Target is required:\n knl-view modules build-graph <ecosystem>::<module_name> [--force]')
1298
+ }
1299
+ const parsed = parseModuleSpecifier(target)
1300
+ if (!parsed) {
1301
+ fail(`Invalid module target "${target}".\nExpected format: <ecosystem>::<module_name> (e.g. npm::express, go::github.com/gin-gonic/gin)`)
1302
+ }
1303
+ const { ecosystem, moduleName } = parsed
1304
+
1305
+ if (!settings.anchordb) {
1306
+ fail('External modules graphs live in AnchorDB, which is not configured.\n' +
1307
+ 'Run "knl-view install anchordb" first (optional layer).')
1308
+ }
1309
+
1310
+ const model = defaultEcosystemModel(ecosystem)
1311
+ const base = anchordbClientOptions(settings.anchordb)
1312
+
1313
+ if (!values.force) {
1314
+ const catalog = await readModulesCatalog(ecosystem)
1315
+ const manifestVersions = await detectProjectManifests(process.cwd())
1316
+ const expectedVersion = manifestVersions[ecosystem]?.[moduleName]
1317
+ if (expectedVersion) {
1318
+ const key = `${moduleName}@${expectedVersion}`
1319
+ if (catalog[key]?.syncedAt) {
1320
+ process.stdout.write(`module ${ecosystem}::${key} is already synced in model ${model} (${catalog[key].symbols.length} symbols). Use --force to rebuild.\n`)
1321
+ return
1322
+ }
1323
+ }
1324
+ }
1325
+
1326
+ process.stdout.write(`building graph for ${ecosystem}::${moduleName} ...\n`)
1327
+ const moduleGraph = await buildModuleGraph(ecosystem, moduleName, { projectRoot: process.cwd() })
1328
+
1329
+ const client = await createAnchorWireClient({ ...base, model })
1330
+ try {
1331
+ const result = await syncModuleToAnchorDB(client, moduleGraph)
1332
+ process.stdout.write(`module synced: ${ecosystem}::${moduleGraph.name}@${moduleGraph.version} -> model "${model}" (${result.symbols} symbols, ${result.edges} edges, +${result.linked} linked)\n`)
1333
+ } finally {
1334
+ client.close()
1335
+ }
1336
+ return
1337
+ }
1338
+
1339
+ if (sub === 'trace') {
1340
+ const rawTarget = args?.[0] || values.target
1341
+ if (!rawTarget) {
1342
+ fail('Target is required:\n knl-view modules trace <ecosystem>::<module_name> [symbol] [--hops=2] [--direction=in|out|both]')
1343
+ }
1344
+
1345
+ let ecosystem, symbolId
1346
+ if (rawTarget.startsWith('ext:')) {
1347
+ const m = rawTarget.match(/^ext:([a-z]+)\//)
1348
+ if (!m) fail(`Cannot determine ecosystem from node ID "${rawTarget}".`)
1349
+ ecosystem = m[1]
1350
+ symbolId = rawTarget
1351
+ } else {
1352
+ const parsed = parseModuleSpecifier(rawTarget)
1353
+ if (!parsed) {
1354
+ fail(`Invalid module target "${rawTarget}".\nExpected format: <ecosystem>::<module_name> [symbol]`)
1355
+ }
1356
+ ecosystem = parsed.ecosystem
1357
+ const catalog = await readModulesCatalog(ecosystem)
1358
+ const subSymbol = args?.[1]
1359
+ const entryKey = Object.keys(catalog).find((k) => k.startsWith(parsed.moduleName + '@') || k === parsed.moduleName)
1360
+ if (!entryKey) {
1361
+ fail(`Module "${rawTarget}" is not indexed in catalog ~/.knlview/modules/${ecosystem}.json.\nRun "knl-view modules build-graph ${rawTarget}" first.`)
1362
+ }
1363
+ const entry = catalog[entryKey]
1364
+ symbolId = subSymbol ? `${entry.nodeId}#${subSymbol}` : entry.nodeId
1365
+ }
1366
+
1367
+ if (!settings.anchordb) {
1368
+ fail('The modules graph lives in AnchorDB, which is not configured.\n' +
1369
+ 'Run "knl-view install anchordb" first.')
1370
+ }
1371
+
1372
+ const model = defaultEcosystemModel(ecosystem)
1373
+ const base = anchordbClientOptions(settings.anchordb)
1374
+ const client = await createAnchorWireClient({ ...base, model })
1375
+ try {
1376
+ let direction = 'both'
1377
+ if (values.direction !== undefined) {
1378
+ if (!['in', 'out', 'both'].includes(values.direction)) {
1379
+ fail(`--direction must be in, out, or both, got "${values.direction}".`)
1380
+ }
1381
+ direction = values.direction
1382
+ }
1383
+ const hops = Number.parseInt(values.hops || '2', 10)
1384
+ const maxHops = Number.isInteger(hops) && hops > 0 ? hops : 2
1385
+ const results = await traceModuleSymbol(client, symbolId, { direction, maxHops })
1386
+ process.stdout.write(JSON.stringify({
1387
+ center: symbolId,
1388
+ model,
1389
+ direction,
1390
+ maxHops,
1391
+ trace: results
1392
+ }, null, 2) + '\n')
1393
+ } finally {
1394
+ client.close()
1395
+ }
1396
+ return
1397
+ }
1398
+
1399
+ fail('Unknown modules subcommand.\n' +
1400
+ 'Usage:\n' +
1401
+ ' knl-view modules build-graph <ecosystem>::<module_name> [--force]\n' +
1402
+ ' knl-view modules trace <ecosystem>::<module_name> [symbol] [--hops=2] [--direction=in|out|both]\n' +
1403
+ ' knl-view modules list [ecosystem]')
1404
+ }
1405
+
873
1406
  function fail(message) {
874
1407
  process.stderr.write(`knl-view: ${message}\n`)
875
1408
  process.exit(1)