okf 1.8.0 → 1.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +615 -38
  3. data/README.md +109 -15
  4. data/lib/okf/bundle/folder.rb +20 -0
  5. data/lib/okf/bundle/search/index.rb +65 -0
  6. data/lib/okf/bundle/search/scan.rb +89 -0
  7. data/lib/okf/bundle/search.rb +262 -66
  8. data/lib/okf/bundle.rb +27 -3
  9. data/lib/okf/cli/catalog.rb +66 -0
  10. data/lib/okf/cli/command.rb +495 -0
  11. data/lib/okf/cli/files.rb +68 -0
  12. data/lib/okf/cli/graph.rb +82 -0
  13. data/lib/okf/cli/index.rb +127 -0
  14. data/lib/okf/cli/lint.rb +139 -0
  15. data/lib/okf/cli/loose.rb +78 -0
  16. data/lib/okf/cli/registry.rb +229 -0
  17. data/lib/okf/cli/render.rb +66 -0
  18. data/lib/okf/cli/search.rb +285 -0
  19. data/lib/okf/cli/server.rb +179 -0
  20. data/lib/okf/cli/skill.rb +57 -0
  21. data/lib/okf/cli/stats.rb +88 -0
  22. data/lib/okf/cli/tags.rb +122 -0
  23. data/lib/okf/cli/types.rb +37 -0
  24. data/lib/okf/cli/validate.rb +66 -0
  25. data/lib/okf/cli.rb +418 -1633
  26. data/lib/okf/{server → render}/graph/template.html.erb +1553 -175
  27. data/lib/okf/{server → render}/graph.rb +85 -9
  28. data/lib/okf/server/app.rb +17 -48
  29. data/lib/okf/server/hub/not_found.rb +663 -0
  30. data/lib/okf/server/hub.rb +504 -38
  31. data/lib/okf/skill/SKILL.md +41 -26
  32. data/lib/okf/skill/playbooks/consume.md +5 -3
  33. data/lib/okf/skill/playbooks/curate.md +3 -1
  34. data/lib/okf/skill/playbooks/maintain.md +4 -3
  35. data/lib/okf/skill/playbooks/menu.md +5 -0
  36. data/lib/okf/skill/playbooks/refine.md +92 -0
  37. data/lib/okf/skill/playbooks/search.md +47 -7
  38. data/lib/okf/skill/reference/authoring.md +3 -2
  39. data/lib/okf/skill/reference/cli.md +98 -21
  40. data/lib/okf/version.rb +1 -1
  41. data/lib/okf.rb +8 -0
  42. metadata +37 -3
data/lib/okf/cli.rb CHANGED
@@ -3,85 +3,16 @@
3
3
  require "optparse"
4
4
 
5
5
  module OKF
6
- # Command-line front end: `okf graph|validate|lint|loose|search|index|catalog|files|tags|types|stats|server <dir>`.
7
- # This is the
8
- # only layer that parses argv, prints, writes files, and decides exit codes — the
9
- # lib classes below it just return data. Streams are injectable for testing.
6
+ # Command-line front end: `okf <command> [options]`.
7
+ #
8
+ # This file is the dispatcher and the registry; the verbs themselves live one
9
+ # per file under `okf/cli/`, each a Command subclass that registers itself at
10
+ # load. It is still the only layer that parses argv, prints, writes files and
11
+ # decides exit codes — the lib classes below it just return data. Streams are
12
+ # injectable for testing.
10
13
  #
11
14
  # Exit codes: 0 success, 1 non-conformant / failing bundle, 2 usage error.
12
15
  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
-
17
- # Lint findings grouped for display, in category order.
18
- LINT_CATEGORIES = {
19
- "Reachability" => %i[orphan not_in_index disconnected_component unlinked],
20
- "Backlog" => %i[missing_concept broken_index_entry],
21
- "Completeness" => %i[stub missing_title missing_description missing_timestamp],
22
- "Freshness" => %i[stale],
23
- "Provenance" => %i[uncited_external broken_citation],
24
- "Hygiene" => %i[duplicate_title unused_reference_def undefined_reference self_link]
25
- }.freeze
26
-
27
- # Runs a Rack app under WEBrick until interrupted. Injected into the CLI so
28
- # tests can drive `server` without opening a socket; the runner loads here
29
- # (not at require time) so `require "okf"` and a Rails mount of the server stay
30
- # light.
31
- WEBRICK = lambda do |app, host, port|
32
- require "okf/server/runner"
33
- OKF::Server::Runner.run(app, host: host, port: port)
34
- end
35
-
36
- def self.start(argv, out: $stdout, err: $stderr)
37
- new(out: out, err: err).run(argv)
38
- end
39
-
40
- def initialize(out: $stdout, err: $stderr, runner: WEBRICK)
41
- @out = out
42
- @err = err
43
- @runner = runner
44
- @pretty = false
45
- end
46
-
47
- def run(argv)
48
- argv = argv.dup
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
82
- end
83
- end
84
-
85
16
  # "every registered bundle" as a ref, in its canonical spelling — what the
86
17
  # messages say, and (normalized) what #all_ref? recognizes. Only `search`
87
18
  # expands it: it is the one verb that merges across bundles, so it is the one
@@ -114,1611 +45,465 @@ module OKF
114
45
  "bundles" => %w[slug title dir mount default missing]
115
46
  }.freeze
116
47
 
117
- private
118
-
119
- def validate(argv)
120
- options = { json: false }
121
- parser = OptionParser.new do |o|
122
- o.banner = "Usage: okf validate <dir|@slug> [--json]"
123
- json_flags(o, options, "emit a JSON report")
124
- help_flag(o)
125
- end
126
- dir = positional_dir(parser, argv) or return 2
127
-
128
- result = OKF::Bundle::Folder.load(dir).validate
129
- options[:json] ? print_validation_json(dir, result) : print_validation(dir, result)
130
- result.valid? ? 0 : 1
131
- end
132
-
133
- def lint(argv)
134
- options = { json: false, min_body: OKF::Bundle::Linter::DEFAULT_MIN_BODY, stale_after: nil, only: nil, except: nil, fail_on: :never }
135
- parser = OptionParser.new do |o|
136
- o.banner = "Usage: okf lint <dir|@slug> [--json] [--min-body N] [--stale-after DUR] [--only a,b] [--except a,b] [--fail-on warn]"
137
- json_flags(o, options, "emit a JSON report")
138
- o.on("--min-body N", Integer, "stub threshold in body characters (default #{OKF::Bundle::Linter::DEFAULT_MIN_BODY})") { |v| options[:min_body] = v }
139
- o.on("--stale-after DUR", "flag concepts older than DUR (e.g. 90d, 12w, 2026-01-01)") { |v| options[:stale_after] = v }
140
- o.on("--only LIST", Array, "run only these checks (comma-separated)") { |v| options[:only] = v.map(&:to_sym) }
141
- o.on("--except LIST", Array, "skip these checks (comma-separated)") { |v| options[:except] = v.map(&:to_sym) }
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)
144
- end
145
- dir = positional_dir(parser, argv) or return 2
146
-
147
- unknown = ((options[:only] || []) + (options[:except] || [])) - OKF::Bundle::Linter::CHECKS
148
- unless unknown.empty?
149
- @err.puts "error: unknown check(s): #{unknown.uniq.join(", ")}"
150
- return 2
151
- end
152
-
153
- stale_before = parse_stale_after(options[:stale_after])
154
- if stale_before == :invalid
155
- @err.puts "error: invalid --stale-after `#{options[:stale_after]}` (use 90d, 12w, or an ISO date like 2026-01-01)"
156
- return 2
157
- end
158
-
159
- folder = OKF::Bundle::Folder.load(dir)
160
- report = folder.lint(min_body: options[:min_body], stale_before: stale_before, only: options[:only], except: options[:except])
161
- note_skipped(report.stats[:skipped])
162
- options[:json] ? print_lint_json(dir, report) : print_lint(dir, report)
163
- options[:fail_on] == :warn && report.warnings.any? ? 1 : 0
164
- end
165
-
166
- # List the "loose" files — concepts with graph degree 0 (no cross-links in or
167
- # out), grouped by folder. A folder-grouped view over lint's `unlinked` check,
168
- # for the common "which files float in the graph?" question. Advisory (exit 0):
169
- # a terminal leaf can be loose by design. `--json` for a machine substrate.
170
- def loose(argv)
171
- options = { json: false }
172
- parser = OptionParser.new do |o|
173
- o.banner = "Usage: okf loose <dir|@slug> [--json]"
174
- json_flags(o, options, "emit the loose files as JSON")
175
- help_flag(o)
176
- end
177
- dir = positional_dir(parser, argv) or return 2
178
-
179
- folder = OKF::Bundle::Folder.load(dir)
180
- report_skipped(folder)
181
- files = loose_files(folder.graph(minimal: true))
182
- options[:json] ? print_loose_json(dir, files) : print_loose(dir, files)
183
- 0
184
- end
185
-
186
- # Deterministic text retrieval — the browser page's search brought to the CLI
187
- # and extended to bodies. Terms after the directory are ANDed case-insensitive
188
- # substrings (Ruby regexps with --regexp); rows rank by where they hit (title >
189
- # id > tags > type/description > body) and carry one bounded context snippet,
190
- # so "which concept covers X?" costs a few rows, not a body read. Advisory
191
- # read: exit 0 even with no matches. Deliberately not fuzzy — the consuming
192
- # agent is the fuzzy layer.
193
- def search(argv)
194
- options = { json: false, regexp: false }
195
- parser = OptionParser.new do |o|
196
- o.banner = "Usage: okf search <dir|@slug…|@all> <term> [term ...] [--regexp] [--in FIELDS] [--type T] [--area A] [--tag T] [--json]"
197
- json_flags(o, options, "emit the matches as JSON")
198
- projection_flags(o, options)
199
- o.on("-e", "--regexp", "treat each term as a Ruby regular expression (case-insensitive)") { options[:regexp] = true }
200
- o.on("--in LIST", Array, "search only these fields (#{OKF::Bundle::Search::FIELDS.join(", ")})") { |v| options[:in] = v.map(&:downcase) }
201
- filter_flags(o, options, :type, :area, :tag)
202
- help_flag(o)
203
- end
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
-
225
- terms = argv
226
- if terms.empty?
227
- @err.puts parser.banner
228
- return 2
229
- end
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
-
237
- unknown = Array(options[:in]) - OKF::Bundle::Search::FIELDS
238
- return usage_error("unknown field(s): #{unknown.join(", ")} (searchable: #{OKF::Bundle::Search::FIELDS.join(", ")})") unless unknown.empty?
239
-
240
- return multi_search(pairs, terms, options) if pairs
241
-
242
- folder = OKF::Bundle::Folder.load(dir)
243
- report_skipped(folder)
244
- rows = OKF::Bundle::Search.call(folder.bundle, terms, fields: options[:in], regexp: options[:regexp])
245
- keep = filter_ids(folder, options)
246
- rows = rows.select { |row| keep.include?(row[:id]) } unless keep.nil?
247
- return print_search_json(dir, terms, rows, options) if options[:json]
248
-
249
- print_search(dir, terms, rows, folder.bundle.concepts.size)
250
- 0
251
- rescue RegexpError => e
252
- usage_error("invalid pattern: #{e.message}")
253
- end
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
48
+ # Runs a Rack app under WEBrick until interrupted. Injected into the CLI so
49
+ # tests can drive `server` without opening a socket; the runner loads here
50
+ # (not at require time) so `require "okf"` and a Rails mount of the server stay
51
+ # light.
52
+ WEBRICK = lambda do |app, host, port|
53
+ require "okf/server/runner"
54
+ OKF::Server::Runner.run(app, host: host, port: port)
298
55
  end
