okf 1.6.0 → 1.8.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,82 @@ 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 row shape each list view emits, so `--fields`/`--except` can be checked
96
+ # against a name even when the result is empty. Without it the typo guard
97
+ # keyed off the data: `--fields bogus` was a usage error against a bundle
98
+ # with matches and silently fine against one without, which made a typo's
99
+ # fate depend on whether a filter happened to match. A test asserts each
100
+ # view's real rows carry exactly these, so the two cannot drift.
101
+ # Declared in emission order, so the "available:" list a typo prints reads
102
+ # the same as the rows themselves.
103
+ ROW_FIELDS = {
104
+ "matches" => %w[id title type area tags matched score snippet],
105
+ # Registry mode labels every row with the bundle it came from; a plain-dir
106
+ # search has one bundle and no slug to carry. Two shapes, because the typo
107
+ # guard checks against the *declared* one — a single shape covering both
108
+ # would let `--fields slug` pass on a search whose rows have none, and hand
109
+ # back an empty object per match under a count that says otherwise.
110
+ "matches_by_ref" => %w[slug id title type area tags matched score snippet],
111
+ "concepts" => %w[id title type description tags timestamp status backlog_ref dir area links_out links_in],
112
+ "files" => %w[path id dir type title description],
113
+ "directories" => %w[dir index_path present synthesized count types tags subdirs body listing],
114
+ "bundles" => %w[slug title dir mount default missing]
115
+ }.freeze
116
+
70
117
  private
71
118
 
72
119
  def validate(argv)
73
120
  options = { json: false }
74
121
  parser = OptionParser.new do |o|
75
- o.banner = "Usage: okf validate <bundle-dir> [--json]"
122
+ o.banner = "Usage: okf validate <dir|@slug> [--json]"
76
123
  json_flags(o, options, "emit a JSON report")
124
+ help_flag(o)
77
125
  end
78
126
  dir = positional_dir(parser, argv) or return 2
79
127
 
@@ -85,13 +133,14 @@ module OKF
85
133
  def lint(argv)
86
134
  options = { json: false, min_body: OKF::Bundle::Linter::DEFAULT_MIN_BODY, stale_after: nil, only: nil, except: nil, fail_on: :never }
87
135
  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]"
136
+ o.banner = "Usage: okf lint <dir|@slug> [--json] [--min-body N] [--stale-after DUR] [--only a,b] [--except a,b] [--fail-on warn]"
89
137
  json_flags(o, options, "emit a JSON report")
90
138
  o.on("--min-body N", Integer, "stub threshold in body characters (default #{OKF::Bundle::Linter::DEFAULT_MIN_BODY})") { |v| options[:min_body] = v }
91
139
  o.on("--stale-after DUR", "flag concepts older than DUR (e.g. 90d, 12w, 2026-01-01)") { |v| options[:stale_after] = v }
92
140
  o.on("--only LIST", Array, "run only these checks (comma-separated)") { |v| options[:only] = v.map(&:to_sym) }
93
141
  o.on("--except LIST", Array, "skip these checks (comma-separated)") { |v| options[:except] = v.map(&:to_sym) }
94
142
  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 }
143
+ help_flag(o)
95
144
  end
96
145
  dir = positional_dir(parser, argv) or return 2
97
146
 
@@ -121,8 +170,9 @@ module OKF
121
170
  def loose(argv)
122
171
  options = { json: false }
123
172
  parser = OptionParser.new do |o|
124
- o.banner = "Usage: okf loose <bundle-dir> [--json]"
173
+ o.banner = "Usage: okf loose <dir|@slug> [--json]"
125
174
  json_flags(o, options, "emit the loose files as JSON")
175
+ help_flag(o)
126
176
  end
127
177
  dir = positional_dir(parser, argv) or return 2
128
178
 
@@ -143,23 +193,52 @@ module OKF
143
193
  def search(argv)
144
194
  options = { json: false, regexp: false }
145
195
  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]"
196
+ o.banner = "Usage: okf search <dir|@slug…|@all> <term> [term ...] [--regexp] [--in FIELDS] [--type T] [--area A] [--tag T] [--json]"
147
197
  json_flags(o, options, "emit the matches as JSON")
148
198
  projection_flags(o, options)
149
199
  o.on("-e", "--regexp", "treat each term as a Ruby regular expression (case-insensitive)") { options[:regexp] = true }
150
200
  o.on("--in LIST", Array, "search only these fields (#{OKF::Bundle::Search::FIELDS.join(", ")})") { |v| options[:in] = v.map(&:downcase) }
151
201
  filter_flags(o, options, :type, :area, :tag)
202
+ help_flag(o)
152
203
  end
153
- dir = positional_dir(parser, argv) or return 2
204
+ begin
205
+ parser.parse!(argv)
206
+ rescue OptionParser::ParseError => e
207
+ @err.puts e.message
208
+ return 2
209
+ end
210
+
211
+ # Registry mode — leading @refs, @all among them — searches several bundles
212
+ # and labels every match; a plain dir keeps the classic single-bundle output.
213
+ if argv.first&.start_with?("@")
214
+ pairs = ref_targets(argv) or return 2
215
+ dir = nil
216
+ else
217
+ dir = argv.shift
218
+ if dir.nil?
219
+ @err.puts parser.banner
220
+ return 2
221
+ end
222
+ dir = resolve_ref(dir) or return 2
223
+ end
224
+
154
225
  terms = argv
155
226
  if terms.empty?
156
227
  @err.puts parser.banner
157
228
  return 2
158
229
  end
159
230
 
231
+ # A non-leading @arg is a literal term by the grammar — say so, since the
232
+ # user may have meant a ref (refs must lead) and would otherwise see only
233
+ # a silent zero-match.
234
+ stray = terms.find { |term| term.start_with?("@") }
235
+ @err.puts "note: '#{stray}' searches as a literal term — an @slug or @all must lead" if stray
236
+
160
237
  unknown = Array(options[:in]) - OKF::Bundle::Search::FIELDS
161
238
  return usage_error("unknown field(s): #{unknown.join(", ")} (searchable: #{OKF::Bundle::Search::FIELDS.join(", ")})") unless unknown.empty?
162
239
 
240
+ return multi_search(pairs, terms, options) if pairs
241
+
163
242
  folder = OKF::Bundle::Folder.load(dir)
164
243
  report_skipped(folder)
165
244
  rows = OKF::Bundle::Search.call(folder.bundle, terms, fields: options[:in], regexp: options[:regexp])
@@ -173,8 +252,102 @@ module OKF
173
252
  usage_error("invalid pattern: #{e.message}")
174
253
  end
175
254
 
