okf 1.7.0 → 1.9.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.
data/lib/okf/cli.rb CHANGED
@@ -10,6 +10,10 @@ module OKF
10
10
  #
11
11
  # Exit codes: 0 success, 1 non-conformant / failing bundle, 2 usage error.
12
12
  class CLI
13
+ # The `registry` umbrella's subcommands — the dispatch, and the words a
14
+ # flag-first invocation is checked against.
15
+ SUBCOMMANDS = %w[set del list default rename].freeze
16
+
13
17
  # Lint findings grouped for display, in category order.
14
18
  LINT_CATEGORIES = {
15
19
  "Reachability" => %i[orphan not_in_index disconnected_component unlinked],
@@ -42,38 +46,88 @@ module OKF
42
46
 
43
47
  def run(argv)
44
48
  argv = argv.dup
45
- case (command = argv.shift)
46
- when "graph" then graph(argv)
47
- when "validate" then validate(argv)
48
- when "lint" then lint(argv)
49
- when "loose" then loose(argv)
50
- when "search" then search(argv)
51
- when "index" then index(argv)
52
- when "catalog" then catalog(argv)
53
- when "files" then files(argv)
54
- when "tags" then tags(argv)
55
- when "types" then types(argv)
56
- when "stats" then stats(argv)
57
- when "server" then server(argv)
58
- when "render" then render(argv)
59
- when "skill" then skill(argv)
60
- when "version", "--version", "-v" then @out.puts(OKF::VERSION); 0
61
- when "help", "--help", "-h" then usage(@out); 0
62
- when nil then usage(@err); 2
63
- else
64
- @err.puts "okf: unknown command '#{command}'"
65
- usage(@err)
66
- 2
49
+ # Per-run state, reset so a reused instance never inherits the last run's
50
+ # answer: the ref→slug memo, and the --pretty a previous argv turned on.
51
+ @ref_slugs = {}
52
+ @pretty = false
53
+ # -h/--help is answered wherever a parser sees it — deep inside
54
+ # positional_dir, where returning would only mean "usage error, exit 2".
55
+ # Thrown here instead, so help keeps the contract every other path keeps:
56
+ # a status this method returns. See #help_flag.
57
+ catch(:help) do
58
+ case (command = argv.shift)
59
+ when "graph" then graph(argv)
60
+ when "validate" then validate(argv)
61
+ when "lint" then lint(argv)
62
+ when "loose" then loose(argv)
63
+ when "search" then search(argv)
64
+ when "index" then index(argv)
65
+ when "catalog" then catalog(argv)
66
+ when "files" then files(argv)
67
+ when "tags" then tags(argv)
68
+ when "types" then types(argv)
69
+ when "stats" then stats(argv)
70
+ when "server" then server(argv)
71
+ when "render" then render(argv)
72
+ when "registry" then registry(argv)
73
+ when "skill" then skill(argv)
74
+ when "version", "--version", "-v" then @out.puts(OKF::VERSION); 0
75
+ when "help", "--help", "-h" then usage(@out); 0
76
+ when nil then usage(@err); 2
77
+ else
78
+ @err.puts "okf: unknown command '#{command}'"
79
+ usage(@err)
80
+ 2
81
+ end
67
82
  end
68
83
  end
69
84
 
85
+ # "every registered bundle" as a ref, in its canonical spelling — what the
86
+ # messages say, and (normalized) what #all_ref? recognizes. Only `search`
87
+ # expands it: it is the one verb that merges across bundles, so it is the one
88
+ # verb for which "all" names something it can answer about. See
89
+ # resolve_registered for why the others refuse it outright rather than
90
+ # treating it as an unknown slug. Its slug half is reserved by
91
+ # Registry::RESERVED_SLUGS so no bundle can answer to it; a test pins the two
92
+ # together.
93
+ ALL_REF = "@all"
94
+
95
+ # The core raises `:regexp`; a user typed `--regexp`. Translating here keeps
96
+ # the flag vocabulary in the shell, where it belongs, and lets the message end
97
+ # with the fix rather than only the complaint: an engine that *can* do what was
98
+ # asked is named, so the next command is obvious.
99
+ CAPABILITY_FLAGS = { regexp: "--regexp", fuzzy: "--fuzzy" }.freeze
100
+
101
+ # The row shape each list view emits, so `--fields`/`--except` can be checked
102
+ # against a name even when the result is empty. Without it the typo guard
103
+ # keyed off the data: `--fields bogus` was a usage error against a bundle
104
+ # with matches and silently fine against one without, which made a typo's
105
+ # fate depend on whether a filter happened to match. A test asserts each
106
+ # view's real rows carry exactly these, so the two cannot drift.
107
+ # Declared in emission order, so the "available:" list a typo prints reads
108
+ # the same as the rows themselves.
109
+ ROW_FIELDS = {
110
+ "matches" => %w[id title type area tags matched score snippet],
111
+ # Registry mode labels every row with the bundle it came from; a plain-dir
112
+ # search has one bundle and no slug to carry. Two shapes, because the typo
113
+ # guard checks against the *declared* one — a single shape covering both
114
+ # would let `--fields slug` pass on a search whose rows have none, and hand
115
+ # back an empty object per match under a count that says otherwise.
116
+ "matches_by_ref" => %w[slug id title type area tags matched score snippet],
117
+ "concepts" => %w[id title type description tags timestamp status backlog_ref dir area links_out links_in],
118
+ "files" => %w[path id dir type title description],
119
+ "directories" => %w[dir index_path present synthesized count types tags subdirs body listing],
120
+ "bundles" => %w[slug title dir mount default missing]
121
+ }.freeze
122
+
70
123
  private
71
124
 
72
125
  def validate(argv)
73
126
  options = { json: false }
74
127
  parser = OptionParser.new do |o|
75
- o.banner = "Usage: okf validate <bundle-dir> [--json]"
128
+ o.banner = "Usage: okf validate <dir|@slug> [--json]"
76
129
  json_flags(o, options, "emit a JSON report")
130
+ help_flag(o)
77
131
  end
78
132
  dir = positional_dir(parser, argv) or return 2
79
133
 
@@ -85,13 +139,14 @@ module OKF
85
139
  def lint(argv)
86
140
  options = { json: false, min_body: OKF::Bundle::Linter::DEFAULT_MIN_BODY, stale_after: nil, only: nil, except: nil, fail_on: :never }
87
141
  parser = OptionParser.new do |o|
88
- o.banner = "Usage: okf lint <bundle-dir> [--json] [--min-body N] [--stale-after DUR] [--only a,b] [--except a,b] [--fail-on warn]"
142
+ o.banner = "Usage: okf lint <dir|@slug> [--json] [--min-body N] [--stale-after DUR] [--only a,b] [--except a,b] [--fail-on warn]"
89
143
  json_flags(o, options, "emit a JSON report")
90
144
  o.on("--min-body N", Integer, "stub threshold in body characters (default #{OKF::Bundle::Linter::DEFAULT_MIN_BODY})") { |v| options[:min_body] = v }
91
145
  o.on("--stale-after DUR", "flag concepts older than DUR (e.g. 90d, 12w, 2026-01-01)") { |v| options[:stale_after] = v }
92
146
  o.on("--only LIST", Array, "run only these checks (comma-separated)") { |v| options[:only] = v.map(&:to_sym) }
93
147
  o.on("--except LIST", Array, "skip these checks (comma-separated)") { |v| options[:except] = v.map(&:to_sym) }
94
148
  o.on("--fail-on LEVEL", %w[never warn], "exit 1 when a finding at LEVEL exists (never | warn)") { |v| options[:fail_on] = v.to_sym }
149
+ help_flag(o)
95
150
  end
96
151
  dir = positional_dir(parser, argv) or return 2
97
152
 
@@ -121,8 +176,9 @@ module OKF
121
176
  def loose(argv)
122
177
  options = { json: false }
123
178
  parser = OptionParser.new do |o|
124
- o.banner = "Usage: okf loose <bundle-dir> [--json]"
179
+ o.banner = "Usage: okf loose <dir|@slug> [--json]"
125
180
  json_flags(o, options, "emit the loose files as JSON")
181
+ help_flag(o)
126
182
  end
127
183
  dir = positional_dir(parser, argv) or return 2
128
184
 
@@ -133,36 +189,80 @@ module OKF
133
189
  0
134
190
  end
135
191
 
136
- # Deterministic text retrieval — the browser page's search brought to the CLI
137
- # and extended to bodies. Terms after the directory are ANDed case-insensitive
138
- # substrings (Ruby regexps with --regexp); rows rank by where they hit (title >
139
- # id > tags > type/description > body) and carry one bounded context snippet,
140
- # so "which concept covers X?" costs a few rows, not a body read. Advisory
141
- # read: exit 0 even with no matches. Deliberately not fuzzy the consuming
142
- # agent is the fuzzy layer.
192
+ # Ranked text retrieval — the browser page's search brought to the CLI on the
193
+ # same engine (a MiniFTS index) and extended to bodies. Terms after the
194
+ # directory are ANDed tokens, matched whole or by prefix (Ruby regexps with
195
+ # --regexp, typo tolerance with --fuzzy); rows rank by BM25+ weighted toward
196
+ # where they hit (title > id > tags > type/description > body) and carry one
197
+ # bounded context snippet, so "which concept covers X?" costs a few rows, not
198
+ # a body read. Advisory read: exit 0 even with no matches. Exact by default —
199
+ # the consuming agent is the fuzzy layer, until it asks not to be.
143
200
  def search(argv)
144
- options = { json: false, regexp: false }
201
+ options = { json: false, regexp: false, fuzzy: false, engine: nil }
145
202
  parser = OptionParser.new do |o|
146
- o.banner = "Usage: okf search <bundle-dir> <term> [term ...] [--regexp] [--in FIELDS] [--type T] [--area A] [--tag T] [--json]"
203
+ o.banner = "Usage: okf search <dir|@slug…|@all> <term…> [--engine NAME] [--regexp|--fuzzy] [--in FIELDS] [--type T] [--area A] [--tag T] [--json]"
204
+ search_engine_note(o)
147
205
  json_flags(o, options, "emit the matches as JSON")
148
206
  projection_flags(o, options)
149
- o.on("-e", "--regexp", "treat each term as a Ruby regular expression (case-insensitive)") { options[:regexp] = true }
207
+ o.on("-e", "--regexp", "read each term as a Ruby regular expression rather",
208
+ "than literal text — case-insensitive (scan engine)") { options[:regexp] = true }
209
+ o.on("--fuzzy",
210
+ "tolerate typos, edit distance #{OKF::Bundle::Search::FUZZY_DISTANCE} × term length (index engine)") { options[:fuzzy] = true }
211
+ o.on("--engine NAME", "match with this engine instead of the default",
212
+ "(#{engine_names}) — index is BM25+ ranked, token-based") { |v| options[:engine] = v }
150
213
  o.on("--in LIST", Array, "search only these fields (#{OKF::Bundle::Search::FIELDS.join(", ")})") { |v| options[:in] = v.map(&:downcase) }
151
214
  filter_flags(o, options, :type, :area, :tag)
215
+ help_flag(o)
152
216
  end
153
- dir = positional_dir(parser, argv) or return 2
217
+ begin
218
+ parser.parse!(argv)
219
+ rescue OptionParser::ParseError => e
220
+ @err.puts e.message
221
+ return 2
222
+ end
223
+
224
+ # Registry mode — leading @refs, @all among them — searches several bundles
225
+ # and labels every match; a plain dir keeps the classic single-bundle output.
226
+ if argv.first&.start_with?("@")
227
+ pairs = ref_targets(argv) or return 2
228
+ dir = nil
229
+ else
230
+ dir = argv.shift
231
+ if dir.nil?
232
+ @err.puts parser.banner
233
+ return 2
234
+ end
235
+ dir = resolve_ref(dir) or return 2
236
+ end
237
+
154
238
  terms = argv
155
239
  if terms.empty?
156
240
  @err.puts parser.banner
157
241
  return 2
158
242
  end
159
243
 
244
+ # A non-leading @arg is a literal term by the grammar — say so, since the
245
+ # user may have meant a ref (refs must lead) and would otherwise see only
246
+ # a silent zero-match.
247
+ stray = terms.find { |term| term.start_with?("@") }
248
+ @err.puts "note: '#{stray}' searches as a literal term — an @slug or @all must lead" if stray
249
+
160
250
  unknown = Array(options[:in]) - OKF::Bundle::Search::FIELDS
161
251
  return usage_error("unknown field(s): #{unknown.join(", ")} (searchable: #{OKF::Bundle::Search::FIELDS.join(", ")})") unless unknown.empty?
162
252
 
253
+ # Two query languages, not two dials on one: a regexp is matched against raw
254
+ # text, --fuzzy is an edit distance over indexed tokens. Silently honouring
255
+ # one and dropping the other would answer a question nobody asked.
256
+ if options[:regexp] && options[:fuzzy]
257
+ return usage_error("--regexp and --fuzzy are mutually exclusive (a pattern is matched literally, not by edit distance)")
258
+ end
259
+
260
+ return multi_search(pairs, terms, options) if pairs
261
+
163
262
  folder = OKF::Bundle::Folder.load(dir)
164
263
  report_skipped(folder)
165
- rows = OKF::Bundle::Search.call(folder.bundle, terms, fields: options[:in], regexp: options[:regexp])
264
+ rows = OKF::Bundle::Search.call(folder.bundle, terms, fields: options[:in], regexp: options[:regexp],
265
+ fuzzy: options[:fuzzy], engine: options[:engine])
166
266
  keep = filter_ids(folder, options)
167
267
  rows = rows.select { |row| keep.include?(row[:id]) } unless keep.nil?
168
268
  return print_search_json(dir, terms, rows, options) if options[:json]
@@ -171,10 +271,115 @@ module OKF
171
271
  0
172
272
  rescue RegexpError => e
173
273
  usage_error("invalid pattern: #{e.message}")
274
+ rescue OKF::Bundle::Search::UnknownEngine => e
275
+ usage_error(e.message)
276
+ rescue OKF::Bundle::Search::UnsupportedQuery => e
277
+ usage_error(unsupported_query_message(e))
278
+ end
279
+
280
+ # Every registered bundle, as [slug, dir] pairs — what @all expands to.
281
+ # Asking for everything tolerates gaps: a registered directory that has since
282
+ # vanished is skipped with a note, the same forgiveness the hub shows a stale
283
+ # entry. Naming one bundle demands it, so a plain @slug still fails hard.
284
+ def all_targets
285
+ registry = load_registry
286
+ return nil unless registry
287
+
288
+ if registry.empty?
289
+ @err.puts "error: no bundles registered (okf registry set <dir>)"
290
+ return nil
291
+ end
292
+ pairs = []
293
+ registry.each do |entry|
294
+ if File.directory?(entry.path)
295
+ pairs << [ entry.slug, entry.path ]
296
+ else
297
+ skip_registered(entry)
298
+ end
299
+ end
300
+ if pairs.empty?
301
+ @err.puts "error: every registered bundle is missing on disk (okf registry list)"
302
+ return nil
303
+ end
304
+ pairs
305
+ end
306
+
307
+ # Dedupe by resolved path, not ref spelling — `@ @one` is one bundle when
308
+ # "one" is the default, and must be searched once. `@all @one` is the same
309
+ # story with a wider first ref: all ⊇ one, so the result is right and the
310
+ # duplicate simply drops. No error branch, because there is no wrong answer
311
+ # to warn about.
312
+ def ref_targets(argv)
313
+ refs = []
314
+ refs << argv.shift while argv.first&.start_with?("@")
315
+ pairs = []
316
+ refs.each do |ref|
317
+ found = all_ref?(ref) ? all_targets : ref_pair(ref)
318
+ return nil unless found
319
+
320
+ found.each { |slug, path| pairs << [ slug, path ] unless pairs.any? { |_, seen| seen == path } }
321
+ end
322
+ pairs
323
+ end
324
+
325
+ # Does this @ref name every registered bundle? Takes a ref, sigil and all —
326
+ # both callers reach it only past a start_with?("@") of their own, so a
327
+ # third check here would be a branch no run can take.
328
+ #
329
+ # Compared *normalized*, because the ref grammar has exactly one
330
+ # normalization and a ref exempt from it is a trapdoor: `@ALL` has to reach
331
+ # `@all` for the same reason `@One` reaches the bundle registered from dir
332
+ # `One`. It normalizes through Registry.normalize — the very call the slug
333
+ # lookup makes — rather than a second downcase that could be forgotten while
334
+ # the first was maintained.
335
+ def all_ref?(ref)
336
+ require "okf/registry"
337
+ OKF::Registry.normalize(ref[1..-1]) == ALL_REF[1..-1]
338
+ end
339
+
340
+ # One @ref as a single-element [[slug, dir]], or nil after reporting.
341
+ def ref_pair(ref)
342
+ path = resolve_registered(ref)
343
+ unless path
344
+ # Only an unknown slug is plausibly a mistyped term — a broken registry
345
+ # or a gone directory has nothing to do with the grammar.
346
+ @err.puts "note: searching for a literal @-term? put a non-@ term first, or use -e '\\@term'" if @ref_failure == :unknown
347
+ return nil
348
+ end
349
+ [ [ ref_slugs[path], path ] ]
350
+ end
351
+
352
+ # Search every bundle at once and merge the rankings, each row labeled with
353
+ # its bundle's slug. The bundles go in as *one* corpus rather than one search
354
+ # each: BM25 weighs a term by how rare it is, so ranking each bundle on its own
355
+ # statistics and then interleaving the lists would let the same match score
356
+ # differently for no reason a reader could see. One index, one ranking.
357
+ #
358
+ # Filters stay per-bundle — they are per-folder questions — so they apply to
359
+ # the merged rows by (slug, id) afterwards.
360
+ def multi_search(pairs, terms, options)
361
+ bundles = []
362
+ keeps = {}
363
+ total = 0
364
+ pairs.each do |slug, dir|
365
+ folder = OKF::Bundle::Folder.load(dir)
366
+ report_skipped(folder)
367
+ total += folder.bundle.concepts.size
368
+ bundles << [ slug, folder.bundle ]
369
+ keep = filter_ids(folder, options)
370
+ keeps[slug] = keep unless keep.nil?
371
+ end
372
+ rows = OKF::Bundle::Search.across(bundles, terms, fields: options[:in], regexp: options[:regexp],
373
+ fuzzy: options[:fuzzy], engine: options[:engine])
374
+ rows = rows.select { |row| !keeps.key?(row[:slug]) || keeps[row[:slug]].include?(row[:id]) }
375
+ return print_multi_search_json(pairs, terms, rows, options) if options[:json]
376
+
377
+ print_multi_search(pairs, terms, rows, total)
378
+ 0
174
379
  end
175
380
 
176
381
  def print_search(dir, terms, rows, total)
177
- @out.puts "Search — #{dir} · #{terms.join(" ")} (#{counted(rows.size, total, "concepts")})"
382
+ @out.puts "Search — #{bundle_label(dir)} · #{terms.join(" ")} (#{counted(rows.size, total, "concept")})"
178
383
  if rows.empty?
179
384
  @out.puts " no matches — fewer or broader terms, or scan `okf tags #{dir}` for the vocabulary"
180
385
  return
@@ -192,71 +397,416 @@ module OKF
192
397
  emit_list_json(dir, "matches", rows.map { |row| stringify(row) }, options, "query" => terms)
193
398
  end
194
399
 
400
+ def print_multi_search(pairs, terms, rows, total)
401
+ @out.puts "Search — #{pairs.map { |slug, _| "@#{slug}" }.join(" ")} · #{terms.join(" ")} (#{counted(rows.size, total, "concept")})"
402
+ if rows.empty?
403
+ @out.puts " no matches — fewer or broader terms, or scan `okf tags @<slug>` for a bundle's vocabulary"
404
+ return
405
+ end
406
+
407
+ slug_width = rows.map { |row| row[:slug].length }.max + 1
408
+ width = rows.map { |row| row[:id].length }.max
409
+ rows.each do |row|
410
+ @out.puts
411
+ @out.puts " #{"@#{row[:slug]}".ljust(slug_width)} #{row[:id].ljust(width)} #{row[:title]} · #{row[:type]} · #{row[:matched].join("+")}"
412
+ @out.puts " #{truncate(row[:snippet], 100)}" unless row[:snippet].empty?
413
+ end
414
+ end
415
+
416
+ # The head maps every searched slug to its directory once, so a row's
417
+ # `slug` resolves to `<dir>/<id>.md` without a second lookup — and without
418
+ # repeating a long path on every row.
419
+ def print_multi_search_json(pairs, terms, rows, options)
420
+ head = { "bundles" => pairs.map { |slug, dir| { "slug" => slug, "dir" => dir } } }
421
+ emit_list_json(head, "matches", rows.map { |row| stringify(row) }, options, { "query" => terms }, "matches_by_ref")
422
+ end
423
+
195
424
  def server(argv)
196
425
  require "okf/server/app"
197
426
  require "rack/deflater"
198
427
 
199
428
  options = { port: 8808, bind: "127.0.0.1", title: nil, link: nil, layout: "cose" }
200
429
  parser = OptionParser.new do |o|
201
- o.banner = "Usage: okf server <bundle-dir> [-p PORT] [--bind ADDR] [--layout NAME] [-t title] [-l url]"
430
+ o.banner = "Usage: okf server [DIR|@slug…] [-p PORT] [--bind ADDR] [--layout NAME] [-t title] [-l url]"
202
431
  o.on("-p", "--port PORT", Integer, "port to serve on (default #{options[:port]})") { |v| options[:port] = v }
203
432
  o.on("--bind ADDR", "address to bind (default #{options[:bind]})") { |v| options[:bind] = v }
204
- o.on("-t", "--title TITLE", "graph title (default: parent/bundle dir name)") { |v| options[:title] = v }
205
- o.on("-l", "--link URL", "source URL shown in the header") { |v| options[:link] = v }
206
- o.on("--layout NAME", OKF::Server::Graph::LAYOUTS, "initial layout (#{OKF::Server::Graph::LAYOUTS.join(", ")})") { |v| options[:layout] = v }
433
+ o.on("-t", "--title TITLE", "graph title, single bundle only (default: parent/bundle dir name)") { |v| options[:title] = v }
434
+ o.on("-l", "--link URL", "source URL shown in the header, single bundle only") { |v| options[:link] = v }
435
+ o.on("--layout NAME", OKF::Render::Graph::LAYOUTS, "initial layout (#{OKF::Render::Graph::LAYOUTS.join(", ")})") { |v| options[:layout] = v }
436
+ help_flag(o)
207
437
  end
208
- dir = positional_dir(parser, argv) or return 2
438
+ dirs = positional_dirs(parser, argv) or return 2
209
439
 
210
- folder = OKF::Bundle::Folder.load(dir)
211
- report_skipped(folder)
212
- run_server(folder, options)
440
+ # A flag that will have no effect in this mode gets a note, not silence.
441
+ @err.puts "note: --title/--link apply to a single-bundle server; ignored" if dirs.size != 1 && (options[:title] || options[:link])
442
+
443
+ # One dir keeps the historical single-bundle server at `/`; zero (the
444
+ # persistent registry) or many (ephemeral) fan out behind a hub.
445
+ if dirs.size == 1
446
+ folder = OKF::Bundle::Folder.load(dirs.first)
447
+ report_skipped(folder)
448
+ run_server(folder, options)
449
+ else
450
+ run_hub(dirs, options)
451
+ end
213
452
  0
453
+ rescue OKF::Error => e
454
+ usage_error(e.message)
214
455
  end
215
456
 
216
- # Build the Rack app and hand it to the runner (WEBrick by default, injected so
217
- # tests drive this without a socket).
457
+ # Build the single-bundle Rack app and hand it to the runner (WEBrick by
458
+ # default, injected so tests drive this without a socket).
218
459
  def run_server(folder, options)
219
460
  app = OKF::Server::App.new(folder, title: options[:title] || folder.name, link: options[:link], layout: options[:layout])
220
- app = Rack::Deflater.new(app) # gzip responses when the client accepts it transparent, no new dependency
221
- @out.puts "serving #{folder.graph.nodes.size} concepts at http://#{options[:bind]}:#{options[:port]} (Ctrl-C to stop)"
222
- @runner.call(app, options[:bind], options[:port])
461
+ # minimal: the banner wants a count, not bodiesand Folder#graph is not
462
+ # memoized, so a full build here parses every concept a second time (the
463
+ # App builds its own) purely to print one number.
464
+ count = folder.graph(minimal: true).nodes.size
465
+ @out.puts "serving #{count} #{pluralize(count, "concept")} at http://#{options[:bind]}:#{options[:port]} (Ctrl-C to stop)"
466
+ serve(app, options)
467
+ end
468
+
469
+ # Build the multi-bundle hub and hand it to the runner. With dirs it serves
470
+ # those ephemerally; with none it serves the persistent registry. Either way
471
+ # the first bundle is the one `/` opens — for the registry that is its own
472
+ # order, and a first entry whose directory has vanished drops out here, so
473
+ # `/` lands on the next one that is actually there.
474
+ def run_hub(dirs, options)
475
+ require "okf/server/hub"
476
+ require "okf/registry"
477
+ if dirs.empty?
478
+ # A malformed registry raises OKF::Error, which `server` rescues into a
479
+ # usage error — no guarded load needed on this path.
480
+ reg = OKF::Registry.load
481
+ bundles = reg.map { |entry| load_registered(entry) }.compact
482
+ else
483
+ bundles = ephemeral_bundles(dirs)
484
+ end
485
+ hub = OKF::Server::Hub.new(bundles, layout: options[:layout])
486
+ concepts = bundles.inject(0) { |sum, bundle| sum + bundle.folder.graph(minimal: true).nodes.size }
487
+ @out.puts "serving #{bundles.size} #{pluralize(bundles.size,
488
+ "bundle")}, #{concepts} #{pluralize(concepts, "concept")} at http://#{options[:bind]}:#{options[:port]} (Ctrl-C to stop)"
489
+ print_mounts(hub)
490
+ serve(hub, options)
491
+ end
492
+
493
+ # The one boot seam every served app passes through, so a hub gzips exactly
494
+ # like a single bundle — the wrap belongs to booting a server, not to either
495
+ # mode, and a mode added later gets it for free. Deliberately not inside the
496
+ # runner: an embedding app mounting OKF::Server::App brings its own middleware.
497
+ def serve(app, options)
498
+ # gzip responses when the client accepts it — transparent, no new dependency
499
+ @runner.call(Rack::Deflater.new(app), options[:bind], options[:port])
500
+ end
501
+
502
+ # The mount table — which dir landed on which /b/<slug>/ and where `/` goes.
503
+ # Mirrors the Hub's own default resolution (explicit slug, else first).
504
+ # Ask the hub which bundle it chose rather than re-deriving the
505
+ # explicit-else-first rule, and mount at its own prefix: two copies of a
506
+ # rule is two answers waiting to disagree.
507
+ def print_mounts(hub)
508
+ hub.bundles.each do |bundle|
509
+ marker = bundle.equal?(hub.default) ? "*" : " "
510
+ @out.puts " #{marker} #{OKF::Server::Hub::MOUNT}/#{bundle.slug}/ #{bundle.title}"
511
+ end
512
+ end
513
+
514
+ # Load the given directories as unregistered bundles, slugged by basename and
515
+ # deduped within the run. The same directory listed twice mounts once — two
516
+ # windows on one bundle would just burn a slug on a URL that vanishes next run.
517
+ def ephemeral_bundles(dirs)
518
+ roots = []
519
+ dirs.each do |dir|
520
+ root = File.expand_path(dir)
521
+ roots << root unless roots.include?(root)
522
+ end
523
+
524
+ # A registered slug owns its mount outright: reserve every ref's slug
525
+ # before any basename is deduped. Otherwise argv order decides, and
526
+ # `server ./two @two` mounts the *unregistered* ./two at /b/two/ while
527
+ # pushing the ref — the bundle whose slug that is — to /b/two-2/, so a
528
+ # bookmark from a bundle-less run silently opens the wrong graph.
529
+ taken = roots.map { |root| ref_slugs[root] }.compact
530
+ roots.each_with_object([]) do |root, bundles|
531
+ folder = OKF::Bundle::Folder.load(root)
532
+ report_skipped(folder)
533
+ slug = ref_slugs[root]
534
+ unless slug
535
+ slug = OKF::Registry.dedupe(File.basename(root), taken)
536
+ taken << slug
537
+ end
538
+ bundles << OKF::Server::Hub::Bundle.new(slug, folder, folder.name)
539
+ end
540
+ end
541
+
542
+ # Load one registered bundle; a path that has gone missing or no longer reads
543
+ # drops to nil with a note (to stderr) rather than sinking the whole run. The
544
+ # directory check is explicit — the Reader maps a nonexistent directory to an
545
+ # empty bundle, so nothing would raise for the common "dir was deleted" case.
546
+ # Method-level rescue (not a `do…end`-block rescue — a 2.6 feature).
547
+ def load_registered(entry)
548
+ return skip_registered(entry) unless File.directory?(entry.path)
549
+
550
+ folder = OKF::Bundle::Folder.load(entry.path)
551
+ report_skipped(folder)
552
+ OKF::Server::Hub::Bundle.new(entry.slug, folder, entry.title)
553
+ rescue SystemCallError, OKF::Error
554
+ skip_registered(entry)
555
+ end
556
+
557
+ def skip_registered(entry)
558
+ @err.puts "note: skipping #{entry.slug} — cannot read #{entry.path}"
559
+ nil
223
560
  end