299
56
 
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.
57
+ # The file a gem ships to add verbs to `okf`. Everything about the seam is in
58
+ # this one constant: a gem that wants to extend the CLI puts `okf/plugin.rb`
59
+ # on its load path and registers from it.
303
60
  #
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
-
349
- def print_search(dir, terms, rows, total)
350
- @out.puts "Search — #{bundle_label(dir)} · #{terms.join(" ")} (#{counted(rows.size, total, "concept")})"
351
- if rows.empty?
352
- @out.puts " no matches — fewer or broader terms, or scan `okf tags #{dir}` for the vocabulary"
353
- return
354
- end
355
-
356
- width = rows.map { |row| row[:id].length }.max
357
- rows.each do |row|
358
- @out.puts
359
- @out.puts " #{row[:id].ljust(width)} #{row[:title]} · #{row[:type]} · #{row[:matched].join("+")}"
360
- @out.puts " #{truncate(row[:snippet], 100)}" unless row[:snippet].empty?
361
- end
362
- end
363
-
364
- def print_search_json(dir, terms, rows, options)
365
- emit_list_json(dir, "matches", rows.map { |row| stringify(row) }, options, "query" => terms)
366
- end
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
-
392
- def server(argv)
393
- require "okf/server/app"
394
- require "rack/deflater"
395
-
396
- options = { port: 8808, bind: "127.0.0.1", title: nil, link: nil, layout: "cose" }
397
- parser = OptionParser.new do |o|
398
- o.banner = "Usage: okf server [DIR|@slug…] [-p PORT] [--bind ADDR] [--layout NAME] [-t title] [-l url]"
399
- o.on("-p", "--port PORT", Integer, "port to serve on (default #{options[:port]})") { |v| options[:port] = v }
400
- o.on("--bind ADDR", "address to bind (default #{options[:bind]})") { |v| options[:bind] = 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 }
403
- o.on("--layout NAME", OKF::Server::Graph::LAYOUTS, "initial layout (#{OKF::Server::Graph::LAYOUTS.join(", ")})") { |v| options[:layout] = v }
404
- help_flag(o)
405
- end
406
- dirs = positional_dirs(parser, argv) or return 2
407
-
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
420
- 0
421
- rescue OKF::Error => e
422
- usage_error(e.message)
423
- end
424
-
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).
427
- def run_server(folder, options)
428
- app = OKF::Server::App.new(folder, title: options[:title] || folder.name, link: options[:link], layout: options[:layout])
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
61
+ # A convention rather than a list the base gem keeps, because the alternative
62
+ # is this gem naming its own addons — and the moment it does, adding an addon
63
+ # means editing okf. `--engine` already set the precedent on the search side:
64
+ # an addon shows up in help *without the CLI knowing it exists*.
65
+ PLUGIN_FILE = "okf/plugin.rb"
66
+
67
+ # Only gems named `okf-*` are loaded — the namespacing convention Jekyll
68
+ # (`jekyll-*`) and Vagrant (`vagrant-*`) use, which is the reason the rule is
69
+ # here: it makes what counts as an okf extension explicit and stops an
70
+ # unrelated gem claiming the `okf/plugin.rb` path. It guards a little too,
71
+ # since `require` runs what it loads, but that window is nearly empty and
72
+ # overselling it would be worse than having no rule. The argument in full is
73
+ # at #plugin_paths.
74
+ PLUGIN_GEM_PREFIX = "okf-"
75
+
76
+ # What #plugin_gem_name answers when it cannot work out a path's owning gem
77
+ # at all — deliberately distinct from nil, which means "belongs to no gem"
78
+ # and is trusted. See #plugin_gem_name for why the two must not merge.
79
+ UNKNOWN_GEM = :unknown
80
+
81
+ # The map's shape: the order the groups print in, and the heading each one
82
+ # carries. Only extensions get a heading — the built-in groups are separated
83
+ # by a blank line and their verbs speak for themselves, which is how this map
84
+ # has always read. A plugin's verbs are labelled because "where did this come
85
+ # from?" is a question only an installed extension raises.
86
+ GROUPS = [
87
+ [ :act, nil ],
88
+ [ :registry, nil ],
89
+ [ :judge, nil ],
90
+ [ :read, nil ],
91
+ [ :graph, nil ],
92
+ [ :extension, " installed extensions:" ]
93
+ ].freeze
94
+
95
+ # Everything the map's grammar column cannot say for itself. A test finds the
96
+ # `@slug names` paragraph by its opening words, so the wording is load-bearing.
97
+ NOTE = <<~NOTE
98
+ @slug names a registered bundle instead of a path — the slug from
99
+ `okf registry set`, or bare @ for the registry default. Anywhere a <dir>
100
+ goes, an @slug goes: `okf lint @handbook`, `okf render @ -o graph.html`.
101
+ The registry lives under $OKF_HOME (default ~/.okf); set it to point
102
+ every verb at another one.
103
+ search spans bundles: several leading @slugs, or @all for every registered one
104
+ (@all skips a bundle whose directory is gone; a named @slug insists on it).
105
+
106
+ [filters] narrow a view to matching concepts: --type TYPE, --area AREA, --tag TAG
107
+ (each view takes the ones orthogonal to it; matching is case-insensitive).
108
+ tags --by DIM regroups the tags per concept dimension — type or area — with
109
+ within-group counts, the view for curating a tag vocabulary.
110
+ --json emits compact JSON (the machine substrate); add --pretty to indent it.
111
+ --fields / --except project the JSON to the properties you want (search/index/catalog/files).
112
+
113
+ okf --version
114
+ NOTE
115
+
116
+ class << self
117
+ # Append-only and idempotent by id: a second registration of an id already
118
+ # present is a no-op, so a double `require` cannot double the registry and
119
+ # **an addon cannot quietly displace a built-in**. Deliberately the same
120
+ # shape as Search.register — three extension points, one idiom.
121
+ #
122
+ # The duck type is checked here rather than at dispatch, so a malformed
123
+ # command fails where it is installed instead of the first time somebody
124
+ # types its verb.
125
+ def register(command)
126
+ missing = Command::DUCK_TYPE.reject { |message| command.respond_to?(message) }
127
+ raise ArgumentError, "#{command} cannot be a command: it does not answer #{missing.join(", ")}" unless missing.empty?
128
+
129
+ @commands ||= []
130
+ existing = @commands.find { |registered| registered.id == command.id }
131
+ return register_declined(command, existing) if existing
132
+
133
+ @commands << command
134
+ command
135
+ end
136
+
137
+ # A frozen snapshot in registration order — which, for the built-ins, is
138
+ # the order this file requires them in at the bottom, and therefore the
139
+ # order `okf help` lists them in.
140
+ def commands
141
+ (@commands ||= []).dup.freeze
142
+ end
143
+
144
+ def lookup(name)
145
+ return nil if OKF.blank?(name)
146
+
147
+ commands.find { |command| command.id.to_s == name.to_s }
148
+ end
149
+
150
+ # Registrations refused because the id was taken. Kept so the refusal can
151
+ # be *reported* — Search can no-op in silence because an engine nobody
152
+ # selected is invisible either way, but a verb that silently does nothing
153
+ # is a bug report waiting to happen.
154
+ def declined
155
+ (@declined ||= []).dup.freeze
156
+ end
157
+
158
+ # Load every installed extension, once. Returns the failures as
159
+ # [ path, error ] pairs rather than printing them: this is a class method
160
+ # with no streams, and the CLI's whole contract is that nothing writes
161
+ # anywhere but the streams it was handed.
162
+ #
163
+ # A plugin that raises is *skipped and reported*, never fatal — the same
164
+ # best-effort posture the reader takes with an unparseable file. One broken
165
+ # addon must not cost a user their `okf lint`.
166
+ def load_plugins
167
+ return @plugin_failures if @plugins_loaded
168
+
169
+ @plugins_loaded = true
170
+ @plugin_failures = []
171
+ @loaded_plugins = []
172
+ plugin_paths.each do |path|
173
+ begin
174
+ require path
175
+ @loaded_plugins << path
176
+ rescue ::LoadError, ::StandardError => e
177
+ @plugin_failures << [ path, e ]
178
+ end
505
179
  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