255
+ # Every registered bundle, as [slug, dir] pairs — what @all expands to.
256
+ # Asking for everything tolerates gaps: a registered directory that has since
257
+ # vanished is skipped with a note, the same forgiveness the hub shows a stale
258
+ # entry. Naming one bundle demands it, so a plain @slug still fails hard.
259
+ def all_targets
260
+ registry = load_registry
261
+ return nil unless registry
262
+
263
+ if registry.empty?
264
+ @err.puts "error: no bundles registered (okf registry set <dir>)"
265
+ return nil
266
+ end
267
+ pairs = []
268
+ registry.each do |entry|
269
+ if File.directory?(entry.path)
270
+ pairs << [ entry.slug, entry.path ]
271
+ else
272
+ skip_registered(entry)
273
+ end
274
+ end
275
+ if pairs.empty?
276
+ @err.puts "error: every registered bundle is missing on disk (okf registry list)"
277
+ return nil
278
+ end
279
+ pairs
280
+ end
281
+
282
+ # Dedupe by resolved path, not ref spelling — `@ @one` is one bundle when
283
+ # "one" is the default, and must be searched once. `@all @one` is the same
284
+ # story with a wider first ref: all ⊇ one, so the result is right and the
285
+ # duplicate simply drops. No error branch, because there is no wrong answer
286
+ # to warn about.
287
+ def ref_targets(argv)
288
+ refs = []
289
+ refs << argv.shift while argv.first&.start_with?("@")
290
+ pairs = []
291
+ refs.each do |ref|
292
+ found = all_ref?(ref) ? all_targets : ref_pair(ref)
293
+ return nil unless found
294
+
295
+ found.each { |slug, path| pairs << [ slug, path ] unless pairs.any? { |_, seen| seen == path } }
296
+ end
297
+ pairs
298
+ end
299
+
300
+ # Does this @ref name every registered bundle? Takes a ref, sigil and all —
301
+ # both callers reach it only past a start_with?("@") of their own, so a
302
+ # third check here would be a branch no run can take.
303
+ #
304
+ # Compared *normalized*, because the ref grammar has exactly one
305
+ # normalization and a ref exempt from it is a trapdoor: `@ALL` has to reach
306
+ # `@all` for the same reason `@One` reaches the bundle registered from dir
307
+ # `One`. It normalizes through Registry.normalize — the very call the slug
308
+ # lookup makes — rather than a second downcase that could be forgotten while
309
+ # the first was maintained.
310
+ def all_ref?(ref)
311
+ require "okf/registry"
312
+ OKF::Registry.normalize(ref[1..-1]) == ALL_REF[1..-1]
313
+ end
314
+
315
+ # One @ref as a single-element [[slug, dir]], or nil after reporting.
316
+ def ref_pair(ref)
317
+ path = resolve_registered(ref)
318
+ unless path
319
+ # Only an unknown slug is plausibly a mistyped term — a broken registry
320
+ # or a gone directory has nothing to do with the grammar.
321
+ @err.puts "note: searching for a literal @-term? put a non-@ term first, or use -e '\\@term'" if @ref_failure == :unknown
322
+ return nil
323
+ end
324
+ [ [ ref_slugs[path], path ] ]
325
+ end
326
+
327
+ # Search each bundle with the same terms and merge the rankings — scores are
328
+ # absolute term weights, so they compare across bundles — every row labeled
329
+ # with its bundle's slug and ties broken deterministically.
330
+ def multi_search(pairs, terms, options)
331
+ rows = []
332
+ total = 0
333
+ pairs.each do |slug, dir|
334
+ folder = OKF::Bundle::Folder.load(dir)
335
+ report_skipped(folder)
336
+ total += folder.bundle.concepts.size
337
+ found = OKF::Bundle::Search.call(folder.bundle, terms, fields: options[:in], regexp: options[:regexp])
338
+ keep = filter_ids(folder, options)
339
+ found = found.select { |row| keep.include?(row[:id]) } unless keep.nil?
340
+ found.each { |row| rows << { slug: slug }.merge(row) }
341
+ end
342
+ rows.sort_by! { |row| [ -row[:score], row[:slug], row[:id] ] }
343
+ return print_multi_search_json(pairs, terms, rows, options) if options[:json]
344
+
345
+ print_multi_search(pairs, terms, rows, total)
346
+ 0
347
+ end
348
+
176
349
  def print_search(dir, terms, rows, total)
177
- @out.puts "Search — #{dir} · #{terms.join(" ")} (#{counted(rows.size, total, "concepts")})"
350
+ @out.puts "Search — #{bundle_label(dir)} · #{terms.join(" ")} (#{counted(rows.size, total, "concept")})"
178
351
  if rows.empty?
179
352
  @out.puts " no matches — fewer or broader terms, or scan `okf tags #{dir}` for the vocabulary"
180
353
  return
@@ -192,32 +365,166 @@ module OKF
192
365
  emit_list_json(dir, "matches", rows.map { |row| stringify(row) }, options, "query" => terms)
193
366
  end
194
367
 
368
+ def print_multi_search(pairs, terms, rows, total)
369
+ @out.puts "Search — #{pairs.map { |slug, _| "@#{slug}" }.join(" ")} · #{terms.join(" ")} (#{counted(rows.size, total, "concept")})"
370
+ if rows.empty?
371
+ @out.puts " no matches — fewer or broader terms, or scan `okf tags @<slug>` for a bundle's vocabulary"
372
+ return
373
+ end
374
+
375
+ slug_width = rows.map { |row| row[:slug].length }.max + 1
376
+ width = rows.map { |row| row[:id].length }.max
377
+ rows.each do |row|
378
+ @out.puts
379
+ @out.puts " #{"@#{row[:slug]}".ljust(slug_width)} #{row[:id].ljust(width)} #{row[:title]} · #{row[:type]} · #{row[:matched].join("+")}"
380
+ @out.puts " #{truncate(row[:snippet], 100)}" unless row[:snippet].empty?
381
+ end
382
+ end
383
+
384
+ # The head maps every searched slug to its directory once, so a row's
385
+ # `slug` resolves to `<dir>/<id>.md` without a second lookup — and without
386
+ # repeating a long path on every row.
387
+ def print_multi_search_json(pairs, terms, rows, options)
388
+ head = { "bundles" => pairs.map { |slug, dir| { "slug" => slug, "dir" => dir } } }
389
+ emit_list_json(head, "matches", rows.map { |row| stringify(row) }, options, { "query" => terms }, "matches_by_ref")
390
+ end
391
+
195
392
  def server(argv)
196
393
  require "okf/server/app"
394
+ require "rack/deflater"
197
395
 
198
396
  options = { port: 8808, bind: "127.0.0.1", title: nil, link: nil, layout: "cose" }
199
397
  parser = OptionParser.new do |o|
200
- o.banner = "Usage: okf server <bundle-dir> [-p PORT] [--bind ADDR] [--layout NAME] [-t title] [-l url]"
398
+ o.banner = "Usage: okf server [DIR|@slug…] [-p PORT] [--bind ADDR] [--layout NAME] [-t title] [-l url]"
201
399
  o.on("-p", "--port PORT", Integer, "port to serve on (default #{options[:port]})") { |v| options[:port] = v }
202
400
  o.on("--bind ADDR", "address to bind (default #{options[:bind]})") { |v| options[:bind] = v }
203
- o.on("-t", "--title TITLE", "graph title (default: parent/bundle dir name)") { |v| options[:title] = v }
204
- o.on("-l", "--link URL", "source URL shown in the header") { |v| options[:link] = v }
401
+ o.on("-t", "--title TITLE", "graph title, single bundle only (default: parent/bundle dir name)") { |v| options[:title] = v }
402
+ o.on("-l", "--link URL", "source URL shown in the header, single bundle only") { |v| options[:link] = v }
205
403
  o.on("--layout NAME", OKF::Server::Graph::LAYOUTS, "initial layout (#{OKF::Server::Graph::LAYOUTS.join(", ")})") { |v| options[:layout] = v }
404
+ help_flag(o)
206
405
  end
207
- dir = positional_dir(parser, argv) or return 2
406
+ dirs = positional_dirs(parser, argv) or return 2
208
407
 
209
- folder = OKF::Bundle::Folder.load(dir)
210
- report_skipped(folder)
211
- run_server(folder, options)
408
+ # A flag that will have no effect in this mode gets a note, not silence.
409
+ @err.puts "note: --title/--link apply to a single-bundle server; ignored" if dirs.size != 1 && (options[:title] || options[:link])
410
+
411
+ # One dir keeps the historical single-bundle server at `/`; zero (the
412
+ # persistent registry) or many (ephemeral) fan out behind a hub.
413
+ if dirs.size == 1
414
+ folder = OKF::Bundle::Folder.load(dirs.first)
415
+ report_skipped(folder)
416
+ run_server(folder, options)
417
+ else
418
+ run_hub(dirs, options)
419
+ end
212
420
  0
421
+ rescue OKF::Error => e
422
+ usage_error(e.message)
213
423
  end
214
424
 
215
- # Build the Rack app and hand it to the runner (WEBrick by default, injected so
216
- # tests drive this without a socket).
425
+ # Build the single-bundle Rack app and hand it to the runner (WEBrick by
426
+ # default, injected so tests drive this without a socket).
217
427
  def run_server(folder, options)
218
428
  app = OKF::Server::App.new(folder, title: options[:title] || folder.name, link: options[:link], layout: options[:layout])
