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,229 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OKF
4
+ class CLI
5
+ # The registry umbrella, split by what each verb keys on. `set`/`del`/`list`
6
+ # act on entries — `set` keys on the bundle's path, so --as means one thing
7
+ # ("the slug this entry has") whether it adds or renames. `default`/`rename`
8
+ # act on slugs, the names actually to hand once a bundle is registered. Every
9
+ # positional stays unambiguous, and `config` is left free for real settings.
10
+ class Registry < Command
11
+ # The `registry` umbrella's subcommands — the dispatch, and the words a
12
+ # flag-first invocation is checked against.
13
+ SUBCOMMANDS = %w[set del list default rename].freeze
14
+
15
+ def self.id
16
+ :registry
17
+ end
18
+
19
+ def self.group
20
+ :registry
21
+ end
22
+
23
+ def self.help_rows
24
+ [
25
+ [ "registry list [--json]", "list registered bundles (* marks the default)" ],
26
+ [ "registry set <dir|@slug> [--as SLUG] [--default]", "add or update a bundle (a bare `server` serves them)" ],
27
+ [ "registry del <dir|@slug>", "remove a bundle from the registry" ],
28
+ [ "registry default <@slug>", "move a bundle to the front (the default)" ],
29
+ [ "registry rename <@slug> <new>", "rename a registered bundle (<new> is a new name, not a ref)" ]
30
+ ]
31
+ end
32
+
33
+ def call(argv)
34
+ require "okf/registry"
35
+
36
+ sub = argv.first
37
+ case sub
38
+ when "set" then registry_set(argv.drop(1))
39
+ when "del" then registry_del(argv.drop(1))
40
+ when "list" then registry_list(argv.drop(1))
41
+ when "default" then registry_default(argv.drop(1))
42
+ when "rename" then registry_rename(argv.drop(1))
43
+ else
44
+ # A bare word that isn't a known subcommand is a typo (`registry remove x`
45
+ # must not silently render the list and read as success).
46
+ return usage_error("unknown registry subcommand '#{sub}' (expected: #{SUBCOMMANDS.join(", ")})") if sub && !sub.start_with?("-")
47
+
48
+ # Same rule for a subcommand hiding behind a flag: `registry --json set
49
+ # dir` would otherwise list an empty registry and exit 0, having written
50
+ # nothing the user asked for. It cannot just be dispatched from wherever
51
+ # it turns up — the word may be a flag's value (`registry --as set <dir>`
52
+ # asks for the slug "set"), and a grammar where that reading depends on
53
+ # which flag precedes it is a trapdoor. So the subcommand must lead, and
54
+ # the error says which one was found rather than guessing at the intent.
55
+ stray = argv.find { |arg| SUBCOMMANDS.include?(arg) }
56
+ return usage_error("put the subcommand first: okf registry #{stray} … (flags follow it)") if stray
57
+
58
+ registry_list(argv)
59
+ end
60
+ end
61
+
62
+ private
63
+
64
+ # Add a bundle to the persistent registry (so a later bare `okf server` finds
65
+ # it), or update one already there. The entry is keyed by the bundle's path: a
66
+ # path already registered refreshes its title in place, and --as renames it. A
67
+ # new path is added, slugged by directory basename unless --as says otherwise.
68
+ def registry_set(argv)
69
+ options = { as: nil, default: false }
70
+ parser = OptionParser.new do |o|
71
+ o.banner = "Usage: okf registry set <dir|@slug> [--as SLUG] [--default]"
72
+ o.on("--as SLUG", "slug to register under (default: directory basename)") { |v| options[:as] = v }
73
+ o.on("--default", "put it first — the bundle a bare `okf server` opens") { options[:default] = true }
74
+ help_flag(o)
75
+ end
76
+ # No no_extras? here: positional_dir has already refused a trailing
77
+ # argument. The sibling subcommands need the call because they take their
78
+ # positional through `positional`, which does not check.
79
+ dir = positional_dir(parser, argv) or return 2
80
+
81
+ reg = OKF::Registry.load
82
+ # Said before the upsert: after it, an update is indistinguishable from an
83
+ # add, and "registered" for what was a rename reads as a duplicate entry.
84
+ known = reg.listing.any? { |row| row[:dir] == File.expand_path(dir) }
85
+ entry = reg.add(dir, as: options[:as], default: options[:default])
86
+ # Through report_skipped like every other bundle-reading verb: the reader
87
+ # tolerates a file it cannot open, so a count taken straight off the graph
88
+ # reports "0 concepts" for a bundle whose files are simply unreadable.
89
+ folder = OKF::Bundle::Folder.load(entry.path)
90
+ report_skipped(folder)
91
+ count = folder.graph(minimal: true).nodes.size
92
+ @out.puts "#{known ? "updated" : "registered"} #{entry.slug} → #{entry.path} (#{count} #{pluralize(count, "concept")})"
93
+ 0
94
+ rescue OKF::Error => e
95
+ usage_error(e.message)
96
+ end
97
+
98
+ # Remove a bundle from the persistent registry by slug or by its directory.
99
+ def registry_del(argv)
100
+ parser = OptionParser.new do |o|
101
+ o.banner = "Usage: okf registry del <dir|@slug>"
102
+ help_flag(o)
103
+ end
104
+ slug = positional(parser, argv) or return 2
105
+ no_extras?(argv) or return 2
106
+
107
+ reg = OKF::Registry.load
108
+ slug = registry_slug(slug, reg) or return 2
109
+ removed = reg.remove(slug)
110
+ return usage_error("no such bundle: #{slug}") unless removed
111
+
112
+ @out.puts "removed #{removed.slug}"
113
+ 0
114
+ rescue OKF::Error => e
115
+ usage_error(e.message)
116
+ end
117
+
118
+ def registry_list(argv)
119
+ options = { json: false }
120
+ parser = OptionParser.new do |o|
121
+ o.banner = "Usage: okf registry list [--json] [--pretty]\n " \
122
+ "okf registry set <dir|@slug> | del <dir|@slug> | default <@slug> | rename <@slug> <new>"
123
+ json_flags(o, options, "emit the registry as JSON")
124
+ help_flag(o)
125
+ end
126
+ begin
127
+ parser.parse!(argv)
128
+ rescue OptionParser::ParseError => e
129
+ @err.puts e.message
130
+ return 2
131
+ end
132
+ no_extras?(argv) or return 2
133
+
134
+ reg = OKF::Registry.load
135
+ return emit_list_json({ "registry" => reg.path }, "bundles", reg.listing.map { |row| stringify(row) }, options) if options[:json]
136
+
137
+ print_registry(reg)
138
+ 0
139
+ rescue OKF::Error => e
140
+ usage_error(e.message)
141
+ end
142
+
143
+ # Choose which registered bundle a bare `okf server` opens at `/`, by moving
144
+ # it to the front of the registry. The listing is ordered and the JSON is
145
+ # meant to be hand-editable, so the move is stated rather than left to be
146
+ # discovered from a reordered file.
147
+ def registry_default(argv)
148
+ parser = OptionParser.new do |o|
149
+ o.banner = "Usage: okf registry default <@slug>\n " \
150
+ "moves it to the front — the first registered bundle is the default until you do"
151
+ help_flag(o)
152
+ end
153
+ slug = positional(parser, argv) or return 2
154
+ no_extras?(argv) or return 2
155
+
156
+ reg = OKF::Registry.load
157
+ slug = registry_slug(slug, reg) or return 2
158
+ reg.default = slug
159
+ @out.puts "default bundle → #{reg.default.slug} (now first)"
160
+ 0
161
+ rescue OKF::Error => e
162
+ usage_error(e.message)
163
+ end
164
+
165
+ # The @ref grammar for a verb that takes a *slug*, read by name. These three
166
+ # must reach an entry whose directory is gone — that is the one worth
167
+ # deleting or renaming — so they cannot go through resolve_ref, which
168
+ # insists the directory exist. Without this the refs only appeared to work:
169
+ # `normalize` strips the `@` off `@slug`, so `default @slug` resolved by
170
+ # accident while a bare `@` normalized to "" and failed. Returns the slug,
171
+ # or nil after reporting.
172
+ def registry_slug(arg, registry)
173
+ return arg unless arg.start_with?("@")
174
+
175
+ asked = arg[1..-1]
176
+ return asked unless asked.empty?
177
+
178
+ default = registry.default
179
+ return default.slug if default
180
+
181
+ @err.puts "error: no bundle is registered, so `@` names nothing (okf registry set <dir>)"
182
+ nil
183
+ end
184
+
185
+ # Rename a registered bundle's slug — its mount path and switcher name.
186
+ def registry_rename(argv)
187
+ parser = OptionParser.new do |o|
188
+ o.banner = "Usage: okf registry rename <@slug> <new>"
189
+ help_flag(o)
190
+ end
191
+ parser.parse!(argv)
192
+ old_slug, new_slug = argv.shift(2)
193
+ if old_slug.nil? || new_slug.nil?
194
+ @err.puts parser.banner
195
+ return 2
196
+ end
197
+ no_extras?(argv) or return 2
198
+
199
+ reg = OKF::Registry.load
200
+ # The old name may be a ref; the new one is a name being minted, never one.
201
+ old_slug = registry_slug(old_slug, reg) or return 2
202
+ entry = reg.rename(old_slug, new_slug)
203
+ # The slug it *found*, not the argv that found it: rename normalizes to look
204
+ # the entry up, so echoing the raw ask names a bundle that never existed.
205
+ @out.puts "renamed #{OKF::Registry.normalize(old_slug)} → #{entry.slug}"
206
+ 0
207
+ rescue OptionParser::ParseError => e
208
+ @err.puts e.message
209
+ 2
210
+ rescue OKF::Error => e
211
+ usage_error(e.message)
212
+ end
213
+
214
+ def print_registry(reg)
215
+ return @out.puts "no bundles registered — okf registry set <dir>" if reg.empty?
216
+
217
+ rows = reg.listing
218
+ width = rows.map { |row| row[:slug].length }.max
219
+ rows.each do |row|
220
+ marker = row[:default] ? "*" : " "
221
+ missing = row[:missing] ? " (missing)" : ""
222
+ @out.puts "#{marker} #{row[:slug].ljust(width)} #{row[:title]} (#{row[:dir]})#{missing}"
223
+ end
224
+ end
225
+ end
226
+
227
+ register(Registry)
228
+ end
229
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OKF
4
+ class CLI
5
+ # The static counterpart to `server`: bake the whole bundle into one
6
+ # self-contained HTML file (bodies, catalog, index, logs baked in, no server
7
+ # needed — e.g. hosting on GitHub Pages). Prints to stdout unless -o is given.
8
+ class Render < Command
9
+ def self.id
10
+ :render
11
+ end
12
+
13
+ def self.group
14
+ :act
15
+ end
16
+
17
+ def self.help_rows
18
+ [
19
+ [ "render <dir|@slug> [-o FILE] [--layout NAME] [...]", "write a static, self-contained HTML graph" ]
20
+ ]
21
+ end
22
+
23
+ def call(argv)
24
+ require "okf/render/graph"
25
+
26
+ options = { output: nil, title: nil, link: nil, layout: "cose" }
27
+ parser = OptionParser.new do |o|
28
+ o.banner = "Usage: okf render <dir|@slug> [-o FILE] [--layout NAME] [-t title] [-l url]"
29
+ o.on("-o", "--output FILE", "write to FILE instead of stdout") { |v| options[:output] = v }
30
+ o.on("-t", "--title TITLE", "graph title (default: parent/bundle dir name)") { |v| options[:title] = v }
31
+ o.on("-l", "--link URL", "source URL shown in the header") { |v| options[:link] = v }
32
+ o.on("--layout NAME", OKF::Render::Graph::LAYOUTS, "initial layout (#{OKF::Render::Graph::LAYOUTS.join(", ")})") { |v| options[:layout] = v }
33
+ help_flag(o)
34
+ end
35
+ dir = positional_dir(parser, argv) or return 2
36
+
37
+ folder = OKF::Bundle::Folder.load(dir)
38
+ report_skipped(folder)
39
+ html = OKF::Render::Graph.static(folder, title: options[:title], link: options[:link], layout: options[:layout])
40
+ if options[:output]
41
+ # A bad -o path (a missing directory, a permission denial) is a bad
42
+ # *argument*: exit 2 with the reason, never a backtrace and an exit code
43
+ # that means "failing bundle".
44
+ begin
45
+ File.write(options[:output], html)
46
+ rescue SystemCallError => e
47
+ return usage_error("cannot write #{options[:output]}: #{e.message}")
48
+ end
49
+ # Off the bundle, not a second graph: Graph.build maps one node per
50
+ # concept, so the counts are identical — and Folder#graph is not
51
+ # memoized, so asking for one here would build a whole second graph
52
+ # (Render::Graph.static already built one) to print one number. Only
53
+ # the graph, to be exact: the concepts are parsed once at Folder.load
54
+ # and Graph.build reads them from memory, so this costs no disk.
55
+ count = folder.bundle.concepts.size
56
+ @out.puts "wrote #{count} #{pluralize(count, "concept")} to #{options[:output]}"
57
+ else
58
+ @out.print html
59
+ end
60
+ 0
61
+ end
62
+ end
63
+
64
+ register(Render)
65
+ end
66
+ end
@@ -0,0 +1,285 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OKF
4
+ class CLI
5
+ # Ranked text retrieval — the browser page's search brought to the CLI on the
6
+ # same engine (a MiniFTS index) and extended to bodies. Terms after the
7
+ # directory are ANDed tokens, matched whole or by prefix (Ruby regexps with
8
+ # --regexp, typo tolerance with --fuzzy); rows rank by BM25+ weighted toward
9
+ # where they hit (title > id > tags > type/description > body) and carry one
10
+ # bounded context snippet, so "which concept covers X?" costs a few rows, not
11
+ # a body read. Advisory read: exit 0 even with no matches. Exact by default —
12
+ # the consuming agent is the fuzzy layer, until it asks not to be.
13
+ class Search < Command
14
+ # The core raises `:regexp`; a user typed `--regexp`. Translating here keeps
15
+ # the flag vocabulary in the shell, where it belongs, and lets the message end
16
+ # with the fix rather than only the complaint: an engine that *can* do what was
17
+ # asked is named, so the next command is obvious.
18
+ CAPABILITY_FLAGS = { regexp: "--regexp", fuzzy: "--fuzzy" }.freeze
19
+
20
+ def self.id
21
+ :search
22
+ end
23
+
24
+ def self.group
25
+ :read
26
+ end
27
+
28
+ def self.help_rows
29
+ [
30
+ [ "search <dir|@slug…|@all> <term…> [--regexp|--fuzzy]", "find concepts by text or regexp, ranked (@all: every bundle)" ]
31
+ ]
32
+ end
33
+
34
+ def call(argv)
35
+ options = { json: false, regexp: false, fuzzy: false, engine: nil }
36
+ parser = OptionParser.new do |o|
37
+ o.banner = "Usage: okf search <dir|@slug…|@all> <term…> [--engine NAME] [--regexp|--fuzzy] [--in FIELDS] [--type T] [--dir D] [--tag T] [--json]"
38
+ search_engine_note(o)
39
+ json_flags(o, options, "emit the matches as JSON")
40
+ projection_flags(o, options)
41
+ o.on("-e", "--regexp", "read each term as a Ruby regular expression rather",
42
+ "than literal text — case-insensitive (scan engine)") { options[:regexp] = true }
43
+ o.on("--fuzzy",
44
+ "tolerate typos, edit distance #{OKF::Bundle::Search::FUZZY_DISTANCE} × term length (index engine)") { options[:fuzzy] = true }
45
+ o.on("--engine NAME", "match with this engine instead of the default",
46
+ "(#{engine_names}) — index is BM25+ ranked, token-based") { |v| options[:engine] = v }
47
+ o.on("--in LIST", Array, "search only these fields (#{OKF::Bundle::Search::FIELDS.join(", ")})") { |v| options[:in] = v.map(&:downcase) }
48
+ filter_flags(o, options, :type, :area, :tag)
49
+ help_flag(o)
50
+ end
51
+ begin
52
+ parser.parse!(argv)
53
+ rescue OptionParser::ParseError => e
54
+ @err.puts e.message
55
+ return 2
56
+ end
57
+
58
+ # Registry mode — leading @refs, @all among them — searches several bundles
59
+ # and labels every match; a plain dir keeps the classic single-bundle output.
60
+ if argv.first&.start_with?("@")
61
+ pairs = ref_targets(argv) or return 2
62
+ dir = nil
63
+ else
64
+ dir = argv.shift
65
+ if dir.nil?
66
+ @err.puts parser.banner
67
+ return 2
68
+ end
69
+ dir = resolve_ref(dir) or return 2
70
+ end
71
+
72
+ terms = argv
73
+ if terms.empty?
74
+ @err.puts parser.banner
75
+ return 2
76
+ end
77
+
78
+ # A non-leading @arg is a literal term by the grammar — say so, since the
79
+ # user may have meant a ref (refs must lead) and would otherwise see only
80
+ # a silent zero-match.
81
+ stray = terms.find { |term| term.start_with?("@") }
82
+ @err.puts "note: '#{stray}' searches as a literal term — an @slug or @all must lead" if stray
83
+
84
+ unknown = Array(options[:in]) - OKF::Bundle::Search::FIELDS
85
+ return usage_error("unknown field(s): #{unknown.join(", ")} (searchable: #{OKF::Bundle::Search::FIELDS.join(", ")})") unless unknown.empty?
86
+
87
+ # Two query languages, not two dials on one: a regexp is matched against raw
88
+ # text, --fuzzy is an edit distance over indexed tokens. Silently honouring
89
+ # one and dropping the other would answer a question nobody asked.
90
+ if options[:regexp] && options[:fuzzy]
91
+ return usage_error("--regexp and --fuzzy are mutually exclusive (a pattern is matched literally, not by edit distance)")
92
+ end
93
+
94
+ return multi_search(pairs, terms, options) if pairs
95
+
96
+ folder = OKF::Bundle::Folder.load(dir)
97
+ report_skipped(folder)
98
+ rows = OKF::Bundle::Search.call(folder.bundle, terms, fields: options[:in], regexp: options[:regexp],
99
+ fuzzy: options[:fuzzy], engine: options[:engine])
100
+ keep = filter_ids(folder, options)
101
+ rows = rows.select { |row| keep.include?(row[:id]) } unless keep.nil?
102
+ return print_search_json(dir, terms, rows, options) if options[:json]
103
+
104
+ print_search(dir, terms, rows, folder.bundle.concepts.size)
105
+ 0
106
+ rescue RegexpError => e
107
+ usage_error("invalid pattern: #{e.message}")
108
+ rescue OKF::Bundle::Search::UnknownEngine => e
109
+ usage_error(e.message)
110
+ rescue OKF::Bundle::Search::UnsupportedQuery => e
111
+ usage_error(unsupported_query_message(e))
112
+ end
113
+
114
+ private
115
+
116
+ # Every registered bundle, as [slug, dir] pairs — what @all expands to.
117
+ # Asking for everything tolerates gaps: a registered directory that has since
118
+ # vanished is skipped with a note, the same forgiveness the hub shows a stale
119
+ # entry. Naming one bundle demands it, so a plain @slug still fails hard.
120
+ def all_targets
121
+ registry = load_registry
122
+ return nil unless registry
123
+
124
+ if registry.empty?
125
+ @err.puts "error: no bundles registered (okf registry set <dir>)"
126
+ return nil
127
+ end
128
+ pairs = []
129
+ registry.each do |entry|
130
+ if File.directory?(entry.path)
131
+ pairs << [ entry.slug, entry.path ]
132
+ else
133
+ skip_registered(entry)
134
+ end
135
+ end
136
+ if pairs.empty?
137
+ @err.puts "error: every registered bundle is missing on disk (okf registry list)"
138
+ return nil
139
+ end
140
+ pairs
141
+ end
142
+
143
+ # Dedupe by resolved path, not ref spelling — `@ @one` is one bundle when
144
+ # "one" is the default, and must be searched once. `@all @one` is the same
145
+ # story with a wider first ref: all ⊇ one, so the result is right and the
146
+ # duplicate simply drops. No error branch, because there is no wrong answer
147
+ # to warn about.
148
+ def ref_targets(argv)
149
+ refs = []
150
+ refs << argv.shift while argv.first&.start_with?("@")
151
+ pairs = []
152
+ refs.each do |ref|
153
+ found = all_ref?(ref) ? all_targets : ref_pair(ref)
154
+ return nil unless found
155
+
156
+ found.each { |slug, path| pairs << [ slug, path ] unless pairs.any? { |_, seen| seen == path } }
157
+ end
158
+ pairs
159
+ end
160
+
161
+ # One @ref as a single-element [[slug, dir]], or nil after reporting.
162
+ def ref_pair(ref)
163
+ path = resolve_registered(ref)
164
+ unless path
165
+ # Only an unknown slug is plausibly a mistyped term — a broken registry
166
+ # or a gone directory has nothing to do with the grammar.
167
+ @err.puts "note: searching for a literal @-term? put a non-@ term first, or use -e '\\@term'" if @ref_failure == :unknown
168
+ return nil
169
+ end
170
+ [ [ ref_slugs[path], path ] ]
171
+ end
172
+
173
+ # Search every bundle at once and merge the rankings, each row labeled with
174
+ # its bundle's slug. The bundles go in as *one* corpus rather than one search
175
+ # each: BM25 weighs a term by how rare it is, so ranking each bundle on its own
176
+ # statistics and then interleaving the lists would let the same match score
177
+ # differently for no reason a reader could see. One index, one ranking.
178
+ #
179
+ # Filters stay per-bundle — they are per-folder questions — so they apply to
180
+ # the merged rows by (slug, id) afterwards.
181
+ def multi_search(pairs, terms, options)
182
+ bundles = []
183
+ keeps = {}
184
+ total = 0
185
+ pairs.each do |slug, dir|
186
+ folder = OKF::Bundle::Folder.load(dir)
187
+ report_skipped(folder)
188
+ total += folder.bundle.concepts.size
189
+ bundles << [ slug, folder.bundle ]
190
+ keep = filter_ids(folder, options)
191
+ keeps[slug] = keep unless keep.nil?
192
+ end
193
+ rows = OKF::Bundle::Search.across(bundles, terms, fields: options[:in], regexp: options[:regexp],
194
+ fuzzy: options[:fuzzy], engine: options[:engine])
195
+ rows = rows.select { |row| !keeps.key?(row[:slug]) || keeps[row[:slug]].include?(row[:id]) }
196
+ return print_multi_search_json(pairs, terms, rows, options) if options[:json]
197
+
198
+ print_multi_search(pairs, terms, rows, total)
199
+ 0
200
+ end
201
+
202
+ def print_search(dir, terms, rows, total)
203
+ @out.puts "Search — #{bundle_label(dir)} · #{terms.join(" ")} (#{counted(rows.size, total, "concept")})"
204
+ if rows.empty?
205
+ @out.puts " no matches — fewer or broader terms, or scan `okf tags #{dir}` for the vocabulary"
206
+ return
207
+ end
208
+
209
+ width = rows.map { |row| row[:id].length }.max
210
+ rows.each do |row|
211
+ @out.puts
212
+ @out.puts " #{row[:id].ljust(width)} #{row[:title]} · #{row[:type]} · #{row[:matched].join("+")}"
213
+ @out.puts " #{truncate(row[:snippet], 100)}" unless row[:snippet].empty?
214
+ end
215
+ end
216
+
217
+ def print_search_json(dir, terms, rows, options)
218
+ emit_list_json(dir, "matches", rows.map { |row| stringify(row) }, options, "query" => terms)
219
+ end
220
+
221
+ def print_multi_search(pairs, terms, rows, total)
222
+ @out.puts "Search — #{pairs.map { |slug, _| "@#{slug}" }.join(" ")} · #{terms.join(" ")} (#{counted(rows.size, total, "concept")})"
223
+ if rows.empty?
224
+ @out.puts " no matches — fewer or broader terms, or scan `okf tags @<slug>` for a bundle's vocabulary"
225
+ return
226
+ end
227
+
228
+ slug_width = rows.map { |row| row[:slug].length }.max + 1
229
+ width = rows.map { |row| row[:id].length }.max
230
+ rows.each do |row|
231
+ @out.puts
232
+ @out.puts " #{"@#{row[:slug]}".ljust(slug_width)} #{row[:id].ljust(width)} #{row[:title]} · #{row[:type]} · #{row[:matched].join("+")}"
233
+ @out.puts " #{truncate(row[:snippet], 100)}" unless row[:snippet].empty?
234
+ end
235
+ end
236
+
237
+ # The head maps every searched slug to its directory once, so a row's
238
+ # `slug` resolves to `<dir>/<id>.md` without a second lookup — and without
239
+ # repeating a long path on every row.
240
+ def print_multi_search_json(pairs, terms, rows, options)
241
+ head = { "bundles" => pairs.map { |slug, dir| { "slug" => slug, "dir" => dir } } }
242
+ emit_list_json(head, "matches", rows.map { |row| stringify(row) }, options, { "query" => terms }, "matches_by_ref")
243
+ end
244
+
245
+ def unsupported_query_message(error)
246
+ wanted = error.missing.map { |name| CAPABILITY_FLAGS.fetch(name, ":#{name}") }.join(", ")
247
+ return "no available search engine offers #{wanted}" if error.engine.nil?
248
+
249
+ able = OKF::Bundle::Search.engines.select { |engine| (error.missing - engine.capabilities).empty? }
250
+ message = "--engine #{error.engine} does not support #{wanted}"
251
+ message += " (try --engine #{able.map(&:id).join(" or ")})" unless able.empty?
252
+ message
253
+ end
254
+
255
+ # The engine story, told once, in the only place there is to tell it. `search`
256
+ # routes on what the query needs — a pattern needs the scan, --fuzzy needs the
257
+ # index — and says nothing about it at runtime: no note on stderr, nothing in
258
+ # the header, and deliberately no --engine flag. So this is where a user learns
259
+ # that the exactness a token index gives up is still reachable, and that -e is
260
+ # how. Without it that capability is present but undiscoverable.
261
+ #
262
+ # It leads rather than trails because #help_flag registers -h with `on_tail`,
263
+ # which OptionParser renders after every separator: a closing paragraph would
264
+ # print *above* the -h line and split the option list in half. Stating the
265
+ # matching model before the flags reads better anyway.
266
+ def search_engine_note(parser)
267
+ parser.separator ""
268
+ parser.separator "Terms match raw text, so a phrase (\"dedup key\"), a dotted identifier (7.2.0,"
269
+ parser.separator "customer_id) and a word inside `backticks` all match literally — the scan engine."
270
+ parser.separator "--engine index matches whole tokens and the tokens they prefix, ranked by BM25+:"
271
+ parser.separator "better ranking and the engine the browser page runs, at the cost of that"
272
+ parser.separator "exactness. --fuzzy implies it. Add -e to read the terms as regular expressions."
273
+ parser.separator ""
274
+ end
275
+
276
+ # The registered engines, read at parse time so an addon that registers one
277
+ # shows up in `--help` without the CLI knowing it exists.
278
+ def engine_names
279
+ OKF::Bundle::Search.engines.map(&:id).join(" | ")
280
+ end
281
+ end
282
+
283
+ register(Search)
284
+ end
285
+ end