@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.
Files changed (115) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +483 -2
  3. package/bin/knl-view.js +1320 -0
  4. package/dist/assets/abnfDiagram-WEYOIA3C-hoSbsMOz.js +1 -0
  5. package/dist/assets/arc-g87Np6E5.js +1 -0
  6. package/dist/assets/architecture-WOLXFQ4H-DOIq-0IA.js +1 -0
  7. package/dist/assets/architectureDiagram-ZHVYKT53-Dxwe8n7m.js +36 -0
  8. package/dist/assets/array-BifhSqXX.js +1 -0
  9. package/dist/assets/blockDiagram-RJAE7QA3-BnGng59Z.js +140 -0
  10. package/dist/assets/c4Diagram-XDPPPE54-C5LjqIIo.js +38 -0
  11. package/dist/assets/channel-BVWkRFBw.js +1 -0
  12. package/dist/assets/chunk-2E7WHVMD-BJ1dyGLq.js +88 -0
  13. package/dist/assets/chunk-2Q5K7J3B-C1jixKkw.js +1 -0
  14. package/dist/assets/chunk-4EMNKR2T-DXF02Sgw.js +1 -0
  15. package/dist/assets/chunk-55XCU76K-CM1akAbu.js +1 -0
  16. package/dist/assets/chunk-5VM5RSS4-ZNzvKenW.js +15 -0
  17. package/dist/assets/chunk-7S6XSTFO-BOUjqldL.js +2 -0
  18. package/dist/assets/chunk-AQDTSKZB-C4isdNLA.js +72 -0
  19. package/dist/assets/chunk-DSNLSHJZ-CbOTkSVM.js +129 -0
  20. package/dist/assets/chunk-DUW6YSOI-Dgx8z5s3.js +1 -0
  21. package/dist/assets/chunk-HAGFOTED-CRfnketR.js +62 -0
  22. package/dist/assets/chunk-HNE4Q36P-rxlpGUU8.js +1 -0
  23. package/dist/assets/chunk-J5ZVWO5B-oDCd-gWO.js +1 -0
  24. package/dist/assets/chunk-JWPE2WC7-DVXcaiue.js +1 -0
  25. package/dist/assets/chunk-NGNAAXSQ-C2Wy9Xmz.js +136 -0
  26. package/dist/assets/chunk-NQOKRVIF-BOYCgTmV.js +213 -0
  27. package/dist/assets/chunk-UHW7PFBP-Cnogp1Fb.js +1 -0
  28. package/dist/assets/chunk-VQ5W62QI-Cu1WY5Uo.js +1 -0
  29. package/dist/assets/chunk-WQQL4CDK-BJc3FPE2.js +10 -0
  30. package/dist/assets/chunk-XXDRQBXY-BlPj4H7s.js +1 -0
  31. package/dist/assets/chunk-Y2CYZVJY-DsF7k-Jl.js +1 -0
  32. package/dist/assets/chunk-YZO67HFG-V85H31x4.js +1 -0
  33. package/dist/assets/chunk-ZAUX2LTX-C3XKXW09.js +1 -0
  34. package/dist/assets/chunk-ZCHMQSOR-B0mmpEgW.js +2 -0
  35. package/dist/assets/classDiagram-v2-4MYWR3UA-oMd7YaHP.js +217 -0
  36. package/dist/assets/cose-bilkent-JH36ORCC-J0kSVsDf.js +1 -0
  37. package/dist/assets/cynefin-EF2NZ3EQ-DK2TNhel.js +1 -0
  38. package/dist/assets/cynefinDiagram-WF24UDRP-DDvKgieA.js +62 -0
  39. package/dist/assets/cytoscape.esm-BegBNzOV.js +321 -0
  40. package/dist/assets/dagre-NK4GIZVX-Ca2Fe-i-.js +4 -0
  41. package/dist/assets/defaultLocale-BFoDCU3G.js +1 -0
  42. package/dist/assets/diagram-CBK3MWTC-L6dmeQ2V.js +24 -0
  43. package/dist/assets/diagram-I5XIIV2D-DhG8R-v5.js +24 -0
  44. package/dist/assets/diagram-ODIL2UCW-BpYYbYBT.js +30 -0
  45. package/dist/assets/diagram-PFBEJUXE-DKQpHSsC.js +3 -0
  46. package/dist/assets/diagram-RJA3I4VV-DP7Dsnya.js +41 -0
  47. package/dist/assets/diagram-TYUNFSNR-CfSFewUS.js +200 -0
  48. package/dist/assets/dist-CQNjnrld.js +1 -0
  49. package/dist/assets/ebnfDiagram-V4P3TTPT-DE3MZoJO.js +1 -0
  50. package/dist/assets/elk-QONTBCTQ-p_aAQPHU.js +27 -0
  51. package/dist/assets/erDiagram-KYQIGQAA-DZO3vQrk.js +99 -0
  52. package/dist/assets/eventmodeling-K75KTNOO-BII9EPl5.js +1 -0
  53. package/dist/assets/flowDiagram-IUGLLWBU-D9uPno39.js +1 -0
  54. package/dist/assets/ganttDiagram-R7GPNTJY-BKiH3v2r.js +292 -0
  55. package/dist/assets/gitGraph-VSP46ZUC-oHL6cr73.js +1 -0
  56. package/dist/assets/gitGraphDiagram-PDYL7XHY-CqPVIEeo.js +106 -0
  57. package/dist/assets/graphlib-DS17s2tU.js +1 -0
  58. package/dist/assets/index-CSpirV16.js +76 -0
  59. package/dist/assets/index-DLgMsUs2.css +1 -0
  60. package/dist/assets/info-OHQRW6UA-Drrbnhpd.js +1 -0
  61. package/dist/assets/infoDiagram-MVDRKAX5-DnQQFMAd.js +2 -0
  62. package/dist/assets/init-C-OQMol4.js +1 -0
  63. package/dist/assets/ishikawaDiagram-HPEHV2GE-DIpQlxRh.js +70 -0
  64. package/dist/assets/journeyDiagram-BOSHW7O7-EBR9I5h1.js +139 -0
  65. package/dist/assets/kanban-definition-FNSA4HC5-CdvNsjik.js +89 -0
  66. package/dist/assets/katex-B_JcIwqj.js +257 -0
  67. package/dist/assets/line-CDLzgxDc.js +1 -0
  68. package/dist/assets/linear-DfXKql9u.js +1 -0
  69. package/dist/assets/mermaid-parser.core-DnXGphT2.js +7 -0
  70. package/dist/assets/mermaid.core-h6fiCaSZ.js +44 -0
  71. package/dist/assets/mindmap-definition-6OY57UG3-BiNfio-j.js +98 -0
  72. package/dist/assets/ordinal-BDEzSJ7C.js +1 -0
  73. package/dist/assets/packet-JDAUHWVQ-D76byv1S.js +1 -0
  74. package/dist/assets/path-fybaL0A-.js +1 -0
  75. package/dist/assets/pegDiagram-ECIUJNBX-Drs8Pxdt.js +1 -0
  76. package/dist/assets/pie-XZMESJXO-D_83z1lM.js +1 -0
  77. package/dist/assets/pieDiagram-GNMPY74T-wvzIphSf.js +39 -0
  78. package/dist/assets/quadrantDiagram-D57K4MC3-B7EfMRWb.js +7 -0
  79. package/dist/assets/radar-ABXABTNO-Bs6dVdAR.js +1 -0
  80. package/dist/assets/railroad-I3PHUGI6-lKaN4miw.js +1 -0
  81. package/dist/assets/railroad-abnf-LNEFI6M7-Bhh16b7Z.js +1 -0
  82. package/dist/assets/railroad-ebnf-DWYD2UWJ-m6ybW-Y_.js +1 -0
  83. package/dist/assets/railroad-peg-JR7BVNR7-DNn72e7r.js +1 -0
  84. package/dist/assets/railroadDiagram-Y72NRBL7-DNeop1iZ.js +1 -0
  85. package/dist/assets/requirementDiagram-EQYSW5RA-BVYZ2XYJ.js +84 -0
  86. package/dist/assets/rough.esm-Dy-Kn_BL.js +1 -0
  87. package/dist/assets/sankeyDiagram-SY24JHO5-B2MP51on.js +40 -0
  88. package/dist/assets/sequenceDiagram-BLEHNHQU-BUvxstXG.js +169 -0
  89. package/dist/assets/sizeCapture-INFHLROL-3uHXrFqA.js +1 -0
  90. package/dist/assets/src-BrR0lqlG.js +1 -0
  91. package/dist/assets/stateDiagram-v2-A6IESKWZ-Ba84OGKE.js +291 -0
  92. package/dist/assets/swimlanes-B3ENRKFQ-BMSl2PNA.js +1 -0
  93. package/dist/assets/swimlanesDiagram-AYQRV4AK-M1dmvq7m.js +8 -0
  94. package/dist/assets/timeline-definition-JXYKGLGB-DOdW-dxq.js +120 -0
  95. package/dist/assets/treeView-D4JQ5SDB-DZ9KQyn5.js +1 -0
  96. package/dist/assets/treemap-SAJKECNS-WEC5wtDV.js +1 -0
  97. package/dist/assets/usecaseDiagram-WPOLBNNX-cldTztFL.js +328 -0
  98. package/dist/assets/vennDiagram-Q3Y3IDCW-c41KySoY.js +34 -0
  99. package/dist/assets/wardley-7MLQ67FV-Bf9_aSSZ.js +1 -0
  100. package/dist/assets/wardleyDiagram-IVAIDKWA-BeKEvEBG.js +78 -0
  101. package/dist/assets/xychartDiagram-4URNHDU4-tNkAxTxG.js +7 -0
  102. package/dist/index.html +22 -0
  103. package/package.json +50 -4
  104. package/server/config.js +389 -0
  105. package/server/consensus.js +180 -0
  106. package/server/graph/anchordb_client.js +253 -0
  107. package/server/graph/model.js +157 -0
  108. package/server/graph/parser.js +327 -0
  109. package/server/graph/sources.js +801 -0
  110. package/server/graph/syntax.js +437 -0
  111. package/server/index.js +742 -0
  112. package/server/install.js +437 -0
  113. package/server/map.js +126 -0
  114. package/server/models_registry.js +109 -0
  115. package/server/scaffold.js +466 -0