219
- @out.puts "serving #{folder.graph.nodes.size} concepts at http://#{options[:bind]}:#{options[:port]} (Ctrl-C to stop)"
220
- @runner.call(app, options[:bind], options[:port])
429
+ # minimal: the banner wants a count, not bodies — and Folder#graph is not
430
+ # memoized, so a full build here parses every concept a second time (the
431
+ # App builds its own) purely to print one number.
432
+ count = folder.graph(minimal: true).nodes.size
433
+ @out.puts "serving #{count} #{pluralize(count, "concept")} at http://#{options[:bind]}:#{options[:port]} (Ctrl-C to stop)"
434
+ serve(app, options)
435
+ end
436
+
437
+ # Build the multi-bundle hub and hand it to the runner. With dirs it serves
438
+ # those ephemerally; with none it serves the persistent registry. Either way
439
+ # the first bundle is the one `/` opens — for the registry that is its own
440
+ # order, and a first entry whose directory has vanished drops out here, so
441
+ # `/` lands on the next one that is actually there.
442
+ def run_hub(dirs, options)
443
+ require "okf/server/hub"
444
+ require "okf/registry"
445
+ if dirs.empty?
446
+ # A malformed registry raises OKF::Error, which `server` rescues into a
447
+ # usage error — no guarded load needed on this path.
448
+ reg = OKF::Registry.load
449
+ bundles = reg.map { |entry| load_registered(entry) }.compact
450
+ else
451
+ bundles = ephemeral_bundles(dirs)
452
+ end
453
+ hub = OKF::Server::Hub.new(bundles, layout: options[:layout])
454
+ concepts = bundles.inject(0) { |sum, bundle| sum + bundle.folder.graph(minimal: true).nodes.size }
455
+ @out.puts "serving #{bundles.size} #{pluralize(bundles.size,
456
+ "bundle")}, #{concepts} #{pluralize(concepts, "concept")} at http://#{options[:bind]}:#{options[:port]} (Ctrl-C to stop)"
457
+ print_mounts(hub)
458
+ serve(hub, options)
459
+ end
460
+
461
+ # The one boot seam every served app passes through, so a hub gzips exactly
462
+ # like a single bundle — the wrap belongs to booting a server, not to either
463
+ # mode, and a mode added later gets it for free. Deliberately not inside the
464
+ # runner: an embedding app mounting OKF::Server::App brings its own middleware.
465
+ def serve(app, options)
466
+ # gzip responses when the client accepts it — transparent, no new dependency
467
+ @runner.call(Rack::Deflater.new(app), options[:bind], options[:port])
468
+ end
469
+
470
+ # The mount table — which dir landed on which /b/<slug>/ and where `/` goes.
471
+ # Mirrors the Hub's own default resolution (explicit slug, else first).
472
+ # Ask the hub which bundle it chose rather than re-deriving the
473
+ # explicit-else-first rule, and mount at its own prefix: two copies of a
474
+ # rule is two answers waiting to disagree.
475
+ def print_mounts(hub)
476
+ hub.bundles.each do |bundle|
477
+ marker = bundle.equal?(hub.default) ? "*" : " "
478
+ @out.puts " #{marker} #{OKF::Server::Hub::MOUNT}/#{bundle.slug}/ #{bundle.title}"
479
+ end
480
+ end
481
+
482
+ # Load the given directories as unregistered bundles, slugged by basename and
483
+ # deduped within the run. The same directory listed twice mounts once — two
484
+ # windows on one bundle would just burn a slug on a URL that vanishes next run.
485
+ def ephemeral_bundles(dirs)
486
+ roots = []
487
+ dirs.each do |dir|
488
+ root = File.expand_path(dir)
489
+ roots << root unless roots.include?(root)
490
+ end
491
+
492
+ # A registered slug owns its mount outright: reserve every ref's slug
493
+ # before any basename is deduped. Otherwise argv order decides, and
494
+ # `server ./two @two` mounts the *unregistered* ./two at /b/two/ while
495
+ # pushing the ref — the bundle whose slug that is — to /b/two-2/, so a
496
+ # bookmark from a bundle-less run silently opens the wrong graph.
497
+ taken = roots.map { |root| ref_slugs[root] }.compact
498
+ roots.each_with_object([]) do |root, bundles|
499
+ folder = OKF::Bundle::Folder.load(root)
500
+ report_skipped(folder)
501
+ slug = ref_slugs[root]
502
+ unless slug
503
+ slug = OKF::Registry.dedupe(File.basename(root), taken)
504
+ taken << slug
505
+ end
506
+ bundles << OKF::Server::Hub::Bundle.new(slug, folder, folder.name)
507
+ end
508
+ end
509
+
510
+ # Load one registered bundle; a path that has gone missing or no longer reads
511
+ # drops to nil with a note (to stderr) rather than sinking the whole run. The
512
+ # directory check is explicit — the Reader maps a nonexistent directory to an
513
+ # empty bundle, so nothing would raise for the common "dir was deleted" case.
514
+ # Method-level rescue (not a `do…end`-block rescue — a 2.6 feature).
515
+ def load_registered(entry)
516
+ return skip_registered(entry) unless File.directory?(entry.path)
517
+
518
+ folder = OKF::Bundle::Folder.load(entry.path)
519
+ report_skipped(folder)
520
+ OKF::Server::Hub::Bundle.new(entry.slug, folder, entry.title)
521
+ rescue SystemCallError, OKF::Error
522
+ skip_registered(entry)
523
+ end
524
+
525
+ def skip_registered(entry)
526
+ @err.puts "note: skipping #{entry.slug} — cannot read #{entry.path}"
527
+ nil
221
528
  end
222
529
 
223
530
  # The static counterpart to `server`: bake the whole bundle into one
@@ -228,11 +535,12 @@ module OKF
228
535
 
229
536
  options = { output: nil, title: nil, link: nil, layout: "cose" }
230
537
  parser = OptionParser.new do |o|
231
- o.banner = "Usage: okf render <bundle-dir> [-o FILE] [--layout NAME] [-t title] [-l url]"
538
+ o.banner = "Usage: okf render <dir|@slug> [-o FILE] [--layout NAME] [-t title] [-l url]"
232
539
  o.on("-o", "--output FILE", "write to FILE instead of stdout") { |v| options[:output] = v }
233
540
  o.on("-t", "--title TITLE", "graph title (default: parent/bundle dir name)") { |v| options[:title] = v }
234
541
  o.on("-l", "--link URL", "source URL shown in the header") { |v| options[:link] = v }
235
542
  o.on("--layout NAME", OKF::Server::Graph::LAYOUTS, "initial layout (#{OKF::Server::Graph::LAYOUTS.join(", ")})") { |v| options[:layout] = v }
543
+ help_flag(o)
236
544
  end
237
545
  dir = positional_dir(parser, argv) or return 2
238
546
 
@@ -240,21 +548,233 @@ module OKF
240
548
  report_skipped(folder)
241
549
  html = OKF::Server::App.new(folder, title: options[:title] || folder.name, link: options[:link], layout: options[:layout]).render_static
242
550
  if options[:output]
243
- File.write(options[:output], html)
244
- @out.puts "wrote #{folder.graph(minimal: true).nodes.size} concepts to #{options[:output]}"
551
+ # A bad -o path (a missing directory, a permission denial) is a bad
552
+ # *argument*: exit 2 with the reason, never a backtrace and an exit code
553
+ # that means "failing bundle".
554
+ begin
555
+ File.write(options[:output], html)
556
+ rescue SystemCallError => e
557
+ return usage_error("cannot write #{options[:output]}: #{e.message}")
558
+ end
559
+ count = folder.graph(minimal: true).nodes.size
560
+ @out.puts "wrote #{count} #{pluralize(count, "concept")} to #{options[:output]}"
245
561
  else
246
562
  @out.print html
247
563
  end
248
564
  0
249
565
  end
250
566
 
