@cavelang/cli 0.4.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/cli.ts CHANGED
@@ -6,9 +6,13 @@
6
6
  * Commands:
7
7
  *
8
8
  * - `cave parse [file]` — lint / dump the AST (`--json`)
9
- * - `cave add [--db <path>] [file…]` — ingest into a store (`--strict`)
10
- * - `cave query [--db <path>] '<pattern>'` — CAVE-Q (`--json`, `--all`)
9
+ * - `cave highlight [file]` — ANSI syntax colors (async, routed in `main.ts`)
10
+ * - `cave add [--db <path>] [file…]` — ingest into a store (`--strict`, `--check`)
11
+ * - `cave query [--db <path>] '<pattern>'` — CAVE-Q (`--json`, `--all`, `--aliases`, `--as-of`)
12
+ * - `cave derive [--db <path>] [rules.cave…]` — fire rules (`--dry-run`, `--full`, `--list`, `--retract`)
13
+ * - `cave check [--db <path>]` — knowledge health report (`--stale`, `--json`)
11
14
  * - `cave export [--db <path>]` — canonical text out (`--current`)
15
+ * - `cave connect [--db <path>] <source>` — deterministic structured ingestion (async, routed in `main.ts`)
12
16
  * - `cave demo` — the cave-loop multi-hop recovery demo
13
17
  * - `cave version` — print the cave version
14
18
  * - `cave help [command]` — overview, or one command's help
@@ -24,6 +28,10 @@ import { parseDocument } from '@cavelang/parser'
24
28
  import { Registry, standardRegistry } from '@cavelang/canonical'
25
29
  import { defaultDbPath, open } from '@cavelang/store'
26
30
  import { query as caveQuery } from '@cavelang/query'
31
+ import { check as caveCheck, defaultStaleDays, gatedIngest } from '@cavelang/shape'
32
+ import type { Report, Violation } from '@cavelang/shape'
33
+ import { declareRules, defaultMaxPasses, defaultMinConf, derive, listRules, retractRule } from '@cavelang/rules'
34
+ import type { Declaration, DeriveReport } from '@cavelang/rules'
27
35
  import { Demo } from '@cavelang/loop'
28
36
 
