okf 1.9.0 → 1.11.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 +696 -133
  3. data/README.md +250 -334
  4. data/lib/okf/bundle/folder.rb +24 -5
  5. data/lib/okf/bundle/linter.rb +1 -1
  6. data/lib/okf/bundle/search/index.rb +13 -3
  7. data/lib/okf/bundle/search.rb +91 -11
  8. data/lib/okf/bundle.rb +26 -2
  9. data/lib/okf/cli/catalog.rb +66 -0
  10. data/lib/okf/cli/command.rb +657 -0
  11. data/lib/okf/cli/dirs.rb +118 -0
  12. data/lib/okf/cli/files.rb +68 -0
  13. data/lib/okf/cli/graph.rb +82 -0
  14. data/lib/okf/cli/index.rb +169 -0
  15. data/lib/okf/cli/lint.rb +139 -0
  16. data/lib/okf/cli/loose.rb +78 -0
  17. data/lib/okf/cli/registry.rb +229 -0
  18. data/lib/okf/cli/render.rb +66 -0
  19. data/lib/okf/cli/search.rb +285 -0
  20. data/lib/okf/cli/server.rb +186 -0
  21. data/lib/okf/cli/skill.rb +57 -0
  22. data/lib/okf/cli/stats.rb +113 -0
  23. data/lib/okf/cli/tags.rb +144 -0
  24. data/lib/okf/cli/types.rb +37 -0
  25. data/lib/okf/cli/validate.rb +66 -0
  26. data/lib/okf/cli.rb +425 -1706
  27. data/lib/okf/render/graph/template.html.erb +1285 -129
  28. data/lib/okf/render/graph.rb +46 -2
  29. data/lib/okf/server/app.rb +71 -4
  30. data/lib/okf/server/hub/not_found.rb +663 -0
  31. data/lib/okf/server/hub.rb +512 -38
  32. data/lib/okf/skill/SKILL.md +26 -19
  33. data/lib/okf/skill/playbooks/consume.md +3 -3
  34. data/lib/okf/skill/playbooks/curate.md +3 -1
  35. data/lib/okf/skill/playbooks/maintain.md +7 -5
  36. data/lib/okf/skill/playbooks/menu.md +5 -0
  37. data/lib/okf/skill/playbooks/refine.md +93 -0
  38. data/lib/okf/skill/playbooks/search.md +7 -7
  39. data/lib/okf/skill/reference/cli.md +122 -22
  40. data/lib/okf/version.rb +1 -1
  41. data/lib/okf.rb +9 -0
  42. metadata +38 -8