567
+ # The registry umbrella, split by what each verb keys on. `set`/`del`/`list`
568
+ # act on entries — `set` keys on the bundle's path, so --as means one thing
569
+ # ("the slug this entry has") whether it adds or renames. `default`/`rename`
570
+ # act on slugs, the names actually to hand once a bundle is registered. Every
571
+ # positional stays unambiguous, and `config` is left free for real settings.
572
+ def registry(argv)
573
+ require "okf/registry"
574
+
575
+ sub = argv.first
576
+ case sub
577
+ when "set" then registry_set(argv.drop(1))
578
+ when "del" then registry_del(argv.drop(1))
579
+ when "list" then registry_list(argv.drop(1))
580
+ when "default" then registry_default(argv.drop(1))
581
+ when "rename" then registry_rename(argv.drop(1))
582
+ else
583
+ # A bare word that isn't a known subcommand is a typo (`registry remove x`
584
+ # must not silently render the list and read as success).
585
+ return usage_error("unknown registry subcommand '#{sub}' (expected: #{SUBCOMMANDS.join(", ")})") if sub && !sub.start_with?("-")
586
+
587
+ # Same rule for a subcommand hiding behind a flag: `registry --json set
588
+ # dir` would otherwise list an empty registry and exit 0, having written
589
+ # nothing the user asked for. It cannot just be dispatched from wherever
590
+ # it turns up — the word may be a flag's value (`registry --as set <dir>`
591
+ # asks for the slug "set"), and a grammar where that reading depends on
592
+ # which flag precedes it is a trapdoor. So the subcommand must lead, and
593
+ # the error says which one was found rather than guessing at the intent.
594
+ stray = argv.find { |arg| SUBCOMMANDS.include?(arg) }
595
+ return usage_error("put the subcommand first: okf registry #{stray} … (flags follow it)") if stray
596
+
597
+ registry_list(argv)
598
+ end
599
+ end
600
+
601
+ # Add a bundle to the persistent registry (so a later bare `okf server` finds
602
+ # it), or update one already there. The entry is keyed by the bundle's path: a
603
+ # path already registered refreshes its title in place, and --as renames it. A
604
+ # new path is added, slugged by directory basename unless --as says otherwise.
605
+ def registry_set(argv)
606
+ options = { as: nil, default: false }
607
+ parser = OptionParser.new do |o|
608
+ o.banner = "Usage: okf registry set <dir|@slug> [--as SLUG] [--default]"
609
+ o.on("--as SLUG", "slug to register under (default: directory basename)") { |v| options[:as] = v }
610
+ o.on("--default", "put it first — the bundle a bare `okf server` opens") { options[:default] = true }
611
+ help_flag(o)
612
+ end
613
+ dir = positional_dir(parser, argv) or return 2
614
+ no_extras?(argv) or return 2
615
+
616
+ reg = OKF::Registry.load
617
+ # Said before the upsert: after it, an update is indistinguishable from an
618
+ # add, and "registered" for what was a rename reads as a duplicate entry.
619
+ known = reg.listing.any? { |row| row[:dir] == File.expand_path(dir) }
620
+ entry = reg.add(dir, as: options[:as], default: options[:default])
621
+ # Through report_skipped like every other bundle-reading verb: the reader
622
+ # tolerates a file it cannot open, so a count taken straight off the graph
623
+ # reports "0 concepts" for a bundle whose files are simply unreadable.
624
+ folder = OKF::Bundle::Folder.load(entry.path)
625
+ report_skipped(folder)
626
+ count = folder.graph(minimal: true).nodes.size
627
+ @out.puts "#{known ? "updated" : "registered"} #{entry.slug} → #{entry.path} (#{count} #{pluralize(count, "concept")})"
628
+ 0
629
+ rescue OKF::Error => e
630
+ usage_error(e.message)
631
+ end
632
+
633
+ # Remove a bundle from the persistent registry by slug or by its directory.
634
+ def registry_del(argv)
635
+ parser = OptionParser.new do |o|
636
+ o.banner = "Usage: okf registry del <dir|@slug>"
637
+ help_flag(o)
638
+ end
639
+ slug = positional(parser, argv) or return 2
640
+ no_extras?(argv) or return 2
641
+
642
+ reg = OKF::Registry.load
643
+ slug = registry_slug(slug, reg) or return 2
644
+ removed = reg.remove(slug)
645
+ return usage_error("no such bundle: #{slug}") unless removed
646
+
647
+ @out.puts "removed #{removed.slug}"
648
+ 0
649
+ rescue OKF::Error => e
650
+ usage_error(e.message)
651
+ end
652
+
653
+ def registry_list(argv)
654
+ options = { json: false }
655
+ parser = OptionParser.new do |o|
656
+ o.banner = "Usage: okf registry list [--json] [--pretty]\n " \
657
+ "okf registry set <dir|@slug> | del <dir|@slug> | default <@slug> | rename <@slug> <new>"
658
+ json_flags(o, options, "emit the registry as JSON")
659
+ help_flag(o)
660
+ end
661
+ begin
662
+ parser.parse!(argv)
663
+ rescue OptionParser::ParseError => e
664
+ @err.puts e.message
665
+ return 2
666
+ end
667
+ no_extras?(argv) or return 2
668
+
669
+ reg = OKF::Registry.load
670
+ return emit_list_json({ "registry" => reg.path }, "bundles", reg.listing.map { |row| stringify(row) }, options) if options[:json]
671
+
672
+ print_registry(reg)
673
+ 0
674
+ rescue OKF::Error => e
675
+ usage_error(e.message)
676
+ end
677
+
678
+ # Choose which registered bundle a bare `okf server` opens at `/`, by moving
679
+ # it to the front of the registry. The listing is ordered and the JSON is
680
+ # meant to be hand-editable, so the move is stated rather than left to be
681
+ # discovered from a reordered file.
682
+ def registry_default(argv)
683
+ parser = OptionParser.new do |o|
684
+ o.banner = "Usage: okf registry default <@slug>\n " \
685
+ "moves it to the front — the first registered bundle is the default until you do"
686
+ help_flag(o)
687
+ end
688
+ slug = positional(parser, argv) or return 2
689
+ no_extras?(argv) or return 2
690
+
691
+ reg = OKF::Registry.load
692
+ slug = registry_slug(slug, reg) or return 2
693
+ reg.default = slug
694
+ @out.puts "default bundle → #{reg.default.slug} (now first)"
695
+ 0
696
+ rescue OKF::Error => e
697
+ usage_error(e.message)
698
+ end
699
+
700
+ # The @ref grammar for a verb that takes a *slug*, read by name. These three
701
+ # must reach an entry whose directory is gone — that is the one worth
702
+ # deleting or renaming — so they cannot go through resolve_ref, which
703
+ # insists the directory exist. Without this the refs only appeared to work:
704
+ # `normalize` strips the `@` off `@slug`, so `default @slug` resolved by
705
+ # accident while a bare `@` normalized to "" and failed. Returns the slug,
706
+ # or nil after reporting.
707
+ def registry_slug(arg, registry)
708
+ return arg unless arg.start_with?("@")
709
+
710
+ asked = arg[1..-1]
711
+ return asked unless asked.empty?
712
+
713
+ default = registry.default
714
+ return default.slug if default
715
+
716
+ @err.puts "error: no bundle is registered, so `@` names nothing (okf registry set <dir>)"
717
+ nil
718
+ end
719
+
720
+ # Rename a registered bundle's slug — its mount path and switcher name.
721
+ def registry_rename(argv)
722
+ parser = OptionParser.new do |o|
723
+ o.banner = "Usage: okf registry rename <@slug> <new>"
724
+ help_flag(o)
725
+ end
726
+ parser.parse!(argv)
727
+ old_slug, new_slug = argv.shift(2)
728
+ if old_slug.nil? || new_slug.nil?
729
+ @err.puts parser.banner
730
+ return 2
731
+ end
732
+ no_extras?(argv) or return 2
733
+
734
+ reg = OKF::Registry.load
735
+ # The old name may be a ref; the new one is a name being minted, never one.
736
+ old_slug = registry_slug(old_slug, reg) or return 2
737
+ entry = reg.rename(old_slug, new_slug)
738
+ # The slug it *found*, not the argv that found it: rename normalizes to look
739
+ # the entry up, so echoing the raw ask names a bundle that never existed.
740
+ @out.puts "renamed #{OKF::Registry.normalize(old_slug)} → #{entry.slug}"
741
+ 0
742
+ rescue OptionParser::ParseError => e
743
+ @err.puts e.message
744
+ 2
745
+ rescue OKF::Error => e
746
+ usage_error(e.message)
747
+ end
748
+
749
+ # The registry verbs take an exact number of positionals — a leftover argument
750
+ # is a typo'd invocation, not something to drop silently.
751
+ def no_extras?(argv)
752
+ return true if argv.empty?
753
+
754
+ @err.puts "error: unexpected argument '#{argv.first}'"
755
+ false
756
+ end
757
+
758
+ def print_registry(reg)
759
+ return @out.puts "no bundles registered — okf registry set <dir>" if reg.empty?
760
+
761
+ rows = reg.listing
762
+ width = rows.map { |row| row[:slug].length }.max
763
+ rows.each do |row|
764
+ marker = row[:default] ? "*" : " "
765
+ missing = row[:missing] ? " (missing)" : ""
766
+ @out.puts "#{marker} #{row[:slug].ljust(width)} #{row[:title]} (#{row[:dir]})#{missing}"
767
+ end
768
+ end
769
+
251
770
  def graph(argv)
252
771
  options = { json: false, minimal: false, body: true }
253
772
  parser = OptionParser.new do |o|
254
- o.banner = "Usage: okf graph <bundle-dir> [--json] [--minimal] [--no-body]"
773
+ o.banner = "Usage: okf graph <dir|@slug> [--json] [--minimal] [--no-body]"
255
774
  json_flags(o, options, "emit nodes and edges as JSON")