528
- end
529
-
530
- # The static counterpart to `server`: bake the whole bundle into one
531
- # self-contained HTML file (bodies, catalog, index, logs baked in, no server
532
- # needed — e.g. hosting on GitHub Pages). Prints to stdout unless -o is given.
533
- def render(argv)
534
- require "okf/server/app"
535
-
536
- options = { output: nil, title: nil, link: nil, layout: "cose" }
537
- parser = OptionParser.new do |o|
538
- o.banner = "Usage: okf render <dir|@slug> [-o FILE] [--layout NAME] [-t title] [-l url]"
539
- o.on("-o", "--output FILE", "write to FILE instead of stdout") { |v| options[:output] = v }
540
- o.on("-t", "--title TITLE", "graph title (default: parent/bundle dir name)") { |v| options[:title] = v }
541
- o.on("-l", "--link URL", "source URL shown in the header") { |v| options[:link] = v }
542
- o.on("--layout NAME", OKF::Server::Graph::LAYOUTS, "initial layout (#{OKF::Server::Graph::LAYOUTS.join(", ")})") { |v| options[:layout] = v }
543
- help_flag(o)
544
- end
545
- dir = positional_dir(parser, argv) or return 2
546
-
547
- folder = OKF::Bundle::Folder.load(dir)
548
- report_skipped(folder)
549
- html = OKF::Server::App.new(folder, title: options[:title] || folder.name, link: options[:link], layout: options[:layout]).render_static
550
- if 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}")
180
+ @plugin_failures
181
+ end
182
+
183
+ # Latest-version-only where RubyGems offers it, so two installed versions
184
+ # of the same addon cannot both register. The fallback keeps the floor:
185
+ # find_latest_files has been there since RubyGems 1.8, but the guard costs
186
+ # nothing and says which method the behaviour depends on.
187
+ #
188
+ # Narrowed to gems named `okf-*` — the convention Jekyll (`jekyll-*`) and
189
+ # Vagrant (`vagrant-*`) use for the same job, and the reason the rule is
190
+ # here: it makes what counts as an okf extension explicit, and stops an
191
+ # unrelated gem claiming the `okf/plugin.rb` path by accident.
192
+ #
193
+ # It is a mild guard as well, since `require` runs whatever it loads, but
194
+ # the window it closes is nearly empty and calling it a **defence** would
195
+ # invite the false confidence that is worse than having no rule at all. A
196
+ # transitive dependency is required by its parent in normal use, so
197
+ # `require "foo"` already runs foo's; under Bundler, discovery is
198
+ # bundle-scoped, so the Gemfile is an allowlist already. What is left is a
199
+ # pure-Ruby gem installed globally and then used by nothing — and nothing
200
+ # here saves anyone from a package deliberately installed under an `okf-`
201
+ # name, because `gem install` has already run on it.
202
+ #
203
+ # The rule underneath this one *is* load-bearing: naming a gem must never
204
+ # load it. See `plugin_gem_name` below, and
205
+ # .okf/design/extension-points.md for the argument in full.
206
+ def plugin_paths
207
+ # Cleared first, so the rescue below cannot return "found nothing" while
208
+ # leaving an earlier call's refusals standing to be reported again.
209
+ @untrusted_plugins = []
210
+ @plugin_gem_error = nil
211
+ @plugin_discovery_error = nil
212
+ @gem_index = nil
213
+ found = if Gem.respond_to?(:find_latest_files)
214
+ Gem.find_latest_files(PLUGIN_FILE)
215
+ else
216
+ Gem.find_files(PLUGIN_FILE)
217
+ end
218
+
219
+ found.select do |path|
220
+ name = plugin_gem_name(path)
221
+ next true if trusted_gem?(name)
222
+
223
+ @untrusted_plugins << [ path, name ]
224
+ false
558
225
  end