224
561
 
225
562
  # The static counterpart to `server`: bake the whole bundle into one
226
563
  # self-contained HTML file (bodies, catalog, index, logs baked in, no server
227
564
  # needed — e.g. hosting on GitHub Pages). Prints to stdout unless -o is given.
228
565
  def render(argv)
229
- require "okf/server/app"
566
+ require "okf/render/graph"
230
567
 
231
568
  options = { output: nil, title: nil, link: nil, layout: "cose" }
232
569
  parser = OptionParser.new do |o|
233
- o.banner = "Usage: okf render <bundle-dir> [-o FILE] [--layout NAME] [-t title] [-l url]"
570
+ o.banner = "Usage: okf render <dir|@slug> [-o FILE] [--layout NAME] [-t title] [-l url]"
234
571
  o.on("-o", "--output FILE", "write to FILE instead of stdout") { |v| options[:output] = v }
235
572
  o.on("-t", "--title TITLE", "graph title (default: parent/bundle dir name)") { |v| options[:title] = v }
236
573
  o.on("-l", "--link URL", "source URL shown in the header") { |v| options[:link] = v }
237
- o.on("--layout NAME", OKF::Server::Graph::LAYOUTS, "initial layout (#{OKF::Server::Graph::LAYOUTS.join(", ")})") { |v| options[:layout] = v }
574
+ o.on("--layout NAME", OKF::Render::Graph::LAYOUTS, "initial layout (#{OKF::Render::Graph::LAYOUTS.join(", ")})") { |v| options[:layout] = v }
575
+ help_flag(o)
238
576
  end