256
775
  o.on("--minimal", "leanest nodes (id + title); adds type/tag indexes") { options[:minimal] = true }
257
776
  o.on("--[no-]body", "include each concept's body (default: yes)") { |v| options[:body] = v }
777
+ help_flag(o)
258
778
  end
259
779
  dir = positional_dir(parser, argv) or return 2
260
780
 
@@ -262,11 +782,15 @@ module OKF
262
782
  graph = folder.graph(minimal: options[:minimal], body: options[:body])
263
783
  report_skipped(folder)
264
784
  if options[:json]
265
- payload = graph.to_h
785
+ # The head every view carries: a payload of nodes and edges that never
786
+ # says which bundle they came from is exactly what an agent holding
787
+ # several bundles has to guess at.
788
+ payload = bundle_head(dir).merge(graph.to_h)
266
789
  payload = payload.merge(types: graph.type_index, tags: graph.tag_index) if options[:minimal]
267
790
  emit_json(payload)
268
791
  else
269
- @out.puts "#{graph.nodes.size} concepts, #{graph.edges.size} links"
792
+ @out.puts "Graph — #{bundle_label(dir)} (#{graph.nodes.size} #{pluralize(graph.nodes.size, "concept")}, " \
793
+ "#{graph.edges.size} #{pluralize(graph.edges.size, "link")})"
270
794
  end
271
795
  0
272
796
  end
@@ -280,11 +804,12 @@ module OKF
280
804
  def index(argv)
281
805
  options = { json: false, body: true, areas: nil }
282
806
  parser = OptionParser.new do |o|
283
- o.banner = "Usage: okf index <bundle-dir> [--area AREA] [--no-body] [--json]"
807
+ o.banner = "Usage: okf index <dir|@slug> [--area AREA] [--no-body] [--json]"
284
808
  json_flags(o, options, "emit the index map as JSON")
285
809
  projection_flags(o, options)
286
810
  o.on("--area AREA", "only this directory/area (repeatable; `root` for the bundle root)") { |v| (options[:areas] ||= []) << v }
287
811
  o.on("--[no-]body", "include each index's prose body (default: yes)") { |v| options[:body] = v }
812
+ help_flag(o)
288
813
  end
289
814
  dir = positional_dir(parser, argv) or return 2
290
815
 
@@ -293,6 +818,13 @@ module OKF
293
818
  entries = folder.directory_index
294
819
  selected = select_directories(entries, options[:areas])
295
820
  if options[:json]
821
+ # --no-body is shorthand for --except body, so asking for the body by
822
+ # name in the same breath is a contradiction. Letting --fields quietly
823
+ # win would hand back the very thing the other flag was there to drop.
824
+ if !options[:body] && Array(options[:fields]).map(&:downcase).include?("body")
825
+ return usage_error("--no-body and --fields body contradict each other: drop one")
826
+ end
827
+
296
828
  options[:except] = Array(options[:except]) + [ "body" ] unless options[:body] || options[:fields]
297
829
  return print_index_map_json(dir, selected, options)
298
830
  end
@@ -312,7 +844,7 @@ module OKF
312
844
 
313
845
  def print_index_map(dir, entries, body)
314
846
  noun = entries.size == 1 ? "directory" : "directories"
315
- @out.puts "Index map — #{dir} (#{entries.size} #{noun})"
847
+ @out.puts "Index map — #{bundle_label(dir)} (#{entries.size} #{noun})"
316
848
  entries.each do |entry|
317
849
  @out.puts
318
850
  @out.puts " #{index_dir_label(entry)}#{index_dir_meta(entry)}"
@@ -332,8 +864,8 @@ module OKF
332
864
  end
333
865
 
334
866
  def index_dir_meta(entry)
335
- count = "#{entry[:count]} #{entry[:count] == 1 ? "concept" : "concepts"}"
336
- types = entry[:types].map { |type, n| "#{type.empty? ? "Untyped" : type} #{n}" }.join(", ")
867
+ count = "#{entry[:count]} #{pluralize(entry[:count], "concept")}"
868
+ types = entry[:types].map { |type, n| "#{OKF.blank?(type) ? "Untyped" : type} #{n}" }.join(", ")
337
869
  types.empty? ? " · #{count}" : " · #{count} · #{types}"
338
870
  end
339
871
 
@@ -375,10 +907,11 @@ module OKF
375
907
  def catalog(argv)
376
908
  options = { json: false }
377
909
  parser = OptionParser.new do |o|
378
- o.banner = "Usage: okf catalog <bundle-dir> [--type T] [--area A] [--tag T] [--json]"
910
+ o.banner = "Usage: okf catalog <dir|@slug> [--type T] [--area A] [--tag T] [--json]"
379
911
  json_flags(o, options, "emit the catalog as JSON")
380
912
  projection_flags(o, options)
381
913
  filter_flags(o, options, :type, :area, :tag)
914
+ help_flag(o)
382
915
  end
383
916
  dir = positional_dir(parser, argv) or return 2
384
917
 
@@ -395,10 +928,11 @@ module OKF
395
928
  def files(argv)
396
929
  options = { json: false }
397
930
  parser = OptionParser.new do |o|
398
- o.banner = "Usage: okf files <bundle-dir> [--type T] [--area A] [--tag T] [--json]"
931
+ o.banner = "Usage: okf files <dir|@slug> [--type T] [--area A] [--tag T] [--json]"
399
932
  json_flags(o, options, "emit the file tree as JSON")
400
933
  projection_flags(o, options)
401
934
  filter_flags(o, options, :type, :area, :tag)
935
+ help_flag(o)
402
936
  end
403
937
  dir = positional_dir(parser, argv) or return 2
404
938
 
@@ -415,10 +949,11 @@ module OKF
415
949
  def tags(argv)
416
950
  options = { json: false, by: nil }
417
951
  parser = OptionParser.new do |o|
418
- o.banner = "Usage: okf tags <bundle-dir> [--by type|area] [--type T] [--area A] [--json]"
952
+ o.banner = "Usage: okf tags <dir|@slug> [--by type|area] [--type T] [--area A] [--json]"
419
953
  json_flags(o, options, "emit the tag index as JSON")
420
954
  o.on("--by DIM", %w[type area], "group the tags by a concept dimension (type | area)") { |v| options[:by] = v.to_sym }
421
955
  filter_flags(o, options, :type, :area)
956
+ help_flag(o)
422
957
  end
423
958
  dir = positional_dir(parser, argv) or return 2
424
959
 
@@ -430,9 +965,10 @@ module OKF
430
965
  def types(argv)
431
966
  options = { json: false }
432
967
  parser = OptionParser.new do |o|
433
- o.banner = "Usage: okf types <bundle-dir> [--area A] [--tag T] [--json]"
968
+ o.banner = "Usage: okf types <dir|@slug> [--area A] [--tag T] [--json]"
434
969
  json_flags(o, options, "emit the type index as JSON")
435
970
  filter_flags(o, options, :area, :tag)
971
+ help_flag(o)
436
972
  end
437
973
  dir = positional_dir(parser, argv) or return 2
438
974
 
@@ -492,15 +1028,15 @@ module OKF
492
1028
 
493
1029
  # A catalog entry's type for display — "Untyped" when blank, matching the graph.
494
1030
  def entry_type(entry)
495
- entry[:type].empty? ? "Untyped" : entry[:type]
1031
+ OKF.blank?(entry[:type]) ? "Untyped" : entry[:type]
496
1032
  end
497
1033
 
498
1034
  def print_grouped_tags(dir, dim, groups, titles)
499
- @out.puts "Tags — #{dir} (#{distinct_tags(groups)} distinct, by #{dim})"
1035
+ @out.puts "Tags — #{bundle_label(dir)} (#{distinct_tags(groups)} distinct, by #{dim})"
500
1036
  groups.each do |key, rows|
501
1037
  label = dim == :area && key != "(root)" ? "#{key}/" : key
502
1038
  @out.puts
503
- @out.puts " #{label} (#{rows.size} tags)"
1039
+ @out.puts " #{label} (#{rows.size} #{pluralize(rows.size, "tag")})"
504
1040
  width = rows.map { |row| row[:tag].length }.max || 0
505
1041
  rows.each do |row|
506
1042
  names = row[:concepts].map { |id| titles[id] || id }.join(", ")
@@ -510,12 +1046,10 @@ module OKF
510
1046
  end
511
1047
 
512
1048
  def print_grouped_tags_json(dir, dim, groups)