559
- count = folder.graph(minimal: true).nodes.size
560
- @out.puts "wrote #{count} #{pluralize(count, "concept")} to #{options[:output]}"
561
- else
562
- @out.print html
563
- end
564
- 0
565
- end
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
-
770
- def graph(argv)
771
- options = { json: false, minimal: false, body: true }
772
- parser = OptionParser.new do |o|
773
- o.banner = "Usage: okf graph <dir|@slug> [--json] [--minimal] [--no-body]"
774
- json_flags(o, options, "emit nodes and edges as JSON")
775
- o.on("--minimal", "leanest nodes (id + title); adds type/tag indexes") { options[:minimal] = true }
776
- o.on("--[no-]body", "include each concept's body (default: yes)") { |v| options[:body] = v }
777
- help_flag(o)
778
- end
779
- dir = positional_dir(parser, argv) or return 2
780
-
781
- folder = OKF::Bundle::Folder.load(dir)
782
- graph = folder.graph(minimal: options[:minimal], body: options[:body])
783
- report_skipped(folder)
784
- if options[:json]
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)
789
- payload = payload.merge(types: graph.type_index, tags: graph.tag_index) if options[:minimal]
790
- emit_json(payload)
791
- else
792
- @out.puts "Graph — #{bundle_label(dir)} (#{graph.nodes.size} #{pluralize(graph.nodes.size, "concept")}, " \
793
- "#{graph.edges.size} #{pluralize(graph.edges.size, "link")})"
794
- end
795
- 0
796
- end
797
-
798
- # The progressive-disclosure map (spec §6): every directory that holds concepts
799
- # or carries an index.md, with its authored index body, a type/tag rollup, its
800
- # child directories, and — for a directory with no index.md — the listing
801
- # synthesized from the concepts there. The "orient before you read" view. `--area`
802
- # is repeatable (one or many directories; `root` is the bundle root); `--no-body`
803
- # drops the prose to a skeleton; advisory, exit 0.
804
- def index(argv)
805
- options = { json: false, body: true, areas: nil }
806
- parser = OptionParser.new do |o|
807
- o.banner = "Usage: okf index <dir|@slug> [--area AREA] [--no-body] [--json]"
808
- json_flags(o, options, "emit the index map as JSON")
809
- projection_flags(o, options)
810
- o.on("--area AREA", "only this directory/area (repeatable; `root` for the bundle root)") { |v| (options[:areas] ||= []) << v }
811
- o.on("--[no-]body", "include each index's prose body (default: yes)") { |v| options[:body] = v }
812
- help_flag(o)
813
- end
814
- dir = positional_dir(parser, argv) or return 2
815
-
816
- folder = OKF::Bundle::Folder.load(dir)
817
- report_skipped(folder)
818
- entries = folder.directory_index
819
- selected = select_directories(entries, options[:areas])
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")
226
+ rescue ::StandardError => e
227
+ # The *search* failing is its own report, and the reason is the one
228
+ # `plugin_gem_name` already answers to one frame down: an empty list and
229
+ # no message is indistinguishable from a machine with nothing installed.
230
+ # There is no path left to hang a refusal on here, so the failure has to
231
+ # carry itself or it is not reported at all.
232
+ @plugin_discovery_error = e
233
+ []
234
+ ensure
235
+ # Only a snapshot for the length of one discovery — a long-lived copy of
236
+ # every installed spec's path would outlive its usefulness and go stale.
237
+ @gem_index = nil
238
+ end
239
+
240
+ # Three answers, and the third has to stay distinct from the second: a gem
241
+ # name; nil when the path belongs to no gem at all — a bare $LOAD_PATH
242
+ # entry, which is how a checkout, `ruby -I`, a Gemfile `path:` and the
243
+ # suite's own fixtures appear, and which stays trusted because someone put
244
+ # it there; and UNKNOWN_GEM when the lookup itself failed.
245
+ #
246
+ # Answering nil for that last case is fail-open, and worth spelling out
247
+ # because it reads as harmless: enumerating the installed specs is what
248
+ # raises when one gemspec anywhere on the machine is corrupt — and every
249
+ # discovered path would come back "belongs to no gem" and load. A rule that
250
+ # quietly switches itself off under failure is the false confidence this
251
+ # one is deliberately modest to avoid, so a name that cannot be read is
252
+ # refused — and the cause is kept, because refusing every extension on the
253
+ # machine while naming no reason leaves the user nothing to act on.
254
+ #
255
+ # Resolved from the spec's full_gem_path rather than by loading anything:
256
+ # naming an extension must never mean running it.
257
+ def plugin_gem_name(path)
258
+ index = gem_index
259
+ return UNKNOWN_GEM if index.equal?(UNKNOWN_GEM)
260
+
261
+ found = index.find { |prefix, _name| path.start_with?(prefix) }
262
+ found&.last
263
+ end
264
+
265
+ # Whether a name clears the prefix rule. Three answers in, two out, and
266
+ # the middle one is the whole point: nil belongs to no gem — a checkout,
267
+ # `ruby -I`, a Gemfile `path:` — and stays trusted because someone put it
268
+ # there deliberately, while UNKNOWN_GEM is the lookup itself failing and is
269
+ # refused, because a rule that switches itself off when it cannot get an
270
+ # answer is the false confidence this one is deliberately modest to avoid.
271
+ def trusted_gem?(name)
272
+ return true if name.nil?
273
+ return false if name.equal?(UNKNOWN_GEM)
274
+
275
+ name.start_with?(PLUGIN_GEM_PREFIX)
276
+ end
277
+
278
+ # Every installed gem's path with the name that owns it, or UNKNOWN_GEM if
279
+ # the specs could not be walked. Not a saving — it replaces a `find` that
280
+ # short-circuited on the first match with a `map` over all of them, so on
281
+ # the ordinary one-path discovery it is strictly more work (1.0ms for 282
282
+ # specs). It buys a shape instead: **one pass has one outcome**, so every
283
+ # path in a discovery gets the same answer. Per-path enumeration made that
284
+ # a lottery — a failure that cleared between paths, a gemspec rewritten by
285
+ # a concurrent `gem install`, would refuse one path and trust the next in
286
+ # the same run.
287
+ #
288
+ # Which is why the failure memoizes too. `||=` over a raising expression
289
+ # caches nothing, so the second path would try again and could get a
290
+ # different answer — the lottery back, in the branch the whole thing was
291
+ # written for.
292
+ #
293
+ # Built lazily, so the ordinary run — nothing discovered — never walks the
294
+ # specs at all.
295
+ def gem_index
296
+ @gem_index ||= begin
297
+ Gem::Specification.map do |spec|
298
+ full = spec.full_gem_path
299
+ [ full.end_with?(File::SEPARATOR) ? full : "#{full}#{File::SEPARATOR}", spec.name ]
300
+ end
301
+ rescue ::StandardError => e
302
+ @plugin_gem_error = e
303
+ UNKNOWN_GEM
826
304
  end
827
-
828
- options[:except] = Array(options[:except]) + [ "body" ] unless options[:body] || options[:fields]
829
- return print_index_map_json(dir, selected, options)
830
305
  end
831
- print_index_map(dir, selected, options[:body])
832
- 0
833
- end
834
306
 
835
- # Narrow the map to the named directories/areas — case-insensitive, `root`
836
- # matching the bundle root (".") so no shell quoting is needed. No --area passed
837
- # keeps the whole map.
838
- def select_directories(entries, areas)
839
- return entries if areas.nil? || areas.empty?
307
+ # Why a name could not be read, when one could not. Reported with the
308
+ # refusal it caused: "could not be determined" on its own names no gem to
309
+ # fix and no reason to look.
310
+ attr_reader :plugin_gem_error
840
311
 
841
- wanted = areas.map { |area| area.downcase == "root" ? "." : area.downcase }
842
- entries.select { |entry| wanted.include?(entry[:dir].downcase) }
843
- end
312
+ # Why the search for extensions could not run at all, when it could not.
313
+ # Distinct from the above: that one refuses paths it found, this one found
314
+ # none — so there is nothing to refuse and the error is the only witness.
315
+ attr_reader :plugin_discovery_error
844
316
 
845
- def print_index_map(dir, entries, body)
846
- noun = entries.size == 1 ? "directory" : "directories"
847
- @out.puts "Index map — #{bundle_label(dir)} (#{entries.size} #{noun})"
848
- entries.each do |entry|
849
- @out.puts
850
- @out.puts " #{index_dir_label(entry)}#{index_dir_meta(entry)}"
851
- subdirs = entry[:subdirs]
852
- @out.puts " → #{subdirs.map { |sub| "#{File.basename(sub)}/" }.join(" ")}" unless subdirs.empty?
853
- if entry[:present]
854
- print_index_body(entry[:body]) if body
855
- else
856
- print_synthesized_listing(entry[:listing])
857
- end
317
+ # Paths discovered but refused for their gem's name, as [ path, gem ]
318
+ # pairs. Kept so the refusal can be *reported*: an extension that is
319
+ # present and deliberately not run is exactly the thing a user needs told.
320
+ def untrusted_plugins
321
+ (@untrusted_plugins ||= []).dup.freeze
858
322
  end
859
- end
860
-
861
- def index_dir_label(entry)
862
- base = entry[:dir] == "." ? "(root)" : "#{entry[:dir]}/"
863
- entry[:present] ? base : "#{base} (no index.md)"
864
- end
865
-
866
- def index_dir_meta(entry)
867
- count = "#{entry[:count]} #{pluralize(entry[:count], "concept")}"
868
- types = entry[:types].map { |type, n| "#{OKF.blank?(type) ? "Untyped" : type} #{n}" }.join(", ")
869
- types.empty? ? " · #{count}" : " · #{count} · #{types}"
870
- end
871
-
872
- def print_index_body(body)
873
- text = body.to_s.strip
874
- return if text.empty?
875
323
 
876
- text.each_line { |line| @out.puts " #{line.chomp}" }
877
- end
878
-
879
- def print_synthesized_listing(listing)
880
- listing.each do |item|
881
- suffix = item[:description].empty? ? "" : " — #{truncate(item[:description], 72)}"
882
- @out.puts " • #{item[:title]}#{suffix}"
324
+ # Called once at the bottom of this file, after the built-ins have
325
+ # registered. Everything registered after it is an extension — which makes
326
+ # "built-in" a fact the CLI knows rather than a group a command claims,
327
+ # and gives a test somewhere to roll back to.
328
+ def seal_builtins!
329
+ @builtins = commands
883
330
  end
884
- end
885
-
886
- def print_index_map_json(dir, entries, options)
887
- emit_list_json(dir, "directories", entries.map { |entry| index_map_entry_json(entry) }, options)
888
- end
889
-
890
- def index_map_entry_json(entry)
891
- {
892
- "dir" => entry[:dir], "index_path" => entry[:index_path],
893
- "present" => entry[:present], "synthesized" => entry[:synthesized],
894
- "count" => entry[:count], "types" => entry[:types], "tags" => entry[:tags],
895
- "subdirs" => entry[:subdirs], "body" => entry[:body],
896
- "listing" => entry[:listing].map { |item| stringify(item) }
897
- }
898
- end
899
331
 
900
- # The Catalog / Files / Tags / Stats views the server renders in the browser,
901
- # reproduced on the CLI so an agent can read the same knowledge without one.
902
- # Each prints a scannable human view by default and machine JSON with --json;
903
- # all are advisory reads (exit 0). They share OKF::Bundle#catalog for their data,
904
- # and (with `types`) narrow through the same --type/--area/--tag filters the
905
- # server UI offers, so browser and CLI can answer the same questions.
906
-
907
- def catalog(argv)
908
- options = { json: false }
909
- parser = OptionParser.new do |o|
910
- o.banner = "Usage: okf catalog <dir|@slug> [--type T] [--area A] [--tag T] [--json]"
911
- json_flags(o, options, "emit the catalog as JSON")
912
- projection_flags(o, options)
913
- filter_flags(o, options, :type, :area, :tag)
914
- help_flag(o)
332
+ # The verbs this gem ships, frozen at seal time.
333
+ def builtins
334
+ (@builtins ||= []).dup.freeze
915
335
  end