239
577
  dir = positional_dir(parser, argv) or return 2
240
578
 
241
579
  folder = OKF::Bundle::Folder.load(dir)
242
580
  report_skipped(folder)
243
- html = OKF::Server::App.new(folder, title: options[:title] || folder.name, link: options[:link], layout: options[:layout]).render_static
581
+ html = OKF::Render::Graph.static(folder, title: options[:title], link: options[:link], layout: options[:layout])
244
582
  if options[:output]
245
- File.write(options[:output], html)
246
- @out.puts "wrote #{folder.graph(minimal: true).nodes.size} concepts to #{options[:output]}"
583
+ # A bad -o path (a missing directory, a permission denial) is a bad
584
+ # *argument*: exit 2 with the reason, never a backtrace and an exit code
585
+ # that means "failing bundle".
586
+ begin
587
+ File.write(options[:output], html)
588
+ rescue SystemCallError => e
589
+ return usage_error("cannot write #{options[:output]}: #{e.message}")
590
+ end
591
+ count = folder.graph(minimal: true).nodes.size
592
+ @out.puts "wrote #{count} #{pluralize(count, "concept")} to #{options[:output]}"
247
593
  else
248
594
  @out.print html
249
595
  end
250
596
  0
251
597
  end
252
598
 
599
+ # The registry umbrella, split by what each verb keys on. `set`/`del`/`list`
600
+ # act on entries — `set` keys on the bundle's path, so --as means one thing
601
+ # ("the slug this entry has") whether it adds or renames. `default`/`rename`
602
+ # act on slugs, the names actually to hand once a bundle is registered. Every
603
+ # positional stays unambiguous, and `config` is left free for real settings.
604
+ def registry(argv)
605
+ require "okf/registry"
606
+
607
+ sub = argv.first
608
+ case sub
609
+ when "set" then registry_set(argv.drop(1))
610
+ when "del" then registry_del(argv.drop(1))
611
+ when "list" then registry_list(argv.drop(1))
612
+ when "default" then registry_default(argv.drop(1))
613
+ when "rename" then registry_rename(argv.drop(1))
614
+ else
615
+ # A bare word that isn't a known subcommand is a typo (`registry remove x`
616
+ # must not silently render the list and read as success).
617
+ return usage_error("unknown registry subcommand '#{sub}' (expected: #{SUBCOMMANDS.join(", ")})") if sub && !sub.start_with?("-")
618
+
619
+ # Same rule for a subcommand hiding behind a flag: `registry --json set
620
+ # dir` would otherwise list an empty registry and exit 0, having written
621
+ # nothing the user asked for. It cannot just be dispatched from wherever
622
+ # it turns up — the word may be a flag's value (`registry --as set <dir>`
623
+ # asks for the slug "set"), and a grammar where that reading depends on
624
+ # which flag precedes it is a trapdoor. So the subcommand must lead, and
625
+ # the error says which one was found rather than guessing at the intent.
626
+ stray = argv.find { |arg| SUBCOMMANDS.include?(arg) }
627
+ return usage_error("put the subcommand first: okf registry #{stray} … (flags follow it)") if stray
628
+
629
+ registry_list(argv)
630
+ end
631
+ end
632
+
633
+ # Add a bundle to the persistent registry (so a later bare `okf server` finds
634
+ # it), or update one already there. The entry is keyed by the bundle's path: a
635
+ # path already registered refreshes its title in place, and --as renames it. A
636
+ # new path is added, slugged by directory basename unless --as says otherwise.
637
+ def registry_set(argv)
638
+ options = { as: nil, default: false }
639
+ parser = OptionParser.new do |o|
640
+ o.banner = "Usage: okf registry set <dir|@slug> [--as SLUG] [--default]"
641
+ o.on("--as SLUG", "slug to register under (default: directory basename)") { |v| options[:as] = v }
642
+ o.on("--default", "put it first — the bundle a bare `okf server` opens") { options[:default] = true }
643
+ help_flag(o)
644
+ end
645
+ dir = positional_dir(parser, argv) or return 2
646
+ no_extras?(argv) or return 2
647
+
648
+ reg = OKF::Registry.load
649
+ # Said before the upsert: after it, an update is indistinguishable from an
650
+ # add, and "registered" for what was a rename reads as a duplicate entry.
651
+ known = reg.listing.any? { |row| row[:dir] == File.expand_path(dir) }
652
+ entry = reg.add(dir, as: options[:as], default: options[:default])
653
+ # Through report_skipped like every other bundle-reading verb: the reader
654
+ # tolerates a file it cannot open, so a count taken straight off the graph
655
+ # reports "0 concepts" for a bundle whose files are simply unreadable.
656
+ folder = OKF::Bundle::Folder.load(entry.path)
657
+ report_skipped(folder)
658
+ count = folder.graph(minimal: true).nodes.size
659
+ @out.puts "#{known ? "updated" : "registered"} #{entry.slug} → #{entry.path} (#{count} #{pluralize(count, "concept")})"
660
+ 0
661
+ rescue OKF::Error => e
662
+ usage_error(e.message)
663
+ end
664
+
665
+ # Remove a bundle from the persistent registry by slug or by its directory.
666
+ def registry_del(argv)
667
+ parser = OptionParser.new do |o|
668
+ o.banner = "Usage: okf registry del <dir|@slug>"
669
+ help_flag(o)
670
+ end
671
+ slug = positional(parser, argv) or return 2
672
+ no_extras?(argv) or return 2
673
+
674
+ reg = OKF::Registry.load
675
+ slug = registry_slug(slug, reg) or return 2
676
+ removed = reg.remove(slug)
677
+ return usage_error("no such bundle: #{slug}") unless removed
678
+
679
+ @out.puts "removed #{removed.slug}"
680
+ 0
681
+ rescue OKF::Error => e
682
+ usage_error(e.message)
683
+ end
684
+
685
+ def registry_list(argv)
686
+ options = { json: false }
687
+ parser = OptionParser.new do |o|
688
+ o.banner = "Usage: okf registry list [--json] [--pretty]\n " \
689
+ "okf registry set <dir|@slug> | del <dir|@slug> | default <@slug> | rename <@slug> <new>"
690
+ json_flags(o, options, "emit the registry as JSON")
691
+ help_flag(o)
692
+ end
693
+ begin
694
+ parser.parse!(argv)
695
+ rescue OptionParser::ParseError => e
696
+ @err.puts e.message
697
+ return 2
698
+ end
699
+ no_extras?(argv) or return 2
700
+
701
+ reg = OKF::Registry.load
702
+ return emit_list_json({ "registry" => reg.path }, "bundles", reg.listing.map { |row| stringify(row) }, options) if options[:json]
703
+
704
+ print_registry(reg)
705
+ 0
706
+ rescue OKF::Error => e
707
+ usage_error(e.message)
708
+ end
709
+
710
+ # Choose which registered bundle a bare `okf server` opens at `/`, by moving
711
+ # it to the front of the registry. The listing is ordered and the JSON is
712
+ # meant to be hand-editable, so the move is stated rather than left to be
713
+ # discovered from a reordered file.
714
+ def registry_default(argv)
715
+ parser = OptionParser.new do |o|
716
+ o.banner = "Usage: okf registry default <@slug>\n " \
717
+ "moves it to the front — the first registered bundle is the default until you do"
718
+ help_flag(o)
719
+ end
720
+ slug = positional(parser, argv) or return 2
721
+ no_extras?(argv) or return 2
722
+
723
+ reg = OKF::Registry.load
724
+ slug = registry_slug(slug, reg) or return 2
725
+ reg.default = slug
726
+ @out.puts "default bundle → #{reg.default.slug} (now first)"
727
+ 0
728
+ rescue OKF::Error => e
729
+ usage_error(e.message)
730
+ end
731
+
732
+ # The @ref grammar for a verb that takes a *slug*, read by name. These three
733
+ # must reach an entry whose directory is gone — that is the one worth
734
+ # deleting or renaming — so they cannot go through resolve_ref, which
735
+ # insists the directory exist. Without this the refs only appeared to work:
736
+ # `normalize` strips the `@` off `@slug`, so `default @slug` resolved by
737
+ # accident while a bare `@` normalized to "" and failed. Returns the slug,
738
+ # or nil after reporting.
739
+ def registry_slug(arg, registry)
740
+ return arg unless arg.start_with?("@")
741
+
742
+ asked = arg[1..-1]
743
+ return asked unless asked.empty?
744
+
745
+ default = registry.default
746
+ return default.slug if default
747
+
748
+ @err.puts "error: no bundle is registered, so `@` names nothing (okf registry set <dir>)"
749
+ nil
750
+ end
751
+
752
+ # Rename a registered bundle's slug — its mount path and switcher name.
753
+ def registry_rename(argv)
754
+ parser = OptionParser.new do |o|
755
+ o.banner = "Usage: okf registry rename <@slug> <new>"
756
+ help_flag(o)
757
+ end
758
+ parser.parse!(argv)
759
+ old_slug, new_slug = argv.shift(2)
760
+ if old_slug.nil? || new_slug.nil?
761
+ @err.puts parser.banner
762
+ return 2
763
+ end
764
+ no_extras?(argv) or return 2
765
+
766
+ reg = OKF::Registry.load
767
+ # The old name may be a ref; the new one is a name being minted, never one.
768
+ old_slug = registry_slug(old_slug, reg) or return 2
769
+ entry = reg.rename(old_slug, new_slug)
770
+ # The slug it *found*, not the argv that found it: rename normalizes to look
771
+ # the entry up, so echoing the raw ask names a bundle that never existed.
772
+ @out.puts "renamed #{OKF::Registry.normalize(old_slug)} → #{entry.slug}"
773
+ 0
774
+ rescue OptionParser::ParseError => e
775
+ @err.puts e.message
776
+ 2
777
+ rescue OKF::Error => e
778
+ usage_error(e.message)
779
+ end
780
+
781
+ # The registry verbs take an exact number of positionals — a leftover argument
782
+ # is a typo'd invocation, not something to drop silently.
783
+ def no_extras?(argv)
784
+ return true if argv.empty?
785
+
786
+ @err.puts "error: unexpected argument '#{argv.first}'"
787
+ false
788
+ end
789
+
790
+ def print_registry(reg)
791
+ return @out.puts "no bundles registered — okf registry set <dir>" if reg.empty?
792
+
793
+ rows = reg.listing
794
+ width = rows.map { |row| row[:slug].length }.max
795
+ rows.each do |row|
796
+ marker = row[:default] ? "*" : " "
797
+ missing = row[:missing] ? " (missing)" : ""
798
+ @out.puts "#{marker} #{row[:slug].ljust(width)} #{row[:title]} (#{row[:dir]})#{missing}"
799
+ end
800
+ end
801
+
253
802
  def graph(argv)
