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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +615 -38
- data/README.md +109 -15
- data/lib/okf/bundle/folder.rb +20 -0
- data/lib/okf/bundle/search/index.rb +65 -0
- data/lib/okf/bundle/search/scan.rb +89 -0
- data/lib/okf/bundle/search.rb +262 -66
- data/lib/okf/bundle.rb +27 -3
- data/lib/okf/cli/catalog.rb +66 -0
- data/lib/okf/cli/command.rb +495 -0
- data/lib/okf/cli/files.rb +68 -0
- data/lib/okf/cli/graph.rb +82 -0
- data/lib/okf/cli/index.rb +127 -0
- data/lib/okf/cli/lint.rb +139 -0
- data/lib/okf/cli/loose.rb +78 -0
- data/lib/okf/cli/registry.rb +229 -0
- data/lib/okf/cli/render.rb +66 -0
- data/lib/okf/cli/search.rb +285 -0
- data/lib/okf/cli/server.rb +179 -0
- data/lib/okf/cli/skill.rb +57 -0
- data/lib/okf/cli/stats.rb +88 -0
- data/lib/okf/cli/tags.rb +122 -0
- data/lib/okf/cli/types.rb +37 -0
- data/lib/okf/cli/validate.rb +66 -0
- data/lib/okf/cli.rb +418 -1633
- data/lib/okf/{server → render}/graph/template.html.erb +1553 -175
- data/lib/okf/{server → render}/graph.rb +85 -9
- data/lib/okf/server/app.rb +17 -48
- data/lib/okf/server/hub/not_found.rb +663 -0
- data/lib/okf/server/hub.rb +504 -38
- data/lib/okf/skill/SKILL.md +41 -26
- data/lib/okf/skill/playbooks/consume.md +5 -3
- data/lib/okf/skill/playbooks/curate.md +3 -1
- data/lib/okf/skill/playbooks/maintain.md +4 -3
- data/lib/okf/skill/playbooks/menu.md +5 -0
- data/lib/okf/skill/playbooks/refine.md +92 -0
- data/lib/okf/skill/playbooks/search.md +47 -7
- data/lib/okf/skill/reference/authoring.md +3 -2
- data/lib/okf/skill/reference/cli.md +98 -21
- data/lib/okf/version.rb +1 -1
- data/lib/okf.rb +8 -0
- 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
|