916
- dir = positional_dir(parser, argv) or return 2
917
-
918
- folder = OKF::Bundle::Folder.load(dir)
919
- report_skipped(folder)
920
- entries = folder.catalog
921
- selected = filter_entries(entries, options)
922
- return print_catalog_json(dir, selected, options) if options[:json]
923
-
924
- print_catalog(dir, selected, entries.size)
925
- 0
926
- end
927
336
 
928
- def files(argv)
929
- options = { json: false }
930
- parser = OptionParser.new do |o|
931
- o.banner = "Usage: okf files <dir|@slug> [--type T] [--area A] [--tag T] [--json]"
932
- json_flags(o, options, "emit the file tree as JSON")
933
- projection_flags(o, options)
934
- filter_flags(o, options, :type, :area, :tag)
935
- help_flag(o)
337
+ def extension?(command)
338
+ !builtins.include?(command)
936
339
  end
937
- dir = positional_dir(parser, argv) or return 2
938
-
939
- folder = OKF::Bundle::Folder.load(dir)
940
- report_skipped(folder)
941
- entries = folder.catalog
942
- selected = filter_entries(entries, options)
943
- return print_files_json(dir, selected, options) if options[:json]
944
340
 
945
- print_files(dir, selected, entries.size)
946
- 0
947
- end
948
-
949
- def tags(argv)
950
- options = { json: false, by: nil }
951
- parser = OptionParser.new do |o|
952
- o.banner = "Usage: okf tags <dir|@slug> [--by type|area] [--type T] [--area A] [--json]"
953
- json_flags(o, options, "emit the tag index as JSON")
954
- o.on("--by DIM", %w[type area], "group the tags by a concept dimension (type | area)") { |v| options[:by] = v.to_sym }
955
- filter_flags(o, options, :type, :area)
956
- help_flag(o)
957
- end
958
- dir = positional_dir(parser, argv) or return 2
959
-
960
- return grouped_tags(dir, options) if options[:by]
961
-
962
- print_inverted_index(dir, "Tags", :tag, "tags", options)
963
- end
964
-
965
- def types(argv)
966
- options = { json: false }
967
- parser = OptionParser.new do |o|
968
- o.banner = "Usage: okf types <dir|@slug> [--area A] [--tag T] [--json]"
969
- json_flags(o, options, "emit the type index as JSON")
970
- filter_flags(o, options, :area, :tag)
971
- help_flag(o)
341
+ # Test seam: put the registry back to what shipped and forget the load
342
+ # latch. Registration happens at require time, so without this a test that
343
+ # installs a fake plugin would leak it into every test that runs after it.
344
+ #
345
+ # Dropping the files from $LOADED_FEATURES is not optional, and the reason
346
+ # is worth stating: `require` is idempotent, so clearing the registry
347
+ # alone leaves a plugin *unregistered and unloadable* — the next
348
+ # load_plugins would find the file, require it, get `false`, and register
349
+ # nothing. That only stays hidden while each test writes its plugin to a
350
+ # fresh tmpdir; the moment one points at a real gem's lib/, the verb
351
+ # vanishes after the first reset.
352
+ def reset_plugins!
353
+ Array(@loaded_plugins).each { |path| $LOADED_FEATURES.delete(path) }
354
+ @loaded_plugins = []
355
+ # Unconditional, because there is always a seal to roll back to:
356
+ # `seal_builtins!` runs at the bottom of this file, so a caller that can
357
+ # name this method has already loaded it. An earlier version guarded on
358
+ # `@builtins.nil?` to cover a pre-seal call — a state that cannot occur,
359
+ # and a test that could not have detected it either way, since `builtins`
360
+ # memoizes `@builtins ||= []` and so stops being nil on its first read.
361
+ @commands = builtins.dup
362
+ @plugins_loaded = false
363
+ @plugin_failures = []
364
+ @plugin_gem_error = nil
365
+ @plugin_discovery_error = nil
366
+ @untrusted_plugins = []
367
+ @declined = []
972
368
  end
973
- dir = positional_dir(parser, argv) or return 2
974
369
 
975
- print_inverted_index(dir, "Types", :type, "types", options)
976
- end
370
+ private
977
371
 
978
- # The shared back half of `tags` and `types`: load, narrow, print.
979
- def print_inverted_index(dir, label, key, plural, options)
980
- folder = OKF::Bundle::Folder.load(dir)
981
- report_skipped(folder)
982
- graph = folder.graph(minimal: true)
983
- index = key == :tag ? graph.tag_index : graph.type_index
984
- rows = index_rows(index, key, folder, options)
985
- if options[:json]
986
- print_index_json(dir, plural, key, rows)
987
- else
988
- titles = graph.nodes.map { |node| [ node[:id], node[:title] ] }.to_h
989
- print_index(dir, label, key, rows, titles)
372
+ def register_declined(command, existing)
373
+ (@declined ||= []) << [ command, existing ] unless existing.equal?(command)
374
+ existing
990
375
  end
991
- 0
992
- end
993
-
994
- # `tags --by type|area`: the tag index re-cut per concept type or top-level
995
- # area, with within-group counts — the curation view. A tag confined to one
996
- # group at count 1 is scattered; one recurring across groups is connective.
997
- # The --type/--area filters narrow the concepts first, then the grouping cuts.
998
- def grouped_tags(dir, options)
999
- folder = OKF::Bundle::Folder.load(dir)
1000
- report_skipped(folder)
1001
- graph = folder.graph(minimal: true)
1002
- titles = graph.nodes.map { |node| [ node[:id], node[:title] ] }.to_h
1003
- groups = tag_groups(graph.tag_index, folder, options)
1004
- options[:json] ? print_grouped_tags_json(dir, options[:by], groups) : print_grouped_tags(dir, options[:by], groups, titles)
1005
- 0
1006
376
  end
1007
377
 
1008
- # [ [ group, rows ], … ] — groups sorted by name, rows shaped like index_rows'.
1009
- # A tag carried in several groups appears in each, counted per group.
1010
- def tag_groups(tag_index, folder, options)
1011
- by_id = filter_entries(folder.catalog, options).map { |entry| [ entry[:id], entry ] }.to_h
1012
- groups = {}
1013
- tag_index.each do |tag, ids|
1014
- ids.each do |id|
1015
- entry = by_id[id]
1016
- next if entry.nil?
1017
-
1018
- key = options[:by] == :type ? entry_type(entry) : entry[:area]
1019
- ((groups[key] ||= {})[tag] ||= []) << id
1020
- end
1021
- end
1022
- groups.map do |key, tags|
1023
- rows = tags.map { |tag, ids| { tag: tag, count: ids.length, concepts: ids } }
1024
- .sort_by { |row| [ -row[:count], row[:tag] ] }
1025
- [ key, rows ]
1026
- end.sort_by(&:first)
378
+ def self.start(argv, out: $stdout, err: $stderr, input: $stdin)
379
+ new(out: out, err: err, input: input).run(argv)
1027
380
  end
1028
381
 
1029
- # A catalog entry's type for display — "Untyped" when blank, matching the graph.
1030
- def entry_type(entry)
1031
- OKF.blank?(entry[:type]) ? "Untyped" : entry[:type]
382
+ def initialize(out: $stdout, err: $stderr, runner: WEBRICK, input: $stdin)
383
+ @out = out
384
+ @err = err
385
+ @runner = runner
386
+ @input = input
387
+ @plugin_notes_reported = false
1032
388
  end
1033
389
 
1034
- def print_grouped_tags(dir, dim, groups, titles)
1035
- @out.puts "Tags — #{bundle_label(dir)} (#{distinct_tags(groups)} distinct, by #{dim})"
1036
- groups.each do |key, rows|
1037
- label = dim == :area && key != "(root)" ? "#{key}/" : key
1038
- @out.puts
1039
- @out.puts " #{label} (#{rows.size} #{pluralize(rows.size, "tag")})"
1040
- width = rows.map { |row| row[:tag].length }.max || 0
1041
- rows.each do |row|
1042
- names = row[:concepts].map { |id| titles[id] || id }.join(", ")
1043
- @out.puts " #{row[:tag].ljust(width)} #{row[:count].to_s.rjust(3)} #{truncate(names, 76)}"
390
+ def run(argv)
391
+ argv = argv.dup
392
+ # -h/--help is answered wherever a parser sees it — deep inside
393
+ # positional_dir, where returning would only mean "usage error, exit 2".
394
+ # Thrown here instead, so help keeps the contract every other path keeps:
395
+ # a status this method returns. See Command#help_flag.
396
+ catch(:help) do
397
+ case (name = argv.shift)
398
+ when "version", "--version", "-v" then @out.puts(OKF::VERSION); 0
399
+ when "help", "--help", "-h" then usage(@out); 0
400
+ when nil then usage(@err); 2
401
+ else dispatch(name, argv)
1044
402
  end