@@ -0,0 +1,657 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+
5
+ module OKF
6
+ class CLI
7
+ # What every command inherits: the injected streams, and the shared surface
8
+ # a verb leans on — ref resolution, the flags several of them offer, the
9
+ # JSON emitters, the list-view printers.
10
+ #
11
+ # A command answers four questions about *itself* (.id, .group, .help_rows,
12
+ # .hidden?) and one about a *run* (#call, returning an exit status). That is
13
+ # the whole contract, and it is the same one a plugin implements — there is
14
+ # no second, lesser interface for an addon, because a seam only the base gem
15
+ # can use is not a seam.
16
+ #
17
+ # Privacy is the boundary, the one idea worth taking from Thor without
18
+ # taking Thor: #call is the entire public surface, so a helper added below
19
+ # can never become a verb by accident.
20
+ class Command
21
+ # What .register checks before admitting a command. Checked at
22
+ # registration rather than at dispatch, so a malformed addon fails where
23
+ # it is installed instead of the first time a user types its verb.
24
+ DUCK_TYPE = %i[id group help_rows hidden? new].freeze
25
+
26
+ class << self
27
+ # The verb this answers to, as a Symbol. The registry is keyed on it.
28
+ def id
29
+ raise NotImplementedError, "#{self}.id must name the verb it answers to"
30
+ end
31
+
32
+ # Where the verb sits in the map `okf help` prints. CLI::GROUPS fixes
33
+ # the order; anything else — which is what a plugin gets by default —
34
+ # falls to the end, under its own heading.
35
+ def group
36
+ :extension
37
+ end
38
+
39
+ # [ [ left-column, description ], … ] — one row per line of the map.
40
+ # A list rather than a pair because `registry` is an umbrella: five
41
+ # subcommands under one verb, each of which has to be findable alone.
42
+ def help_rows
43
+ []
44
+ end
45
+
46
+ # A command that works but is not advertised.
47
+ def hidden?
48
+ false
49
+ end
50
+ end
51
+
52
+ # `runner:` is the server's injected boot seam and `input:` the terminal
53
+ # a full-screen command needs. Both live here rather than on the two
54
+ # commands that want them, so construction is uniform: a plugin is built
55
+ # exactly the way a built-in is, and the CLI needs to know nothing about
56
+ # which is which.
57
+ def initialize(out:, err:, runner: nil, input: nil)
58
+ @out = out
59
+ @err = err
60
+ @runner = runner
61
+ @input = input
62
+ @pretty = false
63
+ @ref_slugs = {}
64
+ end
65
+
66
+ # The run. Returns the exit status; it never calls exit, and never writes
67
+ # anywhere but the injected streams.
68
+ def call(argv)
69
+ raise NotImplementedError, "#{self.class} must implement #call(argv) and return an exit status"
70
+ end
71
+
72
+ private
73
+
74
+ # The terminal, for a command that needs one. Nothing built in does —
75
+ # `okf` is a one-shot tool — but a full-screen addon cannot work without
76
+ # it, and reaching for $stdin behind the CLI's back would put a command
77
+ # outside the stream injection every test depends on.
78
+ attr_reader :input
79
+
80
+ # Which slug each @ref resolved to, by absolute path — so a hub built from
81
+ # refs mounts each bundle under its registered slug, not its dir basename.
82
+ # Per command instance, which is per run: a command is built fresh for each
83
+ # dispatch, so the memo cannot outlive the argv that filled it. (It used to
84
+ # need clearing by hand at the top of #run; one command object per run is
85
+ # what retired that.)
86
+ attr_reader :ref_slugs
87
+
88
+ # `@all` is a ref, not a flag, and only `search` expands it — but the
89
+ # refusal the other verbs give lives in the shared resolver, so the
90
+ # recognizer has to be shared too.
91
+ def all_ref?(ref)
92
+ require "okf/registry"
93
+ OKF::Registry.normalize(ref[1..-1]) == ALL_REF[1..-1]
94
+ end
95
+
96
+ # A registered bundle whose directory cannot be read, noted and skipped.
97
+ # Shared because both the verbs that tolerate a gap — `search @all` and the
98
+ # bundle-less `server` — have to skip it the same way, and say so the same
99
+ # way. A named @slug never lands here: it fails hard instead.
100
+ def skip_registered(entry)
101
+ @err.puts "note: skipping #{entry.slug} — cannot read #{entry.path}"
102
+ nil
103
+ end
104
+
105
+ # Nothing left over. The registry subcommands each take a fixed number of
106
+ # positionals, and so does every `<dir>` verb through positional_dir — a
107
+ # trailing argument means the command was misunderstood, not that it can be
108
+ # answered anyway.
109
+ def no_extras?(argv)
110
+ return true if argv.empty?
111
+
112
+ @err.puts "error: unexpected argument '#{argv.first}'"
113
+ false
114
+ end
115
+
116
+ # ── the read views ──
117
+ # The Catalog / Files / Tags / Stats views the server renders in the browser,
118
+ # reproduced on the CLI so an agent can read the same knowledge without one.
119
+ # Each prints a scannable human view by default and machine JSON with --json;
120
+ # all are advisory reads (exit 0). They share OKF::Bundle#catalog for their data,
121
+ # and (with `types`) narrow through the same --type/--dir/--tag filters the
122
+ # server UI offers, so browser and CLI can answer the same questions.
123
+ #
124
+ # ── their shared --type/--dir/--tag narrowing ──
125
+ # Each view takes the filters orthogonal to it (tags can't filter by tag).
126
+ # Matching is case-insensitive; --type and --tag are exact, --dir is a prefix
127
+ # over the whole path (see #under_dir?). The bundle root is `.`, spellable
128
+ # `root` so no shell quoting is needed. --area is --dir's deprecated
129
+ # predecessor and keeps its old first-segment-only behavior.
130
+
131
+ # The shared back half of `tags` and `types`: load, narrow, print.
132
+ def print_inverted_index(dir, label, key, plural, options)
133
+ folder = OKF::Bundle::Folder.load(dir)
134
+ report_skipped(folder)
135
+ graph = folder.graph(minimal: true)
136
+ index = key == :tag ? graph.tag_index : graph.type_index
137
+ rows = index_rows(index, key, folder, options)
138
+ if options[:json]
139
+ print_index_json(dir, plural, key, rows)
140
+ else
141
+ titles = graph.nodes.map { |node| [ node[:id], node[:title] ] }.to_h
142
+ print_index(dir, label, key, rows, titles)
143
+ end
144
+ 0
145
+ end
146
+
147
+ # The --json / --pretty pair every emitting verb shares. --json is the compact
148
+ # machine substrate (the default JSON form, aligned with the server); --pretty
149
+ # indents it for a human and implies --json. Both route through emit_json.
150
+ def json_flags(parser, options, desc)
151
+ parser.on("--json", desc) { options[:json] = true }
152
+ parser.on("--pretty", "indent the JSON for reading (implies --json)") { options[:json] = true; @pretty = true }
153
+ end
154
+
155
+ # Every parser answers its own -h/--help, so no parser inherits
156
+ # OptionParser's officious one: that prints to the process's $stdout rather
157
+ # than @out (an embedding app that injects streams never sees it) and ends
158
+ # the process with `exit` rather than returning a status (a test that asks a
159
+ # command for help takes the whole runner down with it). Thrown, not
160
+ # returned — #run catches it — because a parser is parsed inside
161
+ # positional_dir, where every other early exit means "exit 2".
162
+ # on_tail, so help sorts last in the list it is printing.
163
+ def help_flag(parser)
164
+ parser.on_tail("-h", "--help", "print this message") do
165
+ @out.puts parser.help
166
+ throw :help, 0
167
+ end
168
+ end
169
+
170
+ # --fields/--except project the JSON down to the properties an agent wants, so it
171
+ # never pays tokens for fields it will not read. --fields is an allowlist,
172
+ # --except a denylist (mutually exclusive); both imply --json and apply per item
173
+ # in a list view (catalog, files, index). Names are the JSON keys, matched
174
+ # case-insensitively.
175
+ def projection_flags(parser, options)
176
+ parser.on("--fields LIST", Array, "emit only these JSON properties (comma-separated)") { |v| options[:json] = true; options[:fields] = v }
177
+ parser.on("--except LIST", Array, "emit every JSON property but these") { |v| options[:json] = true; options[:except] = v }
178
+ end
179
+
180
+ def filter_flags(parser, options, *keys)
181
+ parser.on("--type TYPE", "only concepts of this type") { |v| options[:type] = v } if keys.include?(:type)
182
+ if keys.include?(:area)
183
+ parser.on("--dir PATH", "only concepts in this directory or below it",
184
+ "(`root` — or `.` — for the bundle root)") { |v| options[:dir] = v }
185
+ parser.on("--area AREA", "deprecated: use --dir (matches the first path segment only)") do |v|
186
+ options[:area] = v
187
+ deprecated("--area", "--dir")
188
+ end
189
+ end
190
+ parser.on("--tag TAG", "only concepts carrying this tag") { |v| options[:tag] = v } if keys.include?(:tag)
191
+ end
192
+
193
+ def filter_entries(entries, options)
194
+ entries.select do |entry|
195
+ (options[:type].nil? || fold(entry[:type]) == fold(options[:type])) &&
196
+ (options[:area].nil? || fold(entry[:area]) == fold_area(options[:area])) &&
197
+ (options[:dir].nil? || under_dir?(entry[:dir], options[:dir])) &&
198
+ (options[:tag].nil? || entry[:tags].any? { |tag| fold(tag) == fold(options[:tag]) })
199
+ end
200
+ end
201
+
202
+ # The one rule --dir is built on: a dir names itself and everything beneath
203
+ # it. `--dir foo` reaches foo/bar, `--dir foo/bar` narrows, and `--dir .`
204
+ # needs no special case at all — nothing starts with "./", so the root
205
+ # selects only what lives directly in it.
206
+ def under_dir?(entry_dir, wanted)
207
+ entry = fold(entry_dir)
208
+ path = fold_dir(wanted)
209
+ entry == path || entry.start_with?("#{path}/")
210
+ end
211
+
212
+ def fold(value)
213
+ value.to_s.downcase
214
+ end
215
+
216
+ def fold_area(value)
217
+ folded = trim_slash(fold(value))
218
+ folded == "root" ? "(root)" : folded
219
+ end
220
+
221
+ # `.` is the stored spelling of the root everywhere; `root` is the one a
222
+ # shell needs no quoting for, and the only reason the two exist.
223
+ def fold_dir(value)
224
+ folded = trim_slash(fold(value))
225
+ folded.empty? || folded == "root" ? "." : folded
226
+ end
227
+
228
+ # The human views print a directory with the slash that says it is one —
229
+ # `tables/`, `docs/api/` — so the flag has to accept the label the CLI
230
+ # itself just printed. Without this, pasting a row back into --dir matched
231
+ # nothing and exited 0: an empty answer that reads like a real one.
232
+ def trim_slash(value)
233
+ value.sub(%r{/+\z}, "")
234
+ end
235
+
236
+ # The inverse of fold_dir: `.` is the stored spelling of the root and
237
+ # "(root)" the human one, and every grouped view keeps that split so a
238
+ # table and its --json never disagree about which spelling is the data.
239
+ # `slash:` adds the trailing slash the listing views use to say "directory"
240
+ # — the same one fold_dir now accepts back.
241
+ def dir_label(dir, slash: false)
242
+ return "(root)" if [ ".", "(root)" ].include?(dir)
243
+
244
+ slash ? "#{dir}/" : dir
245
+ end
246
+
247
+ # How many path segments deep a directory sits. The root is 0.
248
+ def dir_depth(dir)
249
+ dir == "." ? 0 : dir.count("/") + 1
250
+ end
251
+
252
+ # --depth N: how many directory levels below the *starting point* to keep,
253
+ # where the starting point is each --dir when one is given and the bundle
254
+ # root otherwise. Relative rather than absolute on purpose: `--dir a/b
255
+ # --depth 1` reads "a/b and one level under it" without the caller first
256
+ # working out how deep a/b already is — and the two flags then compose the
257
+ # way a reader descending a tree actually moves.
258
+ def depth_flag(parser, options)
259
+ parser.on("--depth N", "keep only this many directory levels below the",
260
+ "starting point (--dir when given, else the bundle root)") { |v| options[:depth] = v }
261
+ end
262
+
263
+ # Checked here rather than with OptionParser's Integer coercion, which
264
+ # accepts "-1" and "0x2" and reports in its own words. Returns the exit
265
+ # status to hand back, or nil when the value is fine.
266
+ def depth_error(options)
267
+ raw = options[:depth]
268
+ return nil if raw.nil? || raw.to_s =~ /\A\d+\z/
269
+
270
+ usage_error("--depth takes a whole number of levels (got #{raw.inspect})")
271
+ end
272
+
273
+ # The chain from the bundle root down to each --dir, so a branch is never
274
+ # shown adrift. On by default in the *directory* views (`index`, `dirs`):
275
+ # the map's job there is orientation, and a subtree printed with nothing
276
+ # above it has dropped the authored context that says what it is — the root
277
+ # index.md's prose first among it. Off with --no-ancestors, which restores
278
+ # the subtree alone.
279
+ #
280
+ # Deliberately not offered on the concept filters (search/catalog/files/…):
281
+ # there --dir narrows *concepts*, and a concept in `a/` is simply not in
282
+ # `a/b`. Same flag, one meaning, because it is asked about two different
283
+ # kinds of row.
284
+ def ancestors_flag(parser, options)
285
+ parser.on("--[no-]ancestors", "with --dir, also show the chain up to the root",
286
+ "so the branch is placed (default: yes)") { |v| options[:ancestors] = v }
287
+ end
288
+
289
+ # Every proper ancestor of each --dir, root included. Empty unless --dir
290
+ # named something below the root: with no --dir the whole bundle is already
291
+ # the starting point, and `--dir .` has nothing above it.
292
+ #
293
+ # `known` is the map's own directory list, and a base outside it contributes
294
+ # no chain. Without that check `--dir typo` came back with the root — a
295
+ # chain to a place that does not exist, which reads as a partial answer to
296
+ # a query that in fact matched nothing.
297
+ #
298
+ # The deprecated --area gains no chain either: it is exact, and a deprecated
299
+ # flag that quietly answers with more than it used to is worse than one that
300
+ # is merely old.
301
+ # Matching folds case, but a row is found by its *stored* spelling, so the
302
+ # chain is walked folded and handed back in the map's own words. Returning
303
+ # the folded string instead dropped every ancestor a bundle spelled with a
304
+ # capital — the rows are selected with `include?`, which does not fold.
305
+ def ancestor_dirs(options, known)
306
+ return [] unless options[:ancestors]
307
+
308
+ stored = known.each_with_object({}) { |dir, out| out[fold(dir)] = dir }
309
+ Array(options[:dirs]).each_with_object([]) do |path, out|
310
+ base = fold_dir(path)
311
+ next unless stored.key?(base)
312
+
313
+ current = dir_parent(base)
314
+ while current
315
+ out << stored.fetch(current, current)
316
+ current = dir_parent(current)
317
+ end
318
+ end.uniq
319
+ end
320
+
321
+ # nil above the root, so the walk above terminates on it rather than on ".".
322
+ def dir_parent(dir)
323
+ return nil if dir == "."
324
+
325
+ slash = dir.rindex("/")
326
+ slash ? dir[0, slash] : "."
327
+ end
328
+
329
+ # The directories a --dir/--depth pair selects, out of the map's own
330
+ # ordered list. Neither flag given keeps everything — these narrow a view,
331
+ # they do not define one. The ancestor chain is unioned on top by the
332
+ # caller, which is also what tells a row apart from context.
333
+ def select_dirs(dirs, options)
334
+ bases = Array(options[:dirs]).map { |path| fold_dir(path) }
335
+ depth = options[:depth]&.to_i
336
+ return dirs if bases.empty? && depth.nil?
337
+ # No --dir means the whole bundle is the starting point, which is *not*
338
+ # `--dir .`: that one selects the root alone, by the same prefix rule
339
+ # everything else here uses.
340
+ return dirs.select { |dir| dir_depth(dir) <= depth } if bases.empty?
341
+
342
+ dirs.select do |dir|
343
+ bases.any? do |base|
344
+ # --depth bounds the *descent*; the chain above is the ascent, and the
345
+ # two are separate axes. That is what keeps `--depth 0` meaning "the
346
+ # named directory alone" even while its chain is printed with it.
347
+ under_dir?(dir, base) && (depth.nil? || dir_depth(dir) - dir_depth(base) <= depth)
348
+ end
349
+ end
350
+ end
351
+
352
+ # A deprecated spelling still does what it always did — never silently
353
+ # something else — and says so once per run, on stderr so a --json
354
+ # consumer's stdout stays a clean machine substrate.
355
+ def deprecated(what, instead)
356
+ @deprecated ||= {}
357
+ return if @deprecated[what]
358
+
359
+ @deprecated[what] = true
360
+ @err.puts "warning: #{what} is deprecated, use #{instead}"
361
+ end
362
+
363
+ # Turn an inverted index ({ value => [id, …] }) into display rows ordered by
364
+ # count, narrowed to the concepts the active filters select; rows the narrowing
365
+ # empties drop. With no filters the index passes through whole.
366
+ def index_rows(index, key, folder, options)
367
+ keep = filter_ids(folder, options)
368
+ index.each_with_object([]) do |(value, ids), rows|
369
+ ids = ids.select { |id| keep.include?(id) } unless keep.nil?
370
+ rows << { key => value, count: ids.length, concepts: ids } unless ids.empty?
371
+ end.sort_by { |row| [ -row[:count], row[key] ] }
372
+ end
373
+
374
+ # The ids the filters select, resolved through the catalog metadata — or nil
375
+ # when no filter is active, meaning keep everything.
376
+ def filter_ids(folder, options)
377
+ return nil if options[:type].nil? && options[:area].nil? && options[:dir].nil? && options[:tag].nil?
378
+
379
+ filter_entries(folder.catalog, options).map { |entry| entry[:id] }
380
+ end
381
+
382
+ # §9 best-effort: the graph is built from concepts that parse. Surface any that
383
+ # the reader could not parse (to stderr, so JSON on stdout stays clean) rather
384
+ # than dropping them silently.
385
+ def report_skipped(folder)
386
+ note_skipped(folder.bundle.unparseable.size)
387
+ end
388
+
389
+ # The bucket holds two kinds now — frontmatter that would not parse, and a
390
+ # file that would not open — so the note names neither and points at the verb
391
+ # that names both. "invalid frontmatter" was a guess the summary had no need
392
+ # to make: `validate` prints the file and the reason for every one of them.
393
+ def note_skipped(count)
394
+ return if count.nil? || count <= 0
395
+
396
+ @err.puts "note: skipped #{count} unusable file(s) (run `okf validate` for details)"
397
+ end
398
+
399
+ # Parse options, then require a single bundle positional — a directory, or an
400
+ # @ref into the registry. Returns the bundle's directory, or nil (after
401
+ # reporting) so the caller returns 2.
402
+ def positional_dir(parser, argv)
403
+ parser.parse!(argv)
404
+ dir = argv.shift
405
+ if dir.nil?
406
+ @err.puts parser.banner
407
+ return nil
408
+ end
409
+ # A second bundle is a question this verb cannot answer: only `search`
410
+ # merges across bundles and only `server` mounts several. Reading the
411
+ # first and dropping the rest would answer confidently about a bundle the
412
+ # user never asked about — the silent-wrong-answer shape, so: exit 2.
413
+ return nil unless no_extras?(argv)
414
+
415
+ resolve_ref(dir)
416
+ rescue OptionParser::ParseError => e
417
+ @err.puts e.message
418
+ nil
419
+ end
420
+
421
+ # Parse options, then take zero or more bundle positionals (the multi-bundle
422
+ # server) — directories or @refs. Returns the resolved array (possibly
423
+ # empty), or nil (after reporting) so the caller returns 2.
424
+ def positional_dirs(parser, argv)
425
+ parser.parse!(argv)
426
+ dirs = argv.map { |dir| resolve_ref(dir) }
427
+ dirs.include?(nil) ? nil : dirs
428
+ rescue OptionParser::ParseError => e
429
+ @err.puts e.message
430
+ nil
431
+ end
432
+
433
+ # "@slug" — or bare "@", the registry's default — names a registered bundle
434
+ # wherever a <dir> goes; anything else must be a directory on disk. A
435
+ # leading @ always means the registry (a directory literally named that way
436
+ # stays reachable as ./@name), and the registry loads only when a ref
437
+ # appears, so plain-dir invocations never pay for it. Returns the bundle's
438
+ # directory, or nil after reporting.
439
+ def resolve_ref(arg)
440
+ return resolve_registered(arg) if arg.start_with?("@")
441
+
442
+ unless File.directory?(arg)
443
+ @err.puts "error: #{arg} is not a directory or a registry ref " \
444
+ "(@slug names a registered bundle, @ the default; okf registry list)"
445
+ return nil
446
+ end
447
+ arg
448
+ end
449
+
450
+ # Load the registry, turning a malformed file into a reported usage error
451
+ # instead of an OKF::Error escaping through whatever verb took an @ref —
452
+ # only `server` and the `registry` verbs rescue one. Returns nil after
453
+ # reporting, so every caller returns 2.
454
+ def load_registry
455
+ require "okf/registry"
456
+ OKF::Registry.load
457
+ rescue OKF::Error => e
458
+ @err.puts "error: #{e.message}"
459
+ nil
460
+ end
461
+
462
+ # Resolve one @ref through the registry under $OKF_HOME (default ~/.okf).
463
+ # The slug part is normalized
464
+ # exactly as registration normalized it, so @One finds the bundle
465
+ # registered from dir One — but never through #slugify's mint-a-name
466
+ # placeholder, so "@***" is a bad ref rather than whatever is slugged
467
+ # "bundle". An explicit ask fails hard: an unknown slug or a
468
+ # registered-but-gone directory is a usage error naming the registry file
469
+ # and the next move, never a silent skip.
470
+ #
471
+ # @all never resolves here. `search` expands it before this point; every
472
+ # other verb takes exactly one bundle, so letting it through would mean
473
+ # @all lints when one bundle is registered and exits 2 when two are —
474
+ # behavior that varies with the size of the registry, which is the
475
+ # silent-wrong-answer shape the second-bundle rule exists to stop. Say what
476
+ # @all is instead of calling it a bundle nobody registered ("all" cannot be
477
+ # registered — Registry::RESERVED_SLUGS sees to that).
478
+ def resolve_registered(ref)
479
+ @ref_failure = :registry
480
+ if all_ref?(ref)
481
+ @err.puts "error: #{ALL_REF} is only supported by `okf search` (it names every registered bundle)"
482
+ return nil
483
+ end
484
+ registry = load_registry
485
+ return nil unless registry
486
+
487
+ asked = ref[1..-1]
488
+ slug = OKF::Registry.normalize(asked)
489
+ entry = if asked.empty?
490
+ registry.default # bare "@"
491
+ elsif slug.empty?
492
+ nil # "@***" — nothing to look up, and no placeholder to fall back on
493
+ else
494
+ registry.get(slug)
495
+ end
496
+ if entry.nil?
497
+ @ref_failure = :unknown
498
+ hint = registry.empty? ? "okf registry set <dir>" : "okf registry list"
499
+ @err.puts "error: not a registered bundle: #{ref} in #{registry.path} (#{hint})"
500
+ return nil
501
+ end
502
+ unless File.directory?(entry.path)
503
+ @ref_failure = :missing
504
+ @err.puts "error: #{ref} points to #{entry.path}, which is not a directory (okf registry del #{entry.slug}, or restore it)"
505
+ return nil
506
+ end
507
+ ref_slugs[entry.path] = entry.slug
508
+ entry.path
509
+ end
510
+
511
+ # Every bundle-scoped output names its bundle in the identity the caller
512
+ # used: `@handbook (/path)` when they named a registered bundle, the plain
513
+ # path otherwise. A dir named by path stays a path — inventing a slug for it
514
+ # would imply a registration that does not exist, and looking one up would
515
+ # cost a registry read on every plain-dir run.
516
+ def bundle_label(dir)
517
+ slug = ref_slugs[dir]
518
+ slug ? "@#{slug} (#{dir})" : dir.to_s
519
+ end
520
+
521
+ # The JSON head for one bundle. `bundle` is always its directory and `slug`
522
+ # always a registry slug — never the same key meaning two things — so a
523
+ # consumer resolves a row to a file without a second lookup.
524
+ def bundle_head(dir)
525
+ head = { "bundle" => dir }
526
+ slug = ref_slugs[dir]
527
+ head["slug"] = slug if slug
528
+ head
529
+ end
530
+
531
+ # Parse options, then require a single non-directory positional (e.g. a slug).
532
+ # Returns it, or nil (after reporting the banner) so the caller returns 2.
533
+ def positional(parser, argv)
534
+ parser.parse!(argv)
535
+ value = argv.shift
536
+ if value.nil?
537
+ @err.puts parser.banner
538
+ return nil
539
+ end
540
+ value
541
+ rescue OptionParser::ParseError => e
542
+ @err.puts e.message
543
+ nil
544
+ end
545
+
546
+ def print_index(dir, label, key, rows, titles)
547
+ @out.puts "#{label} — #{bundle_label(dir)} (#{rows.size} distinct)"
548
+ @out.puts
549
+ width = rows.map { |row| row[key].length }.max || 0
550
+ rows.each do |row|
551
+ names = row[:concepts].map { |id| titles[id] || id }.join(", ")
552
+ @out.puts " #{row[key].ljust(width)} #{row[:count].to_s.rjust(3)} #{truncate(names, 78)}"
553
+ end
554
+ end
555
+
556
+ def print_index_json(dir, plural, key, rows)
557
+ emit_json(bundle_head(dir).merge("count" => rows.size, plural => index_rows_json(key, rows)))
558
+ end
559
+
560
+ def index_rows_json(key, rows)
561
+ rows.map { |row| { key.to_s => row[key], "count" => row[:count], "concepts" => row[:concepts] } }
562
+ end
563
+
564
+ # "3 concepts", "1 concept", "1 of 7 concepts" — the noun agrees with the
565
+ # number it follows: the size when that is all we show, the total otherwise.
566
+ def counted(size, total, noun)
567
+ return "#{size} #{pluralize(size, noun)}" if size == total
568
+
569
+ "#{size} of #{total} #{pluralize(total, noun)}"
570
+ end
571
+
572
+ # The gem's whole vocabulary is regular, so a naive +s is not a shortcut —
573
+ # it is the rule. Callers pass the singular.
574
+ def pluralize(count, noun)
575
+ count == 1 ? noun : "#{noun}s"
576
+ end
577
+
578
+ # The single JSON writer. Compact by default — the token-efficient substrate an
579
+ # agent consumes; --pretty indents it for a human. JSON semantics are identical
580
+ # either way, so a parser never cares which was emitted.
581
+ def emit_json(payload)
582
+ @out.puts(@pretty ? JSON.pretty_generate(payload) : JSON.generate(payload))
583
+ end
584
+
585
+ # Emit a list view's JSON envelope with --fields/--except projection applied to
586
+ # each item. Returns the verb's exit code (0, or 2 on a bad projection request —
587
+ # both flags at once, or a field name no item carries).
588
+ # +dir+ is the bundle's directory — or a ready-made head Hash when the
589
+ # payload spans bundles (multi-bundle search's "bundles" key).
590
+ # +key+ names the JSON property the rows land under; +shape+ names the row
591
+ # shape to check --fields/--except against. They are the same for every view
592
+ # but search, whose two modes emit the same property from different rows.
593
+ def emit_list_json(dir, key, items, options, extra = {}, shape = key)
594
+ return usage_error("--fields and --except are mutually exclusive") if options[:fields] && options[:except]
595
+
596
+ unknown = unknown_fields(items, options, shape)
597
+ return usage_error("unknown field(s): #{unknown.join(", ")} (available: #{available_fields(items, shape).join(", ")})") unless unknown.empty?
598
+
599
+ payload = (dir.is_a?(Hash) ? dir.dup : bundle_head(dir)).merge(extra)
600
+ payload["count"] = items.size
601
+ payload[key] = project(items, options)
602
+ emit_json(payload)
603
+ 0
604
+ end
605
+
606
+ # Keep only --fields (allowlist) or drop --except (denylist) from each item's
607
+ # top-level properties; unset flags pass the items through whole.
608
+ def project(items, options)
609
+ return items if options[:fields].nil? && options[:except].nil?
610
+
611
+ fields = options[:fields]&.map(&:downcase)
612
+ except = options[:except]&.map(&:downcase)
613
+ items.map do |item|
614
+ fields ? item.select { |k, _| fields.include?(k.to_s.downcase) } : item.reject { |k, _| except.include?(k.to_s.downcase) }
615
+ end
616
+ end
617
+
618
+ # The declared shape wins over the data's, so the same typo gets the same
619
+ # answer whether or not the result happened to have rows; a view with no
620
+ # declared shape falls back to what it actually emitted.
621
+ def available_fields(items, key = nil)
622
+ ROW_FIELDS[key] || (items.first ? items.first.keys.map(&:to_s) : [])
623
+ end
624
+
625
+ # Requested field names that no item actually carries — a typo guard (exit 2),
626
+ # matching how lint rejects unknown check names.
627
+ def unknown_fields(items, options, key = nil)
628
+ requested = (Array(options[:fields]) + Array(options[:except])).map(&:downcase)
629
+ return [] if requested.empty?
630
+
631
+ known = available_fields(items, key).map(&:downcase)
632
+ return [] if known.empty? # an unknown view: no shape to check against, so accept
633
+
634
+ requested.reject { |field| known.include?(field) }.uniq
635
+ end
636
+
637
+ def usage_error(message)
638
+ @err.puts "error: #{message}"
639
+ 2
640
+ end
641
+
642
+ def stringify(hash)
643
+ hash.map { |key, value| [ key.to_s, value ] }.to_h
644
+ end
645
+
646
+ def truncate(str, max)
647
+ str.length > max ? "#{str[0, max - 1]}…" : str
648
+ end
649
+
650
+ def paint(text, code)
651
+ return text unless @out.respond_to?(:tty?) && @out.tty?
652
+
653
+ "\e[#{code}m#{text}\e[0m"
654
+ end
655
+ end
656
+ end
657
+ end