513
- emit_json(
514
- "bundle" => dir, "count" => distinct_tags(groups), "by" => dim.to_s,
515
- "groups" => groups.map do |key, rows|
516
- { dim.to_s => key, "count" => rows.size, "tags" => index_rows_json(:tag, rows) }
517
- end
518
- )
1049
+ groups_json = groups.map do |key, rows|
1050
+ { dim.to_s => key, "count" => rows.size, "tags" => index_rows_json(:tag, rows) }
1051
+ end
1052
+ emit_json(bundle_head(dir).merge("count" => distinct_tags(groups), "by" => dim.to_s, "groups" => groups_json))
519
1053
  end
520
1054
 
521
1055
  def distinct_tags(groups)
@@ -525,8 +1059,9 @@ module OKF
525
1059
  def stats(argv)
526
1060
  options = { json: false }
527
1061
  parser = OptionParser.new do |o|
528
- o.banner = "Usage: okf stats <bundle-dir> [--json]"
1062
+ o.banner = "Usage: okf stats <dir|@slug> [--json]"
529
1063
  json_flags(o, options, "emit the stats as JSON")
1064
+ help_flag(o)
530
1065
  end
531
1066
  dir = positional_dir(parser, argv) or return 2
532
1067
 
@@ -567,6 +1102,21 @@ module OKF
567
1102
  parser.on("--pretty", "indent the JSON for reading (implies --json)") { options[:json] = true; @pretty = true }
568
1103
  end
569
1104
 
1105
+ # Every parser answers its own -h/--help, so no parser inherits
1106
+ # OptionParser's officious one: that prints to the process's $stdout rather
1107
+ # than @out (an embedding app that injects streams never sees it) and ends
1108
+ # the process with `exit` rather than returning a status (a test that asks a
1109
+ # command for help takes the whole runner down with it). Thrown, not
1110
+ # returned — #run catches it — because a parser is parsed inside
1111
+ # positional_dir, where every other early exit means "exit 2".
1112
+ # on_tail, so help sorts last in the list it is printing.
1113
+ def help_flag(parser)
1114
+ parser.on_tail("-h", "--help", "print this message") do
1115
+ @out.puts parser.help
1116
+ throw :help, 0
1117
+ end
1118
+ end
1119
+
570
1120
  # --fields/--except project the JSON down to the properties an agent wants, so it
571
1121
  # never pays tokens for fields it will not read. --fields is an allowlist,
572
1122
  # --except a denylist (mutually exclusive); both imply --json and apply per item
@@ -631,6 +1181,7 @@ module OKF
631
1181
  o.banner = "Usage: okf skill <dest-dir> [--here] [--force]"
632
1182
  o.on("--here", "install straight into <dest-dir>, wherever it is (no skills/okf nesting)") { options[:nest] = false }
633
1183
  o.on("--force", "overwrite a non-empty destination") { options[:force] = true }
1184
+ help_flag(o)
634
1185
  end
635
1186
  parser.parse!(argv)
636
1187
  dest = argv.shift
@@ -660,10 +1211,14 @@ module OKF
660
1211
  note_skipped(folder.bundle.unparseable.size)
661
1212
  end
662
1213
 
1214
+ # The bucket holds two kinds now — frontmatter that would not parse, and a
1215
+ # file that would not open — so the note names neither and points at the verb
1216
+ # that names both. "invalid frontmatter" was a guess the summary had no need
1217
+ # to make: `validate` prints the file and the reason for every one of them.
663
1218
  def note_skipped(count)
664
1219
  return if count.nil? || count <= 0
665
1220
 
666
- @err.puts "note: skipped #{count} file(s) with invalid frontmatter (run `okf validate` for details)"
1221
+ @err.puts "note: skipped #{count} unusable file(s) (run `okf validate` for details)"
667
1222
  end
668
1223
 
669
1224
  # Turn a --stale-after value (90d, 12w, or an ISO date) into an absolute cutoff
@@ -682,8 +1237,9 @@ module OKF
682
1237
  :invalid
683
1238
  end
684
1239
 
685
- # Parse options, then require a single existing-directory positional argument.
686
- # Returns the directory, or nil (after reporting) so the caller returns 2.
1240
+ # Parse options, then require a single bundle positional — a directory, or an
1241
+ # @ref into the registry. Returns the bundle's directory, or nil (after
1242
+ # reporting) so the caller returns 2.
687
1243
  def positional_dir(parser, argv)
688
1244
  parser.parse!(argv)
689
1245
  dir = argv.shift
@@ -691,11 +1247,142 @@ module OKF
691
1247
  @err.puts parser.banner
692
1248
  return nil
693
1249
  end
694
- unless File.directory?(dir)
695
- @err.puts "error: #{dir} is not a directory"
1250
+ # A second bundle is a question this verb cannot answer: only `search`
1251
+ # merges across bundles and only `server` mounts several. Reading the
1252
+ # first and dropping the rest would answer confidently about a bundle the
1253
+ # user never asked about — the silent-wrong-answer shape, so: exit 2.
1254
+ return nil unless no_extras?(argv)
1255
+
1256
+ resolve_ref(dir)
1257
+ rescue OptionParser::ParseError => e
1258
+ @err.puts e.message
1259
+ nil
1260
+ end
1261
+
1262
+ # Parse options, then take zero or more bundle positionals (the multi-bundle
1263
+ # server) — directories or @refs. Returns the resolved array (possibly
1264
+ # empty), or nil (after reporting) so the caller returns 2.
1265
+ def positional_dirs(parser, argv)
1266
+ parser.parse!(argv)
1267
+ dirs = argv.map { |dir| resolve_ref(dir) }
1268
+ dirs.include?(nil) ? nil : dirs
1269
+ rescue OptionParser::ParseError => e
1270
+ @err.puts e.message
1271
+ nil
1272
+ end
1273
+
1274
+ # "@slug" — or bare "@", the registry's default — names a registered bundle
1275
+ # wherever a <dir> goes; anything else must be a directory on disk. A
1276
+ # leading @ always means the registry (a directory literally named that way
1277
+ # stays reachable as ./@name), and the registry loads only when a ref
1278
+ # appears, so plain-dir invocations never pay for it. Returns the bundle's
1279
+ # directory, or nil after reporting.
1280
+ def resolve_ref(arg)
1281
+ return resolve_registered(arg) if arg.start_with?("@")
1282
+
1283
+ unless File.directory?(arg)
1284
+ @err.puts "error: #{arg} is not a directory"
696
1285
  return nil
697
1286
  end