254
803
  options = { json: false, minimal: false, body: true }
255
804
  parser = OptionParser.new do |o|
256
- o.banner = "Usage: okf graph <bundle-dir> [--json] [--minimal] [--no-body]"
805
+ o.banner = "Usage: okf graph <dir|@slug> [--json] [--minimal] [--no-body]"
257
806
  json_flags(o, options, "emit nodes and edges as JSON")
258
807
  o.on("--minimal", "leanest nodes (id + title); adds type/tag indexes") { options[:minimal] = true }
259
808
  o.on("--[no-]body", "include each concept's body (default: yes)") { |v| options[:body] = v }
809
+ help_flag(o)
260
810
  end
261
811
  dir = positional_dir(parser, argv) or return 2
262
812
 
@@ -264,11 +814,15 @@ module OKF
264
814
  graph = folder.graph(minimal: options[:minimal], body: options[:body])
265
815
  report_skipped(folder)
266
816
  if options[:json]
267
- payload = graph.to_h
817
+ # The head every view carries: a payload of nodes and edges that never
818
+ # says which bundle they came from is exactly what an agent holding
819
+ # several bundles has to guess at.
820
+ payload = bundle_head(dir).merge(graph.to_h)
268
821
  payload = payload.merge(types: graph.type_index, tags: graph.tag_index) if options[:minimal]
