okf 1.7.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,34 +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"
197
394
  require "rack/deflater"
198
395
 
199
396
  options = { port: 8808, bind: "127.0.0.1", title: nil, link: nil, layout: "cose" }
200
397
  parser = OptionParser.new do |o|
201
- 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]"
202
399
  o.on("-p", "--port PORT", Integer, "port to serve on (default #{options[:port]})") { |v| options[:port] = v }
203
400
  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 }
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 }
206
403
  o.on("--layout NAME", OKF::Server::Graph::LAYOUTS, "initial layout (#{OKF::Server::Graph::LAYOUTS.join(", ")})") { |v| options[:layout] = v }
404
+ help_flag(o)
207
405
  end
208
- dir = positional_dir(parser, argv) or return 2
406
+ dirs = positional_dirs(parser, argv) or return 2
209
407
 
210
- folder = OKF::Bundle::Folder.load(dir)
211
- report_skipped(folder)
212
- 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
213
420
  0
421
+ rescue OKF::Error => e
422
+ usage_error(e.message)
214
423
  end
215
424
 
216
- # Build the Rack app and hand it to the runner (WEBrick by default, injected so
217
- # 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).
218
427
  def run_server(folder, options)
219
428
  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])
429
+ # minimal: the banner wants a count, not bodiesand 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
223
528
  end
224
529
 
225
530
  # The static counterpart to `server`: bake the whole bundle into one
@@ -230,11 +535,12 @@ module OKF
230
535
 
231
536
  options = { output: nil, title: nil, link: nil, layout: "cose" }
232
537
  parser = OptionParser.new do |o|
233
- 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]"
234
539
  o.on("-o", "--output FILE", "write to FILE instead of stdout") { |v| options[:output] = v }
235
540
  o.on("-t", "--title TITLE", "graph title (default: parent/bundle dir name)") { |v| options[:title] = v }
236
541
  o.on("-l", "--link URL", "source URL shown in the header") { |v| options[:link] = v }
237
542
  o.on("--layout NAME", OKF::Server::Graph::LAYOUTS, "initial layout (#{OKF::Server::Graph::LAYOUTS.join(", ")})") { |v| options[:layout] = v }
543
+ help_flag(o)
238
544
  end
239
545
  dir = positional_dir(parser, argv) or return 2
240
546
 
@@ -242,21 +548,233 @@ module OKF
242
548
  report_skipped(folder)
243
549
  html = OKF::Server::App.new(folder, title: options[:title] || folder.name, link: options[:link], layout: options[:layout]).render_static
244
550
  if options[:output]
245
- File.write(options[:output], html)
246
- @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]}"
247
561
  else
248
562
  @out.print html
249
563
  end
250
564
  0
251
565
  end
252
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
+
253
770
  def graph(argv)
254
771
  options = { json: false, minimal: false, body: true }
255
772
  parser = OptionParser.new do |o|
256
- o.banner = "Usage: okf graph <bundle-dir> [--json] [--minimal] [--no-body]"
773
+ o.banner = "Usage: okf graph <dir|@slug> [--json] [--minimal] [--no-body]"
257
774
  json_flags(o, options, "emit nodes and edges as JSON")
258
775
  o.on("--minimal", "leanest nodes (id + title); adds type/tag indexes") { options[:minimal] = true }
259
776
  o.on("--[no-]body", "include each concept's body (default: yes)") { |v| options[:body] = v }
777
+ help_flag(o)
260
778
  end
261
779
  dir = positional_dir(parser, argv) or return 2
262
780
 
@@ -264,11 +782,15 @@ module OKF
264
782
  graph = folder.graph(minimal: options[:minimal], body: options[:body])
265
783
  report_skipped(folder)
266
784
  if options[:json]
267
- 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)
268
789
  payload = payload.merge(types: graph.type_index, tags: graph.tag_index) if options[:minimal]
269
790
  emit_json(payload)
270
791
  else
271
- @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")})"
272
794
  end
273
795
  0
274
796
  end
@@ -282,11 +804,12 @@ module OKF
282
804
  def index(argv)
283
805
  options = { json: false, body: true, areas: nil }