1045
403
  end
1046
404
  end
1047
405
 
1048
- def print_grouped_tags_json(dir, dim, groups)
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))
1053
- end
1054
-
1055
- def distinct_tags(groups)
1056
- groups.flat_map { |_, rows| rows.map { |row| row[:tag] } }.uniq.size
1057
- end
1058
-
1059
- def stats(argv)
1060
- options = { json: false }
1061
- parser = OptionParser.new do |o|
1062
- o.banner = "Usage: okf stats <dir|@slug> [--json]"
1063
- json_flags(o, options, "emit the stats as JSON")
1064
- help_flag(o)
1065
- end
1066
- dir = positional_dir(parser, argv) or return 2
1067
-
1068
- folder = OKF::Bundle::Folder.load(dir)
1069
- report_skipped(folder)
1070
- stats = bundle_stats(folder)
1071
- options[:json] ? print_stats_json(dir, stats) : print_stats(dir, stats)
1072
- 0
1073
- end
1074
-
1075
- # Bundle-level rollups derived from the catalog and the graph indexes.
1076
- def bundle_stats(folder)
1077
- graph = folder.graph(minimal: true)
1078
- entries = folder.catalog
1079
- by_type = graph.type_index.transform_values(&:size).sort_by { |_, n| -n }.to_h
1080
- by_area = entries.group_by { |entry| entry[:area] }.transform_values(&:size).sort_by { |_, n| -n }.to_h
1081
- {
1082
- concepts: entries.size,
1083
- areas: by_area.size,
1084
- types: by_type.size,
1085
- cross_links: graph.edges.size,
1086
- tags: graph.tag_index.size,
1087
- by_type: by_type,
1088
- by_area: by_area
1089
- }
1090
- end
1091
-
1092
- # ── the read views' shared --type/--area/--tag narrowing ──
1093
- # Each view takes the filters orthogonal to it (tags can't filter by tag).
1094
- # Matching is case-insensitive and exact; a concept at the bundle root lives in
1095
- # the "(root)" area, which --area also accepts as plain `root` (no shell quoting).
1096
-
1097
- # The --json / --pretty pair every emitting verb shares. --json is the compact
1098
- # machine substrate (the default JSON form, aligned with the server); --pretty
1099
- # indents it for a human and implies --json. Both route through emit_json.
1100
- def json_flags(parser, options, desc)
1101
- parser.on("--json", desc) { options[:json] = true }
1102
- parser.on("--pretty", "indent the JSON for reading (implies --json)") { options[:json] = true; @pretty = true }
1103
- end
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
-
1120
- # --fields/--except project the JSON down to the properties an agent wants, so it
1121
- # never pays tokens for fields it will not read. --fields is an allowlist,
1122
- # --except a denylist (mutually exclusive); both imply --json and apply per item
1123
- # in a list view (catalog, files, index). Names are the JSON keys, matched
1124
- # case-insensitively.
1125
- def projection_flags(parser, options)
1126
- parser.on("--fields LIST", Array, "emit only these JSON properties (comma-separated)") { |v| options[:json] = true; options[:fields] = v }
1127
- parser.on("--except LIST", Array, "emit every JSON property but these") { |v| options[:json] = true; options[:except] = v }
1128
- end
1129
-
1130
- def filter_flags(parser, options, *keys)
1131
- parser.on("--type TYPE", "only concepts of this type") { |v| options[:type] = v } if keys.include?(:type)
1132
- parser.on("--area AREA", "only concepts in this top-level area") { |v| options[:area] = v } if keys.include?(:area)
1133
- parser.on("--tag TAG", "only concepts carrying this tag") { |v| options[:tag] = v } if keys.include?(:tag)
1134
- end
406
+ private
1135
407
 
1136
- def filter_entries(entries, options)
1137
- entries.select do |entry|
1138
- (options[:type].nil? || fold(entry[:type]) == fold(options[:type])) &&
1139
- (options[:area].nil? || fold(entry[:area]) == fold_area(options[:area])) &&
1140
- (options[:tag].nil? || entry[:tags].any? { |tag| fold(tag) == fold(options[:tag]) })
408
+ # Built-ins answer without a plugin ever being loaded — the scan only
409
+ # happens once a name misses, which is every run of `okf lint` and no run
410
+ # of `okf tui`. Discovery is cheap (about 11ms on the 2.4 floor) but not
411
+ # free, and a one-shot CLI that already refuses to build a search index for
412
+ # a single query should not pay it to answer a verb it shipped with.
413
+ def dispatch(name, argv)
414
+ command = self.class.lookup(name) || begin
415
+ report_plugin_failures(self.class.load_plugins)
416
+ self.class.lookup(name)
1141
417
  end
1142
- end
1143
-
1144
- def fold(value)
1145
- value.to_s.downcase
1146
- end
1147
-
1148
- def fold_area(value)
1149
- folded = fold(value)
1150
- folded == "root" ? "(root)" : folded
1151
- end
418
+ return unknown(name) if command.nil?
1152
419
 
1153
- # Turn an inverted index ({ value => [id, …] }) into display rows ordered by
1154
- # count, narrowed to the concepts the active filters select; rows the narrowing
1155
- # empties drop. With no filters the index passes through whole.
1156
- def index_rows(index, key, folder, options)
1157
- keep = filter_ids(folder, options)
1158
- index.each_with_object([]) do |(value, ids), rows|
1159
- ids = ids.select { |id| keep.include?(id) } unless keep.nil?
1160
- rows << { key => value, count: ids.length, concepts: ids } unless ids.empty?
1161
- end.sort_by { |row| [ -row[:count], row[key] ] }
420
+ command.new(out: @out, err: @err, runner: @runner, input: @input).call(argv)
1162
421
  end
1163
422
 
1164
- # The ids the filters select, resolved through the catalog metadata — or nil
1165
- # when no filter is active, meaning keep everything.
1166
- def filter_ids(folder, options)
1167
- return nil if options[:type].nil? && options[:area].nil? && options[:tag].nil?
1168
-
1169
- filter_entries(folder.catalog, options).map { |entry| entry[:id] }
1170
- end
1171
-
1172
- # Install this gem's companion agent skill into a destination directory. The
1173
- # destination is required (no magic default) so the user always decides where
1174
- # their agent picks the skill up. By default the skill lands in a skills/okf/
1175
- # folder under it — point at a project or skills dir (.claude, .agents/skills)
1176
- # and it settles in its own folder, never loose among the others — so the
1177
- # resolved path is echoed back. --here installs straight into <dest-dir>.
1178
- def skill(argv)
1179
- options = { force: false, nest: true }
1180
- parser = OptionParser.new do |o|
1181
- o.banner = "Usage: okf skill <dest-dir> [--here] [--force]"
1182
- o.on("--here", "install straight into <dest-dir>, wherever it is (no skills/okf nesting)") { options[:nest] = false }
1183
- o.on("--force", "overwrite a non-empty destination") { options[:force] = true }
1184
- help_flag(o)
1185
- end
1186
- parser.parse!(argv)
1187
- dest = argv.shift
1188
- if dest.nil?
1189
- @err.puts parser.banner
1190
- return 2
1191
- end
1192
-
1193
- skill = OKF::Skill.new(dest, force: options[:force], nest: options[:nest])
1194
- files = skill.install
1195
- @out.puts "installed the okf skill (#{files.size} files) -> #{skill.dest}"
1196
- files.each { |f| @out.puts " #{f}" }
1197
- @out.puts "your agent picks it up from #{skill.dest} (needs the `okf` CLI, which you already have)."
1198
- 0
1199
- rescue OptionParser::ParseError => e
1200
- @err.puts e.message
423
+ def unknown(name)
424
+ @err.puts "okf: unknown command '#{name}'"
425
+ usage(@err)
1201
426
  2
1202
- rescue OKF::Skill::Error => e
1203
- @err.puts "error: #{e.message}"
1204
- 2
1205
- end
1206
-
1207
- # §9 best-effort: the graph is built from concepts that parse. Surface any that
1208
- # the reader could not parse (to stderr, so JSON on stdout stays clean) rather
1209
- # than dropping them silently.
1210
- def report_skipped(folder)
1211
- note_skipped(folder.bundle.unparseable.size)
1212
- end
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.
1218
- def note_skipped(count)
1219
- return if count.nil? || count <= 0
1220
-
1221
- @err.puts "note: skipped #{count} unusable file(s) (run `okf validate` for details)"
1222
427
  end
1223
428
 