269
822
  emit_json(payload)
270
823
  else
271
- @out.puts "#{graph.nodes.size} concepts, #{graph.edges.size} links"
824
+ @out.puts "Graph — #{bundle_label(dir)} (#{graph.nodes.size} #{pluralize(graph.nodes.size, "concept")}, " \
825
+ "#{graph.edges.size} #{pluralize(graph.edges.size, "link")})"
272
826
  end
273
827
  0
274
828
  end
@@ -282,11 +836,12 @@ module OKF
282
836
  def index(argv)
283
837
  options = { json: false, body: true, areas: nil }
284
838
  parser = OptionParser.new do |o|
285
- o.banner = "Usage: okf index <bundle-dir> [--area AREA] [--no-body] [--json]"
839
+ o.banner = "Usage: okf index <dir|@slug> [--area AREA] [--no-body] [--json]"
286
840
  json_flags(o, options, "emit the index map as JSON")
287
841
  projection_flags(o, options)
288
842
  o.on("--area AREA", "only this directory/area (repeatable; `root` for the bundle root)") { |v| (options[:areas] ||= []) << v }
289
843
  o.on("--[no-]body", "include each index's prose body (default: yes)") { |v| options[:body] = v }
844
+ help_flag(o)
290
845
  end
291
846
  dir = positional_dir(parser, argv) or return 2
292
847
 
@@ -295,6 +850,13 @@ module OKF
295
850
  entries = folder.directory_index
296
851
  selected = select_directories(entries, options[:areas])
297
852
  if options[:json]
853
+ # --no-body is shorthand for --except body, so asking for the body by
854
+ # name in the same breath is a contradiction. Letting --fields quietly
855
+ # win would hand back the very thing the other flag was there to drop.
856
+ if !options[:body] && Array(options[:fields]).map(&:downcase).include?("body")
857
+ return usage_error("--no-body and --fields body contradict each other: drop one")
858
+ end
859
+
298
860
  options[:except] = Array(options[:except]) + [ "body" ] unless options[:body] || options[:fields]
299
861
  return print_index_map_json(dir, selected, options)
300
862
  end
@@ -314,7 +876,7 @@ module OKF
314
876
 
315
877
  def print_index_map(dir, entries, body)
316
878
  noun = entries.size == 1 ? "directory" : "directories"
317
- @out.puts "Index map — #{dir} (#{entries.size} #{noun})"
879
+ @out.puts "Index map — #{bundle_label(dir)} (#{entries.size} #{noun})"
318
880
  entries.each do |entry|
319
881
  @out.puts
320
882
  @out.puts " #{index_dir_label(entry)}#{index_dir_meta(entry)}"
@@ -334,8 +896,8 @@ module OKF
334
896
  end
335
897
 
336
898
  def index_dir_meta(entry)
337
- count = "#{entry[:count]} #{entry[:count] == 1 ? "concept" : "concepts"}"
338
- types = entry[:types].map { |type, n| "#{type.empty? ? "Untyped" : type} #{n}" }.join(", ")
899
+ count = "#{entry[:count]} #{pluralize(entry[:count], "concept")}"
900
+ types = entry[:types].map { |type, n| "#{OKF.blank?(type) ? "Untyped" : type} #{n}" }.join(", ")
339
901
  types.empty? ? " · #{count}" : " · #{count} · #{types}"
340
902
  end
341
903
 
@@ -377,10 +939,11 @@ module OKF
377
939
  def catalog(argv)
378
940
  options = { json: false }
379
941
  parser = OptionParser.new do |o|
380
- o.banner = "Usage: okf catalog <bundle-dir> [--type T] [--area A] [--tag T] [--json]"
942
+ o.banner = "Usage: okf catalog <dir|@slug> [--type T] [--area A] [--tag T] [--json]"
381
943
  json_flags(o, options, "emit the catalog as JSON")
382
944
  projection_flags(o, options)
383
945
  filter_flags(o, options, :type, :area, :tag)
946
+ help_flag(o)
384
947
  end
385
948
  dir = positional_dir(parser, argv) or return 2
386
949
 
@@ -397,10 +960,11 @@ module OKF
397
960
  def files(argv)
398
961
  options = { json: false }
399
962
  parser = OptionParser.new do |o|
400
- o.banner = "Usage: okf files <bundle-dir> [--type T] [--area A] [--tag T] [--json]"
963
+ o.banner = "Usage: okf files <dir|@slug> [--type T] [--area A] [--tag T] [--json]"
401
964
  json_flags(o, options, "emit the file tree as JSON")
402
965
  projection_flags(o, options)
403
966
  filter_flags(o, options, :type, :area, :tag)
967
+ help_flag(o)
404
968
  end
405
969
  dir = positional_dir(parser, argv) or return 2
406
970
 
@@ -417,10 +981,11 @@ module OKF
417
981
  def tags(argv)
418
982
  options = { json: false, by: nil }
419
983
  parser = OptionParser.new do |o|
420
- o.banner = "Usage: okf tags <bundle-dir> [--by type|area] [--type T] [--area A] [--json]"
984
+ o.banner = "Usage: okf tags <dir|@slug> [--by type|area] [--type T] [--area A] [--json]"
421
985
  json_flags(o, options, "emit the tag index as JSON")
422
986
  o.on("--by DIM", %w[type area], "group the tags by a concept dimension (type | area)") { |v| options[:by] = v.to_sym }
423
987
  filter_flags(o, options, :type, :area)
988
+ help_flag(o)
424
989
  end
425
990
  dir = positional_dir(parser, argv) or return 2
426
991
 
@@ -432,9 +997,10 @@ module OKF
432
997
  def types(argv)
433
998
  options = { json: false }