284
806
  parser = OptionParser.new do |o|
285
- 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]"
286
808
  json_flags(o, options, "emit the index map as JSON")
287
809
  projection_flags(o, options)
288
810
  o.on("--area AREA", "only this directory/area (repeatable; `root` for the bundle root)") { |v| (options[:areas] ||= []) << v }
289
811
  o.on("--[no-]body", "include each index's prose body (default: yes)") { |v| options[:body] = v }
812
+ help_flag(o)
290
813
  end
291
814
  dir = positional_dir(parser, argv) or return 2
292
815
 
@@ -295,6 +818,13 @@ module OKF
295
818
  entries = folder.directory_index
296
819
  selected = select_directories(entries, options[:areas])
297
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
+
298
828
  options[:except] = Array(options[:except]) + [ "body" ] unless options[:body] || options[:fields]
299
829
  return print_index_map_json(dir, selected, options)
300
830
  end
@@ -314,7 +844,7 @@ module OKF
314
844
 
315
845
  def print_index_map(dir, entries, body)
316
846
  noun = entries.size == 1 ? "directory" : "directories"
317
- @out.puts "Index map — #{dir} (#{entries.size} #{noun})"
847
+ @out.puts "Index map — #{bundle_label(dir)} (#{entries.size} #{noun})"
318
848
  entries.each do |entry|
319
849
  @out.puts
320
850
  @out.puts " #{index_dir_label(entry)}#{index_dir_meta(entry)}"
@@ -334,8 +864,8 @@ module OKF
334
864
  end
335
865
 
336
866
  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(", ")
867
+ count = "#{entry[:count]} #{pluralize(entry[:count], "concept")}"
868
+ types = entry[:types].map { |type, n| "#{OKF.blank?(type) ? "Untyped" : type} #{n}" }.join(", ")
339
869
  types.empty? ? " · #{count}" : " · #{count} · #{types}"
340
870
  end
341
871
 
@@ -377,10 +907,11 @@ module OKF
377
907
  def catalog(argv)
378
908
  options = { json: false }
379
909
  parser = OptionParser.new do |o|
380
- 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]"
381
911
  json_flags(o, options, "emit the catalog as JSON")
382
912
  projection_flags(o, options)
383
913
  filter_flags(o, options, :type, :area, :tag)
914
+ help_flag(o)
384
915
  end
385
916
  dir = positional_dir(parser, argv) or return 2
386
917
 
@@ -397,10 +928,11 @@ module OKF
397
928
  def files(argv)
398
929
  options = { json: false }
399
930
  parser = OptionParser.new do |o|
400
- 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]"
401
932
  json_flags(o, options, "emit the file tree as JSON")
402
933
  projection_flags(o, options)
403
934
  filter_flags(o, options, :type, :area, :tag)
935
+ help_flag(o)
404
936
  end
405
937
  dir = positional_dir(parser, argv) or return 2
406
938
 
@@ -417,10 +949,11 @@ module OKF
417
949
  def tags(argv)
418
950
  options = { json: false, by: nil }
419
951
  parser = OptionParser.new do |o|
420
- 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]"
421
953
  json_flags(o, options, "emit the tag index as JSON")
422
954
  o.on("--by DIM", %w[type area], "group the tags by a concept dimension (type | area)") { |v| options[:by] = v.to_sym }
423
955
  filter_flags(o, options, :type, :area)
956
+ help_flag(o)
424
957
  end
425
958
  dir = positional_dir(parser, argv) or return 2
426
959
 
@@ -432,9 +965,10 @@ module OKF
432
965
  def types(argv)
433
966
  options = { json: false }
434
967
  parser = OptionParser.new do |o|
435
- 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]"
436
969
  json_flags(o, options, "emit the type index as JSON")
437
970
  filter_flags(o, options, :area, :tag)
971
+ help_flag(o)
438
972
  end
439
973
  dir = positional_dir(parser, argv) or return 2
440
974
 
@@ -494,15 +1028,15 @@ module OKF
494
1028
 
495
1029
  # A catalog entry's type for display — "Untyped" when blank, matching the graph.