1224
- # Turn a --stale-after value (90d, 12w, or an ISO date) into an absolute cutoff
1225
- # Time so the pure Linter never reads the clock. nil when unset, :invalid on a
1226
- # bad value.
1227
- def parse_stale_after(value)
1228
- return nil if value.nil?
1229
-
1230
- if (match = value.match(/\A(\d+)([dw])\z/))
1231
- days = match[1].to_i * (match[2] == "w" ? 7 : 1)
1232
- Time.now - (days * 86_400)
1233
- else
1234
- Date.iso8601(value).to_time
1235
- end
1236
- rescue ArgumentError
1237
- :invalid
1238
- end
1239
-
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.
1243
- def positional_dir(parser, argv)
1244
- parser.parse!(argv)
1245
- dir = argv.shift
1246
- if dir.nil?
1247
- @err.puts parser.banner
1248
- return nil
1249
- end
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"
1285
- return nil
1286
- end
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.
429
+ # On stderr, so a `--json` run's stdout stays a clean machine substrate even
430
+ # when an addon is broken — or when one was deliberately not run.
1310
431
  #
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
1386
- rescue OptionParser::ParseError => e
1387
- @err.puts e.message
1388
- nil
1389
- end
1390
-
1391
- def print_validation(dir, result)
1392
- counts = result.counts
1393
- @out.puts "OKF v0.1 conformance — #{bundle_label(dir)}"
1394
- @out.puts " concepts: #{counts[:concepts]} index.md: #{counts[:indexes]} log.md: #{counts[:logs]}"
1395
- result.errors.each { |e| @out.puts " #{paint("✗ ERROR", 31)} #{e[:path]}: #{e[:message]}" }
1396
- result.warnings.each { |w| @out.puts " #{paint("! warn", 33)} #{w[:path]}: #{w[:message]}" }
1397
- if result.valid? && result.warnings.empty?
1398
- @out.puts " #{paint("✓ conformant — no issues", 32)}"
1399
- elsif result.valid?
1400
- @out.puts " #{paint("✓ conformant", 32)} (#{result.warnings.size} warning(s))"
1401
- else
1402
- @out.puts " #{paint("✗ non-conformant", 31)} (#{result.errors.size} error(s))"
1403
- end
1404
- end
1405
-
1406
- def print_validation_json(dir, result)
1407
- emit_json(bundle_head(dir).merge(
1408
- "conformant" => result.valid?,
1409
- "counts" => result.counts,
1410
- "errors" => result.errors,
1411
- "warnings" => result.warnings
1412
- ))
1413
- end
1414
-
1415
- def print_lint(dir, report)
1416
- stats = report.stats
1417
- @out.puts "OKF lint — #{bundle_label(dir)}"
1418
- @out.puts " concepts: #{stats[:concepts]} edges: #{stats[:edges]} index.md: #{stats[:indexes]} log.md: #{stats[:logs]}"
1419
- summary = lint_summary(stats)
1420
- @out.puts " #{summary}" unless summary.empty?
1421
-
1422
- LINT_CATEGORIES.each do |name, checks|
1423
- findings = report.findings.select { |finding| checks.include?(finding[:check]) }
1424
- next if findings.empty?
1425
-
1426
- @out.puts
1427
- @out.puts " #{name}"
1428
- findings.each do |finding|
1429
- @out.puts " #{lint_glyph(finding)} #{[ finding[:path], finding[:message] ].compact.join(": ")}"
1430
- end
1431
- end
1432
-
1433
- @out.puts
1434
- @out.puts " #{lint_verdict(report)}"
1435
- end
1436
-
1437
- def print_lint_json(dir, report)
1438
- emit_json(bundle_head(dir).merge(
1439
- "healthy" => report.healthy?,
1440
- "stats" => report.stats,
1441
- "findings" => report.findings
1442
- ))
1443
- end
1444
-
1445
- # Degree-0 nodes as { id:, title:, dir: }, sorted by path — the same set lint's
1446
- # `unlinked` check reports, resolved to titles/folders for display.
1447
- def loose_files(graph)
1448
- titles = graph.nodes.map { |node| [ node[:id], node[:title] ] }.to_h
1449
- graph.unlinked_ids
1450
- .map { |id| { id: id, title: titles[id], dir: File.dirname("#{id}.md") } }
1451
- .sort_by { |file| file[:id] }
1452
- end
1453
-
1454
- def print_loose(dir, files)
1455
- @out.puts "Loose files — #{bundle_label(dir)} (#{files.size})"
1456
- if files.empty?
1457
- @out.puts " #{paint("✓ none — every concept links or is linked", 32)}"
1458
- return
1459
- end
1460
-
1461
- files.group_by { |file| file[:dir] }.sort_by(&:first).each do |folder, group|
1462
- width = group.map { |file| File.basename("#{file[:id]}.md").length }.max
1463
- @out.puts
1464
- @out.puts " #{folder == "." ? "(root)" : "#{folder}/"}"
1465
- group.each do |file|
1466
- @out.puts " #{File.basename("#{file[:id]}.md").ljust(width)} #{file[:title]}"
1467
- end
1468
- end
1469
- end
1470
-
1471
- def print_loose_json(dir, files)
1472
- emit_json(bundle_head(dir).merge(
1473
- "count" => files.size,
1474
- "loose" => files.map { |file| stringify(file) }
1475
- ))
1476
- end
1477
-
1478
- def print_catalog(dir, entries, total)
1479
- @out.puts "Catalog — #{bundle_label(dir)} (#{counted(entries.size, total, "concept")})"
1480
- entries.group_by { |entry| entry[:area] }.sort_by(&:first).each do |area, group|
1481
- @out.puts
1482
- @out.puts " #{area == "(root)" ? "(root)" : "#{area}/"} (#{group.size})"
1483
- group.each do |entry|
1484
- links = entry[:links_out] + entry[:links_in]
1485
- meta = [ entry[:type], (links.positive? ? "↳#{links}" : nil), entry[:status] ].compact.join(" · ")
1486
- @out.puts " #{entry[:title]} · #{meta}"
1487
- @out.puts " #{truncate(entry[:description], 92)}" unless entry[:description].empty?
1488
- end
1489
- end
1490
- end
1491
-
1492
- def print_catalog_json(dir, entries, options)
1493
- emit_list_json(dir, "concepts", entries.map { |entry| stringify(entry) }, options)
1494
- end
1495
-
1496
- def print_files(dir, entries, total)
1497
- @out.puts "Files — #{bundle_label(dir)} (#{counted(entries.size, total, "file")})"
1498
- entries.group_by { |entry| entry[:dir] }.sort_by(&:first).each do |folder, group|
1499
- width = group.map { |entry| File.basename("#{entry[:id]}.md").length }.max
1500
- @out.puts
1501
- @out.puts " #{folder == "." ? "(root)" : "#{folder}/"}"
1502
- group.each do |entry|
1503
- @out.puts " #{File.basename("#{entry[:id]}.md").ljust(width)} #{entry[:title]}"
432
+ # A refused extension is *louder* than a broken one on purpose. A gem that
433
+ # ships okf/plugin.rb under a name outside the okf- prefix is either an
434
+ # honest mistake somebody needs told about, or something that wanted to run
435
+ # code on a machine where nobody asked it to. Both want saying out loud.
436
+ # Once per run, not once per caller. An unknown verb reaches this twice —
437
+ # dispatch looks, misses, and then prints the map, which looks again — and a
438
+ # warning repeated is a warning that reads like two problems.
439
+ def report_plugin_failures(failures)
440
+ return if @plugin_notes_reported
441
+
442
+ @plugin_notes_reported = true
443
+ if (error = self.class.plugin_discovery_error)
444
+ @err.puts "okf: could not look for installed extensions (#{error.class}: #{error.message})"
445
+ @err.puts " none were loaded, so a verb an extension provides will read as unknown"
446
+ end
447
+ Array(failures).each do |path, error|
448
+ @err.puts "okf: extension at #{path} failed to load (#{error.class}: #{error.message})"
449
+ end
450
+ self.class.untrusted_plugins.each do |path, gem_name|
451
+ if gem_name == UNKNOWN_GEM
452
+ cause = self.class.plugin_gem_error
453
+ @err.puts "okf: ignoring the extension at #{path} — its owning gem could not be determined" \
454
+ "#{cause && " (#{cause.class}: #{cause.message})"}"
455
+ @err.puts " extensions are loaded only from gems named #{PLUGIN_GEM_PREFIX}*, and a name that cannot be read cannot be checked"
456
+ else
457
+ @err.puts "okf: ignoring an extension shipped by `#{gem_name}` (#{path})"
458
+ @err.puts " extensions are loaded only from gems named #{PLUGIN_GEM_PREFIX}*, since loading one runs its code"
1504
459
  end
1505
460
  end
1506
461
  end
1507
462
 
1508
- def print_files_json(dir, entries, options)
1509
- files = entries.map do |entry|
1510
- { "path" => "#{entry[:id]}.md", "id" => entry[:id], "dir" => entry[:dir], "type" => entry[:type], "title" => entry[:title],
1511
- "description" => entry[:description] }
1512
- end
1513
- emit_list_json(dir, "files", files, options)
1514
- end
1515
-
1516
- def print_index(dir, label, key, rows, titles)
1517
- @out.puts "#{label} — #{bundle_label(dir)} (#{rows.size} distinct)"
1518
- @out.puts
1519
- width = rows.map { |row| row[key].length }.max || 0
1520
- rows.each do |row|
1521
- names = row[:concepts].map { |id| titles[id] || id }.join(", ")
1522
- @out.puts " #{row[key].ljust(width)} #{row[:count].to_s.rjust(3)} #{truncate(names, 78)}"
1523
- end
1524
- end
1525
-
1526
- def print_index_json(dir, plural, key, rows)
1527
- emit_json(bundle_head(dir).merge("count" => rows.size, plural => index_rows_json(key, rows)))
1528
- end
1529
-
1530
- def index_rows_json(key, rows)
1531
- rows.map { |row| { key.to_s => row[key], "count" => row[:count], "concepts" => row[:concepts] } }
1532
- end
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.
1536
- def counted(size, 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"
1546
- end
1547
-
1548
- def print_stats(dir, stats)
1549
- @out.puts "Stats — #{bundle_label(dir)}"
1550
- @out.puts
1551
- @out.puts " concepts #{stats[:concepts]}"
1552
- @out.puts " areas #{stats[:areas]}"
1553
- @out.puts " concept types #{stats[:types]}"
1554
- @out.puts " cross-links #{stats[:cross_links]}"
1555
- @out.puts " distinct tags #{stats[:tags]}"
1556
- print_stat_breakdown("By type", stats[:by_type])
1557
- print_stat_breakdown("By area", stats[:by_area])
1558
- end
1559
-
1560
- def print_stat_breakdown(title, counts)
1561
- return if counts.empty?
1562
-
1563
- width = counts.keys.map(&:length).max
1564
- @out.puts
1565
- @out.puts " #{title}"
1566
- counts.each { |label, count| @out.puts " #{label.ljust(width)} #{count}" }
1567
- end
1568
-
1569
- def print_stats_json(dir, stats)
1570
- emit_json(bundle_head(dir).merge(
1571
- "concepts" => stats[:concepts], "areas" => stats[:areas],
1572
- "concept_types" => stats[:types], "cross_links" => stats[:cross_links], "distinct_tags" => stats[:tags],
1573
- "by_type" => stats[:by_type], "by_area" => stats[:by_area]
1574
- ))
1575
- end
1576
-
1577
- # The single JSON writer. Compact by default — the token-efficient substrate an
1578
- # agent consumes; --pretty indents it for a human. JSON semantics are identical
1579
- # either way, so a parser never cares which was emitted.
1580
- def emit_json(payload)
1581
- @out.puts(@pretty ? JSON.pretty_generate(payload) : JSON.generate(payload))
1582
- end
1583
-
1584
- # Emit a list view's JSON envelope with --fields/--except projection applied to
1585
- # each item. Returns the verb's exit code (0, or 2 on a bad projection request —
1586
- # both flags at once, or a field name no item carries).
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)
1593
- return usage_error("--fields and --except are mutually exclusive") if options[:fields] && options[:except]
1594
-
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?
1597
-
1598
- payload = (dir.is_a?(Hash) ? dir.dup : bundle_head(dir)).merge(extra)
1599
- payload["count"] = items.size
1600
- payload[key] = project(items, options)
1601
- emit_json(payload)
1602
- 0
1603
- end
1604
-
1605
- # Keep only --fields (allowlist) or drop --except (denylist) from each item's
1606
- # top-level properties; unset flags pass the items through whole.
1607
- def project(items, options)
1608
- return items if options[:fields].nil? && options[:except].nil?
1609
-
1610
- fields = options[:fields]&.map(&:downcase)
1611
- except = options[:except]&.map(&:downcase)
1612
- items.map do |item|
1613
- fields ? item.select { |k, _| fields.include?(k.to_s.downcase) } : item.reject { |k, _| except.include?(k.to_s.downcase) }
1614
- end
1615
- end
1616
-
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) : [])
1622
- end
1623
-
1624
- # Requested field names that no item actually carries — a typo guard (exit 2),
1625
- # matching how lint rejects unknown check names.
1626
- def unknown_fields(items, options, key = nil)
1627
- requested = (Array(options[:fields]) + Array(options[:except])).map(&:downcase)
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
1632
-
1633
- requested.reject { |field| known.include?(field) }.uniq
1634
- end
1635
-
1636
- def usage_error(message)
1637
- @err.puts "error: #{message}"
1638
- 2
1639
- end
1640
-
1641
- def stringify(hash)
1642
- hash.map { |key, value| [ key.to_s, value ] }.to_h
1643
- end
1644
-
1645
- def truncate(str, max)
1646
- str.length > max ? "#{str[0, max - 1]}…" : str
1647
- end
1648
-
1649
- def lint_summary(stats)
1650
- parts = []
1651
- hubs = stats[:hubs].map { |hub| "#{hub[:id]} (×#{hub[:in_degree]})" }.join(", ")
1652
- types = stats[:types].map { |type, count| "#{type} #{count}" }.join(", ")
1653
- parts << "hubs: #{hubs}" unless hubs.empty?
1654
- parts << "types: #{types}" unless types.empty?
1655
- parts.join(" ")
1656
- end
1657
-
1658
- def lint_glyph(finding)
1659
- finding[:severity] == :warn ? paint("! warn", 33) : "· info"
1660
- end
1661
-
1662
- def lint_verdict(report)
1663
- warnings = report.warnings.size
1664
- infos = report.info.size
1665
- return paint("✓ healthy — no issues", 32) if warnings.zero? && infos.zero?
1666
-
1667
- marker = warnings.zero? ? paint("✓", 32) : paint("⚠", 33)
1668
- "#{marker} #{warnings} warn, #{infos} info"
1669
- end
1670
-
1671
- def paint(text, code)
1672
- return text unless @out.respond_to?(:tty?) && @out.tty?
1673
-
1674
- "\e[#{code}m#{text}\e[0m"
1675
- end
1676
-
1677
463
  def usage(io)