434
999
  parser = OptionParser.new do |o|
435
- o.banner = "Usage: okf types <bundle-dir> [--area A] [--tag T] [--json]"
1000
+ o.banner = "Usage: okf types <dir|@slug> [--area A] [--tag T] [--json]"
436
1001
  json_flags(o, options, "emit the type index as JSON")
437
1002
  filter_flags(o, options, :area, :tag)
1003
+ help_flag(o)
438
1004
  end
439
1005
  dir = positional_dir(parser, argv) or return 2
440
1006
 
@@ -494,15 +1060,15 @@ module OKF
494
1060
 
495
1061
  # A catalog entry's type for display — "Untyped" when blank, matching the graph.
496
1062
  def entry_type(entry)
497
- entry[:type].empty? ? "Untyped" : entry[:type]
1063
+ OKF.blank?(entry[:type]) ? "Untyped" : entry[:type]
498
1064
  end
499
1065
 
500
1066
  def print_grouped_tags(dir, dim, groups, titles)
501
- @out.puts "Tags — #{dir} (#{distinct_tags(groups)} distinct, by #{dim})"
1067
+ @out.puts "Tags — #{bundle_label(dir)} (#{distinct_tags(groups)} distinct, by #{dim})"
502
1068
  groups.each do |key, rows|
503
1069
  label = dim == :area && key != "(root)" ? "#{key}/" : key
504
1070
  @out.puts
505
- @out.puts " #{label} (#{rows.size} tags)"
1071
+ @out.puts " #{label} (#{rows.size} #{pluralize(rows.size, "tag")})"
506
1072
  width = rows.map { |row| row[:tag].length }.max || 0
507
1073
  rows.each do |row|
508
1074
  names = row[:concepts].map { |id| titles[id] || id }.join(", ")
@@ -512,12 +1078,10 @@ module OKF
512
1078
  end
513
1079
 
514
1080
  def print_grouped_tags_json(dir, dim, groups)
515
- emit_json(
516
- "bundle" => dir, "count" => distinct_tags(groups), "by" => dim.to_s,
517
- "groups" => groups.map do |key, rows|
518
- { dim.to_s => key, "count" => rows.size, "tags" => index_rows_json(:tag, rows) }
519
- end
520
- )
1081
+ groups_json = groups.map do |key, rows|
1082
+ { dim.to_s => key, "count" => rows.size, "tags" => index_rows_json(:tag, rows) }
1083
+ end
1084
+ emit_json(bundle_head(dir).merge("count" => distinct_tags(groups), "by" => dim.to_s, "groups" => groups_json))
521
1085
  end
522
1086
 
523
1087
  def distinct_tags(groups)
@@ -527,8 +1091,9 @@ module OKF
527
1091
  def stats(argv)
528
1092
  options = { json: false }
529
1093
  parser = OptionParser.new do |o|
530
- o.banner = "Usage: okf stats <bundle-dir> [--json]"
1094
+ o.banner = "Usage: okf stats <dir|@slug> [--json]"
531
1095
  json_flags(o, options, "emit the stats as JSON")
1096
+ help_flag(o)
532
1097
  end
533
1098
  dir = positional_dir(parser, argv) or return 2
534
1099
 
@@ -569,6 +1134,58 @@ module OKF
569
1134
  parser.on("--pretty", "indent the JSON for reading (implies --json)") { options[:json] = true; @pretty = true }
570
1135
  end
571
1136
 
1137
+ # Every parser answers its own -h/--help, so no parser inherits
1138
+ # OptionParser's officious one: that prints to the process's $stdout rather
1139
+ # than @out (an embedding app that injects streams never sees it) and ends
1140
+ # the process with `exit` rather than returning a status (a test that asks a
1141
+ # command for help takes the whole runner down with it). Thrown, not
1142
+ # returned — #run catches it — because a parser is parsed inside
1143
+ # positional_dir, where every other early exit means "exit 2".
1144
+ # on_tail, so help sorts last in the list it is printing.
1145
+ def help_flag(parser)
1146
+ parser.on_tail("-h", "--help", "print this message") do
1147
+ @out.puts parser.help
1148
+ throw :help, 0
1149
+ end
1150
+ end
1151
+
1152
+ # The registered engines, read at parse time so an addon that registers one
1153
+ # shows up in `--help` without the CLI knowing it exists.
1154
+ def engine_names
1155
+ OKF::Bundle::Search.engines.map(&:id).join(" | ")
1156
+ end
1157
+
1158
+ def unsupported_query_message(error)
1159
+ wanted = error.missing.map { |name| CAPABILITY_FLAGS.fetch(name, ":#{name}") }.join(", ")
1160
+ return "no available search engine offers #{wanted}" if error.engine.nil?
1161
+
1162
+ able = OKF::Bundle::Search.engines.select { |engine| (error.missing - engine.capabilities).empty? }
1163
+ message = "--engine #{error.engine} does not support #{wanted}"
1164
+ message += " (try --engine #{able.map(&:id).join(" or ")})" unless able.empty?
1165
+ message
1166
+ end
1167
+
1168
+ # The engine story, told once, in the only place there is to tell it. `search`
1169
+ # routes on what the query needs — a pattern needs the scan, --fuzzy needs the
1170
+ # index — and says nothing about it at runtime: no note on stderr, nothing in
1171
+ # the header, and deliberately no --engine flag. So this is where a user learns
1172
+ # that the exactness a token index gives up is still reachable, and that -e is
1173
+ # how. Without it that capability is present but undiscoverable.
1174
+ #
1175
+ # It leads rather than trails because #help_flag registers -h with `on_tail`,
1176
+ # which OptionParser renders after every separator: a closing paragraph would
1177
+ # print *above* the -h line and split the option list in half. Stating the
1178
+ # matching model before the flags reads better anyway.
1179
+ def search_engine_note(parser)
1180
+ parser.separator ""
1181
+ parser.separator "Terms match raw text, so a phrase (\"dedup key\"), a dotted identifier (7.2.0,"
1182
+ parser.separator "customer_id) and a word inside `backticks` all match literally — the scan engine."
1183
+ parser.separator "--engine index matches whole tokens and the tokens they prefix, ranked by BM25+:"
1184
+ parser.separator "better ranking and the engine the browser page runs, at the cost of that"
1185
+ parser.separator "exactness. --fuzzy implies it. Add -e to read the terms as regular expressions."
1186
+ parser.separator ""
1187
+ end
1188
+
572
1189
  # --fields/--except project the JSON down to the properties an agent wants, so it
573
1190
  # never pays tokens for fields it will not read. --fields is an allowlist,
574
1191
  # --except a denylist (mutually exclusive); both imply --json and apply per item
@@ -633,6 +1250,7 @@ module OKF
633
1250
  o.banner = "Usage: okf skill <dest-dir> [--here] [--force]"
634
1251
  o.on("--here", "install straight into <dest-dir>, wherever it is (no skills/okf nesting)") { options[:nest] = false }
635
1252
  o.on("--force", "overwrite a non-empty destination") { options[:force] = true }
1253
+ help_flag(o)
636
1254
  end
637
1255
  parser.parse!(argv)
638
1256
  dest = argv.shift
@@ -662,10 +1280,14 @@ module OKF
662
1280
  note_skipped(folder.bundle.unparseable.size)
663
1281
  end
664
1282
 
1283
+ # The bucket holds two kinds now — frontmatter that would not parse, and a
1284
+ # file that would not open — so the note names neither and points at the verb
1285
+ # that names both. "invalid frontmatter" was a guess the summary had no need
1286
+ # to make: `validate` prints the file and the reason for every one of them.
665
1287
  def note_skipped(count)
666
1288
  return if count.nil? || count <= 0
667
1289
 
668
- @err.puts "note: skipped #{count} file(s) with invalid frontmatter (run `okf validate` for details)"
1290
+ @err.puts "note: skipped #{count} unusable file(s) (run `okf validate` for details)"
669
1291
  end
670
1292
 
671
1293
  # Turn a --stale-after value (90d, 12w, or an ISO date) into an absolute cutoff
@@ -684,8 +1306,9 @@ module OKF
684
1306
  :invalid
685
1307
  end
686
1308
 
687
- # Parse options, then require a single existing-directory positional argument.
688
- # Returns the directory, or nil (after reporting) so the caller returns 2.
1309
+ # Parse options, then require a single bundle positional — a directory, or an
1310
+ # @ref into the registry. Returns the bundle's directory, or nil (after
1311
+ # reporting) so the caller returns 2.
689
1312
  def positional_dir(parser, argv)
690
1313
  parser.parse!(argv)
691
1314
  dir = argv.shift
@@ -693,11 +1316,143 @@ module OKF
693
1316
  @err.puts parser.banner
694
1317
  return nil
695
1318
  end
696
- unless File.directory?(dir)
697
- @err.puts "error: #{dir} is not a directory"
1319
+ # A second bundle is a question this verb cannot answer: only `search`
1320
+ # merges across bundles and only `server` mounts several. Reading the
1321
+ # first and dropping the rest would answer confidently about a bundle the
1322
+ # user never asked about — the silent-wrong-answer shape, so: exit 2.
1323
+ return nil unless no_extras?(argv)
1324
+
1325
+ resolve_ref(dir)
1326
+ rescue OptionParser::ParseError => e
1327
+ @err.puts e.message
1328
+ nil
1329
+ end
1330
+
1331
+ # Parse options, then take zero or more bundle positionals (the multi-bundle
1332
+ # server) — directories or @refs. Returns the resolved array (possibly
1333
+ # empty), or nil (after reporting) so the caller returns 2.
1334
+ def positional_dirs(parser, argv)
1335
+ parser.parse!(argv)
1336
+ dirs = argv.map { |dir| resolve_ref(dir) }
1337
+ dirs.include?(nil) ? nil : dirs
1338
+ rescue OptionParser::ParseError => e
1339
+ @err.puts e.message
1340
+ nil
1341
+ end
1342
+
1343
+ # "@slug" — or bare "@", the registry's default — names a registered bundle
1344
+ # wherever a <dir> goes; anything else must be a directory on disk. A
1345
+ # leading @ always means the registry (a directory literally named that way
1346
+ # stays reachable as ./@name), and the registry loads only when a ref
1347
+ # appears, so plain-dir invocations never pay for it. Returns the bundle's
1348
+ # directory, or nil after reporting.
1349
+ def resolve_ref(arg)
1350
+ return resolve_registered(arg) if arg.start_with?("@")
1351
+
1352
+ unless File.directory?(arg)
1353
+ @err.puts "error: #{arg} is not a directory or a registry ref " \
1354
+ "(@slug names a registered bundle, @ the default; okf registry list)"
698
1355
  return nil