496
1030
  def entry_type(entry)
497
- entry[:type].empty? ? "Untyped" : entry[:type]
1031
+ OKF.blank?(entry[:type]) ? "Untyped" : entry[:type]
498
1032
  end
499
1033
 
500
1034
  def print_grouped_tags(dir, dim, groups, titles)
501
- @out.puts "Tags — #{dir} (#{distinct_tags(groups)} distinct, by #{dim})"
1035
+ @out.puts "Tags — #{bundle_label(dir)} (#{distinct_tags(groups)} distinct, by #{dim})"
502
1036
  groups.each do |key, rows|
503
1037
  label = dim == :area && key != "(root)" ? "#{key}/" : key
504
1038
  @out.puts
505
- @out.puts " #{label} (#{rows.size} tags)"
1039
+ @out.puts " #{label} (#{rows.size} #{pluralize(rows.size, "tag")})"
506
1040
  width = rows.map { |row| row[:tag].length }.max || 0
507
1041
  rows.each do |row|
508
1042
  names = row[:concepts].map { |id| titles[id] || id }.join(", ")
@@ -512,12 +1046,10 @@ module OKF
512
1046
  end
513
1047
 
514
1048
  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
- )
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))
521
1053
  end
522
1054
 
523
1055
  def distinct_tags(groups)
@@ -527,8 +1059,9 @@ module OKF
527
1059
  def stats(argv)
528
1060
  options = { json: false }
529
1061
  parser = OptionParser.new do |o|
530
- o.banner = "Usage: okf stats <bundle-dir> [--json]"
1062
+ o.banner = "Usage: okf stats <dir|@slug> [--json]"
531
1063
  json_flags(o, options, "emit the stats as JSON")
1064
+ help_flag(o)
532
1065
  end
533
1066
  dir = positional_dir(parser, argv) or return 2
534
1067
 
@@ -569,6 +1102,21 @@ module OKF
569
1102
  parser.on("--pretty", "indent the JSON for reading (implies --json)") { options[:json] = true; @pretty = true }
570
1103
  end
571
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
+
572
1120
  # --fields/--except project the JSON down to the properties an agent wants, so it
573
1121
  # never pays tokens for fields it will not read. --fields is an allowlist,
574
1122
  # --except a denylist (mutually exclusive); both imply --json and apply per item
@@ -633,6 +1181,7 @@ module OKF
633
1181
  o.banner = "Usage: okf skill <dest-dir> [--here] [--force]"
634
1182
  o.on("--here", "install straight into <dest-dir>, wherever it is (no skills/okf nesting)") { options[:nest] = false }
635
1183
  o.on("--force", "overwrite a non-empty destination") { options[:force] = true }
1184
+ help_flag(o)
636
1185
  end
637
1186
  parser.parse!(argv)
638
1187
  dest = argv.shift
@@ -662,10 +1211,14 @@ module OKF
662
1211
  note_skipped(folder.bundle.unparseable.size)
663
1212
  end
664
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.
665
1218
  def note_skipped(count)
666
1219
  return if count.nil? || count <= 0
667
1220
 
668
- @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)"
669
1222
  end
670
1223
 
671
1224
  # Turn a --stale-after value (90d, 12w, or an ISO date) into an absolute cutoff
@@ -684,8 +1237,9 @@ module OKF
684
1237
  :invalid
685
1238
  end
686
1239
 
687
- # Parse options, then require a single existing-directory positional argument.
688
- # 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.
689
1243
  def positional_dir(parser, argv)
690
1244
  parser.parse!(argv)
691
1245
  dir = argv.shift
@@ -693,11 +1247,142 @@ module OKF
693
1247
  @err.puts parser.banner
694
1248
  return nil
695
1249
  end
696
- unless File.directory?(dir)
697
- @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"
698
1285
  return nil
699
1286
  end
700
- 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
701
1386
  rescue OptionParser::ParseError => e
702
1387
  @err.puts e.message
703
1388
  nil
@@ -705,7 +1390,7 @@ module OKF
705
1390
 
706
1391
  def print_validation(dir, result)
707
1392
  counts = result.counts