1678
- io.puts <<~USAGE
1679
- okf <command> [options]
1680
-
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).
464
+ # Help is the one place that must know about every verb, so it is the one
465
+ # place besides an unknown name that pays for discovery.
466
+ report_plugin_failures(self.class.load_plugins)
467
+ io.puts "okf <command> [options]"
468
+ io.puts
469
+ GROUPS.each { |group, heading| print_group(io, group, heading) }
470
+ io.puts NOTE
471
+ end
1712
472
 
1713
- [filters] narrow a view to matching concepts: --type TYPE, --area AREA, --tag TAG
1714
- (each view takes the ones orthogonal to it; matching is case-insensitive).
1715
- tags --by DIM regroups the tags per concept dimension — type or area — with
1716
- within-group counts, the view for curating a tag vocabulary.
1717
- --json emits compact JSON (the machine substrate); add --pretty to indent it.
1718
- --fields / --except project the JSON to the properties you want (search/index/catalog/files).
473
+ def print_group(io, group, heading)
474
+ rows = self.class.commands.reject(&:hidden?).select { |command| command.group == group }.flat_map(&:help_rows)
475
+ return if rows.empty?
1719
476
 
1720
- okf --version
1721
- USAGE
477
+ io.puts heading if heading
478
+ rows.each { |left, desc| io.puts " #{left.to_s.ljust(56)}#{desc}" }
479
+ io.puts
1722
480
  end
1723
481
  end
1724
482
  end
483
+
484
+ require "okf/cli/command"
485
+
486
+ # ── These requires ARE the order `okf help` lists the verbs in ──
487
+ # Registration happens at load, `CLI.commands` is registration order, and the
488
+ # map walks the groups in GROUPS order and the verbs within a group in this
489
+ # one. Reordering these reorders the map. A test pins the result so the
490
+ # coupling cannot drift unnoticed, but the coupling is here, not there.
491
+ require "okf/cli/skill"
492
+ require "okf/cli/server"
493
+ require "okf/cli/render"
494
+ require "okf/cli/registry"
495
+ require "okf/cli/lint"
496
+ require "okf/cli/loose"
497
+ require "okf/cli/validate"
498
+ require "okf/cli/search"
499
+ require "okf/cli/index"
500
+ require "okf/cli/stats"
501
+ require "okf/cli/types"
502
+ require "okf/cli/tags"
503
+ require "okf/cli/files"
504
+ require "okf/cli/catalog"
505
+ require "okf/cli/graph"
506
+
507
+ # The line between what ships and what is installed. Everything above is a
508
+ # built-in; everything registered after this point came from a plugin.
509
+ OKF::CLI.seal_builtins!