29
37
  export type Output = {
@@ -42,12 +50,16 @@ export const usage = `cave — Compressed Atomic Verb Expressions
42
50
 
43
51
  Usage:
44
52
  cave parse [file...] [--json] lint CAVE text (stdin when no file)
45
- cave add [--db <path>] [file...] ingest into a store [--strict] [--no-prelude]
46
- cave import [--db <path>] [file...] restore/merge from CAVE text (same as add)
47
- cave query [--db <path>] <pattern> run a CAVE-Q pattern [--json] [--all] [--no-prelude]
53
+ cave highlight [file...] print CAVE text with ANSI syntax colors
54
+ cave add [--db <path>] [file...] ingest into a store [--strict] [--check] [--no-prelude] [--no-src]
55
+ cave import [--db <path>] [file...] restore/merge from CAVE text (add without @src: stamping)
56
+ cave query [--db <path>] <pattern> run a CAVE-Q pattern [--json] [--all] [--aliases] [--as-of <t>] [--no-prelude]
57
+ cave derive [--db <path>] [rules.cave..] declare + fire rules (spec §24) [--dry-run] [--full] [--list] [--retract <rule>]
58
+ cave check [--db <path>] knowledge health report (spec §20) [--stale <days>] [--json]
48
59
  cave export [--db <path>] [--out <file>] emit canonical CAVE text [--current] [--no-prelude]
49
60
  cave mcp [--db <path>] serve the engine as an MCP server on stdio [--no-prelude]
50
61
  cave ingest [--db <path>] <globs/urls..> LLM-driven ingestion of files and web pages
62
+ cave connect <source> --map <file> deterministic structured ingestion (CSV/JSON/SQLite/URL, spec §23)
51
63
  cave demo run the cave-loop reconstruction demo
52
64
  cave version print the cave version
53
65
  cave help [command] this text, or one command's options and examples
@@ -76,24 +88,46 @@ Examples:
76
88
  echo 'auth USES jwt @ 90%' | cave parse
77
89
  cave parse notes.cave --json | jq '.lines[0]'`,
78
90
 
91
+ highlight: `cave highlight — print CAVE text with ANSI syntax colors
92
+
93
+ Usage:
94
+ cave highlight [file...]
95
+
96
+ Reads stdin when no file (or \`-\`) is given. Colors come from the
97
+ tree-sitter grammar's highlight query (@cavelang/tree-sitter-cave), the
98
+ same source editors use. Output always carries ANSI codes — pipe through
99
+ \`less -R\` to page. \`cave export\` colors its output the same way when
100
+ stdout is a terminal (disable with NO_COLOR).
101
+
102
+ Examples:
103
+ cave highlight notes.cave
104
+ echo 'auth USES jwt @ 90%' | cave highlight
105
+ cave highlight notes.cave | less -R`,
106
+
79
107
  add: `cave add — ingest CAVE text into a knowledge database
80
108
 
81
109
  Usage:
82
- cave add [--db <path>] [file...] [--strict] [--no-prelude]
110
+ cave add [--db <path>] [file...] [--strict] [--check] [--no-prelude] [--no-src]
83
111
 
84
112
  Options:
85
113
  ${dbHelp}
86
114
  --strict reject the whole ingest on any problem
115
+ --check shape gate (spec §20.3): roll the append back if it
116
+ introduces new expectation violations
87
117
  --no-prelude open the store without the standard verb registry
118
+ --no-src do not stamp actor provenance on appended claims
88
119
 
89
- Reads stdin when no file (or \`-\`) is given.
120
+ Reads stdin when no file (or \`-\`) is given. Claims that carry no @src:
121
+ context are stamped @src:cli (spec §9.5); use \`cave import\` to replay
122
+ exported text without stamping.
90
123
 
91
124
  Examples:
92
125
  cave add --db k.db notes.cave
93
126
  echo 'auth USES jwt @ 90%' | cave add
94
- cave add --db k.db --strict reviewed.cave`,
127
+ cave add --db k.db --strict reviewed.cave
128
+ cave add --db k.db --check new-services.cave`,
95
129
 
96
- import: `cave import — restore/merge a database from CAVE text (same as add)
130
+ import: `cave import — restore/merge a database from CAVE text (add without @src: stamping)
97
131
 
98
132
  Usage:
99
133
  cave import [--db <path>] [file...] [--strict] [--no-prelude]
@@ -105,7 +139,9 @@ Options:
105
139
 
106
140
  Canonical CAVE text is the interchange format (spec §2.2): importing a
107
141
  file written by \`cave export\` replays its claims append-only, preserving
108
- the belief series, qualifier edges and in-band declarations.
142
+ the belief series, qualifier edges and in-band declarations. Unlike
143
+ \`cave add\`, import never stamps actor provenance — replayed claims must
144
+ keep the claim keys they were exported with (spec §9.5).
109
145
 
110
146
  Examples:
111
147
  cave export --db old.db --out backup.cave
@@ -114,12 +150,16 @@ Examples:
114
150
  query: `cave query — run a CAVE-Q pattern against a store
115
151
 
116
152
  Usage:
117
- cave query [--db <path>] <pattern> [WHERE <filter>] [--json] [--all] [--no-prelude]
153
+ cave query [--db <path>] <pattern> [WHERE <filter>] [--json] [--all] [--aliases] [--as-of <t>] [--no-prelude]
118
154
 
119
155
  Options:
120
156
  ${dbHelp}
121
157
  --json emit matches as JSON
122
158
  --all match all beliefs, not just current ones
159
+ --aliases resolve entities through current ALIAS claims (spec §13.6)
160
+ --as-of <t> resolve beliefs as of a past moment (spec §12.3): a date
161
+ (whole day included), a timestamp (whole second), or a
162
+ transaction id — rows recorded later are invisible
123
163
  --no-prelude open the store without the standard verb registry
124
164
 
125
165
  Patterns are claim triples with ?variables and optional metadata filters
@@ -131,8 +171,71 @@ Examples:
131
171
  cave query --db k.db '?x HAS bug: ?bug #security'
132
172
  cave query --db k.db '?cause CAUSE app/crash' 'WHERE conf >= 0.5'
133
173
  cave query --db k.db 'terrier EXTENDS+ animal'
174
+ cave query --db k.db '?x USES postgres' --aliases
175
+ cave query --db k.db 'server IS compromised' --as-of 2026-01-15
134
176
  cave query --db k.db '?x ?verb ?y @production' --json`,
135
177
 
178
+ derive: `cave derive — declare and fire rules (spec §24)
179
+
180
+ Usage:
181
+ cave derive [--db <path>] [rules.cave...] [--dry-run] [--full] [--aliases]
182
+ [--min-conf <p>] [--max-passes <n>] [--json] [--no-prelude]
183
+ cave derive [--db <path>] --list
184
+ cave derive [--db <path>] --retract <digest|subject>
185
+
186
+ Options:
187
+ ${dbHelp}
188
+ --dry-run compute and report inside a rolled-back transaction
189
+ --full ignore stored watermarks — re-fire every rule (spec §24.4)
190
+ --aliases premises match through the alias closure (spec §13.6)
191
+ --min-conf <p> do not assert conclusions below this confidence
192
+ (0..1 or N%, default ${defaultMinConf})
193
+ --max-passes <n> fixpoint guard (default ${defaultMaxPasses})
194
+ --list print the store's current rules and exit
195
+ --retract <r> retract a rule (digest, unambiguous prefix, or subject)
196
+ together with everything it derived, and exit
197
+ --json emit the report as JSON
198
+ --no-prelude open the store without the standard verb registry
199
+
200
+ Rule files are CAVE documents whose \`premises => conclusion\` lines are
201
+ rules (spec §24.1); other lines — verb declarations the rules need — are
202
+ prelude, ingested first. Rules are stored in-band as
203
+ \`rule/<digest> HAS rule: \\\`…\\\`\` claims, so \`cave derive\` with no file
204
+ fires whatever the store already knows. Derived claims are stamped
205
+ @src:rule/<digest>, carry BECAUSE edges to their premise rows and a VIA
206
+ edge to the rule, and update append-only: re-runs are idempotent and
207
+ watermark-incremental, and conclusions whose premises no longer hold are
208
+ retracted (spec §24.2–§24.5).
209
+
210
+ Examples:
211
+ cave derive --db k.db rules.cave
212
+ cave derive --db k.db
213
+ echo '?x NEEDS ?y, ?y NEEDS ?z => ?x NEEDS ?z' | cave derive --db k.db -
214
+ cave derive --db k.db --dry-run --json
215
+ cave derive --db k.db --list
216
+ cave derive --db k.db --retract 4a0bb974f43c`,
217
+
218
+ check: `cave check — knowledge health report (spec §20)
219
+
220
+ Usage:
221
+ cave check [--db <path>] [--stale <days>] [--json] [--no-prelude]
222
+
223
+ Options:
224
+ ${dbHelp}
225
+ --stale <days> staleness horizon (default: ${defaultStaleDays})
226
+ --json emit the full report as JSON
227
+ --no-prelude open the store without the standard verb registry
228
+
229
+ Reads the store against its own in-band EXPECTS declarations (spec §20.1)
230
+ and reports shape violations, stale current beliefs, review candidates
231
+ (conf 0.3–0.7), alias disagreements (spec §13.6) and coverage stats.
232
+ Exits 1 when violations exist; everything else is advisory.
233
+
234
+ Examples:
235
+ cave check --db k.db
236
+ cave check --db k.db --stale 30
237
+ cave check --db k.db --json | jq '.violations'`,
238
+
136
239
  export: `cave export — emit canonical CAVE text from a store
137
240
 
138
241
  Usage:
@@ -195,26 +298,63 @@ export const parseCommand = (argv: readonly string[]): Output => {
195
298
  return { code: 1, out: `${summary}\n`, err: `${problems}\n` }
196
299
  }
197
300
 
198
- const ingestCommand = (name: string) => (argv: readonly string[]): Output => {
301
+ /**
302
+ * `cave highlight` — ANSI syntax colors from the tree-sitter grammar's own
303
+ * highlight query. Async (the grammar WASM loads on first use), so `main.ts`
304
+ * routes it separately, like `mcp` and `ingest`.
305
+ */
306
+ export const highlightCommand = async (argv: readonly string[]): Promise<Output> => {
307
+ const { positionals } = parseArgs({ args: [...argv], options: {}, allowPositionals: true })
308
+ const input = readInput(positionals)
309
+ const { highlighter } = await import('@cavelang/highlight')
310
+ const { ansi } = await highlighter()
311
+ return ok(ansi(input))
312
+ }
313
+
314
+ /**
315
+ * Shared by `add` and `import`; the difference is actor provenance
316
+ * (spec §9.5). `add` records knowledge authored now, so claims without a
317
+ * `@src:` context are stamped `@src:cli` (`--no-src` opts out); `import`
318
+ * replays interchange text, which must preserve claim keys as exported,
319
+ * so it never stamps.
320
+ */
321
+ const formatViolation = (violation: Violation): string =>
322
+ `${violation.entity} missing ${violation.expectation.kind} ${violation.expectation.name} ` +
323
+ `(${violation.entity} IS ${violation.via}; ${violation.expectation.type} EXPECTS ${violation.expectation.name})`
324
+
325
+ const ingestCommand = (name: 'add' | 'import') => (argv: readonly string[]): Output => {
199
326
  const { values, positionals } = parseArgs({
200
327
  args: [...argv],
201
328
  options: {
202
329
  db: { type: 'string' },
203
330
  strict: { type: 'boolean' },
204
- 'no-prelude': { type: 'boolean' }
331
+ 'no-prelude': { type: 'boolean' },
332
+ ...name === 'add' ? { 'no-src': { type: 'boolean' }, check: { type: 'boolean' } } : {}
205
333
  },
206
334
  allowPositionals: true
207
335
  })
208
336
  const input = readInput(positionals)
209
337
  const store = open(values.db ?? defaultDbPath(), values['no-prelude'] === true ? { registry: Registry.empty } : {})
210
338
  try {
211
- const result = store.ingest(input, { strict: values.strict === true })
212
- const problems = result.problems
339
+ const options = {
340
+ strict: values.strict === true,
341
+ ...name === 'add' && values['no-src'] !== true ? { source: 'cli' } : {}
342
+ }
343
+ // The shape gate (spec §20.3): append + check in one transaction,
344
+ // rolled back when the append introduces new expectation violations.
345
+ const outcome = name === 'add' && values.check === true ?
346
+ gatedIngest(store, input, options) :
347
+ { ok: true as const, result: store.ingest(input, options) }
348
+ if (!outcome.ok) {
349
+ const detail = outcome.violations.map(violation => ` ${formatViolation(violation)}`).join('\n')
350
+ return fail(`rejected: ${outcome.violations.length} new violation(s), nothing added (spec §20.3)\n${detail}\n`)
351
+ }
352
+ const problems = outcome.result.problems
213
353
  .map(problem => `line ${problem.line}: ${problem.message}`)
214
354
  .join('\n')
215
355
  return {
216
356
  code: 0,
217
- out: `added ${result.ids.length} claim(s), ${result.edges} edge(s)\n`,
357
+ out: `added ${outcome.result.ids.length} claim(s), ${outcome.result.edges} edge(s)\n`,
218
358
  err: problems === '' ? '' : `${problems}\n`
219
359
  }
220
360
  } catch (error) {
@@ -228,13 +368,15 @@ export const addCommand = ingestCommand('add')
228
368
 
229
369
  /**
230
370
  * `cave import` — restore/merge a knowledge database from CAVE text files.
231
- * Identical to `add` by design: canonical CAVE text *is* the interchange
371
+ * `add` minus provenance stamping: canonical CAVE text *is* the interchange
232
372
  * format (spec §2.2), so importing a file exported with `cave export`
233
- * replays its claims append-only. What the text round trip preserves:
234
- * every claim with metadata, full belief-series order (rows export in tx
235
- * order and re-ingest with fresh monotonic tx ids), qualifier/grouping
236
- * edges, and in-band registry declarations. Original tx timestamps are
237
- * re-minted — the text format carries no transaction identity.
373
+ * replays its claims append-only — and because contexts are part of claim
374
+ * identity, replay must not add a `@src:` stamp (spec §9.5). What the text
375
+ * round trip preserves: every claim with metadata, full belief-series order
376
+ * (rows export in tx order and re-ingest with fresh monotonic tx ids),
377
+ * qualifier/grouping edges, and in-band registry declarations. Original tx
378
+ * timestamps are re-minted — the text format carries no transaction
379
+ * identity.
238
380
  */
239
381
  export const importCommand = ingestCommand('import')
240
382
 
@@ -245,6 +387,8 @@ export const queryCommand = (argv: readonly string[]): Output => {
245
387
  db: { type: 'string' },
246
388
  json: { type: 'boolean' },
247
389
  all: { type: 'boolean' },
390
+ aliases: { type: 'boolean' },
391
+ 'as-of': { type: 'string' },
248
392
  'no-prelude': { type: 'boolean' }
249
393
  },
250
394
  allowPositionals: true
@@ -255,7 +399,11 @@ export const queryCommand = (argv: readonly string[]): Output => {
255
399
  const pattern = positionals.join('\n')
256
400
  const store = open(values.db ?? defaultDbPath(), values['no-prelude'] === true ? { registry: Registry.empty } : {})
257
401
  try {
258
- const matches = caveQuery(store, pattern, { all: values.all === true })
402
+ const matches = caveQuery(store, pattern, {
403
+ all: values.all === true,
404
+ aliases: values.aliases === true,
405
+ ...values['as-of'] === undefined ? {} : { asOf: values['as-of'] }
406
+ })
259
407
  if (values.json === true) {
260
408
  return ok(`${JSON.stringify(matches, undefined, 2)}\n`)
261
409
  }
@@ -278,6 +426,189 @@ export const queryCommand = (argv: readonly string[]): Output => {
278
426
  }
279
427
  }
280
428
 
429
+ export const deriveCommand = (argv: readonly string[]): Output => {
430
+ const { values, positionals } = parseArgs({
431
+ args: [...argv],
432
+ options: {
433
+ db: { type: 'string' },
434
+ 'dry-run': { type: 'boolean' },
435
+ full: { type: 'boolean' },
436
+ aliases: { type: 'boolean' },
437
+ 'min-conf': { type: 'string' },
438
+ 'max-passes': { type: 'string' },
439
+ list: { type: 'boolean' },
440
+ retract: { type: 'string' },
441
+ json: { type: 'boolean' },
442
+ 'no-prelude': { type: 'boolean' }
443
+ },
444
+ allowPositionals: true
445
+ })
446
+ const minConf = values['min-conf'] === undefined ?
447
+ undefined :
448
+ values['min-conf'].endsWith('%') ? Number(values['min-conf'].slice(0, -1)) / 100 : Number(values['min-conf'])
449
+ if (minConf !== undefined && (!Number.isFinite(minConf) || minConf < 0 || minConf > 1)) {
450
+ return fail(`cave derive: --min-conf expects a confidence in 0..1 or N%, got ${JSON.stringify(values['min-conf'])}\n`)
451
+ }
452
+ const maxPasses = values['max-passes'] === undefined ? undefined : Number(values['max-passes'])
453
+ if (maxPasses !== undefined && (!Number.isInteger(maxPasses) || maxPasses < 1)) {
454
+ return fail(`cave derive: --max-passes expects a positive integer, got ${JSON.stringify(values['max-passes'])}\n`)
455
+ }
456
+ const store = open(values.db ?? defaultDbPath(), values['no-prelude'] === true ? { registry: Registry.empty } : {})
457
+ try {
458
+ if (values.list === true) {
459
+ const rules = listRules(store)
460
+ if (values.json === true) {
461
+ return ok(`${JSON.stringify(rules, undefined, 2)}\n`)
462
+ }
463
+ if (rules.length === 0) {
464
+ return ok('no rules\n')
465
+ }
466
+ const lines = rules.map(rule =>
467
+ `${rule.subject} \`${rule.text}\`${rule.label === undefined ? '' : ` ; ${rule.label}`}` +
468
+ (rule.ok ? '' : `\n problems: ${rule.problems.join('; ')}`))
469
+ return ok(`${lines.join('\n')}\n`)
470
+ }
471
+ if (values.retract !== undefined) {
472
+ const outcome = retractRule(store, values.retract)
473
+ if (!outcome.ok) {
474
+ return fail(`cave derive: ${outcome.error}\n`)
475
+ }
476
+ return ok(`retracted ${outcome.subjects.join(', ')} and ${outcome.derived} derived claim(s)\n`)
477
+ }
478
+
479
+ const err: string[] = []
480
+ const out: string[] = []
481
+ // Unlike parse/add, no positional means "fire the stored rules", never
482
+ // stdin — pass `-` to read a rule file from stdin explicitly.
483
+ const input = positionals.length > 0 ? readInput(positionals) : undefined
484
+ const options = {
485
+ full: values.full === true,
486
+ aliases: values.aliases === true,
487
+ ...minConf === undefined ? {} : { minConf },
488
+ ...maxPasses === undefined ? {} : { maxPasses }
489
+ }
490
+ let declaration: undefined | Declaration
491
+ let report: DeriveReport
492
+ if (values['dry-run'] === true) {
493
+ // A dry run persists nothing — rule declarations from the file
494
+ // included, so declare + fire share one rolled-back transaction.
495
+ const rolledBack = Symbol('cave derive --dry-run')
496
+ let outcome: undefined | { declaration?: Declaration, report: DeriveReport }
497
+ try {
498
+ store.transaction(() => {
499
+ outcome = {
500
+ ...input === undefined ? {} : { declaration: declareRules(store, input) },
501
+ report: derive(store, options)
502
+ }
503
+ throw rolledBack
504
+ })
505
+ } catch (error) {
506
+ if (error !== rolledBack) {
507
+ throw error
508
+ }
509
+ }
510
+ declaration = outcome!.declaration
511
+ report = outcome!.report
512
+ } else {
513
+ declaration = input === undefined ? undefined : declareRules(store, input)
514
+ report = derive(store, options)
515
+ }
516
+ if (declaration !== undefined) {
517
+ err.push(...declaration.problems.map(problem => `rules line ${problem.line}: ${problem.message}`))
518
+ out.push(`declared ${declaration.declared} rule(s)` +
519
+ (declaration.unchanged > 0 ? `, ${declaration.unchanged} unchanged` : '') +
520
+ (declaration.prelude > 0 ? `, +${declaration.prelude} prelude claim(s)` : ''))
521
+ }
522
+ if (values.json === true) {
523
+ return { code: report.problems.length > 0 ? 1 : 0, out: `${JSON.stringify(report, undefined, 2)}\n`, err: err.join('\n') + (err.length > 0 ? '\n' : '') }
524
+ }
525
+ for (const problem of report.problems) {
526
+ err.push(`${problem.subject}: ${problem.problems.join('; ')}`)
527
+ }
528
+ for (const rule of report.rules) {
529
+ const state = rule.fired ?
530
+ `${rule.solutions} solution(s), +${rule.appended} appended, ${rule.updated} updated, ${rule.retracted} retracted, ${rule.unchanged} unchanged` :
531
+ 'unchanged premises, skipped'
532
+ out.push(`${rule.subject}: ${state}${rule.label === undefined ? '' : ` ; ${rule.label}`}`)
533
+ err.push(...rule.problems.map(problem => `${rule.subject}: ${problem}`))
534
+ }
535
+ out.push(...report.notes.map(note => `note: ${note}`))
536
+ out.push(
537
+ `derived${values['dry-run'] === true ? ' (dry run)' : ''}: ` +
538
+ `+${report.appended} appended, ${report.updated} updated, ${report.retracted} retracted, ` +
539
+ `${report.unchanged} unchanged (${report.passes} pass(es))`)
540
+ const hasProblems = report.problems.length > 0 || report.rules.some(rule => rule.problems.length > 0)
541
+ return { code: hasProblems ? 1 : 0, out: `${out.join('\n')}\n`, err: err.length === 0 ? '' : `${err.join('\n')}\n` }
542
+ } catch (error) {
543
+ return fail(`${error instanceof Error ? error.message : String(error)}\n`)
544
+ } finally {
545
+ store.close()
546
+ }
547
+ }
548
+
549
+ /** Text rendering of the §20.2 report: always shape + coverage, non-empty advisory sections. */
550
+ const renderReport = (report: Report, staleDays: number): string => {
551
+ const lines: string[] = []
552
+ const { coverage } = report
553
+ lines.push(`shape: ${coverage.expectations} expectation(s), ${coverage.instances} instance(s), ${coverage.satisfied}/${coverage.checks} satisfied`)
554
+ if (report.violations.length > 0) {
555
+ lines.push(`violations (${report.violations.length}):`)
556
+ lines.push(...report.violations.map(violation => ` ${formatViolation(violation)}`))
557
+ }
558
+ if (report.stale.length > 0) {
559
+ lines.push(`stale (${report.stale.length}, older than ${staleDays} day(s)):`)
560
+ lines.push(...report.stale.map(({ row, ageDays }) => ` ${row.raw_line} (${ageDays}d)`))
561
+ }
562
+ if (report.review.length > 0) {
563
+ lines.push(`review candidates (${report.review.length}, conf 0.3-0.7):`)
564
+ lines.push(...report.review.map(row => ` ${row.raw_line}`))
565
+ }
566
+ if (report.disagreements.length > 0) {
567
+ lines.push(`alias disagreements (${report.disagreements.length}):`)
568
+ for (const disagreement of report.disagreements) {
569
+ lines.push(` ${disagreement.about} across ${disagreement.entities.join(', ')}:`)
570
+ lines.push(...disagreement.rows.map(row => ` ${row.raw_line}`))
571
+ }
572
+ }
573
+ const confidence = coverage.averageConfidence === null ?
574
+ '' :
575
+ `; avg conf ${Math.round(coverage.averageConfidence * 100)}%, ${coverage.lowConfidence} low (< 0.3)`
576
+ lines.push(
577
+ `coverage: ${coverage.rows} row(s), ${coverage.facts} fact(s) — ` +
578
+ `${coverage.current} current, ${coverage.retracted} retracted, ${coverage.negated} negated` +
579
+ `${confidence}; ${coverage.entities} entities, ${coverage.typedEntities} typed`
580
+ )
581
+ return `${lines.join('\n')}\n`
582
+ }
583
+
584
+ export const checkCommand = (argv: readonly string[]): Output => {
585
+ const { values } = parseArgs({
586
+ args: [...argv],
587
+ options: {
588
+ db: { type: 'string' },
589
+ stale: { type: 'string' },
590
+ json: { type: 'boolean' },
591
+ 'no-prelude': { type: 'boolean' }
592
+ },
593
+ allowPositionals: false
594
+ })
595
+ const staleDays = values.stale === undefined ? defaultStaleDays : Number(values.stale)
596
+ if (!Number.isFinite(staleDays) || staleDays < 0) {
597
+ return fail(`cave check: --stale expects a non-negative number of days, got ${JSON.stringify(values.stale)}\n`)
598
+ }
599
+ const store = open(values.db ?? defaultDbPath(), values['no-prelude'] === true ? { registry: Registry.empty } : {})
600
+ try {
601
+ const report = caveCheck(store, { staleDays })
602
+ const out = values.json === true ?
603
+ `${JSON.stringify(report, undefined, 2)}\n` :
604
+ renderReport(report, staleDays)
605
+ // Violations fail the check (spec §20.2); the other sections are advisory.
606
+ return { code: report.violations.length > 0 ? 1 : 0, out, err: '' }
607
+ } finally {
608
+ store.close()
609
+ }
610
+ }
611
+
281
612
  export const exportCommand = (argv: readonly string[]): Output => {
282
613
  const { values } = parseArgs({
283
614
  args: [...argv],
@@ -319,8 +650,8 @@ export const helpCommand = (argv: readonly string[]): Output => {
319
650
  if (text !== undefined) {
320
651
  return ok(`${text}\n`)
321
652
  }
322
- // mcp and ingest own their help (main.ts forwards `help X` to `X --help`).
323
- if (topic === 'mcp' || topic === 'ingest') {
653
+ // mcp, ingest and connect own their help (main.ts forwards `help X` to `X --help`).
654
+ if (topic === 'mcp' || topic === 'ingest' || topic === 'connect') {
324
655
  return ok(`see: cave ${topic} --help\n`)
325
656
  }
326
657
  return fail(`cave help: unknown command ${JSON.stringify(topic)}\n\n${usage}\n`, 2)
@@ -344,6 +675,10 @@ export const cave = (argv: readonly string[]): Output => {
344
675
  return importCommand(rest)
345
676
  case 'query':
346
677
  return queryCommand(rest)
678
+ case 'derive':
679
+ return deriveCommand(rest)
680
+ case 'check':
681
+ return checkCommand(rest)
347
682
  case 'export':
348
683
  return exportCommand(rest)
349
684
  case 'demo':
package/src/index.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
- * `@cavelang/cli` — the `cave` command (parse / add / query / export / demo).
2
+ * `@cavelang/cli` — the `cave` command (parse / add / query / derive / check / export / demo).
3
3
  * Programmatic access to the same implementations lives here.
4
4
  */
5
5
 
6
- export { addCommand, cave, commandHelp, demoCommand, exportCommand, helpCommand, importCommand, parseCommand, queryCommand, usage, versionCommand } from './cli.ts'
6
+ export { addCommand, cave, checkCommand, commandHelp, demoCommand, deriveCommand, exportCommand, helpCommand, highlightCommand, importCommand, parseCommand, queryCommand, usage, versionCommand } from './cli.ts'
7
7
  export type { Output } from './cli.ts'
package/src/main.ts CHANGED
@@ -1,11 +1,21 @@
1
1
  #!/usr/bin/env node
2
2
  /** `cave` binary entry point — see `cli.ts` for the command implementations. */
3
3
 
4
- import { cave } from './cli.ts'
4
+ import { cave, highlightCommand } from './cli.ts'
5
5
 
6
- // mcp and ingest own their help text, so `cave help X` becomes `cave X --help`.
6
+ const write = (code: number, out: string, err: string): void => {
7
+ if (out !== '') {
8
+ process.stdout.write(out)
9
+ }
10
+ if (err !== '') {
11
+ process.stderr.write(err)
12
+ }
13
+ process.exitCode = code
14
+ }
15
+
16
+ // mcp, ingest and connect own their help text, so `cave help X` becomes `cave X --help`.
7
17
  const raw = process.argv.slice(2)
8
- const argv = raw[0] === 'help' && (raw[1] === 'mcp' || raw[1] === 'ingest') ?
18
+ const argv = raw[0] === 'help' && (raw[1] === 'mcp' || raw[1] === 'ingest' || raw[1] === 'connect') ?
9
19
  [raw[1], '--help'] :
10
20
  raw
11
21
  if (argv[0] === 'mcp') {
@@ -16,13 +26,24 @@ if (argv[0] === 'mcp') {
16
26
  // Long-running: drives an LLM agent over batches of files.
17
27
  const { runIngest } = await import('@cavelang/ingest')
18
28
  process.exitCode = await runIngest(argv.slice(1))
29
+ } else if (argv[0] === 'connect') {
30
+ // Async (URL sources fetch) and potentially long-running (--watch).
31
+ const { runConnect } = await import('@cavelang/connect')
32
+ process.exitCode = await runConnect(argv.slice(1))
33
+ } else if (argv[0] === 'highlight' && !argv.includes('--help') && !argv.includes('-h')) {
34
+ // Async (grammar WASM loads on first use); --help stays on the sync path.
35
+ const { code, out, err } = await highlightCommand(argv.slice(1))
36
+ write(code, out, err)
19
37
  } else {
20
38
  const { code, out, err } = cave(argv)
21
- if (out !== '') {
22
- process.stdout.write(out)
39
+ // Canonical CAVE text going to a terminal gets syntax colors from the same
40
+ // grammar query `cave highlight` uses; piping or NO_COLOR keeps bytes clean.
41
+ const colorize = code === 0 && argv[0] === 'export' && !argv.includes('--out') &&
42
+ out !== '' && process.stdout.isTTY === true && process.env['NO_COLOR'] === undefined
43
+ if (colorize) {
44
+ const { highlighter } = await import('@cavelang/highlight')
45
+ write(code, (await highlighter()).ansi(out), err)
46
+ } else {
47
+ write(code, out, err)
23
48
  }
24
- if (err !== '') {
25
- process.stderr.write(err)
26
- }
27
- process.exitCode = code
28
49
  }
package/License.md DELETED
@@ -1,6 +0,0 @@
1
- # CC0 1.0 Universal (CC0 1.0) Public Domain Dedication
2
-
3
- To the extent possible under law, the authors listed in [Authors.md](./Authors.md) have waived all copyright and related or neighboring rights to this software and associated documentation files (the "Software").
4
-
5
- For more information, please see:
6
- https://creativecommons.org/publicdomain/zero/1.0/