@@ -0,0 +1,1320 @@
1
+ #!/usr/bin/env node
2
+ import { parseArgs, promisify } from 'node:util'
3
+ import path from 'node:path'
4
+ import fs from 'node:fs'
5
+ import readline from 'node:readline/promises'
6
+ import { execFile, spawn } from 'node:child_process'
7
+ import os from 'node:os'
8
+ import { fileURLToPath } from 'node:url'
9
+ import { createKnowledgeServer, attachAnchorDB } from '../server/index.js'
10
+ import { scaffoldBase, seedSkill, ensureGitignore, seedAgentInstructions } from '../server/scaffold.js'
11
+ import { buildGraphFromRoot } from '../server/graph/parser.js'
12
+ import {
13
+ deriveStatus,
14
+ recordClaim,
15
+ readClaimsIndex,
16
+ readConsensusMirror,
17
+ updateConsensusMirror,
18
+ inboundConsensus,
19
+ outboundConsensus,
20
+ CONSENSUS_RELATIONS
21
+ } from '../server/consensus.js'
22
+ import { readProjectConfig, readGlobalConfig, projectSlug, anchordbClientOptions, resolveModel, resolveSettings, knlGlobalDir, globalConfigPath } from '../server/config.js'
23
+ import { createAnchorWireClient, mirrorStillValid } from '../server/graph/anchordb_client.js'
24
+ import { syncSourcesToAnchorDB, traceSources, purgeArchivedRecords } from '../server/graph/sources.js'
25
+ import { readModelRegistry, registerModel, generateModelName, modelRegistryPath } from '../server/models_registry.js'
26
+ import {
27
+ latestRelease,
28
+ releaseAssetName,
29
+ generateToken,
30
+ downloadFile,
31
+ verifySha256,
32
+ mergeGlobalAnchordb,
33
+ availableContainerRuntimes,
34
+ detectRunningDaemon,
35
+ inspectContainerStatus,
36
+ startContainer,
37
+ removeContainer,
38
+ detectGraphify,
39
+ checkDecisionSkillsStatus,
40
+ installDecisionSkills,
41
+ resolveAnchorPorts,
42
+ GHCR_IMAGE,
43
+ HTTP_PORT_DEFAULT,
44
+ WIRE_PORT_DEFAULT,
45
+ CONTAINER_NAME
46
+ } from '../server/install.js'
47
+
48
+ const exec = promisify(execFile)
49
+
50
+ const here = path.dirname(fileURLToPath(import.meta.url))
51
+ const pkg = JSON.parse(fs.readFileSync(path.join(here, '..', 'package.json'), 'utf8'))
52
+
53
+ // Model roles must be lowercase slugs; used by runModelCommand (called from
54
+ // the top-level flow, so declared before use).
55
+ const MODEL_ROLE_RE = /^[a-z][a-z0-9_-]*$/
56
+
57
+ // Claim ids are content-addressed: "c-" + 16 hex chars of the statement
58
+ // hash. Used by runConsensusCommand (invoked from the top-level flow, so
59
+ // declared before use).
60
+ const CLAIM_ID_RE = /^c-[0-9a-f]{16}$/
61
+
62
+ const HELP = `knl-view ${pkg.version}
63
+ Local viewer for the .knowledges knowledge base.
64
+
65
+ Usage:
66
+ knl-view --port 8086 --path=.knowledges
67
+ knl-view --init --path=.knowledges
68
+ knl-view graph trace --target=<id> [--hops=2] [--direction=in|out|both] [--path=.knowledges]
69
+
70
+ Defaults for --port/--path/--host can be stored in knl-config.json in the
71
+ project folder (machine-local, gitignored). CLI flags always override config.
72
+
73
+ AnchorDB is optional and read from the global config
74
+ ~/.knlview/knl-config.json (URL, token, sync) so every project using it
75
+ shares the same server. Without that block, the viewer runs fully local
76
+ and never touches AnchorDB. Per-project model names are managed through
77
+ the "models" map in knl-config.json: role → name; names may be random
78
+ because the map records what each name is for. The global registry
79
+ ~/.knlview/models.json records the owner of every model name.
80
+
81
+ Commands:
82
+ model new <role> Generate a random model name for a role (e.g. knowledges),
83
+ write it to knl-config.json and the global registry
84
+ model check Check this project's models map and the global registry
85
+ install anchordb Install AnchorDB: binary from GitHub Releases on
86
+ Linux and macOS; docker/podman container elsewhere
87
+ consensus claim "<text>" Register a content-addressed claim (c-<hash>)
88
+ consensus agree <a> <b> Claim a supports claim b (stored in AnchorDB)
89
+ consensus contradict <a> <b> Claim a opposes claim b
90
+ consensus retract <a> <b> Remove any relation between two claims
91
+ consensus status <id> Consensus status of one claim
92
+ consensus list Locally known claims with their status
93
+ sources sync Sync the whole-project dependency graph (file
94
+ nodes + import edges) into the AnchorDB
95
+ "sources" model; server-authoritative, no mirror
96
+ sources collect Purge archived (tombstoned) records of the
97
+ "sources" model via the REST collector
98
+ sources trace --target=<path> File lineage over the sources graph
99
+ (--hops, --direction in|out|both)
100
+ setup Bootstrap project in one command (scaffold base, skill, and models)
101
+
102
+ Options:
103
+ --port, -p <number> HTTP port (default: 8085, or knl-config.json)
104
+ --path <folder> Knowledge base folder (default: .knowledges, or knl-config.json)
105
+ --host <address> Bind address (default: 127.0.0.1, or knl-config.json)
106
+ --anchordb <url> AnchorWire URL for this session, overrides the
107
+ "anchordb" block in knl-config.json
108
+ --method <method> Method for "install anchordb": binary | container
109
+ --runtime <engine> Container runtime for "install anchordb": docker | podman
110
+ --token <text> Auth token for "install anchordb" (default: random)
111
+ --direction <dir> Lineage direction for "graph trace": in | out | both
112
+ (default: both; see README "Two-way lineage")
113
+ --target <id> Target node id for the subgraph query
114
+ --hops <number> Graph trace hop radius (default: 2)
115
+ --init Scaffold a new base via the wizard (does not start the server)
116
+ --structure <layout> Layout for non-interactive --init:
117
+ standard | empty | custom (default: standard)
118
+ --folders <a,b,c> Folder names for --structure=custom
119
+ --seed-skill=knl-view Write the OpenCode "knl-view" skill (skipped if it already exists)
120
+ --dir <location> Skill location: project (.agents/skills) | global
121
+ (~/.config/opencode/skills). Default: project
122
+ --yes, -y Use all default answers, no prompting
123
+ --help, -h Show this help
124
+ --version, -v Show version
125
+
126
+ Examples:
127
+ knl-view --path=../anchor-connector/.knowledges
128
+ knl-view --port 8086 --path=.knowledges
129
+ knl-view graph trace --target=README.md --hops=1
130
+ knl-view graph trace --target=DEC-20261030-135001
131
+ knl-view --init
132
+ knl-view --seed-skill=knl-view --dir=global --yes
133
+ `
134
+
135
+ let values, positionals
136
+ try {
137
+ ({ values, positionals } = parseArgs({
138
+ options: {
139
+ port: { type: 'string', short: 'p' },
140
+ path: { type: 'string' },
141
+ host: { type: 'string' },
142
+ anchordb: { type: 'string' },
143
+ method: { type: 'string' },
144
+ runtime: { type: 'string' },
145
+ token: { type: 'string' },
146
+ target: { type: 'string' },
147
+ direction: { type: 'string' },
148
+ hops: { type: 'string', default: '2' },
149
+ init: { type: 'boolean', default: false },
150
+ structure: { type: 'string', default: 'standard' },
151
+ folders: { type: 'string', default: '' },
152
+ 'seed-skill': { type: 'string' },
153
+ dir: { type: 'string', default: 'project' },
154
+ yes: { type: 'boolean', short: 'y', default: false },
155
+ help: { type: 'boolean', short: 'h', default: false },
156
+ version: { type: 'boolean', short: 'v', default: false }
157
+ },
158
+ allowPositionals: true
159
+ }))
160
+ } catch (err) {
161
+ fail(`${err.message}\n\nRun "knl-view --help" for help.`)
162
+ }
163
+
164
+ if (values['seed-skill'] !== undefined && values['seed-skill'] !== 'knl-view') {
165
+ fail(`--seed-skill only knows "knl-view", got "${values['seed-skill']}".`)
166
+ }
167
+ if (!['project', 'global'].includes(values.dir)) {
168
+ fail(`--dir must be project or global, got "${values.dir}".`)
169
+ }
170
+ if (!['standard', 'empty', 'custom'].includes(values.structure)) {
171
+ fail(`--structure must be standard, empty, or custom, got "${values.structure}".`)
172
+ }
173
+
174
+ if (values.help) {
175
+ process.stdout.write(HELP)
176
+ process.exit(0)
177
+ }
178
+ if (values.version) {
179
+ process.stdout.write(`${pkg.version}\n`)
180
+ process.exit(0)
181
+ }
182
+
183
+ // Two-layer config: project (knl-config.json) and global (~/.knlview/).
184
+ // A missing file is fine; a broken one is ignored with a single warning on
185
+ // stderr so unused values are not a mystery.
186
+ const { config: projectConfig, error: projectError } = await readProjectConfig()
187
+ if (projectError) {
188
+ process.stderr.write(`knl-view: ignoring knl-config.json (${projectError})\n`)
189
+ }
190
+ const { config: globalConfig, error: globalError } = await readGlobalConfig()
191
+ if (globalError) {
192
+ process.stderr.write(`knl-view: ignoring ~/.knlview/knl-config.json (${globalError})\n`)
193
+ }
194
+ const settings = resolveSettings({
195
+ cli: values,
196
+ project: projectConfig,
197
+ global: globalConfig
198
+ })
199
+ if (values.anchordb !== undefined && !settings.anchordb) {
200
+ fail('--anchordb must be a wire:// or tcp:// URL, example: --anchordb=wire://127.0.0.1:7448?token=xxx')
201
+ }
202
+
203
+ // The "setup" command: one-shot bootstrap for the project
204
+ if (positionals?.[0] === 'setup') {
205
+ await runSetupCommand({ values, settings, projectConfig, globalConfig })
206
+ process.exit(0)
207
+ }
208
+
209
+ // The "model" command: manage the role → AnchorDB model name map. No other
210
+ // code ever guesses names; everything resolves through the role in this map.
211
+ const modelCommand = positionals?.[0] === 'model' ? positionals?.[1] : null
212
+ if (modelCommand) {
213
+ await runModelCommand(modelCommand, positionals?.[2], { projectConfig, settings })
214
+ process.exit(0)
215
+ }
216
+
217
+ // The "install" command: install AnchorDB (binary on Linux, container on
218
+ // other platforms) then write the anchordb block to the global config.
219
+ if (positionals?.[0] === 'install') {
220
+ await runInstallCommand(positionals?.[1], { values })
221
+ process.exit(0)
222
+ }
223
+
224
+ // The "consensus" command: agreement/conflict between claims, stored purely
225
+ // in the AnchorDB consensus model (the "consensus" role in the models map).
226
+ const consensusSub = positionals?.[0] === 'consensus' ? positionals?.[1] : null
227
+ if (consensusSub) {
228
+ try {
229
+ await runConsensusCommand(consensusSub, positionals?.slice(2), { settings })
230
+ } catch (err) {
231
+ fail(`Consensus command failed: ${err.message}`)
232
+ }
233
+ process.exit(0)
234
+ }
235
+
236
+ // The "sources" command: dependency graph of the whole project (not just
237
+ // .knowledges) stored purely in the AnchorDB sources model.
238
+ const sourcesSub = positionals?.[0] === 'sources' ? positionals?.[1] : null
239
+ if (sourcesSub) {
240
+ try {
241
+ await runSourcesCommand(sourcesSub, values, { settings })
242
+ } catch (err) {
243
+ fail(`Sources command failed: ${err.message}`)
244
+ }
245
+ process.exit(0)
246
+ }
247
+
248
+ // Register the project's whole models map to the global registry (updating
249
+ // path/timestamp when this project owns them) and warn about names claimed
250
+ // by other projects. This local registration needs no server connection.
251
+ if (settings.models) {
252
+ for (const [role, name] of Object.entries(settings.models)) {
253
+ const { previous, error } = await registerModel(name, {
254
+ role,
255
+ project: projectSlug(process.cwd()),
256
+ cwd: process.cwd()
257
+ })
258
+ if (error) {
259
+ process.stderr.write(`knl-view: ignoring model registry (${error})\n`)
260
+ } else if (previous) {
261
+ process.stdout.write(` warning: model "${name}" (${role}) is also used by another project: ${previous.cwd}\n`)
262
+ }
263
+ }
264
+ }
265
+
266
+ const isGraphTrace = (positionals?.[0] === 'graph' && positionals?.[1] === 'trace') || values.target !== undefined
267
+ if (isGraphTrace) {
268
+ const target = values.target || positionals?.[2]
269
+ if (!target) {
270
+ fail('Parameter --target is required:\n knl-view graph trace --target=<id> [--hops=2] [--direction=in|out|both]')
271
+ }
272
+ const root = path.resolve(process.cwd(), settings.path)
273
+ if (!fs.existsSync(root)) {
274
+ fail(`Folder not found: ${root}\nCheck --path, or run from a project folder that has a .knowledges base.`)
275
+ }
276
+ if (!fs.statSync(root).isDirectory()) {
277
+ fail(`--path must be a folder, not a file: ${root}`)
278
+ }
279
+ const hops = parseInt(values.hops || '2', 10)
280
+ let direction = 'both'
281
+ if (values.direction !== undefined) {
282
+ if (!['in', 'out', 'both'].includes(values.direction)) {
283
+ fail(`--direction must be in, out, or both, got "${values.direction}".`)
284
+ }
285
+ direction = values.direction
286
+ }
287
+ const graph = await buildGraphFromRoot(root)
288
+ const subGraph = graph.extractSubGraph(target, Number.isInteger(hops) && hops > 0 ? hops : 2, direction)
289
+ process.stdout.write(JSON.stringify(subGraph, null, 2) + '\n')
290
+ process.exit(0)
291
+ }
292
+
293
+ if (values.init || values['seed-skill'] !== undefined) {
294
+ await runSetup()
295
+ process.exit(0)
296
+ }
297
+
298
+ if (positionals && positionals.length > 0) {
299
+ fail(`Unknown command "${positionals[0]}".\n\nRun "knl-view --help" for available commands.`)
300
+ }
301
+
302
+ const port = Number(settings.port)
303
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
304
+ fail(`--port must be a number 1-65535, got "${settings.port}".`)
305
+ }
306
+
307
+ const root = path.resolve(process.cwd(), settings.path)
308
+ if (!fs.existsSync(root)) {
309
+ fail(`Folder not found: ${root}\nCheck --path, or run from a project folder that has a .knowledges base.`)
310
+ }
311
+ if (!fs.statSync(root).isDirectory()) {
312
+ fail(`--path must be a folder, not a file: ${root}`)
313
+ }
314
+
315
+ const dist = path.join(here, '..', 'dist')
316
+ if (!fs.existsSync(path.join(dist, 'index.html'))) {
317
+ fail(`UI not built yet (${dist}).\nRun first: npm run build`)
318
+ }
319
+
320
+ const server = createKnowledgeServer({
321
+ root,
322
+ dist,
323
+ host: settings.host,
324
+ port,
325
+ anchordb: settings.anchordb,
326
+ consensusModel: resolveModel(settings.models, 'consensus'),
327
+ sourcesModel: resolveModel(settings.models, 'sources'),
328
+ include: settings.include,
329
+ exclude: settings.exclude
330
+ })
331
+
332
+ server.on('error', (err) => {
333
+ if (err.code === 'EADDRINUSE') {
334
+ fail(`Port ${port} is already in use by another process.\nTry another port: knl-view --port ${port + 1}`)
335
+ }
336
+ fail(`Failed to start server: ${err.message}`)
337
+ })
338
+
339
+ server.listen(port, settings.host, () => {
340
+ const addr = `http://${settings.host}:${port}`
341
+ process.stdout.write(
342
+ `knl-view ${pkg.version}\n` +
343
+ ` address ${addr}\n` +
344
+ ` source ${root}\n` +
345
+ `Press Ctrl+C to stop.\n`
346
+ )
347
+
348
+ if (settings.anchordb) {
349
+ if (!settings.anchordb.model) {
350
+ // The map is the only source of model names; without an entry, AnchorDB
351
+ // is deliberately left untouched so no meaningless name appears.
352
+ process.stdout.write(
353
+ ' anchordb idle: role "knowledges" not yet mapped in knl-config.json\n' +
354
+ ' run: knl-view model new knowledges\n'
355
+ )
356
+ } else {
357
+ attachAnchorDB(server).then((result) => {
358
+ if (result.ok) {
359
+ let line = ` anchordb connected (${result.model}`
360
+ if (result.synced) {
361
+ if (result.failed) {
362
+ line += `, sync ${result.linked}+${result.unlinked} edges, ${result.failed} failed (retried on next start)`
363
+ } else if (result.linked || result.unlinked) {
364
+ line += `, sync +${result.linked} -${result.unlinked} (${result.kept} edges kept)`
365
+ } else {
366
+ line += `, graph already in sync (${result.kept} edges)`
367
+ }
368
+ }
369
+ process.stdout.write(`${line})\n`)
370
+ if (server.knlAnchorDB.consensusClient) {
371
+ process.stdout.write(` consensus ready (${server.knlAnchorDB.consensusModel})\n`)
372
+ } else if (server.knlAnchorDB.consensusError) {
373
+ process.stdout.write(` consensus unavailable: ${server.knlAnchorDB.consensusError}\n`)
374
+ }
375
+ if (server.knlAnchorDB.sourcesClient) {
376
+ process.stdout.write(` sources ready (${server.knlAnchorDB.sourcesModel})\n`)
377
+ } else if (server.knlAnchorDB.sourcesError) {
378
+ process.stdout.write(` sources unavailable: ${server.knlAnchorDB.sourcesError}\n`)
379
+ }
380
+ } else {
381
+ process.stdout.write(` anchordb failed: ${result.reason} (viewer runs local only)\n`)
382
+ }
383
+ }).catch((err) => {
384
+ process.stdout.write(` anchordb error: ${err.message} (viewer runs local only)\n`)
385
+ })
386
+ }
387
+ }
388
+ })
389
+
390
+ // The --init / --seed-skill wizard. Ask mode only when stdin is a TTY and
391
+ // no --yes is given; otherwise default answers are used so it never hangs
392
+ // (pipe, CI, or scripts).
393
+ async function runSetup() {
394
+ const root = path.resolve(process.cwd(), settings.path)
395
+ const interactive = Boolean(process.stdin.isTTY) && !values.yes
396
+ const rl = interactive ? readline.createInterface({ input: process.stdin, output: process.stdout }) : null
397
+
398
+ try {
399
+ let structure = values.structure
400
+ let folders = values.folders.split(',').map((s) => s.trim()).filter(Boolean)
401
+ let seedScope = values['seed-skill'] !== undefined ? values.dir : null
402
+ let gitignore = true
403
+
404
+ if (values.init) {
405
+ if (rl) {
406
+ structure = await askChoice(rl, 'Layout for the new base?', [
407
+ ['standard', 'Standard: README.md + roadmap/ features/ docs/'],
408
+ ['empty', 'Empty: only the .knowledges/ folder'],
409
+ ['custom', 'Custom: pick your own folders']
410
+ ], structure)
411
+ if (structure === 'custom' && !folders.length) {
412
+ const answer = await ask(rl, 'Folder names (comma-separated): ', 'roadmap, features, docs')
413
+ folders = answer.split(',').map((s) => s.trim()).filter(Boolean)
414
+ }
415
+ seedScope = await askChoice(rl, 'OpenCode knl-view skill?', [
416
+ ['none', 'Skip'],
417
+ ['project', 'Project: .agents/skills/knl-view/'],
418
+ ['global', 'Global: ~/.config/opencode/skills/knl-view/']
419
+ ], seedScope || 'none')
420
+ if (seedScope === 'none') seedScope = null
421
+ gitignore = await askYesNo(rl, 'Add .knowledges/ to .gitignore?', true)
422
+ }
423
+
424
+ process.stdout.write(`Scaffolding base at ${root} (layout: ${structure})...\n`)
425
+ const actions = await scaffoldBase({ root, structure, folders })
426
+ report(actions)
427
+ }
428
+
429
+ if (seedScope) {
430
+ const result = await seedSkill({ scope: seedScope, cwd: process.cwd() })
431
+ const scope = seedScope === 'global' ? 'global' : 'project'
432
+ process.stdout.write(
433
+ result.status === 'skipped'
434
+ ? ` skip skill already exists: ${result.path}\n`
435
+ : ` create skill (${scope}): ${result.path}\n`
436
+ )
437
+ }
438
+
439
+ if (values.init && gitignore) {
440
+ const result = await ensureGitignore({ cwd: process.cwd() })
441
+ process.stdout.write(
442
+ result.status === 'skipped'
443
+ ? ` skip .gitignore already mentions .knowledges\n`
444
+ : ` create adding .knowledges/ to ${result.path}\n`
445
+ )
446
+ }
447
+
448
+ process.stdout.write('Done. Run "knl-view" to view this base.\n')
449
+ } finally {
450
+ rl?.close()
451
+ }
452
+ }
453
+
454
+ async function runSetupCommand({ values, settings, projectConfig, globalConfig }) {
455
+ const root = path.resolve(process.cwd(), values.path || settings.path || '.knowledges')
456
+ const interactive = Boolean(process.stdin.isTTY) && !values.yes
457
+ const rl = interactive ? readline.createInterface({ input: process.stdin, output: process.stdout }) : null
458
+
459
+ try {
460
+ process.stdout.write('knl-view setup: bootstrapping project ...\n')
461
+
462
+ // Scaffold base
463
+ const actions = await scaffoldBase({
464
+ root,
465
+ structure: values.structure || 'standard',
466
+ folders: values.folders ? values.folders.split(',').map((s) => s.trim()).filter(Boolean) : []
467
+ })
468
+ const createdCount = actions.filter((a) => a.status === 'created').length
469
+ const relRoot = path.relative(process.cwd(), root) || root
470
+ process.stdout.write(` [1/5] knowledge base: ${relRoot} (${createdCount > 0 ? 'created' : 'already present'})\n`)
471
+
472
+ // Audit and seed skill
473
+ const skillPath = path.join(process.cwd(), '.agents', 'skills', 'knl-view', 'SKILL.md')
474
+ let skillPresent = false
475
+ try {
476
+ await fs.promises.stat(skillPath)
477
+ skillPresent = true
478
+ } catch {}
479
+
480
+ if (skillPresent) {
481
+ process.stdout.write(` [2/5] agent skill: ${skillPath} (already present)\n`)
482
+ } else {
483
+ let wantSkill = true
484
+ if (interactive) {
485
+ wantSkill = await askYesNo(rl, 'Install knl-view agent skill to .agents/skills/knl-view?', true)
486
+ }
487
+ if (wantSkill) {
488
+ const skillResult = await seedSkill({ scope: values.dir || 'project', cwd: process.cwd() })
489
+ process.stdout.write(` [2/5] agent skill: ${skillResult.path} (${skillResult.status})\n`)
490
+ } else {
491
+ process.stdout.write(` [2/5] agent skill: skipped by user\n`)
492
+ }
493
+ }
494
+
495
+ // Audit and seed decision-history skill
496
+ const home = process.env.HOME || process.cwd()
497
+ const decisionStatus = await checkDecisionSkillsStatus({ cwd: process.cwd(), home })
498
+ const statusLabel =
499
+ decisionStatus.status === 'not_installed'
500
+ ? 'not installed'
501
+ : decisionStatus.status === 'needs_update'
502
+ ? `update available (current: ${decisionStatus.currentVersion || 'unknown'}, latest: ${decisionStatus.latestVersion})`
503
+ : 'already installed'
504
+
505
+ process.stdout.write(` [skill] decision-history: ${statusLabel}\n`)
506
+
507
+ if (decisionStatus.status === 'not_installed' || decisionStatus.status === 'needs_update') {
508
+ let wantDecisionSkill = true
509
+ if (interactive) {
510
+ wantDecisionSkill = await askYesNo(
511
+ rl,
512
+ 'Would you like to install/update the decision-history skill (rachmanzz/decisionskills)?',
513
+ true
514
+ )
515
+ }
516
+ if (wantDecisionSkill) {
517
+ let scope = 'project'
518
+ if (interactive) {
519
+ scope = await askChoice(
520
+ rl,
521
+ 'Scope for decision-history skill?',
522
+ [
523
+ ['project', 'per-project (.agents/skills/decision-history)'],
524
+ ['global', 'global (all agent terminals: agy, codex, opencode, claude)']
525
+ ],
526
+ 'project'
527
+ )
528
+ }
529
+ process.stdout.write(`Installing decision-history skill (${scope}) ...\n`)
530
+ await installDecisionSkills({ scope, cwd: process.cwd(), home, exec })
531
+ process.stdout.write(` [skill] decision-history: installed (${scope})\n`)
532
+ } else {
533
+ process.stdout.write(` [skill] decision-history: skipped by user\n`)
534
+ }
535
+ }
536
+
537
+ // Ensure gitignore
538
+ const giResult = await ensureGitignore({ cwd: process.cwd() })
539
+ process.stdout.write(` [3/5] gitignore: ${giResult.path} (${giResult.status})\n`)
540
+
541
+ // Daemon and container lifecycle
542
+ let currentGlobalConfig = globalConfig
543
+ let containerStatus = await inspectContainerStatus(exec)
544
+ let daemonMeta = await detectRunningDaemon(exec)
545
+
546
+ if (containerStatus.exists && !containerStatus.valid) {
547
+ let wantRecreate = true
548
+ if (interactive) {
549
+ wantRecreate = await askYesNo(
550
+ rl,
551
+ `AnchorDB container "${containerStatus.name}" is in an invalid state (${containerStatus.status}). Would you like to recreate it?`,
552
+ true
553
+ )
554
+ }
555
+ if (wantRecreate) {
556
+ process.stdout.write(`Removing invalid container "${containerStatus.name}" ...\n`)
557
+ try {
558
+ await removeContainer(exec, containerStatus.runtime, containerStatus.name)
559
+ } catch {}
560
+ await runInstallCommand('anchordb', {
561
+ values: { ...values, method: 'container' },
562
+ rl
563
+ })
564
+ const freshGlobal = await readGlobalConfig()
565
+ currentGlobalConfig = freshGlobal.config || currentGlobalConfig
566
+ daemonMeta = await detectRunningDaemon(exec)
567
+ }
568
+ } else if (containerStatus.exists && !containerStatus.running && containerStatus.valid) {
569
+ let wantStart = true
570
+ if (interactive) {
571
+ wantStart = await askYesNo(
572
+ rl,
573
+ `AnchorDB container "${containerStatus.name}" is stopped. Would you like to start it now?`,
574
+ true
575
+ )
576
+ }
577
+ if (wantStart) {
578
+ process.stdout.write(`Starting container "${containerStatus.name}" ...\n`)
579
+ await startContainer(exec, containerStatus.runtime, containerStatus.name)
580
+ await waitAnchorHealth()
581
+ daemonMeta = await detectRunningDaemon(exec)
582
+ if (daemonMeta) {
583
+ const updatedAnchor = {
584
+ ...(currentGlobalConfig?.anchordb || {}),
585
+ url: `wire://127.0.0.1:${daemonMeta.wire_port || WIRE_PORT_DEFAULT}`,
586
+ token: daemonMeta.token || currentGlobalConfig?.anchordb?.token || '',
587
+ sync: currentGlobalConfig?.anchordb?.sync !== false,
588
+ daemon: daemonMeta.daemon || daemonMeta.deamon,
589
+ deamon: daemonMeta.daemon || daemonMeta.deamon,
590
+ container_name: daemonMeta.container_name,
591
+ container_id: daemonMeta.container_id,
592
+ container_created_on: daemonMeta.container_created_on
593
+ }
594
+ await mergeGlobalAnchordb(home, updatedAnchor)
595
+ currentGlobalConfig = { ...currentGlobalConfig, anchordb: updatedAnchor }
596
+ process.stdout.write(` [info] global config updated with ${daemonMeta.daemon || daemonMeta.deamon} daemon metadata\n`)
597
+ }
598
+ }
599
+ } else if (!daemonMeta && !containerStatus.exists) {
600
+ const hasConfigured = Boolean(currentGlobalConfig?.anchordb?.url)
601
+ let wantInstall = Boolean(values.runtime)
602
+ if (!wantInstall && interactive && !hasConfigured) {
603
+ wantInstall = await askYesNo(
604
+ rl,
605
+ 'AnchorDB is not running. Would you like to create an AnchorDB container now?',
606
+ false
607
+ )
608
+ }
609
+ if (wantInstall) {
610
+ process.stdout.write('\nProvisioning AnchorDB container ...\n')
611
+ await runInstallCommand('anchordb', {
612
+ values: { ...values, method: 'container' },
613
+ rl
614
+ })
615
+ const freshGlobal = await readGlobalConfig()
616
+ currentGlobalConfig = freshGlobal.config || currentGlobalConfig
617
+ daemonMeta = await detectRunningDaemon(exec)
618
+ }
619
+ }
620
+
621
+ if (daemonMeta && currentGlobalConfig?.anchordb) {
622
+ const existing = currentGlobalConfig.anchordb
623
+ const daemonType = daemonMeta.daemon || daemonMeta.deamon || existing.daemon || existing.deamon
624
+ const updated = {
625
+ ...existing,
626
+ daemon: daemonType,
627
+ deamon: daemonType
628
+ }
629
+ if (daemonMeta.container_name) updated.container_name = daemonMeta.container_name
630
+ if (daemonMeta.container_id) updated.container_id = daemonMeta.container_id
631
+ if (daemonMeta.container_created_on) updated.container_created_on = daemonMeta.container_created_on
632
+
633
+ if (JSON.stringify(existing) !== JSON.stringify(updated)) {
634
+ await mergeGlobalAnchordb(home, updated)
635
+ currentGlobalConfig = { ...currentGlobalConfig, anchordb: updated }
636
+ }
637
+ }
638
+
639
+ let currentConfig = projectConfig || {}
640
+ const hasGlobalAnchor = Boolean(currentGlobalConfig?.anchordb)
641
+ const hasLocalAnchor = Boolean(projectConfig?.anchordb)
642
+ const hasAnchor = hasGlobalAnchor || hasLocalAnchor
643
+
644
+ if (hasAnchor) {
645
+ const slug = projectSlug(process.cwd())
646
+ const neededRoles = ['knowledges', 'sources']
647
+ let modelsUpdated = false
648
+ const nextModels = { ...(currentConfig.models || {}) }
649
+
650
+ for (const role of neededRoles) {
651
+ if (!nextModels[role]) {
652
+ const name = generateModelName()
653
+ await registerModel(name, { role, project: slug, cwd: process.cwd() })
654
+ nextModels[role] = name
655
+ modelsUpdated = true
656
+ }
657
+ }
658
+
659
+ if (modelsUpdated) {
660
+ currentConfig = { ...currentConfig, models: nextModels }
661
+ delete currentConfig.model
662
+ fs.writeFileSync(path.join(process.cwd(), 'knl-config.json'), JSON.stringify(currentConfig, null, 2) + '\n', 'utf8')
663
+ process.stdout.write(` [4/5] anchordb models: mapped knowledges (${nextModels.knowledges}) and sources (${nextModels.sources}) in knl-config.json\n`)
664
+ } else {
665
+ process.stdout.write(` [4/5] anchordb models: already mapped in knl-config.json\n`)
666
+ }
667
+
668
+ const targetAnchordb = {
669
+ ...(settings.anchordb || currentGlobalConfig?.anchordb || currentConfig?.anchordb),
670
+ model: nextModels.sources
671
+ }
672
+ const opts = anchordbClientOptions(targetAnchordb)
673
+ if (opts) {
674
+ try {
675
+ process.stdout.write(' syncing project sources to AnchorDB ...\n')
676
+ const client = await createAnchorWireClient(opts)
677
+ const result = await syncSourcesToAnchorDB(client, process.cwd(), {
678
+ labelDir: settings.labelDir,
679
+ include: settings.include,
680
+ exclude: settings.exclude
681
+ })
682
+ client.close()
683
+ process.stdout.write(` sources sync complete: ${result.files} files, ${result.edges} edges\n`)
684
+ } catch (err) {
685
+ process.stdout.write(` (sources sync deferred: ${err.message})\n`)
686
+ }
687
+ }
688
+ } else {
689
+ process.stdout.write(' [4/5] anchordb: inactive (run "knl-view install anchordb" to enable)\n')
690
+ }
691
+
692
+ // Seed agent rules
693
+ let wantAgentRules = true
694
+ if (interactive) {
695
+ wantAgentRules = await askYesNo(
696
+ rl,
697
+ 'Would you like to configure KNL-View as your primary code intelligence tool in your agent rule files (AGENTS.md, etc.)?',
698
+ true
699
+ )
700
+ }
701
+ if (wantAgentRules) {
702
+ const hasGraphify = await detectGraphify(exec)
703
+ const ruleActions = await seedAgentInstructions({ cwd: process.cwd(), home, hasGraphify })
704
+ for (const action of ruleActions) {
705
+ const rel = path.relative(process.cwd(), action.path)
706
+ const display = rel.startsWith('..') ? action.path : rel
707
+ process.stdout.write(` [5/5] agent rules: ${display} (${action.status}${hasGraphify ? ', graphify fallback enabled' : ''})\n`)
708
+ }
709
+ } else {
710
+ process.stdout.write(' [5/5] agent rules: skipped by user\n')
711
+ }
712
+
713
+ process.stdout.write('\nSetup complete! You can now run:\n knl-view # start the viewer\n knl-view graph trace --target=README.md\n')
714
+ } finally {
715
+ if (rl) rl.close()
716
+ }
717
+ }
718
+
719
+ function report(actions) {
720
+ for (const action of actions) {
721
+ const label = action.kind === 'dir' ? 'folder ' : 'file '
722
+ const verb = action.status === 'skipped' ? 'skip' : 'create'
723
+ process.stdout.write(` ${verb} ${label}${action.path}\n`)
724
+ }
725
+ }
726
+
727
+ // All questions go through here so EOF (Ctrl+D) mid-wizard stops cleanly
728
+ // instead of throwing an AbortError with a stack trace.
729
+ async function ask(rl, question, fallback) {
730
+ let answer
731
+ try {
732
+ answer = (await rl.question(question)).trim()
733
+ } catch {
734
+ process.stdout.write('\n')
735
+ fail('Cancelled.')
736
+ }
737
+ return answer || fallback
738
+ }
739
+
740
+ async function askChoice(rl, question, choices, current) {
741
+ process.stdout.write(`${question}\n`)
742
+ choices.forEach(([value, label], i) => {
743
+ const marker = value === current ? '>' : ' '
744
+ process.stdout.write(` ${marker} ${i + 1}. ${label}\n`)
745
+ })
746
+ const answer = await ask(rl, `Choice [1-${choices.length}, default: ${defaultIndex(choices, current)}]: `, '')
747
+ const byNumber = Number(answer)
748
+ if (Number.isInteger(byNumber) && byNumber >= 1 && byNumber <= choices.length) return choices[byNumber - 1][0]
749
+ const byValue = choices.find(([value]) => value === answer)
750
+ if (byValue) return byValue[0]
751
+ return current
752
+ }
753
+
754
+ function defaultIndex(choices, current) {
755
+ const i = choices.findIndex(([value]) => value === current)
756
+ return i >= 0 ? i + 1 : 1
757
+ }
758
+
759
+ async function askYesNo(rl, question, fallback) {
760
+ const answer = (await ask(rl, `${question} (y/n) [default: ${fallback ? 'y' : 'n'}]: `, '')).toLowerCase()
761
+ if (!answer) return fallback
762
+ return answer === 'y' || answer === 'yes'
763
+ }
764
+
765
+ // The "install anchordb" command. Linux and macOS: binary from GitHub
766
+ // Releases (verified against SHA256SUMS.txt). Windows (and anyone who
767
+ // prefers it): ghcr.io container via docker/podman. After install, the
768
+ // "anchordb" block is written to the global config so every project can
769
+ // connect right away.
770
+ async function runInstallCommand(target, { values, rl: externalRl = null }) {
771
+ if (target !== 'anchordb') {
772
+ fail(`Unknown install target: ${target || '(none)'}\nAvailable: knl-view install anchordb`)
773
+ }
774
+ const nativeOk = process.platform === 'linux' || process.platform === 'darwin'
775
+ const interactive = Boolean(process.stdin.isTTY) && !values.yes
776
+ const ownsRl = !externalRl && interactive
777
+ const rl = externalRl || (interactive ? readline.createInterface({ input: process.stdin, output: process.stdout }) : null)
778
+
779
+ let method = values.method
780
+ if (!method) {
781
+ if (nativeOk) {
782
+ method = interactive
783
+ ? await askChoice(rl, 'AnchorDB install method?', [
784
+ ['binary', 'Binary from GitHub Releases (native)'],
785
+ ['container', 'docker/podman container (ghcr.io)']
786
+ ], 'binary')
787
+ : 'binary'
788
+ } else {
789
+ method = 'container'
790
+ process.stdout.write('No native build for this platform; installing as a Linux container (ghcr.io).\n')
791
+ }
792
+ }
793
+ if (!['binary', 'container'].includes(method)) {
794
+ fail(`--method must be binary or container, got "${method}".`)
795
+ }
796
+
797
+ const token = values.token || generateToken()
798
+ const home = process.env.HOME || process.cwd()
799
+ // fs in this module is the callback API; installer steps need the promise version.
800
+ const fsp = fs.promises
801
+ let started = false
802
+ let daemonType = method === 'binary' ? 'binary' : null
803
+ let containerMetadata = null
804
+
805
+ const portInfo = await resolveAnchorPorts({
806
+ desiredWirePort: WIRE_PORT_DEFAULT,
807
+ desiredHttpPort: HTTP_PORT_DEFAULT
808
+ })
809
+
810
+ let wirePort = portInfo.wirePort
811
+ let httpPort = portInfo.httpPort
812
+
813
+ if (!portInfo.wireAvailable || !portInfo.httpAvailable) {
814
+ const conflictPort = !portInfo.wireAvailable ? wirePort : httpPort
815
+ const suggested = !portInfo.wireAvailable ? portInfo.suggestedWirePort : portInfo.suggestedHttpPort
816
+ let useSuggested = true
817
+ if (interactive) {
818
+ useSuggested = await askYesNo(
819
+ rl,
820
+ `Port ${conflictPort} is already in use. Would you like to use port ${suggested} instead?`,
821
+ true
822
+ )
823
+ }
824
+ if (useSuggested) {
825
+ wirePort = portInfo.suggestedWirePort
826
+ httpPort = portInfo.suggestedHttpPort
827
+ process.stdout.write(` [port] selected wire port ${wirePort} and HTTP port ${httpPort}\n`)
828
+ } else {
829
+ fail(`Port ${conflictPort} is in use and alternative port was declined.`)
830
+ }
831
+ }
832
+
833
+ try {
834
+ if (method === 'binary') {
835
+ if (!nativeOk) {
836
+ fail('The binary method needs Linux or macOS.\nUse --method=container.')
837
+ }
838
+ const { version } = await latestRelease()
839
+ const knlViewDir = knlGlobalDir(home)
840
+ const asset = releaseAssetName(version)
841
+ const workdir = path.join(os.tmpdir(), `knlview-install-${Date.now()}`)
842
+ await fsp.mkdir(workdir, { recursive: true })
843
+ try {
844
+ const binDir = path.join(knlViewDir, 'bin')
845
+
846
+ process.stdout.write(`Downloading ${asset} (v${version}) ...\n`)
847
+ const archive = path.join(workdir, asset)
848
+ await downloadFile(`https://github.com/rachmanzz/anchordb/releases/download/v${version}/${asset}`, archive)
849
+ const sums = path.join(workdir, 'SHA256SUMS.txt')
850
+ await downloadFile(`https://github.com/rachmanzz/anchordb/releases/download/v${version}/SHA256SUMS.txt`, sums)
851
+ await verifySha256(archive, await fsp.readFile(sums, 'utf8'))
852
+ process.stdout.write(' SHA256 matches SHA256SUMS.txt\n')
853
+
854
+ process.stdout.write('Extracting ...\n')
855
+ await exec('tar', ['-xzf', archive, '-C', workdir])
856
+ await fsp.mkdir(binDir, { recursive: true, mode: 0o700 })
857
+ for (const bin of ['anchordb-server', 'anchordb']) {
858
+ const target2 = path.join(binDir, bin)
859
+ await fsp.copyFile(path.join(workdir, bin), target2)
860
+ await fsp.chmod(target2, 0o755)
861
+ }
862
+ process.stdout.write(` installed to ${binDir}\n`)
863
+ } finally {
864
+ await fsp.rm(workdir, { recursive: true, force: true }).catch(() => {})
865
+ }
866
+
867
+ const runNow = interactive
868
+ ? await askYesNo(rl, 'Start the AnchorDB server now?', true)
869
+ : true
870
+ if (runNow) {
871
+ const dataDir = path.join(knlViewDir, 'anchordb-data')
872
+ await fsp.mkdir(dataDir, { recursive: true })
873
+ const logFd = await fsp.open(path.join(knlViewDir, 'anchordb.log'), 'a')
874
+ const child = spawn(path.join(binDir, 'anchordb-server'), [], {
875
+ env: {
876
+ ...process.env,
877
+ ANCHORDB_AUTH_TOKEN: token,
878
+ ANCHORDB_DATA_DIR: dataDir,
879
+ ANCHORDB_PORT: String(httpPort),
880
+ ANCHORDB_WIRE_PORT: String(wirePort)
881
+ },
882
+ detached: true,
883
+ stdio: ['ignore', logFd.fd, logFd.fd]
884
+ })
885
+ child.unref()
886
+ logFd.close()
887
+ await waitAnchorHealth(httpPort)
888
+ started = true
889
+ process.stdout.write(` server running (log: ${path.join(knlViewDir, 'anchordb.log')})\n`)
890
+ }
891
+ } else {
892
+ const preferredRuntime = values.runtime
893
+ if (preferredRuntime && !['docker', 'podman'].includes(preferredRuntime)) {
894
+ fail(`--runtime must be docker or podman, got "${preferredRuntime}".`)
895
+ }
896
+ const which = async (cmd) => {
897
+ try { await exec(cmd, ['--version']); return true } catch { return false }
898
+ }
899
+ const available = await availableContainerRuntimes(which)
900
+ if (!available.length) {
901
+ fail('docker/podman not found.\nInstall one first: Docker Desktop (macOS/Windows) or podman.')
902
+ }
903
+ let runtime = preferredRuntime
904
+ if (!runtime) {
905
+ if (available.length === 1) {
906
+ runtime = available[0]
907
+ } else {
908
+ runtime = interactive
909
+ ? await askChoice(rl, 'Container runtime to use?', available.map((r) => [r, r]), available[0])
910
+ : available[0]
911
+ }
912
+ } else if (!available.includes(runtime)) {
913
+ fail(`Runtime "${runtime}" was requested via --runtime, but it is not installed or available on this system.`)
914
+ }
915
+ const { version } = await latestRelease()
916
+ const image = `${GHCR_IMAGE}:${version}`
917
+ process.stdout.write(`Pulling ${image} via ${runtime} ...\n`)
918
+ await exec(runtime, ['pull', image])
919
+ process.stdout.write(' image ready\n')
920
+
921
+ // Fixed container name: if it already exists, the user must decide;
922
+ // remove it manually so old data is not lost by accident.
923
+ if (await containerExists(runtime)) {
924
+ fail(`Container "${CONTAINER_NAME}" already exists.\nRemove it first if you really want to reinstall: ${runtime} rm -f ${CONTAINER_NAME}`)
925
+ }
926
+
927
+ const knlViewDir = knlGlobalDir(home)
928
+ await fsp.mkdir(knlViewDir, { recursive: true, mode: 0o700 })
929
+ try { await fsp.chmod(knlViewDir, 0o700) } catch {}
930
+ const dataDir = path.join(knlViewDir, 'anchordb-data')
931
+ await fsp.mkdir(dataDir, { recursive: true, mode: 0o770 })
932
+ try { await fsp.chmod(dataDir, 0o770) } catch {}
933
+ const baseArgs = ['run', '-d', '--name', CONTAINER_NAME,
934
+ '-p', `${httpPort}:${httpPort}`,
935
+ '-p', `${wirePort}:${wirePort}`,
936
+ '-e', `ANCHORDB_AUTH_TOKEN=${token}`,
937
+ '-e', 'ANCHORDB_DATA_DIR=/data',
938
+ '-e', `ANCHORDB_PORT=${httpPort}`,
939
+ '-e', `ANCHORDB_WIRE_PORT=${wirePort}`]
940
+ // The image runs as user "anchordb" (not root). Rootless podman maps
941
+ // that uid to a different host uid, so data files owned by the host
942
+ // user become unreadable ("permission denied"). keep-id runs the
943
+ // container process with the host user's uid, so binary data and the
944
+ // container can share the folder. Docker (macOS/Windows) runs in a VM
945
+ // and its bind mounts are unaffected, hence no flag here.
946
+ if (runtime === 'podman') baseArgs.push('--userns=keep-id')
947
+ daemonType = runtime
948
+ let containerId = null
949
+ let containerCreatedOn = new Date().toISOString()
950
+ process.stdout.write(`Starting container ...\n`)
951
+ try {
952
+ const { stdout } = await exec(runtime, [...baseArgs, '-v', `${dataDir}:/data:Z`, image])
953
+ containerId = stdout.trim().split('\n')[0]
954
+ } catch {
955
+ // Rootless/sandbox setups sometimes reject volume binds; without a
956
+ // volume the data dies with the container, so we say so plainly.
957
+ process.stdout.write(' volume could not be mounted; data is ephemeral inside the container\n')
958
+ const { stdout } = await exec(runtime, [...baseArgs, image])
959
+ containerId = stdout.trim().split('\n')[0]
960
+ }
961
+ try {
962
+ const { stdout: idOut } = await exec(runtime, ['inspect', '--format', '{{.Id}}', CONTAINER_NAME])
963
+ if (idOut.trim()) containerId = idOut.trim()
964
+ const { stdout: createdOut } = await exec(runtime, ['inspect', '--format', '{{.Created}}', CONTAINER_NAME])
965
+ if (createdOut.trim()) containerCreatedOn = createdOut.trim()
966
+ } catch {
967
+ // fallback to captured containerId and timestamp
968
+ }
969
+ containerMetadata = {
970
+ container_created_on: containerCreatedOn,
971
+ container_name: CONTAINER_NAME,
972
+ container_id: containerId
973
+ }
974
+ await waitAnchorHealth(httpPort)
975
+ started = true
976
+ }
977
+ } catch (err) {
978
+ fail(`Install failed: ${err.message || err}`)
979
+ }
980
+
981
+ const anchordbBlock = {
982
+ url: `wire://127.0.0.1:${wirePort}`,
983
+ token,
984
+ sync: true,
985
+ deamon: daemonType
986
+ }
987
+ if (containerMetadata) {
988
+ anchordbBlock.container_created_on = containerMetadata.container_created_on
989
+ anchordbBlock.container_name = containerMetadata.container_name
990
+ anchordbBlock.container_id = containerMetadata.container_id
991
+ }
992
+
993
+ const before = await mergeGlobalAnchordb(home, anchordbBlock)
994
+ if (before) {
995
+ process.stdout.write(' (old anchordb block in the global config overwritten; new token applied)\n')
996
+ }
997
+
998
+ process.stdout.write(
999
+ `\nAnchorDB ready (optional layer: projects without an anchordb block\n` +
1000
+ ` in the global config still run fully local).\n` +
1001
+ ` HTTP http://127.0.0.1:${httpPort}/health${started ? ' (already responding)' : ''}\n` +
1002
+ ` wire wire://127.0.0.1:${wirePort}\n` +
1003
+ ` token ${token.slice(0, 8)}... (full value in ${globalConfigPath(home)})\n` +
1004
+ ` config ${globalConfigPath(home)}\n\n` +
1005
+ `Next steps (if this project should use sync):\n` +
1006
+ ` 1. In your project: knl-view model new knowledges\n` +
1007
+ ` 2. knl-view, the "anchordb connected" line appears once the graph is in sync\n`
1008
+ )
1009
+ if (!started) {
1010
+ process.stdout.write(
1011
+ ` 3. Server not started yet; start it manually:\n` +
1012
+ ` ~/.knlview/bin/anchordb-server (env: ANCHORDB_AUTH_TOKEN, ANCHORDB_DATA_DIR)\n`
1013
+ )
1014
+ }
1015
+ if (ownsRl && rl) rl.close()
1016
+ }
1017
+
1018
+ async function waitAnchorHealth(httpPort = HTTP_PORT_DEFAULT, attempts = 30) {
1019
+ for (let i = 0; i < attempts; i++) {
1020
+ try {
1021
+ const res = await fetch(`http://127.0.0.1:${httpPort}/health`, { signal: AbortSignal.timeout(2000) })
1022
+ if (res.ok) return
1023
+ } catch {
1024
+ // not ready yet, retry
1025
+ }
1026
+ await new Promise((resolve) => setTimeout(resolve, 500))
1027
+ }
1028
+ throw new Error(`server not responding at http://127.0.0.1:${httpPort}/health within ${attempts / 2} seconds, check the anchordb log`)
1029
+ }
1030
+
1031
+ async function containerExists(runtime) {
1032
+ try {
1033
+ await exec(runtime, ['container', 'inspect', CONTAINER_NAME])
1034
+ return true
1035
+ } catch {
1036
+ return false
1037
+ }
1038
+ }
1039
+
1040
+ // Claims and their agrees_with/contradicts relations live in the AnchorDB
1041
+ // consensus model; the role must be mapped in the project's models map.
1042
+ async function runConsensusCommand(sub, args, { settings }) {
1043
+ if (!settings.anchordb) {
1044
+ fail('Consensus lives in AnchorDB, which is not configured.\n' +
1045
+ 'Run "knl-view install anchordb" first (optional layer).')
1046
+ }
1047
+ const consensusModel = settings.models?.consensus
1048
+ if (!consensusModel) {
1049
+ fail('Role "consensus" is not mapped in this project\'s models map.\n' +
1050
+ 'Run: knl-view model new consensus')
1051
+ }
1052
+ const base = anchordbClientOptions(settings.anchordb)
1053
+ const client = await createAnchorWireClient({ ...base, model: consensusModel })
1054
+
1055
+ const usage = 'Usage:\n' +
1056
+ ' knl-view consensus claim "<statement>"\n' +
1057
+ ' knl-view consensus agree <claimId> <otherClaimId>\n' +
1058
+ ' knl-view consensus contradict <claimId> <otherClaimId>\n' +
1059
+ ' knl-view consensus retract <claimId> <otherClaimId>\n' +
1060
+ ' knl-view consensus status <claimId>\n' +
1061
+ ' knl-view consensus list'
1062
+
1063
+ const validClaimArg = (value) => {
1064
+ if (!CLAIM_ID_RE.test(value || '')) {
1065
+ fail(`Not a claim id: "${value || '(empty)'}".\nClaim ids look like c-a1b2c3d4e5f6a1b2; create one with:\n knl-view consensus claim "<statement>"`)
1066
+ }
1067
+ return value
1068
+ }
1069
+
1070
+ const link = async (type, args) => {
1071
+ const [from, to] = args
1072
+ validClaimArg(from)
1073
+ validClaimArg(to)
1074
+ if (from === to) fail('A claim cannot agree with or contradict itself.')
1075
+ await client.link(from, type, to, 1.0)
1076
+ const { error } = await updateConsensusMirror(consensusModel, { op: 'link', from, type, to })
1077
+ if (error) process.stderr.write(`knl-view: mirror not updated (${error})\n`)
1078
+ process.stdout.write(`${type}: ${from} -> ${to}\n`)
1079
+ }
1080
+
1081
+ if (sub === 'claim') {
1082
+ const text = (args || []).join(' ').trim()
1083
+ if (!text) fail(`A claim needs a statement.\n${usage}`)
1084
+ const { id, error } = await recordClaim(consensusModel, text)
1085
+ if (error) process.stderr.write(`knl-view: claim index not updated (${error})\n`)
1086
+ process.stdout.write(`claim ${id}\n "${text}"\n (relations are created by agree/contradict; status: knl-view consensus status ${id})\n`)
1087
+ return
1088
+ }
1089
+
1090
+ if (sub === 'agree') return link('agrees_with', args)
1091
+ if (sub === 'contradict') return link('contradicts', args)
1092
+
1093
+ if (sub === 'retract') {
1094
+ const [from, to] = args
1095
+ validClaimArg(from)
1096
+ validClaimArg(to)
1097
+ for (const type of CONSENSUS_RELATIONS) {
1098
+ // Unlinking a missing edge is not an error: retract is idempotent.
1099
+ await client.unlink(from, type, to).catch(() => {})
1100
+ }
1101
+ const { error } = await updateConsensusMirror(consensusModel, { op: 'unlink', from, type: '*', to })
1102
+ if (error) process.stderr.write(`knl-view: mirror not updated (${error})\n`)
1103
+ process.stdout.write(`retracted: ${from} -/-> ${to}\n`)
1104
+ return
1105
+ }
1106
+
1107
+ if (sub === 'status' || sub === 'list') {
1108
+ const { claims, error } = await readClaimsIndex(consensusModel)
1109
+ if (error) process.stderr.write(`knl-view: claim index ignored (${error})\n`)
1110
+
1111
+ const statusOne = async (id) => {
1112
+ const mirrorEdges = await readConsensusMirror(consensusModel)
1113
+ // null = no mirror file at all; an existing-but-empty mirror is still
1114
+ // valid knowledge (everything was retracted). Non-empty mirrors are
1115
+ // probed against the server first: it may have been wiped or replaced
1116
+ // since the mirror was written; distrust it the same way the
1117
+ // knowledges sync does.
1118
+ let mirrored = Array.isArray(mirrorEdges)
1119
+ if (mirrored && mirrorEdges.length > 0) {
1120
+ mirrored = await mirrorStillValid(client, mirrorEdges).catch(() => false)
1121
+ }
1122
+ const usable = mirrored ? mirrorEdges : null
1123
+ const inbound = inboundConsensus(usable || [], id)
1124
+ const stance = outboundConsensus(usable || [], id)
1125
+ const status = deriveStatus(inbound.supporters, inbound.objectors)
1126
+ const text = claims[id]?.text ? ` "${claims[id].text}"` : ''
1127
+ process.stdout.write(`${id}${text}\n status: ${status}${mirrored ? '' : ' (no valid local mirror; relation data unavailable)'}\n`)
1128
+ process.stdout.write(` supporters: ${inbound.supporters.join(', ') || '(none)'}\n`)
1129
+ process.stdout.write(` objectors: ${inbound.objectors.join(', ') || '(none)'}\n`)
1130
+ process.stdout.write(` stance: agrees with ${stance.agrees.join(', ') || '(none)'}; contradicts ${stance.contradicts.join(', ') || '(none)'}\n`)
1131
+ }
1132
+
1133
+ if (sub === 'status') {
1134
+ await statusOne(validClaimArg(args?.[0]))
1135
+ return
1136
+ }
1137
+
1138
+ const ids = Object.keys(claims)
1139
+ if (!ids.length) {
1140
+ process.stdout.write('No claims known locally. Create one:\n knl-view consensus claim "<statement>"\n')
1141
+ return
1142
+ }
1143
+ for (const id of ids) await statusOne(id)
1144
+ return
1145
+ }
1146
+
1147
+ fail(`Unknown consensus subcommand: ${sub || '(empty)'}\n${usage}`)
1148
+ }
1149
+
1150
+ // The "sources" subcommands: the whole project as a dependency graph in the
1151
+ // AnchorDB sources model. Everything lives on the server: file nodes are
1152
+ // records (list-able), import edges are stored in both directions, and sync
1153
+ // enumerates the server's own state instead of a local mirror.
1154
+ async function runSourcesCommand(sub, values, { settings }) {
1155
+ if (!settings.anchordb) {
1156
+ fail('The sources graph lives in AnchorDB, which is not configured.\n' +
1157
+ 'Run "knl-view install anchordb" first (optional layer).')
1158
+ }
1159
+ const sourcesModel = settings.models?.sources
1160
+ if (!sourcesModel) {
1161
+ fail('Role "sources" is not mapped in this project\'s models map.\n' +
1162
+ 'Run: knl-view model new sources')
1163
+ }
1164
+ const base = anchordbClientOptions(settings.anchordb)
1165
+ const client = await createAnchorWireClient({ ...base, model: sourcesModel })
1166
+ const projectRoot = process.cwd()
1167
+
1168
+ if (sub === 'sync') {
1169
+ const result = await syncSourcesToAnchorDB(client, projectRoot, {
1170
+ labelDir: settings.labelDir,
1171
+ include: settings.include,
1172
+ exclude: settings.exclude
1173
+ })
1174
+ const parts = [
1175
+ `${result.files} files`,
1176
+ `${result.symbols} symbols`,
1177
+ `${result.edges} edges`
1178
+ ]
1179
+ if (result.linked) parts.push(`+${result.linked} linked`)
1180
+ if (result.unlinked) parts.push(`-${result.unlinked} unlinked`)
1181
+ if (result.saved) parts.push(`${result.saved} record${result.saved === 1 ? '' : 's'} saved`)
1182
+ if (result.tombstoned) parts.push(`${result.tombstoned} tombstoned`)
1183
+ if (result.orphans) parts.push(`${result.orphans} ORPHAN EDGES`)
1184
+ if (result.labels.written) parts.push(`${result.labels.written} labels written`)
1185
+ if (result.labels.deleted) parts.push(`${result.labels.deleted} labels deleted`)
1186
+ if (result.failed) parts.push(`${result.failed} FAILED`)
1187
+ process.stdout.write(`sources synced: ${parts.join(', ')}\n`)
1188
+ if (result.failed) {
1189
+ process.stderr.write('knl-view: some operations failed; run "knl-view sources sync" again to retry.\n')
1190
+ }
1191
+ if (result.tombstoned) {
1192
+ await runSourcesCollector(base, sourcesModel, { quiet: false })
1193
+ }
1194
+ return
1195
+ }
1196
+
1197
+ if (sub === 'collect') {
1198
+ const purged = await runSourcesCollector(base, sourcesModel, { quiet: false })
1199
+ if (!purged) process.stdout.write('nothing to purge: no archived records for this model\n')
1200
+ return
1201
+ }
1202
+
1203
+ if (sub === 'trace') {
1204
+ const target = values.target
1205
+ if (!target) {
1206
+ fail('Parameter --target is required:\n knl-view sources trace --target=<repo-relative path> [--hops=2] [--direction=in|out|both]')
1207
+ }
1208
+ let direction = 'both'
1209
+ if (values.direction !== undefined) {
1210
+ if (!['in', 'out', 'both'].includes(values.direction)) {
1211
+ fail(`--direction must be in, out, or both, got "${values.direction}".`)
1212
+ }
1213
+ direction = values.direction
1214
+ }
1215
+ const hops = Number.parseInt(values.hops || '2', 10)
1216
+ const maxHops = Number.isInteger(hops) && hops > 0 ? hops : 2
1217
+ const { out, in: inc } = await traceSources(client, target, direction, maxHops)
1218
+ process.stdout.write(JSON.stringify({
1219
+ center: target,
1220
+ maxHops,
1221
+ direction,
1222
+ out: out.map(([id, hop]) => ({ id, hop })),
1223
+ in: inc.map(([id, hop]) => ({ id, hop }))
1224
+ }, null, 2) + '\n')
1225
+ return
1226
+ }
1227
+
1228
+ fail('Unknown sources subcommand.\n' +
1229
+ 'Usage:\n' +
1230
+ ' knl-view sources sync\n' +
1231
+ ' knl-view sources collect\n' +
1232
+ ' knl-view sources trace --target=<repo-relative path> [--hops=2] [--direction=in|out|both]')
1233
+ }
1234
+
1235
+ // Wire cannot start collector jobs, so archived records are purged over
1236
+ // REST. Best effort: a missing endpoint must not fail a sync.
1237
+ async function runSourcesCollector(base, model, { quiet }) {
1238
+ try {
1239
+ const purged = await purgeArchivedRecords(`wire://${base.host}:${base.port}`, base.token, model)
1240
+ if (purged) process.stdout.write(` collector purged ${purged} archived record${purged === 1 ? '' : 's'}\n`)
1241
+ else if (!quiet) process.stdout.write(' collector ran: nothing to purge\n')
1242
+ return purged
1243
+ } catch (err) {
1244
+ process.stderr.write(`knl-view: collector unavailable (${err.message}); archived records stay hidden until collected.\n` +
1245
+ ' Run "knl-view sources collect" once the REST endpoint is reachable.\n')
1246
+ return 0
1247
+ }
1248
+ }
1249
+
1250
+ function fail(message) {
1251
+ process.stderr.write(`knl-view: ${message}\n`)
1252
+ process.exit(1)
1253
+ }
1254
+
1255
+ // The "model new" and "model check" commands manage the role → AnchorDB
1256
+ // model name map. Names may be random; what gives them meaning is this map
1257
+ // plus the global registry ~/.knlview/models.json (owner of every name).
1258
+
1259
+ async function runModelCommand(sub, roleArg, { projectConfig, settings }) {
1260
+ if (sub === 'new') {
1261
+ const role = (roleArg || '').trim().toLowerCase()
1262
+ if (!MODEL_ROLE_RE.test(role)) {
1263
+ fail(`Role must be a lowercase slug (letters, digits, -, _), got "${roleArg || '(none)'}".\nExample: knl-view model new knowledges`)
1264
+ }
1265
+ const existing = settings.models?.[role]
1266
+ if (existing) {
1267
+ fail(`Role "${role}" is already mapped to model "${existing}".\nEdit knl-config.json if you really want to change it.`)
1268
+ }
1269
+ const name = generateModelName()
1270
+ const { previous, error } = await registerModel(name, {
1271
+ role,
1272
+ project: projectSlug(process.cwd()),
1273
+ cwd: process.cwd()
1274
+ })
1275
+ if (error) fail(`Failed to write registry (${error}). The new name is not used.`)
1276
+ if (previous) {
1277
+ fail(`Name "${name}" is already used by another project (${previous.cwd}) for role "${previous.role}".\nRun again for a new name.`)
1278
+ }
1279
+ const next = { ...projectConfig, models: { ...(projectConfig?.models || {}), [role]: name } }
1280
+ delete next.model // the map replaces the legacy "model" shape
1281
+ fs.writeFileSync(path.join(process.cwd(), 'knl-config.json'), JSON.stringify(next, null, 2) + '\n', 'utf8')
1282
+ process.stdout.write(
1283
+ `model ${name} → role ${role}\n` +
1284
+ ` knl-config.json: models.${role} = "${name}"\n` +
1285
+ ` registry: ${modelRegistryPath()}\n`
1286
+ )
1287
+ return
1288
+ }
1289
+
1290
+ if (sub === 'check') {
1291
+ const { models: registry, error } = await readModelRegistry()
1292
+ if (error) process.stderr.write(`knl-view: ignoring registry (${error})\n`)
1293
+ const cwd = path.resolve(process.cwd())
1294
+ const map = settings.models || {}
1295
+ const roles = Object.keys(map)
1296
+ process.stdout.write(`Models map of this project (${projectSlug(cwd)}):\n`)
1297
+ if (!roles.length) {
1298
+ process.stdout.write(' (empty, run "knl-view model new knowledges")\n')
1299
+ }
1300
+ for (const role of roles) {
1301
+ const name = map[role]
1302
+ const entry = registry[name]
1303
+ const owner = entry ? `${entry.project}/${entry.role}` : 'not registered'
1304
+ const clash = entry && path.resolve(entry.cwd || '') !== cwd
1305
+ process.stdout.write(` ${role.padEnd(12)} ${name} (${owner}${clash ? ' - CLASH: claimed by ' + entry.cwd : ''})\n`)
1306
+ }
1307
+ const others = Object.entries(registry).filter(([, e]) => path.resolve(e.cwd || '') !== cwd)
1308
+ if (others.length) {
1309
+ process.stdout.write('Models of other projects in the registry:\n')
1310
+ for (const [name, e] of others) {
1311
+ process.stdout.write(` ${name} → ${e.project}/${e.role} (${e.cwd})\n`)
1312
+ }
1313
+ }
1314
+ process.stdout.write('Note: model existence on the server cannot be checked over the wire protocol;\n')
1315
+ process.stdout.write('what is checked here is map consistency, the registry, and name ownership.\n')
1316
+ return
1317
+ }
1318
+
1319
+ fail(`Unknown model command: ${sub || '(none)'}\nUse "model new <role>" or "model check".`)
1320
+ }