699
1356
  end
700
- dir
1357
+ arg
1358
+ end
1359
+
1360
+ # Load the registry, turning a malformed file into a reported usage error
1361
+ # instead of an OKF::Error escaping through whatever verb took an @ref —
1362
+ # only `server` and the `registry` verbs rescue one. Returns nil after
1363
+ # reporting, so every caller returns 2.
1364
+ def load_registry
1365
+ require "okf/registry"
1366
+ OKF::Registry.load
1367
+ rescue OKF::Error => e
1368
+ @err.puts "error: #{e.message}"
1369
+ nil
1370
+ end
1371
+
1372
+ # Resolve one @ref through the registry under $OKF_HOME (default ~/.okf).
1373
+ # The slug part is normalized
1374
+ # exactly as registration normalized it, so @One finds the bundle
1375
+ # registered from dir One — but never through #slugify's mint-a-name
1376
+ # placeholder, so "@***" is a bad ref rather than whatever is slugged
1377
+ # "bundle". An explicit ask fails hard: an unknown slug or a
1378
+ # registered-but-gone directory is a usage error naming the registry file
1379
+ # and the next move, never a silent skip.
1380
+ #
1381
+ # @all never resolves here. `search` expands it before this point; every
1382
+ # other verb takes exactly one bundle, so letting it through would mean
1383
+ # @all lints when one bundle is registered and exits 2 when two are —
1384
+ # behavior that varies with the size of the registry, which is the
1385
+ # silent-wrong-answer shape the second-bundle rule exists to stop. Say what
1386
+ # @all is instead of calling it a bundle nobody registered ("all" cannot be
1387
+ # registered — Registry::RESERVED_SLUGS sees to that).
1388
+ def resolve_registered(ref)
1389
+ @ref_failure = :registry
1390
+ if all_ref?(ref)
1391
+ @err.puts "error: #{ALL_REF} is only supported by `okf search` (it names every registered bundle)"
1392
+ return nil
1393
+ end
1394
+ registry = load_registry
1395
+ return nil unless registry
1396
+
1397
+ asked = ref[1..-1]
1398
+ slug = OKF::Registry.normalize(asked)
1399
+ entry = if asked.empty?
1400
+ registry.default # bare "@"
1401
+ elsif slug.empty?
1402
+ nil # "@***" — nothing to look up, and no placeholder to fall back on
1403
+ else
1404
+ registry.get(slug)
1405
+ end
1406
+ if entry.nil?
1407
+ @ref_failure = :unknown
1408
+ hint = registry.empty? ? "okf registry set <dir>" : "okf registry list"
1409
+ @err.puts "error: not a registered bundle: #{ref} in #{registry.path} (#{hint})"
1410
+ return nil
1411
+ end
1412
+ unless File.directory?(entry.path)
1413
+ @ref_failure = :missing
1414
+ @err.puts "error: #{ref} points to #{entry.path}, which is not a directory (okf registry del #{entry.slug}, or restore it)"
1415
+ return nil
1416
+ end
1417
+ ref_slugs[entry.path] = entry.slug
1418
+ entry.path
1419
+ end
1420
+
1421
+ # Which slug each @ref resolved to, by absolute path — so a hub built from
1422
+ # refs mounts each bundle under its registered slug, not its dir basename.
1423
+ # Reset by every run; never memoized here, or a stale run would seed it.
1424
+ attr_reader :ref_slugs
1425
+
1426
+ # Every bundle-scoped output names its bundle in the identity the caller
1427
+ # used: `@handbook (/path)` when they named a registered bundle, the plain
1428
+ # path otherwise. A dir named by path stays a path — inventing a slug for it
1429
+ # would imply a registration that does not exist, and looking one up would
1430
+ # cost a registry read on every plain-dir run.
1431
+ def bundle_label(dir)
1432
+ slug = ref_slugs[dir]
1433
+ slug ? "@#{slug} (#{dir})" : dir.to_s
1434
+ end
1435
+
1436
+ # The JSON head for one bundle. `bundle` is always its directory and `slug`
1437
+ # always a registry slug — never the same key meaning two things — so a
1438
+ # consumer resolves a row to a file without a second lookup.
1439
+ def bundle_head(dir)
1440
+ head = { "bundle" => dir }
1441
+ slug = ref_slugs[dir]
1442
+ head["slug"] = slug if slug
1443
+ head
1444
+ end
1445
+
1446
+ # Parse options, then require a single non-directory positional (e.g. a slug).
1447
+ # Returns it, or nil (after reporting the banner) so the caller returns 2.
1448
+ def positional(parser, argv)
1449
+ parser.parse!(argv)
1450
+ value = argv.shift
1451
+ if value.nil?
1452
+ @err.puts parser.banner
1453
+ return nil
1454
+ end
1455
+ value
701
1456
  rescue OptionParser::ParseError => e
702
1457
  @err.puts e.message
703
1458
  nil
@@ -705,7 +1460,7 @@ module OKF
705
1460
 
706
1461
  def print_validation(dir, result)
707
1462
  counts = result.counts
708
- @out.puts "OKF v0.1 conformance — #{dir}"
1463
+ @out.puts "OKF v0.1 conformance — #{bundle_label(dir)}"
709
1464
  @out.puts " concepts: #{counts[:concepts]} index.md: #{counts[:indexes]} log.md: #{counts[:logs]}"
710
1465
  result.errors.each { |e| @out.puts " #{paint("✗ ERROR", 31)} #{e[:path]}: #{e[:message]}" }
711
1466
  result.warnings.each { |w| @out.puts " #{paint("! warn", 33)} #{w[:path]}: #{w[:message]}" }
@@ -719,18 +1474,17 @@ module OKF
719
1474
  end
720
1475
 
721
1476
  def print_validation_json(dir, result)
722
- emit_json(
723
- "bundle" => dir,
1477
+ emit_json(bundle_head(dir).merge(
724
1478
  "conformant" => result.valid?,
725
1479
  "counts" => result.counts,
726
1480
  "errors" => result.errors,
727
1481
  "warnings" => result.warnings
728
- )
1482
+ ))
729
1483
  end
730
1484
 
731
1485
  def print_lint(dir, report)
732
1486
  stats = report.stats
733
- @out.puts "OKF lint — #{dir}"
1487
+ @out.puts "OKF lint — #{bundle_label(dir)}"
734
1488
  @out.puts " concepts: #{stats[:concepts]} edges: #{stats[:edges]} index.md: #{stats[:indexes]} log.md: #{stats[:logs]}"
735
1489
  summary = lint_summary(stats)
736
1490
  @out.puts " #{summary}" unless summary.empty?
@@ -751,12 +1505,11 @@ module OKF
751
1505
  end
752
1506
 
753
1507
  def print_lint_json(dir, report)
754
- emit_json(
755
- "bundle" => dir,
1508
+ emit_json(bundle_head(dir).merge(
756
1509
  "healthy" => report.healthy?,
757
1510
  "stats" => report.stats,
758
1511
  "findings" => report.findings
759
- )
1512
+ ))
760
1513
  end
761
1514
 
762
1515
  # Degree-0 nodes as { id:, title:, dir: }, sorted by path — the same set lint's
@@ -769,7 +1522,7 @@ module OKF
769
1522
  end
770
1523
 
771
1524
  def print_loose(dir, files)
772
- @out.puts "Loose files — #{dir} (#{files.size})"
1525
+ @out.puts "Loose files — #{bundle_label(dir)} (#{files.size})"
773
1526
  if files.empty?
774
1527
  @out.puts " #{paint("✓ none — every concept links or is linked", 32)}"
775
1528
  return
@@ -786,15 +1539,14 @@ module OKF
786
1539
  end
787
1540
 
788
1541
  def print_loose_json(dir, files)
789
- emit_json(
790
- "bundle" => dir,
1542
+ emit_json(bundle_head(dir).merge(
791
1543
  "count" => files.size,
792
1544
  "loose" => files.map { |file| stringify(file) }
793
- )
1545
+ ))
794
1546
  end
795
1547
 
796
1548
  def print_catalog(dir, entries, total)
797
- @out.puts "Catalog — #{dir} (#{counted(entries.size, total, "concepts")})"
1549
+ @out.puts "Catalog — #{bundle_label(dir)} (#{counted(entries.size, total, "concept")})"
798
1550
  entries.group_by { |entry| entry[:area] }.sort_by(&:first).each do |area, group|
799
1551
  @out.puts
800
1552
  @out.puts " #{area == "(root)" ? "(root)" : "#{area}/"} (#{group.size})"
@@ -812,7 +1564,7 @@ module OKF
812
1564
  end
813
1565
 
814
1566
  def print_files(dir, entries, total)
815
- @out.puts "Files — #{dir} (#{counted(entries.size, total, "files")})"
1567
+ @out.puts "Files — #{bundle_label(dir)} (#{counted(entries.size, total, "file")})"
816
1568
  entries.group_by { |entry| entry[:dir] }.sort_by(&:first).each do |folder, group|
817
1569
  width = group.map { |entry| File.basename("#{entry[:id]}.md").length }.max