708
- @out.puts "OKF v0.1 conformance — #{dir}"
1393
+ @out.puts "OKF v0.1 conformance — #{bundle_label(dir)}"
709
1394
  @out.puts " concepts: #{counts[:concepts]} index.md: #{counts[:indexes]} log.md: #{counts[:logs]}"
710
1395
  result.errors.each { |e| @out.puts " #{paint("✗ ERROR", 31)} #{e[:path]}: #{e[:message]}" }
711
1396
  result.warnings.each { |w| @out.puts " #{paint("! warn", 33)} #{w[:path]}: #{w[:message]}" }
@@ -719,18 +1404,17 @@ module OKF
719
1404
  end
720
1405
 
721
1406
  def print_validation_json(dir, result)
722
- emit_json(
723
- "bundle" => dir,
1407
+ emit_json(bundle_head(dir).merge(
724
1408
  "conformant" => result.valid?,
725
1409
  "counts" => result.counts,
726
1410
  "errors" => result.errors,
727
1411
  "warnings" => result.warnings
728
- )
1412
+ ))
729
1413
  end
730
1414
 
731
1415
  def print_lint(dir, report)
732
1416
  stats = report.stats
733
- @out.puts "OKF lint — #{dir}"
1417
+ @out.puts "OKF lint — #{bundle_label(dir)}"
734
1418
  @out.puts " concepts: #{stats[:concepts]} edges: #{stats[:edges]} index.md: #{stats[:indexes]} log.md: #{stats[:logs]}"
735
1419
  summary = lint_summary(stats)
736
1420
  @out.puts " #{summary}" unless summary.empty?
@@ -751,12 +1435,11 @@ module OKF
751
1435
  end
752
1436
 
753
1437
  def print_lint_json(dir, report)
754
- emit_json(
755
- "bundle" => dir,
1438
+ emit_json(bundle_head(dir).merge(
756
1439
  "healthy" => report.healthy?,
757
1440
  "stats" => report.stats,
758
1441
  "findings" => report.findings
759
- )
1442
+ ))
760
1443
  end
761
1444
 
762
1445
  # Degree-0 nodes as { id:, title:, dir: }, sorted by path — the same set lint's
@@ -769,7 +1452,7 @@ module OKF
769
1452
  end
770
1453
 
771
1454
  def print_loose(dir, files)
772
- @out.puts "Loose files — #{dir} (#{files.size})"
1455
+ @out.puts "Loose files — #{bundle_label(dir)} (#{files.size})"
773
1456
  if files.empty?
774
1457
  @out.puts " #{paint("✓ none — every concept links or is linked", 32)}"
775
1458
  return
@@ -786,15 +1469,14 @@ module OKF
786
1469
  end
787
1470
 
788
1471
  def print_loose_json(dir, files)
789
- emit_json(
790
- "bundle" => dir,
1472
+ emit_json(bundle_head(dir).merge(
791
1473
  "count" => files.size,
792
1474
  "loose" => files.map { |file| stringify(file) }
793
- )
1475
+ ))
794
1476
  end
795
1477
 
796
1478
  def print_catalog(dir, entries, total)
797
- @out.puts "Catalog — #{dir} (#{counted(entries.size, total, "concepts")})"
1479
+ @out.puts "Catalog — #{bundle_label(dir)} (#{counted(entries.size, total, "concept")})"
798
1480
  entries.group_by { |entry| entry[:area] }.sort_by(&:first).each do |area, group|
799
1481
  @out.puts
800
1482
  @out.puts " #{area == "(root)" ? "(root)" : "#{area}/"} (#{group.size})"
@@ -812,7 +1494,7 @@ module OKF
812
1494
  end
813
1495
 
814
1496
  def print_files(dir, entries, total)
815
- @out.puts "Files — #{dir} (#{counted(entries.size, total, "files")})"
1497
+ @out.puts "Files — #{bundle_label(dir)} (#{counted(entries.size, total, "file")})"
816
1498
  entries.group_by { |entry| entry[:dir] }.sort_by(&:first).each do |folder, group|
817
1499
  width = group.map { |entry| File.basename("#{entry[:id]}.md").length }.max
