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
@@ -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] [--area A] [--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
@@ -0,0 +1,179 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OKF
4
+ class CLI
5
+ # Boot the graph server. One verb covers three intentions and the argument
6
+ # count is the whole interface: one dir serves it at /, several serve them
7
+ # behind a hub, none serves the registry. Passing dirs never registers them.
8
+ class Server < Command
9
+ def self.id
10
+ :server
11
+ end
12
+
13
+ def self.group
14
+ :act
15
+ end
16
+
17
+ def self.help_rows
18
+ [
19
+ [ "server [DIR|@slug…] [-p PORT] [--bind ADDR] [...]", "serve one bundle, or many behind a hub" ]
20
+ ]
21
+ end
22
+
23
+ def call(argv)
24
+ require "okf/server/app"
25
+ require "rack/deflater"
26
+
27
+ options = { port: 8808, bind: "127.0.0.1", title: nil, link: nil, layout: "cose", read_only: false }
28
+ parser = OptionParser.new do |o|
29
+ o.banner = "Usage: okf server [DIR|@slug…] [-p PORT] [--bind ADDR] [--layout NAME] [-t title] [-l url]"
30
+ o.on("-p", "--port PORT", Integer, "port to serve on (default #{options[:port]})") { |v| options[:port] = v }
31
+ o.on("--bind ADDR", "address to bind (default #{options[:bind]})") { |v| options[:bind] = v }
32
+ o.on("-t", "--title TITLE", "graph title, single bundle only (default: parent/bundle dir name)") { |v| options[:title] = v }
33
+ o.on("-l", "--link URL", "source URL shown in the header, single bundle only") { |v| options[:link] = v }
34
+ o.on("--layout NAME", OKF::Render::Graph::LAYOUTS, "initial layout (#{OKF::Render::Graph::LAYOUTS.join(", ")})") { |v| options[:layout] = v }
35
+ o.on("--read-only", "serve the bundles list without its registry controls") { options[:read_only] = true }
36
+ help_flag(o)
37
+ end
38
+ dirs = positional_dirs(parser, argv) or return 2
39
+
40
+ # A flag that will have no effect in this mode gets a note, not silence.
41
+ @err.puts "note: --title/--link apply to a single-bundle server; ignored" if dirs.size != 1 && (options[:title] || options[:link])
42
+
43
+ # One dir keeps the historical single-bundle server at `/`; zero (the
44
+ # persistent registry) or many (ephemeral) fan out behind a hub.
45
+ if dirs.size == 1
46
+ folder = OKF::Bundle::Folder.load(dirs.first)
47
+ report_skipped(folder)
48
+ run_server(folder, options)
49
+ else
50
+ run_hub(dirs, options)
51
+ end
52
+ 0
53
+ rescue OKF::Error => e
54
+ usage_error(e.message)
55
+ end
56
+
57
+ private
58
+
59
+ # Build the single-bundle Rack app and hand it to the runner (WEBrick by
60
+ # default, injected so tests drive this without a socket).
61
+ def run_server(folder, options)
62
+ app = OKF::Server::App.new(folder, title: options[:title] || folder.name, link: options[:link], layout: options[:layout])
63
+ # minimal: the banner wants a count, not bodies — and Folder#graph is not
64
+ # memoized, so a full build here parses every concept a second time (the
65
+ # App builds its own) purely to print one number.
66
+ count = folder.graph(minimal: true).nodes.size
67
+ @out.puts "serving #{count} #{pluralize(count, "concept")} at http://#{options[:bind]}:#{options[:port]} (Ctrl-C to stop)"
68
+ serve(app, options)
69
+ end
70
+
71
+ # Build the multi-bundle hub and hand it to the runner. With dirs it serves
72
+ # those ephemerally; with none it serves the persistent registry. Either way
73
+ # the first bundle is the one `/` opens — for the registry that is its own
74
+ # order, and a first entry whose directory has vanished drops out here, so
75
+ # `/` lands on the next one that is actually there.
76
+ def run_hub(dirs, options)
77
+ require "okf/server/hub"
78
+ require "okf/registry"
79
+ reg = nil
80
+ if dirs.empty?
81
+ # A malformed registry raises OKF::Error, which `server` rescues into a
82
+ # usage error — no guarded load needed on this path.
83
+ reg = OKF::Registry.load
84
+ # The hub's own loader, so the set it rebuilds after a browser-side
85
+ # write is built exactly the way this one was.
86
+ bundles = OKF::Server::Hub.bundles_for(reg) { |entry| skip_registered(entry) }
87
+ bundles.each { |bundle| report_skipped(bundle.folder) }
88
+ else
89
+ bundles = ephemeral_bundles(dirs)
90
+ end
91
+ # The hub keeps the registry so its /b/ manager can report on entries it
92
+ # could not host — a folder deleted out from under one is the question
93
+ # "where did my bundle go?", and only the registry can answer it.
94
+ hub = OKF::Server::Hub.new(bundles, layout: options[:layout], registry: reg, writable: writable?(options))
95
+ concepts = bundles.inject(0) { |sum, bundle| sum + bundle.folder.graph(minimal: true).nodes.size }
96
+ @out.puts "serving #{bundles.size} #{pluralize(bundles.size,
97
+ "bundle")}, #{concepts} #{pluralize(concepts, "concept")} at http://#{options[:bind]}:#{options[:port]} (Ctrl-C to stop)"
98
+ print_mounts(hub)
99
+ serve(hub, options)
100
+ end
101
+
102
+ # The one boot seam every served app passes through, so a hub gzips exactly
103
+ # like a single bundle — the wrap belongs to booting a server, not to either
104
+ # mode, and a mode added later gets it for free. Deliberately not inside the
105
+ # runner: an embedding app mounting OKF::Server::App brings its own middleware.
106
+ def serve(app, options)
107
+ # gzip responses when the client accepts it — transparent, no new dependency
108
+ @runner.call(Rack::Deflater.new(app), options[:bind], options[:port])
109
+ end
110
+
111
+ # The mount table — which dir landed on which /b/<slug>/ and where `/` goes.
112
+ # Mirrors the Hub's own default resolution (explicit slug, else first).
113
+ # Ask the hub which bundle it chose rather than re-deriving the
114
+ # explicit-else-first rule, and mount at its own prefix: two copies of a
115
+ # rule is two answers waiting to disagree.
116
+ def print_mounts(hub)
117
+ hub.bundles.each do |bundle|
118
+ marker = bundle.equal?(hub.default) ? "*" : " "
119
+ @out.puts " #{marker} #{OKF::Server::Hub::MOUNT}/#{bundle.slug}/ #{bundle.title}"
120
+ end
121
+ end
122
+
123
+ # Load the given directories as unregistered bundles, slugged by basename and
124
+ # deduped within the run. The same directory listed twice mounts once — two
125
+ # windows on one bundle would just burn a slug on a URL that vanishes next run.
126
+ def ephemeral_bundles(dirs)
127
+ roots = []
128
+ dirs.each do |dir|
129
+ root = File.expand_path(dir)
130
+ roots << root unless roots.include?(root)
131
+ end
132
+
133
+ # A registered slug owns its mount outright: reserve every ref's slug
134
+ # before any basename is deduped. Otherwise argv order decides, and
135
+ # `server ./two @two` mounts the *unregistered* ./two at /b/two/ while
136
+ # pushing the ref — the bundle whose slug that is — to /b/two-2/, so a
137
+ # bookmark from a bundle-less run silently opens the wrong graph.
138
+ taken = roots.map { |root| ref_slugs[root] }.compact
139
+ roots.each_with_object([]) do |root, bundles|
140
+ folder = OKF::Bundle::Folder.load(root)
141
+ report_skipped(folder)
142
+ slug = ref_slugs[root]
143
+ unless slug
144
+ slug = OKF::Registry.dedupe(File.basename(root), taken)
145
+ taken << slug
146
+ end
147
+ bundles << OKF::Server::Hub::Bundle.new(slug, folder, folder.name)
148
+ end
149
+ end
150
+
151
+ # May the browser change the registry? On a loopback bind, yes: the server
152
+ # is reachable only from this machine, and the audience this was built for
153
+ # should not need a flag to use the page they were pointed at. Anywhere
154
+ # else, no — and there is no flag that says otherwise, because the registry
155
+ # is a per-user file and the machine that owns it is the machine that
156
+ # manages it. `--bind 0.0.0.0` is how a personal tool becomes a public one,
157
+ # and 0.0.0.0 is *not* loopback: it is every interface, which is the exact
158
+ # case this guards.
159
+ #
160
+ # So the only flag is the way *out*. It was `--allow-manage`, an opt-in
161
+ # that read as the on switch and was not one — the loopback default had
162
+ # already turned management on, and the flag only widened it to a bind
163
+ # nobody should widen it to. Naming the exception instead means the flag
164
+ # cannot be misread as permission: `--read-only` is the word the hub's own
165
+ # refusal already uses, and this server never writes a reader's markdown
166
+ # anyway, so in context it can only mean the registry.
167
+ def writable?(options)
168
+ !options[:read_only] && loopback?(options[:bind])
169
+ end
170
+
171
+ def loopback?(bind)
172
+ address = bind.to_s
173
+ address == "localhost" || address == "::1" || address.start_with?("127.")
174
+ end
175
+ end
176
+
177
+ register(Server)
178
+ end
179
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OKF
4
+ class CLI
5
+ # Install this gem's companion agent skill into a destination directory. The
6
+ # destination is required (no magic default) so the user always decides where
7
+ # their agent picks the skill up. By default the skill lands in a skills/okf/
8
+ # folder under it — point at a project or skills dir (.claude, .agents/skills)
9
+ # and it settles in its own folder, never loose among the others — so the
10
+ # resolved path is echoed back. --here installs straight into <dest-dir>.
11
+ class Skill < Command
12
+ def self.id
13
+ :skill
14
+ end
15
+
16
+ def self.group
17
+ :act
18
+ end
19
+
20
+ def self.help_rows
21
+ [
22
+ [ "skill <dest> [--here] [--force]", "install the companion agent skill" ]
23
+ ]
24
+ end
25
+
26
+ def call(argv)
27
+ options = { force: false, nest: true }
28
+ parser = OptionParser.new do |o|
29
+ o.banner = "Usage: okf skill <dest-dir> [--here] [--force]"
30
+ o.on("--here", "install straight into <dest-dir>, wherever it is (no skills/okf nesting)") { options[:nest] = false }
31
+ o.on("--force", "overwrite a non-empty destination") { options[:force] = true }
32
+ help_flag(o)
33
+ end
34
+ # Through the shared pair like every other verb that takes one positional:
35
+ # `positional` for the value, `no_extras?` for what must not follow it.
36
+ # Hand-rolling the shift is how this one came to accept a second
37
+ # destination, install into the first and exit 0 — the silent-wrong-answer
38
+ # shape the <dir> verbs are guarded against, on the one verb whose
39
+ # positional is not a <dir>.
40
+ dest = positional(parser, argv) or return 2
41
+ no_extras?(argv) or return 2
42
+
43
+ skill = OKF::Skill.new(dest, force: options[:force], nest: options[:nest])
44
+ files = skill.install
45
+ @out.puts "installed the okf skill (#{files.size} files) -> #{skill.dest}"
46
+ files.each { |f| @out.puts " #{f}" }
47
+ @out.puts "your agent picks it up from #{skill.dest} (needs the `okf` CLI, which you already have)."
48
+ 0
49
+ rescue OKF::Skill::Error => e
50
+ @err.puts "error: #{e.message}"
51
+ 2
52
+ end
53
+ end
54
+
55
+ register(Skill)
56
+ end
57
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OKF
4
+ class CLI
5
+ # Bundle rollups — concepts, types, areas, links, tags — in one screen.
6
+ class Stats < Command
7
+ def self.id
8
+ :stats
9
+ end
10
+
11
+ def self.group
12
+ :read
13
+ end
14
+
15
+ def self.help_rows
16
+ [
17
+ [ "stats <dir|@slug> [--json]", "bundle rollups (concepts, types, areas, links, tags)" ]
18
+ ]
19
+ end
20
+
21
+ def call(argv)
22
+ options = { json: false }
23
+ parser = OptionParser.new do |o|
24
+ o.banner = "Usage: okf stats <dir|@slug> [--json]"
25
+ json_flags(o, options, "emit the stats as JSON")
26
+ help_flag(o)
27
+ end
28
+ dir = positional_dir(parser, argv) or return 2
29
+
30
+ folder = OKF::Bundle::Folder.load(dir)
31
+ report_skipped(folder)
32
+ stats = bundle_stats(folder)
33
+ options[:json] ? print_stats_json(dir, stats) : print_stats(dir, stats)
34
+ 0
35
+ end
36
+
37
+ private
38
+
39
+ # Bundle-level rollups derived from the catalog and the graph indexes.
40
+ def bundle_stats(folder)
41
+ graph = folder.graph(minimal: true)
42
+ entries = folder.catalog
43
+ by_type = graph.type_index.transform_values(&:size).sort_by { |_, n| -n }.to_h
44
+ by_area = entries.group_by { |entry| entry[:area] }.transform_values(&:size).sort_by { |_, n| -n }.to_h
45
+ {
46
+ concepts: entries.size,
47
+ areas: by_area.size,
48
+ types: by_type.size,
49
+ cross_links: graph.edges.size,
50
+ tags: graph.tag_index.size,
51
+ by_type: by_type,
52
+ by_area: by_area
53
+ }
54
+ end
55
+
56
+ def print_stats(dir, stats)
57
+ @out.puts "Stats — #{bundle_label(dir)}"
58
+ @out.puts
59
+ @out.puts " concepts #{stats[:concepts]}"
60
+ @out.puts " areas #{stats[:areas]}"
61
+ @out.puts " concept types #{stats[:types]}"
62
+ @out.puts " cross-links #{stats[:cross_links]}"
63
+ @out.puts " distinct tags #{stats[:tags]}"
64
+ print_stat_breakdown("By type", stats[:by_type])
65
+ print_stat_breakdown("By area", stats[:by_area])
66
+ end
67
+
68
+ def print_stat_breakdown(title, counts)
69
+ return if counts.empty?
70
+
71
+ width = counts.keys.map(&:length).max
72
+ @out.puts
73
+ @out.puts " #{title}"
74
+ counts.each { |label, count| @out.puts " #{label.ljust(width)} #{count}" }
75
+ end
76
+
77
+ def print_stats_json(dir, stats)
78
+ emit_json(bundle_head(dir).merge(
79
+ "concepts" => stats[:concepts], "areas" => stats[:areas],
80
+ "concept_types" => stats[:types], "cross_links" => stats[:cross_links], "distinct_tags" => stats[:tags],
81
+ "by_type" => stats[:by_type], "by_area" => stats[:by_area]
82
+ ))
83
+ end
84
+ end
85
+
86
+ register(Stats)
87
+ end
88
+ end