818
1570
  @out.puts
@@ -832,7 +1584,7 @@ module OKF
832
1584
  end
833
1585
 
834
1586
  def print_index(dir, label, key, rows, titles)
835
- @out.puts "#{label} — #{dir} (#{rows.size} distinct)"
1587
+ @out.puts "#{label} — #{bundle_label(dir)} (#{rows.size} distinct)"
836
1588
  @out.puts
837
1589
  width = rows.map { |row| row[key].length }.max || 0
838
1590
  rows.each do |row|
@@ -842,19 +1594,29 @@ module OKF
842
1594
  end
843
1595
 
844
1596
  def print_index_json(dir, plural, key, rows)
845
- emit_json("bundle" => dir, "count" => rows.size, plural => index_rows_json(key, rows))
1597
+ emit_json(bundle_head(dir).merge("count" => rows.size, plural => index_rows_json(key, rows)))
846
1598
  end
847
1599
 
848
1600
  def index_rows_json(key, rows)
849
1601
  rows.map { |row| { key.to_s => row[key], "count" => row[:count], "concepts" => row[:concepts] } }
850
1602
  end
851
1603
 
1604
+ # "3 concepts", "1 concept", "1 of 7 concepts" — the noun agrees with the
1605
+ # number it follows: the size when that is all we show, the total otherwise.
852
1606
  def counted(size, total, noun)
853
- size == total ? "#{size} #{noun}" : "#{size} of #{total} #{noun}"
1607
+ return "#{size} #{pluralize(size, noun)}" if size == total
1608
+
1609
+ "#{size} of #{total} #{pluralize(total, noun)}"
1610
+ end
1611
+
1612
+ # The gem's whole vocabulary is regular, so a naive +s is not a shortcut —
1613
+ # it is the rule. Callers pass the singular.
1614
+ def pluralize(count, noun)
1615
+ count == 1 ? noun : "#{noun}s"
854
1616
  end
855
1617
 
856
1618
  def print_stats(dir, stats)
857
- @out.puts "Stats — #{dir}"
1619
+ @out.puts "Stats — #{bundle_label(dir)}"
858
1620
  @out.puts
859
1621
  @out.puts " concepts #{stats[:concepts]}"
860
1622
  @out.puts " areas #{stats[:areas]}"
@@ -875,11 +1637,11 @@ module OKF
875
1637
  end
876
1638
 
877
1639
  def print_stats_json(dir, stats)
878
- emit_json(
879
- "bundle" => dir, "concepts" => stats[:concepts], "areas" => stats[:areas],
1640
+ emit_json(bundle_head(dir).merge(
1641
+ "concepts" => stats[:concepts], "areas" => stats[:areas],
880
1642
  "concept_types" => stats[:types], "cross_links" => stats[:cross_links], "distinct_tags" => stats[:tags],
881
1643
  "by_type" => stats[:by_type], "by_area" => stats[:by_area]
882
- )
1644
+ ))
883
1645
  end
884
1646
 
885
1647
  # The single JSON writer. Compact by default — the token-efficient substrate an
@@ -892,13 +1654,18 @@ module OKF
892
1654
  # Emit a list view's JSON envelope with --fields/--except projection applied to
893
1655
  # each item. Returns the verb's exit code (0, or 2 on a bad projection request —
894
1656
  # both flags at once, or a field name no item carries).
895
- def emit_list_json(dir, key, items, options, extra = {})
1657
+ # +dir+ is the bundle's directory or a ready-made head Hash when the
1658
+ # payload spans bundles (multi-bundle search's "bundles" key).
1659
+ # +key+ names the JSON property the rows land under; +shape+ names the row
1660
+ # shape to check --fields/--except against. They are the same for every view
1661
+ # but search, whose two modes emit the same property from different rows.
1662
+ def emit_list_json(dir, key, items, options, extra = {}, shape = key)
896
1663
  return usage_error("--fields and --except are mutually exclusive") if options[:fields] && options[:except]
897
1664
 
898
- unknown = unknown_fields(items, options)
899
- return usage_error("unknown field(s): #{unknown.join(", ")} (available: #{available_fields(items).join(", ")})") unless unknown.empty?
1665
+ unknown = unknown_fields(items, options, shape)
1666
+ return usage_error("unknown field(s): #{unknown.join(", ")} (available: #{available_fields(items, shape).join(", ")})") unless unknown.empty?
900
1667
 
901
- payload = { "bundle" => dir }.merge(extra)
1668
+ payload = (dir.is_a?(Hash) ? dir.dup : bundle_head(dir)).merge(extra)
902
1669
  payload["count"] = items.size
903
1670
  payload[key] = project(items, options)
904
1671
  emit_json(payload)
@@ -917,17 +1684,22 @@ module OKF
917
1684
  end
918
1685
  end
919
1686
 
920
- def available_fields(items)
921
- items.first ? items.first.keys.map(&:to_s) : []
1687
+ # The declared shape wins over the data's, so the same typo gets the same
1688
+ # answer whether or not the result happened to have rows; a view with no
1689
+ # declared shape falls back to what it actually emitted.
1690
+ def available_fields(items, key = nil)
1691
+ ROW_FIELDS[key] || (items.first ? items.first.keys.map(&:to_s) : [])
922
1692
  end
923
1693
 
924
1694
  # Requested field names that no item actually carries — a typo guard (exit 2),
925
1695
  # matching how lint rejects unknown check names.
926
- def unknown_fields(items, options)
1696
+ def unknown_fields(items, options, key = nil)
927
1697
  requested = (Array(options[:fields]) + Array(options[:except])).map(&:downcase)
928
- return [] if requested.empty? || items.empty?
1698
+ return [] if requested.empty?
1699
+
1700
+ known = available_fields(items, key).map(&:downcase)
1701
+ return [] if known.empty? # an unknown view: no shape to check against, so accept
929
1702
 
930
- known = available_fields(items).map(&:downcase)
931
1703
  requested.reject { |field| known.include?(field) }.uniq
932
1704
  end
933
1705
 
@@ -976,23 +1748,37 @@ module OKF
976
1748
  io.puts <<~USAGE
977
1749
  okf <command> [options]
978
1750
 
979
- skill <dest> [--here] [--force] install the companion agent skill
980
- server <dir> [-p PORT] [--bind ADDR] [...] serve an interactive HTML graph
981
- render <dir> [-o FILE] [--layout NAME] [...] write a static, self-contained HTML graph
982
-
983
- lint <dir> [--json] [--fail-on warn] [...] report curation-quality issues
984
- loose <dir> [--json] list files with no graph links, by folder
985
- validate <dir> [--json] check OKF v0.1 conformance
986
-
987
- search <dir> <term…> [-e] [--in FIELDS] [...] find concepts by text or regexp, ranked
988
- index <dir> [--json] [--area A] [--no-body] the index map: dirs, their listings and rollups
989
- stats <dir> [--json] bundle rollups (concepts, types, areas, links, tags)
990
- types <dir> [--json] [filters] list types with their concepts, by count
991
- tags <dir> [--json] [--by DIM] [filters] list tags with their concepts, by count
992
- files <dir> [--json] [filters] list files with titles, by folder
993
- catalog <dir> [--json] [filters] list concepts with metadata, by area
994
-
995
- graph <dir> [--json] [--minimal] [--no-body] print the knowledge graph
1751
+ skill <dest> [--here] [--force] install the companion agent skill
1752
+ server [DIR|@slug…] [-p PORT] [--bind ADDR] [...] serve one bundle, or many behind a hub
1753
+ render <dir|@slug> [-o FILE] [--layout NAME] [...] write a static, self-contained HTML graph
1754
+
1755
+ registry list [--json] list registered bundles (* marks the default)
1756
+ registry set <dir|@slug> [--as SLUG] [--default] add or update a bundle (a bare `server` serves them)
1757
+ registry del <dir|@slug> remove a bundle from the registry
1758
+ registry default <@slug> move a bundle to the front (the default)
1759
+ registry rename <@slug> <new> rename a registered bundle (<new> is a new name, not a ref)
1760
+
1761
+ lint <dir|@slug> [--json] [--fail-on warn] [...] report curation-quality issues
1762
+ loose <dir|@slug> [--json] list files with no graph links, by folder
1763
+ validate <dir|@slug> [--json] check OKF v0.1 conformance
1764
+
1765
+ search <dir|@slug…|@all> <term…> [-e|--fuzzy] [...] find concepts by text or regexp, ranked (@all: every bundle)
1766
+ index <dir|@slug> [--json] [--area A] [--no-body] the index map: dirs, their listings and rollups
1767
+ stats <dir|@slug> [--json] bundle rollups (concepts, types, areas, links, tags)
1768
+ types <dir|@slug> [--json] [filters] list types with their concepts, by count
1769
+ tags <dir|@slug> [--json] [--by DIM] [filters] list tags with their concepts, by count
1770
+ files <dir|@slug> [--json] [filters] list files with titles, by folder
1771
+ catalog <dir|@slug> [--json] [filters] list concepts with metadata, by area
1772
+
1773
+ graph <dir|@slug> [--json] [--minimal] [--no-body] print the knowledge graph
1774
+
1775
+ @slug names a registered bundle instead of a path — the slug from
1776
+ `okf registry set`, or bare @ for the registry default. Anywhere a <dir>
1777
+ goes, an @slug goes: `okf lint @handbook`, `okf render @ -o graph.html`.
1778
+ The registry lives under $OKF_HOME (default ~/.okf); set it to point
1779
+ every verb at another one.
1780
+ search spans bundles: several leading @slugs, or @all for every registered one
1781
+ (@all skips a bundle whose directory is gone; a named @slug insists on it).
996
1782
 
997
1783
  [filters] narrow a view to matching concepts: --type TYPE, --area AREA, --tag TAG
998
1784
  (each view takes the ones orthogonal to it; matching is case-insensitive).