818
1500
  @out.puts
@@ -832,7 +1514,7 @@ module OKF
832
1514
  end
833
1515
 
834
1516
  def print_index(dir, label, key, rows, titles)
835
- @out.puts "#{label} — #{dir} (#{rows.size} distinct)"
1517
+ @out.puts "#{label} — #{bundle_label(dir)} (#{rows.size} distinct)"
836
1518
  @out.puts
837
1519
  width = rows.map { |row| row[key].length }.max || 0
838
1520
  rows.each do |row|
@@ -842,19 +1524,29 @@ module OKF
842
1524
  end
843
1525
 
844
1526
  def print_index_json(dir, plural, key, rows)
845
- 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)))
846
1528
  end
847
1529
 
848
1530
  def index_rows_json(key, rows)
849
1531
  rows.map { |row| { key.to_s => row[key], "count" => row[:count], "concepts" => row[:concepts] } }
850
1532
  end
851
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.
852
1536
  def counted(size, total, noun)
853
- 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"
854
1546
  end
855
1547
 
856
1548
  def print_stats(dir, stats)
857
- @out.puts "Stats — #{dir}"
1549
+ @out.puts "Stats — #{bundle_label(dir)}"
858
1550
  @out.puts
859
1551
  @out.puts " concepts #{stats[:concepts]}"
860
1552
  @out.puts " areas #{stats[:areas]}"
@@ -875,11 +1567,11 @@ module OKF
875
1567
  end
876
1568
 
877
1569
  def print_stats_json(dir, stats)
878
- emit_json(
879
- "bundle" => dir, "concepts" => stats[:concepts], "areas" => stats[:areas],
1570
+ emit_json(bundle_head(dir).merge(
1571
+ "concepts" => stats[:concepts], "areas" => stats[:areas],
880
1572
  "concept_types" => stats[:types], "cross_links" => stats[:cross_links], "distinct_tags" => stats[:tags],
881
1573
  "by_type" => stats[:by_type], "by_area" => stats[:by_area]
882
- )
1574
+ ))
883
1575
  end
884
1576
 
885
1577
  # The single JSON writer. Compact by default — the token-efficient substrate an
@@ -892,13 +1584,18 @@ module OKF
892
1584
  # Emit a list view's JSON envelope with --fields/--except projection applied to
893
1585
  # each item. Returns the verb's exit code (0, or 2 on a bad projection request —
894
1586
  # both flags at once, or a field name no item carries).
895
- 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)
896
1593
  return usage_error("--fields and --except are mutually exclusive") if options[:fields] && options[:except]
897
1594
 
898
- unknown = unknown_fields(items, options)
899
- 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?
900
1597
 
901
- payload = { "bundle" => dir }.merge(extra)
1598
+ payload = (dir.is_a?(Hash) ? dir.dup : bundle_head(dir)).merge(extra)
902
1599
  payload["count"] = items.size
903
1600
  payload[key] = project(items, options)
904
1601
  emit_json(payload)
@@ -917,17 +1614,22 @@ module OKF
917
1614
  end
918
1615
  end
919
1616
 
920
- def available_fields(items)
921
- 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) : [])
922
1622
  end
923
1623
 
924
1624
  # Requested field names that no item actually carries — a typo guard (exit 2),
925
1625
  # matching how lint rejects unknown check names.
926
- def unknown_fields(items, options)
1626
+ def unknown_fields(items, options, key = nil)
927
1627
  requested = (Array(options[:fields]) + Array(options[:except])).map(&:downcase)
928
- 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
929
1632
 
930
- known = available_fields(items).map(&:downcase)
931
1633
  requested.reject { |field| known.include?(field) }.uniq
932
1634
  end
933
1635
 
@@ -976,23 +1678,37 @@ module OKF
976
1678
  io.puts <<~USAGE
977
1679
  okf <command> [options]
978
1680
 
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
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).
996
1712
 
997
1713
  [filters] narrow a view to matching concepts: --type TYPE, --area AREA, --tag TAG
998
1714
  (each view takes the ones orthogonal to it; matching is case-insensitive).