698
- dir
1287
+ arg
1288
+ end
1289
+
1290
+ # Load the registry, turning a malformed file into a reported usage error
1291
+ # instead of an OKF::Error escaping through whatever verb took an @ref —
1292
+ # only `server` and the `registry` verbs rescue one. Returns nil after
1293
+ # reporting, so every caller returns 2.
1294
+ def load_registry
1295
+ require "okf/registry"
1296
+ OKF::Registry.load
1297
+ rescue OKF::Error => e
1298
+ @err.puts "error: #{e.message}"
1299
+ nil
1300
+ end
1301
+
1302
+ # Resolve one @ref through the registry under $OKF_HOME (default ~/.okf).
1303
+ # The slug part is normalized
1304
+ # exactly as registration normalized it, so @One finds the bundle
1305
+ # registered from dir One — but never through #slugify's mint-a-name
1306
+ # placeholder, so "@***" is a bad ref rather than whatever is slugged
1307
+ # "bundle". An explicit ask fails hard: an unknown slug or a
1308
+ # registered-but-gone directory is a usage error naming the registry file
1309
+ # and the next move, never a silent skip.
1310
+ #
1311
+ # @all never resolves here. `search` expands it before this point; every
1312
+ # other verb takes exactly one bundle, so letting it through would mean
1313
+ # @all lints when one bundle is registered and exits 2 when two are —
1314
+ # behavior that varies with the size of the registry, which is the
1315
+ # silent-wrong-answer shape the second-bundle rule exists to stop. Say what
1316
+ # @all is instead of calling it a bundle nobody registered ("all" cannot be
1317
+ # registered — Registry::RESERVED_SLUGS sees to that).
1318
+ def resolve_registered(ref)
1319
+ @ref_failure = :registry
1320
+ if all_ref?(ref)
1321
+ @err.puts "error: #{ALL_REF} is only supported by `okf search` (it names every registered bundle)"
1322
+ return nil
1323
+ end
1324
+ registry = load_registry
1325
+ return nil unless registry
1326
+
1327
+ asked = ref[1..-1]
1328
+ slug = OKF::Registry.normalize(asked)
1329
+ entry = if asked.empty?
1330
+ registry.default # bare "@"
1331
+ elsif slug.empty?
1332
+ nil # "@***" — nothing to look up, and no placeholder to fall back on
1333
+ else
1334
+ registry.get(slug)
1335
+ end
1336
+ if entry.nil?
1337
+ @ref_failure = :unknown
1338
+ hint = registry.empty? ? "okf registry set <dir>" : "okf registry list"
1339
+ @err.puts "error: not a registered bundle: #{ref} in #{registry.path} (#{hint})"
1340
+ return nil
1341
+ end
1342
+ unless File.directory?(entry.path)
1343
+ @ref_failure = :missing
1344
+ @err.puts "error: #{ref} points to #{entry.path}, which is not a directory (okf registry del #{entry.slug}, or restore it)"
1345
+ return nil
1346
+ end
1347
+ ref_slugs[entry.path] = entry.slug
1348
+ entry.path
1349
+ end
1350
+
1351
+ # Which slug each @ref resolved to, by absolute path — so a hub built from
1352
+ # refs mounts each bundle under its registered slug, not its dir basename.
1353
+ # Reset by every run; never memoized here, or a stale run would seed it.
1354
+ attr_reader :ref_slugs
1355
+
1356
+ # Every bundle-scoped output names its bundle in the identity the caller
1357
+ # used: `@handbook (/path)` when they named a registered bundle, the plain
1358
+ # path otherwise. A dir named by path stays a path — inventing a slug for it
1359
+ # would imply a registration that does not exist, and looking one up would
1360
+ # cost a registry read on every plain-dir run.
1361
+ def bundle_label(dir)
1362
+ slug = ref_slugs[dir]
1363
+ slug ? "@#{slug} (#{dir})" : dir.to_s
1364
+ end
1365
+
1366
+ # The JSON head for one bundle. `bundle` is always its directory and `slug`
1367
+ # always a registry slug — never the same key meaning two things — so a
1368
+ # consumer resolves a row to a file without a second lookup.
1369
+ def bundle_head(dir)
1370
+ head = { "bundle" => dir }
1371
+ slug = ref_slugs[dir]
1372
+ head["slug"] = slug if slug
1373
+ head
1374
+ end
1375
+
1376
+ # Parse options, then require a single non-directory positional (e.g. a slug).
1377
+ # Returns it, or nil (after reporting the banner) so the caller returns 2.
1378
+ def positional(parser, argv)
1379
+ parser.parse!(argv)
1380
+ value = argv.shift
1381
+ if value.nil?
1382
+ @err.puts parser.banner
1383
+ return nil
1384
+ end
1385
+ value
699
1386
  rescue OptionParser::ParseError => e
700
1387
  @err.puts e.message
701
1388
  nil
@@ -703,7 +1390,7 @@ module OKF
703
1390
 
704
1391
  def print_validation(dir, result)
705
1392
  counts = result.counts
706
- @out.puts "OKF v0.1 conformance — #{dir}"
1393
+ @out.puts "OKF v0.1 conformance — #{bundle_label(dir)}"
707
1394
  @out.puts " concepts: #{counts[:concepts]} index.md: #{counts[:indexes]} log.md: #{counts[:logs]}"
708
1395
  result.errors.each { |e| @out.puts " #{paint("✗ ERROR", 31)} #{e[:path]}: #{e[:message]}" }
709
1396
  result.warnings.each { |w| @out.puts " #{paint("! warn", 33)} #{w[:path]}: #{w[:message]}" }
@@ -717,18 +1404,17 @@ module OKF
717
1404
  end
718
1405
 
719
1406
  def print_validation_json(dir, result)
720
- emit_json(
721
- "bundle" => dir,
1407
+ emit_json(bundle_head(dir).merge(
722
1408
  "conformant" => result.valid?,
723
1409
  "counts" => result.counts,
724
1410
  "errors" => result.errors,
725
1411
  "warnings" => result.warnings
726
- )
1412
+ ))
727
1413
  end
728
1414
 
729
1415
  def print_lint(dir, report)
730
1416
  stats = report.stats
731
- @out.puts "OKF lint — #{dir}"
1417
+ @out.puts "OKF lint — #{bundle_label(dir)}"
732
1418
  @out.puts " concepts: #{stats[:concepts]} edges: #{stats[:edges]} index.md: #{stats[:indexes]} log.md: #{stats[:logs]}"
733
1419
  summary = lint_summary(stats)
734
1420
  @out.puts " #{summary}" unless summary.empty?
@@ -749,12 +1435,11 @@ module OKF
749
1435
  end
750
1436
 
751
1437
  def print_lint_json(dir, report)
752
- emit_json(
753
- "bundle" => dir,
1438
+ emit_json(bundle_head(dir).merge(
754
1439
  "healthy" => report.healthy?,
755
1440
  "stats" => report.stats,
756
1441
  "findings" => report.findings
757
- )
1442
+ ))
758
1443
  end
759
1444
 
760
1445
  # Degree-0 nodes as { id:, title:, dir: }, sorted by path — the same set lint's
@@ -767,7 +1452,7 @@ module OKF
767
1452
  end
768
1453
 
769
1454
  def print_loose(dir, files)
770
- @out.puts "Loose files — #{dir} (#{files.size})"
1455
+ @out.puts "Loose files — #{bundle_label(dir)} (#{files.size})"
771
1456
  if files.empty?
772
1457
  @out.puts " #{paint("✓ none — every concept links or is linked", 32)}"
773
1458
  return
@@ -784,15 +1469,14 @@ module OKF
784
1469
  end
785
1470
 
786
1471
  def print_loose_json(dir, files)
787
- emit_json(
788
- "bundle" => dir,
1472
+ emit_json(bundle_head(dir).merge(
789
1473
  "count" => files.size,
790
1474
  "loose" => files.map { |file| stringify(file) }
791
- )
1475
+ ))
792
1476
  end
793
1477
 
794
1478
  def print_catalog(dir, entries, total)
795
- @out.puts "Catalog — #{dir} (#{counted(entries.size, total, "concepts")})"
1479
+ @out.puts "Catalog — #{bundle_label(dir)} (#{counted(entries.size, total, "concept")})"
796
1480
  entries.group_by { |entry| entry[:area] }.sort_by(&:first).each do |area, group|
797
1481
  @out.puts
798
1482
  @out.puts " #{area == "(root)" ? "(root)" : "#{area}/"} (#{group.size})"
@@ -810,7 +1494,7 @@ module OKF
810
1494
  end
811
1495
 
812
1496
  def print_files(dir, entries, total)
813
- @out.puts "Files — #{dir} (#{counted(entries.size, total, "files")})"
1497
+ @out.puts "Files — #{bundle_label(dir)} (#{counted(entries.size, total, "file")})"
814
1498
  entries.group_by { |entry| entry[:dir] }.sort_by(&:first).each do |folder, group|
815
1499
  width = group.map { |entry| File.basename("#{entry[:id]}.md").length }.max
816
1500
  @out.puts
@@ -830,7 +1514,7 @@ module OKF
830
1514
  end
831
1515
 
832
1516
  def print_index(dir, label, key, rows, titles)
833
- @out.puts "#{label} — #{dir} (#{rows.size} distinct)"
1517
+ @out.puts "#{label} — #{bundle_label(dir)} (#{rows.size} distinct)"
834
1518
  @out.puts
835
1519
  width = rows.map { |row| row[key].length }.max || 0
836
1520
  rows.each do |row|
@@ -840,19 +1524,29 @@ module OKF
840
1524
  end
841
1525
 
842
1526
  def print_index_json(dir, plural, key, rows)
843
- emit_json("bundle" => dir, "count" => rows.size, plural => index_rows_json(key, rows))
1527
+ emit_json(bundle_head(dir).merge("count" => rows.size, plural => index_rows_json(key, rows)))
844
1528
  end
845
1529
 
846
1530
  def index_rows_json(key, rows)
847
1531
  rows.map { |row| { key.to_s => row[key], "count" => row[:count], "concepts" => row[:concepts] } }
848
1532
  end
849
1533
 
1534
+ # "3 concepts", "1 concept", "1 of 7 concepts" — the noun agrees with the
1535
+ # number it follows: the size when that is all we show, the total otherwise.
850
1536
  def counted(size, total, noun)
851
- size == total ? "#{size} #{noun}" : "#{size} of #{total} #{noun}"
1537
+ return "#{size} #{pluralize(size, noun)}" if size == total
1538
+
1539
+ "#{size} of #{total} #{pluralize(total, noun)}"
1540
+ end
1541
+
1542
+ # The gem's whole vocabulary is regular, so a naive +s is not a shortcut —
1543
+ # it is the rule. Callers pass the singular.
1544
+ def pluralize(count, noun)
1545
+ count == 1 ? noun : "#{noun}s"
852
1546
  end
853
1547
 
854
1548
  def print_stats(dir, stats)
855
- @out.puts "Stats — #{dir}"
1549
+ @out.puts "Stats — #{bundle_label(dir)}"
856
1550
  @out.puts
857
1551
  @out.puts " concepts #{stats[:concepts]}"
858
1552
  @out.puts " areas #{stats[:areas]}"
@@ -873,11 +1567,11 @@ module OKF
873
1567
  end
874
1568
 
875
1569
  def print_stats_json(dir, stats)
876
- emit_json(
877
- "bundle" => dir, "concepts" => stats[:concepts], "areas" => stats[:areas],
1570
+ emit_json(bundle_head(dir).merge(
1571
+ "concepts" => stats[:concepts], "areas" => stats[:areas],
878
1572
  "concept_types" => stats[:types], "cross_links" => stats[:cross_links], "distinct_tags" => stats[:tags],
879
1573
  "by_type" => stats[:by_type], "by_area" => stats[:by_area]
880
- )
1574
+ ))
881
1575
  end
882
1576
 
883
1577
  # The single JSON writer. Compact by default — the token-efficient substrate an
@@ -890,13 +1584,18 @@ module OKF
890
1584
  # Emit a list view's JSON envelope with --fields/--except projection applied to
891
1585
  # each item. Returns the verb's exit code (0, or 2 on a bad projection request —
892
1586
  # both flags at once, or a field name no item carries).
893
- def emit_list_json(dir, key, items, options, extra = {})
1587
+ # +dir+ is the bundle's directory or a ready-made head Hash when the
1588
+ # payload spans bundles (multi-bundle search's "bundles" key).
1589
+ # +key+ names the JSON property the rows land under; +shape+ names the row
1590
+ # shape to check --fields/--except against. They are the same for every view
1591
+ # but search, whose two modes emit the same property from different rows.
1592
+ def emit_list_json(dir, key, items, options, extra = {}, shape = key)
894
1593
  return usage_error("--fields and --except are mutually exclusive") if options[:fields] && options[:except]
895
1594
 
896
- unknown = unknown_fields(items, options)
897
- return usage_error("unknown field(s): #{unknown.join(", ")} (available: #{available_fields(items).join(", ")})") unless unknown.empty?
1595
+ unknown = unknown_fields(items, options, shape)
1596
+ return usage_error("unknown field(s): #{unknown.join(", ")} (available: #{available_fields(items, shape).join(", ")})") unless unknown.empty?
898
1597
 
899
- payload = { "bundle" => dir }.merge(extra)
1598
+ payload = (dir.is_a?(Hash) ? dir.dup : bundle_head(dir)).merge(extra)
900
1599
  payload["count"] = items.size
901
1600
  payload[key] = project(items, options)
902
1601
  emit_json(payload)
@@ -915,17 +1614,22 @@ module OKF
915
1614
  end
916
1615
  end
917
1616
 
918
- def available_fields(items)
919
- items.first ? items.first.keys.map(&:to_s) : []
1617
+ # The declared shape wins over the data's, so the same typo gets the same
1618
+ # answer whether or not the result happened to have rows; a view with no
1619
+ # declared shape falls back to what it actually emitted.
1620
+ def available_fields(items, key = nil)
1621
+ ROW_FIELDS[key] || (items.first ? items.first.keys.map(&:to_s) : [])
920
1622
  end
921
1623
 
922
1624
  # Requested field names that no item actually carries — a typo guard (exit 2),
923
1625
  # matching how lint rejects unknown check names.
924
- def unknown_fields(items, options)
1626
+ def unknown_fields(items, options, key = nil)
925
1627
  requested = (Array(options[:fields]) + Array(options[:except])).map(&:downcase)
926
- return [] if requested.empty? || items.empty?
1628
+ return [] if requested.empty?
1629
+
1630
+ known = available_fields(items, key).map(&:downcase)
1631
+ return [] if known.empty? # an unknown view: no shape to check against, so accept
927
1632
 
928
- known = available_fields(items).map(&:downcase)
929
1633
  requested.reject { |field| known.include?(field) }.uniq
930
1634
  end
931
1635
 
@@ -974,23 +1678,37 @@ module OKF
974
1678
  io.puts <<~USAGE
975
1679
  okf <command> [options]
976
1680
 
977
- skill <dest> [--here] [--force] install the companion agent skill
978
- server <dir> [-p PORT] [--bind ADDR] [...] serve an interactive HTML graph
979
- render <dir> [-o FILE] [--layout NAME] [...] write a static, self-contained HTML graph
980
-
981
- lint <dir> [--json] [--fail-on warn] [...] report curation-quality issues
982
- loose <dir> [--json] list files with no graph links, by folder
983
- validate <dir> [--json] check OKF v0.1 conformance
984
-
985
- search <dir> <term…> [-e] [--in FIELDS] [...] find concepts by text or regexp, ranked
986
- index <dir> [--json] [--area A] [--no-body] the index map: dirs, their listings and rollups
987
- stats <dir> [--json] bundle rollups (concepts, types, areas, links, tags)
988
- types <dir> [--json] [filters] list types with their concepts, by count
989
- tags <dir> [--json] [--by DIM] [filters] list tags with their concepts, by count
990
- files <dir> [--json] [filters] list files with titles, by folder
991
- catalog <dir> [--json] [filters] list concepts with metadata, by area
992
-
993
- graph <dir> [--json] [--minimal] [--no-body] print the knowledge graph
1681
+ skill <dest> [--here] [--force] install the companion agent skill
1682
+ server [DIR|@slug…] [-p PORT] [--bind ADDR] [...] serve one bundle, or many behind a hub
1683
+ render <dir|@slug> [-o FILE] [--layout NAME] [...] write a static, self-contained HTML graph
1684
+
1685
+ registry list [--json] list registered bundles (* marks the default)
1686
+ registry set <dir|@slug> [--as SLUG] [--default] add or update a bundle (a bare `server` serves them)
1687
+ registry del <dir|@slug> remove a bundle from the registry
1688
+ registry default <@slug> move a bundle to the front (the default)
1689
+ registry rename <@slug> <new> rename a registered bundle (<new> is a new name, not a ref)
1690
+
1691
+ lint <dir|@slug> [--json] [--fail-on warn] [...] report curation-quality issues
1692
+ loose <dir|@slug> [--json] list files with no graph links, by folder
1693
+ validate <dir|@slug> [--json] check OKF v0.1 conformance
1694
+
1695
+ search <dir|@slug…|@all> <term…> [-e] [...] find concepts by text or regexp, ranked (@all: every bundle)
1696
+ index <dir|@slug> [--json] [--area A] [--no-body] the index map: dirs, their listings and rollups
1697
+ stats <dir|@slug> [--json] bundle rollups (concepts, types, areas, links, tags)
1698
+ types <dir|@slug> [--json] [filters] list types with their concepts, by count
1699
+ tags <dir|@slug> [--json] [--by DIM] [filters] list tags with their concepts, by count
1700
+ files <dir|@slug> [--json] [filters] list files with titles, by folder
1701
+ catalog <dir|@slug> [--json] [filters] list concepts with metadata, by area
1702
+
1703
+ graph <dir|@slug> [--json] [--minimal] [--no-body] print the knowledge graph
1704
+
1705
+ @slug names a registered bundle instead of a path — the slug from
1706
+ `okf registry set`, or bare @ for the registry default. Anywhere a <dir>
1707
+ goes, an @slug goes: `okf lint @handbook`, `okf render @ -o graph.html`.
1708
+ The registry lives under $OKF_HOME (default ~/.okf); set it to point
1709
+ every verb at another one.
1710
+ search spans bundles: several leading @slugs, or @all for every registered one
1711
+ (@all skips a bundle whose directory is gone; a named @slug insists on it).
994
1712
 
995
1713
  [filters] narrow a view to matching concepts: --type TYPE, --area AREA, --tag TAG
996
1714
  (each view takes the ones orthogonal to it; matching is case-insensitive).