okf 1.7.0 → 1.9.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 +368 -0
- data/README.md +78 -24
- data/lib/okf/bundle/folder.rb +25 -2
- data/lib/okf/bundle/graph.rb +5 -1
- data/lib/okf/bundle/linter.rb +6 -1
- data/lib/okf/bundle/reader.rb +21 -4
- 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 +2 -2
- data/lib/okf/cli.rb +917 -131
- data/lib/okf/registry.rb +370 -0
- data/lib/okf/{server → render}/graph/template.html.erb +1081 -130
- data/lib/okf/{server → render}/graph.rb +67 -9
- data/lib/okf/server/app.rb +23 -45
- data/lib/okf/server/hub.rb +207 -0
- data/lib/okf/skill/SKILL.md +28 -17
- data/lib/okf/skill/playbooks/consume.md +5 -3
- data/lib/okf/skill/playbooks/maintain.md +1 -1
- data/lib/okf/skill/playbooks/search.md +50 -7
- data/lib/okf/skill/reference/authoring.md +3 -2
- data/lib/okf/skill/reference/cli.md +200 -23
- data/lib/okf/version.rb +1 -1
- data/lib/okf.rb +8 -0
- metadata +21 -3
data/lib/okf/cli.rb
CHANGED
|
@@ -10,6 +10,10 @@ module OKF
|
|
|
10
10
|
#
|
|
11
11
|
# Exit codes: 0 success, 1 non-conformant / failing bundle, 2 usage error.
|
|
12
12
|
class CLI
|
|
13
|
+
# The `registry` umbrella's subcommands — the dispatch, and the words a
|
|
14
|
+
# flag-first invocation is checked against.
|
|
15
|
+
SUBCOMMANDS = %w[set del list default rename].freeze
|
|
16
|
+
|
|
13
17
|
# Lint findings grouped for display, in category order.
|
|
14
18
|
LINT_CATEGORIES = {
|
|
15
19
|
"Reachability" => %i[orphan not_in_index disconnected_component unlinked],
|
|
@@ -42,38 +46,88 @@ module OKF
|
|
|
42
46
|
|
|
43
47
|
def run(argv)
|
|
44
48
|
argv = argv.dup
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
49
|
+
# Per-run state, reset so a reused instance never inherits the last run's
|
|
50
|
+
# answer: the ref→slug memo, and the --pretty a previous argv turned on.
|
|
51
|
+
@ref_slugs = {}
|
|
52
|
+
@pretty = false
|
|
53
|
+
# -h/--help is answered wherever a parser sees it — deep inside
|
|
54
|
+
# positional_dir, where returning would only mean "usage error, exit 2".
|
|
55
|
+
# Thrown here instead, so help keeps the contract every other path keeps:
|
|
56
|
+
# a status this method returns. See #help_flag.
|
|
57
|
+
catch(:help) do
|
|
58
|
+
case (command = argv.shift)
|
|
59
|
+
when "graph" then graph(argv)
|
|
60
|
+
when "validate" then validate(argv)
|
|
61
|
+
when "lint" then lint(argv)
|
|
62
|
+
when "loose" then loose(argv)
|
|
63
|
+
when "search" then search(argv)
|
|
64
|
+
when "index" then index(argv)
|
|
65
|
+
when "catalog" then catalog(argv)
|
|
66
|
+
when "files" then files(argv)
|
|
67
|
+
when "tags" then tags(argv)
|
|
68
|
+
when "types" then types(argv)
|
|
69
|
+
when "stats" then stats(argv)
|
|
70
|
+
when "server" then server(argv)
|
|
71
|
+
when "render" then render(argv)
|
|
72
|
+
when "registry" then registry(argv)
|
|
73
|
+
when "skill" then skill(argv)
|
|
74
|
+
when "version", "--version", "-v" then @out.puts(OKF::VERSION); 0
|
|
75
|
+
when "help", "--help", "-h" then usage(@out); 0
|
|
76
|
+
when nil then usage(@err); 2
|
|
77
|
+
else
|
|
78
|
+
@err.puts "okf: unknown command '#{command}'"
|
|
79
|
+
usage(@err)
|
|
80
|
+
2
|
|
81
|
+
end
|
|
67
82
|
end
|
|
68
83
|
end
|
|
69
84
|
|
|
85
|
+
# "every registered bundle" as a ref, in its canonical spelling — what the
|
|
86
|
+
# messages say, and (normalized) what #all_ref? recognizes. Only `search`
|
|
87
|
+
# expands it: it is the one verb that merges across bundles, so it is the one
|
|
88
|
+
# verb for which "all" names something it can answer about. See
|
|
89
|
+
# resolve_registered for why the others refuse it outright rather than
|
|
90
|
+
# treating it as an unknown slug. Its slug half is reserved by
|
|
91
|
+
# Registry::RESERVED_SLUGS so no bundle can answer to it; a test pins the two
|
|
92
|
+
# together.
|
|
93
|
+
ALL_REF = "@all"
|
|
94
|
+
|
|
95
|
+
# The core raises `:regexp`; a user typed `--regexp`. Translating here keeps
|
|
96
|
+
# the flag vocabulary in the shell, where it belongs, and lets the message end
|
|
97
|
+
# with the fix rather than only the complaint: an engine that *can* do what was
|
|
98
|
+
# asked is named, so the next command is obvious.
|
|
99
|
+
CAPABILITY_FLAGS = { regexp: "--regexp", fuzzy: "--fuzzy" }.freeze
|
|
100
|
+
|
|
101
|
+
# The row shape each list view emits, so `--fields`/`--except` can be checked
|
|
102
|
+
# against a name even when the result is empty. Without it the typo guard
|
|
103
|
+
# keyed off the data: `--fields bogus` was a usage error against a bundle
|
|
104
|
+
# with matches and silently fine against one without, which made a typo's
|
|
105
|
+
# fate depend on whether a filter happened to match. A test asserts each
|
|
106
|
+
# view's real rows carry exactly these, so the two cannot drift.
|
|
107
|
+
# Declared in emission order, so the "available:" list a typo prints reads
|
|
108
|
+
# the same as the rows themselves.
|
|
109
|
+
ROW_FIELDS = {
|
|
110
|
+
"matches" => %w[id title type area tags matched score snippet],
|
|
111
|
+
# Registry mode labels every row with the bundle it came from; a plain-dir
|
|
112
|
+
# search has one bundle and no slug to carry. Two shapes, because the typo
|
|
113
|
+
# guard checks against the *declared* one — a single shape covering both
|
|
114
|
+
# would let `--fields slug` pass on a search whose rows have none, and hand
|
|
115
|
+
# back an empty object per match under a count that says otherwise.
|
|
116
|
+
"matches_by_ref" => %w[slug id title type area tags matched score snippet],
|
|
117
|
+
"concepts" => %w[id title type description tags timestamp status backlog_ref dir area links_out links_in],
|
|
118
|
+
"files" => %w[path id dir type title description],
|
|
119
|
+
"directories" => %w[dir index_path present synthesized count types tags subdirs body listing],
|
|
120
|
+
"bundles" => %w[slug title dir mount default missing]
|
|
121
|
+
}.freeze
|
|
122
|
+
|
|
70
123
|
private
|
|
71
124
|
|
|
72
125
|
def validate(argv)
|
|
73
126
|
options = { json: false }
|
|
74
127
|
parser = OptionParser.new do |o|
|
|
75
|
-
o.banner = "Usage: okf validate <
|
|
128
|
+
o.banner = "Usage: okf validate <dir|@slug> [--json]"
|
|
76
129
|
json_flags(o, options, "emit a JSON report")
|
|
130
|
+
help_flag(o)
|
|
77
131
|
end
|
|
78
132
|
dir = positional_dir(parser, argv) or return 2
|
|
79
133
|
|
|
@@ -85,13 +139,14 @@ module OKF
|
|
|
85
139
|
def lint(argv)
|
|
86
140
|
options = { json: false, min_body: OKF::Bundle::Linter::DEFAULT_MIN_BODY, stale_after: nil, only: nil, except: nil, fail_on: :never }
|
|
87
141
|
parser = OptionParser.new do |o|
|
|
88
|
-
o.banner = "Usage: okf lint <
|
|
142
|
+
o.banner = "Usage: okf lint <dir|@slug> [--json] [--min-body N] [--stale-after DUR] [--only a,b] [--except a,b] [--fail-on warn]"
|
|
89
143
|
json_flags(o, options, "emit a JSON report")
|
|
90
144
|
o.on("--min-body N", Integer, "stub threshold in body characters (default #{OKF::Bundle::Linter::DEFAULT_MIN_BODY})") { |v| options[:min_body] = v }
|
|
91
145
|
o.on("--stale-after DUR", "flag concepts older than DUR (e.g. 90d, 12w, 2026-01-01)") { |v| options[:stale_after] = v }
|
|
92
146
|
o.on("--only LIST", Array, "run only these checks (comma-separated)") { |v| options[:only] = v.map(&:to_sym) }
|
|
93
147
|
o.on("--except LIST", Array, "skip these checks (comma-separated)") { |v| options[:except] = v.map(&:to_sym) }
|
|
94
148
|
o.on("--fail-on LEVEL", %w[never warn], "exit 1 when a finding at LEVEL exists (never | warn)") { |v| options[:fail_on] = v.to_sym }
|
|
149
|
+
help_flag(o)
|
|
95
150
|
end
|
|
96
151
|
dir = positional_dir(parser, argv) or return 2
|
|
97
152
|
|
|
@@ -121,8 +176,9 @@ module OKF
|
|
|
121
176
|
def loose(argv)
|
|
122
177
|
options = { json: false }
|
|
123
178
|
parser = OptionParser.new do |o|
|
|
124
|
-
o.banner = "Usage: okf loose <
|
|
179
|
+
o.banner = "Usage: okf loose <dir|@slug> [--json]"
|
|
125
180
|
json_flags(o, options, "emit the loose files as JSON")
|
|
181
|
+
help_flag(o)
|
|
126
182
|
end
|
|
127
183
|
dir = positional_dir(parser, argv) or return 2
|
|
128
184
|
|
|
@@ -133,36 +189,80 @@ module OKF
|
|
|
133
189
|
0
|
|
134
190
|
end
|
|
135
191
|
|
|
136
|
-
#
|
|
137
|
-
# and extended to bodies. Terms after the
|
|
138
|
-
#
|
|
139
|
-
#
|
|
140
|
-
#
|
|
141
|
-
#
|
|
142
|
-
#
|
|
192
|
+
# Ranked text retrieval — the browser page's search brought to the CLI on the
|
|
193
|
+
# same engine (a MiniFTS index) and extended to bodies. Terms after the
|
|
194
|
+
# directory are ANDed tokens, matched whole or by prefix (Ruby regexps with
|
|
195
|
+
# --regexp, typo tolerance with --fuzzy); rows rank by BM25+ weighted toward
|
|
196
|
+
# where they hit (title > id > tags > type/description > body) and carry one
|
|
197
|
+
# bounded context snippet, so "which concept covers X?" costs a few rows, not
|
|
198
|
+
# a body read. Advisory read: exit 0 even with no matches. Exact by default —
|
|
199
|
+
# the consuming agent is the fuzzy layer, until it asks not to be.
|
|
143
200
|
def search(argv)
|
|
144
|
-
options = { json: false, regexp: false }
|
|
201
|
+
options = { json: false, regexp: false, fuzzy: false, engine: nil }
|
|
145
202
|
parser = OptionParser.new do |o|
|
|
146
|
-
o.banner = "Usage: okf search <
|
|
203
|
+
o.banner = "Usage: okf search <dir|@slug…|@all> <term…> [--engine NAME] [--regexp|--fuzzy] [--in FIELDS] [--type T] [--area A] [--tag T] [--json]"
|
|
204
|
+
search_engine_note(o)
|
|
147
205
|
json_flags(o, options, "emit the matches as JSON")
|
|
148
206
|
projection_flags(o, options)
|
|
149
|
-
o.on("-e", "--regexp", "
|
|
207
|
+
o.on("-e", "--regexp", "read each term as a Ruby regular expression rather",
|
|
208
|
+
"than literal text — case-insensitive (scan engine)") { options[:regexp] = true }
|
|
209
|
+
o.on("--fuzzy",
|
|
210
|
+
"tolerate typos, edit distance #{OKF::Bundle::Search::FUZZY_DISTANCE} × term length (index engine)") { options[:fuzzy] = true }
|
|
211
|
+
o.on("--engine NAME", "match with this engine instead of the default",
|
|
212
|
+
"(#{engine_names}) — index is BM25+ ranked, token-based") { |v| options[:engine] = v }
|
|
150
213
|
o.on("--in LIST", Array, "search only these fields (#{OKF::Bundle::Search::FIELDS.join(", ")})") { |v| options[:in] = v.map(&:downcase) }
|
|
151
214
|
filter_flags(o, options, :type, :area, :tag)
|
|
215
|
+
help_flag(o)
|
|
152
216
|
end
|
|
153
|
-
|
|
217
|
+
begin
|
|
218
|
+
parser.parse!(argv)
|
|
219
|
+
rescue OptionParser::ParseError => e
|
|
220
|
+
@err.puts e.message
|
|
221
|
+
return 2
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# Registry mode — leading @refs, @all among them — searches several bundles
|
|
225
|
+
# and labels every match; a plain dir keeps the classic single-bundle output.
|
|
226
|
+
if argv.first&.start_with?("@")
|
|
227
|
+
pairs = ref_targets(argv) or return 2
|
|
228
|
+
dir = nil
|
|
229
|
+
else
|
|
230
|
+
dir = argv.shift
|
|
231
|
+
if dir.nil?
|
|
232
|
+
@err.puts parser.banner
|
|
233
|
+
return 2
|
|
234
|
+
end
|
|
235
|
+
dir = resolve_ref(dir) or return 2
|
|
236
|
+
end
|
|
237
|
+
|
|
154
238
|
terms = argv
|
|
155
239
|
if terms.empty?
|
|
156
240
|
@err.puts parser.banner
|
|
157
241
|
return 2
|
|
158
242
|
end
|
|
159
243
|
|
|
244
|
+
# A non-leading @arg is a literal term by the grammar — say so, since the
|
|
245
|
+
# user may have meant a ref (refs must lead) and would otherwise see only
|
|
246
|
+
# a silent zero-match.
|
|
247
|
+
stray = terms.find { |term| term.start_with?("@") }
|
|
248
|
+
@err.puts "note: '#{stray}' searches as a literal term — an @slug or @all must lead" if stray
|
|
249
|
+
|
|
160
250
|
unknown = Array(options[:in]) - OKF::Bundle::Search::FIELDS
|
|
161
251
|
return usage_error("unknown field(s): #{unknown.join(", ")} (searchable: #{OKF::Bundle::Search::FIELDS.join(", ")})") unless unknown.empty?
|
|
162
252
|
|
|
253
|
+
# Two query languages, not two dials on one: a regexp is matched against raw
|
|
254
|
+
# text, --fuzzy is an edit distance over indexed tokens. Silently honouring
|
|
255
|
+
# one and dropping the other would answer a question nobody asked.
|
|
256
|
+
if options[:regexp] && options[:fuzzy]
|
|
257
|
+
return usage_error("--regexp and --fuzzy are mutually exclusive (a pattern is matched literally, not by edit distance)")
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
return multi_search(pairs, terms, options) if pairs
|
|
261
|
+
|
|
163
262
|
folder = OKF::Bundle::Folder.load(dir)
|
|
164
263
|
report_skipped(folder)
|
|
165
|
-
rows = OKF::Bundle::Search.call(folder.bundle, terms, fields: options[:in], regexp: options[:regexp]
|
|
264
|
+
rows = OKF::Bundle::Search.call(folder.bundle, terms, fields: options[:in], regexp: options[:regexp],
|
|
265
|
+
fuzzy: options[:fuzzy], engine: options[:engine])
|
|
166
266
|
keep = filter_ids(folder, options)
|
|
167
267
|
rows = rows.select { |row| keep.include?(row[:id]) } unless keep.nil?
|
|
168
268
|
return print_search_json(dir, terms, rows, options) if options[:json]
|
|
@@ -171,10 +271,115 @@ module OKF
|
|
|
171
271
|
0
|
|
172
272
|
rescue RegexpError => e
|
|
173
273
|
usage_error("invalid pattern: #{e.message}")
|
|
274
|
+
rescue OKF::Bundle::Search::UnknownEngine => e
|
|
275
|
+
usage_error(e.message)
|
|
276
|
+
rescue OKF::Bundle::Search::UnsupportedQuery => e
|
|
277
|
+
usage_error(unsupported_query_message(e))
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
# Every registered bundle, as [slug, dir] pairs — what @all expands to.
|
|
281
|
+
# Asking for everything tolerates gaps: a registered directory that has since
|
|
282
|
+
# vanished is skipped with a note, the same forgiveness the hub shows a stale
|
|
283
|
+
# entry. Naming one bundle demands it, so a plain @slug still fails hard.
|
|
284
|
+
def all_targets
|
|
285
|
+
registry = load_registry
|
|
286
|
+
return nil unless registry
|
|
287
|
+
|
|
288
|
+
if registry.empty?
|
|
289
|
+
@err.puts "error: no bundles registered (okf registry set <dir>)"
|
|
290
|
+
return nil
|
|
291
|
+
end
|
|
292
|
+
pairs = []
|
|
293
|
+
registry.each do |entry|
|
|
294
|
+
if File.directory?(entry.path)
|
|
295
|
+
pairs << [ entry.slug, entry.path ]
|
|
296
|
+
else
|
|
297
|
+
skip_registered(entry)
|
|
298
|
+
end
|
|
299
|
+
end
|
|
300
|
+
if pairs.empty?
|
|
301
|
+
@err.puts "error: every registered bundle is missing on disk (okf registry list)"
|
|
302
|
+
return nil
|
|
303
|
+
end
|
|
304
|
+
pairs
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
# Dedupe by resolved path, not ref spelling — `@ @one` is one bundle when
|
|
308
|
+
# "one" is the default, and must be searched once. `@all @one` is the same
|
|
309
|
+
# story with a wider first ref: all ⊇ one, so the result is right and the
|
|
310
|
+
# duplicate simply drops. No error branch, because there is no wrong answer
|
|
311
|
+
# to warn about.
|
|
312
|
+
def ref_targets(argv)
|
|
313
|
+
refs = []
|
|
314
|
+
refs << argv.shift while argv.first&.start_with?("@")
|
|
315
|
+
pairs = []
|
|
316
|
+
refs.each do |ref|
|
|
317
|
+
found = all_ref?(ref) ? all_targets : ref_pair(ref)
|
|
318
|
+
return nil unless found
|
|
319
|
+
|
|
320
|
+
found.each { |slug, path| pairs << [ slug, path ] unless pairs.any? { |_, seen| seen == path } }
|
|
321
|
+
end
|
|
322
|
+
pairs
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
# Does this @ref name every registered bundle? Takes a ref, sigil and all —
|
|
326
|
+
# both callers reach it only past a start_with?("@") of their own, so a
|
|
327
|
+
# third check here would be a branch no run can take.
|
|
328
|
+
#
|
|
329
|
+
# Compared *normalized*, because the ref grammar has exactly one
|
|
330
|
+
# normalization and a ref exempt from it is a trapdoor: `@ALL` has to reach
|
|
331
|
+
# `@all` for the same reason `@One` reaches the bundle registered from dir
|
|
332
|
+
# `One`. It normalizes through Registry.normalize — the very call the slug
|
|
333
|
+
# lookup makes — rather than a second downcase that could be forgotten while
|
|
334
|
+
# the first was maintained.
|
|
335
|
+
def all_ref?(ref)
|
|
336
|
+
require "okf/registry"
|
|
337
|
+
OKF::Registry.normalize(ref[1..-1]) == ALL_REF[1..-1]
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
# One @ref as a single-element [[slug, dir]], or nil after reporting.
|
|
341
|
+
def ref_pair(ref)
|
|
342
|
+
path = resolve_registered(ref)
|
|
343
|
+
unless path
|
|
344
|
+
# Only an unknown slug is plausibly a mistyped term — a broken registry
|
|
345
|
+
# or a gone directory has nothing to do with the grammar.
|
|
346
|
+
@err.puts "note: searching for a literal @-term? put a non-@ term first, or use -e '\\@term'" if @ref_failure == :unknown
|
|
347
|
+
return nil
|
|
348
|
+
end
|
|
349
|
+
[ [ ref_slugs[path], path ] ]
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
# Search every bundle at once and merge the rankings, each row labeled with
|
|
353
|
+
# its bundle's slug. The bundles go in as *one* corpus rather than one search
|
|
354
|
+
# each: BM25 weighs a term by how rare it is, so ranking each bundle on its own
|
|
355
|
+
# statistics and then interleaving the lists would let the same match score
|
|
356
|
+
# differently for no reason a reader could see. One index, one ranking.
|
|
357
|
+
#
|
|
358
|
+
# Filters stay per-bundle — they are per-folder questions — so they apply to
|
|
359
|
+
# the merged rows by (slug, id) afterwards.
|
|
360
|
+
def multi_search(pairs, terms, options)
|
|
361
|
+
bundles = []
|
|
362
|
+
keeps = {}
|
|
363
|
+
total = 0
|
|
364
|
+
pairs.each do |slug, dir|
|
|
365
|
+
folder = OKF::Bundle::Folder.load(dir)
|
|
366
|
+
report_skipped(folder)
|
|
367
|
+
total += folder.bundle.concepts.size
|
|
368
|
+
bundles << [ slug, folder.bundle ]
|
|
369
|
+
keep = filter_ids(folder, options)
|
|
370
|
+
keeps[slug] = keep unless keep.nil?
|
|
371
|
+
end
|
|
372
|
+
rows = OKF::Bundle::Search.across(bundles, terms, fields: options[:in], regexp: options[:regexp],
|
|
373
|
+
fuzzy: options[:fuzzy], engine: options[:engine])
|
|
374
|
+
rows = rows.select { |row| !keeps.key?(row[:slug]) || keeps[row[:slug]].include?(row[:id]) }
|
|
375
|
+
return print_multi_search_json(pairs, terms, rows, options) if options[:json]
|
|
376
|
+
|
|
377
|
+
print_multi_search(pairs, terms, rows, total)
|
|
378
|
+
0
|
|
174
379
|
end
|
|
175
380
|
|
|
176
381
|
def print_search(dir, terms, rows, total)
|
|
177
|
-
@out.puts "Search — #{dir} · #{terms.join(" ")} (#{counted(rows.size, total, "
|
|
382
|
+
@out.puts "Search — #{bundle_label(dir)} · #{terms.join(" ")} (#{counted(rows.size, total, "concept")})"
|
|
178
383
|
if rows.empty?
|
|
179
384
|
@out.puts " no matches — fewer or broader terms, or scan `okf tags #{dir}` for the vocabulary"
|
|
180
385
|
return
|
|
@@ -192,71 +397,416 @@ module OKF
|
|
|
192
397
|
emit_list_json(dir, "matches", rows.map { |row| stringify(row) }, options, "query" => terms)
|
|
193
398
|
end
|
|
194
399
|
|
|
400
|
+
def print_multi_search(pairs, terms, rows, total)
|
|
401
|
+
@out.puts "Search — #{pairs.map { |slug, _| "@#{slug}" }.join(" ")} · #{terms.join(" ")} (#{counted(rows.size, total, "concept")})"
|
|
402
|
+
if rows.empty?
|
|
403
|
+
@out.puts " no matches — fewer or broader terms, or scan `okf tags @<slug>` for a bundle's vocabulary"
|
|
404
|
+
return
|
|
405
|
+
end
|
|
406
|
+
|
|
407
|
+
slug_width = rows.map { |row| row[:slug].length }.max + 1
|
|
408
|
+
width = rows.map { |row| row[:id].length }.max
|
|
409
|
+
rows.each do |row|
|
|
410
|
+
@out.puts
|
|
411
|
+
@out.puts " #{"@#{row[:slug]}".ljust(slug_width)} #{row[:id].ljust(width)} #{row[:title]} · #{row[:type]} · #{row[:matched].join("+")}"
|
|
412
|
+
@out.puts " #{truncate(row[:snippet], 100)}" unless row[:snippet].empty?
|
|
413
|
+
end
|
|
414
|
+
end
|
|
415
|
+
|
|
416
|
+
# The head maps every searched slug to its directory once, so a row's
|
|
417
|
+
# `slug` resolves to `<dir>/<id>.md` without a second lookup — and without
|
|
418
|
+
# repeating a long path on every row.
|
|
419
|
+
def print_multi_search_json(pairs, terms, rows, options)
|
|
420
|
+
head = { "bundles" => pairs.map { |slug, dir| { "slug" => slug, "dir" => dir } } }
|
|
421
|
+
emit_list_json(head, "matches", rows.map { |row| stringify(row) }, options, { "query" => terms }, "matches_by_ref")
|
|
422
|
+
end
|
|
423
|
+
|
|
195
424
|
def server(argv)
|
|
196
425
|
require "okf/server/app"
|
|
197
426
|
require "rack/deflater"
|
|
198
427
|
|
|
199
428
|
options = { port: 8808, bind: "127.0.0.1", title: nil, link: nil, layout: "cose" }
|
|
200
429
|
parser = OptionParser.new do |o|
|
|
201
|
-
o.banner = "Usage: okf server
|
|
430
|
+
o.banner = "Usage: okf server [DIR|@slug…] [-p PORT] [--bind ADDR] [--layout NAME] [-t title] [-l url]"
|
|
202
431
|
o.on("-p", "--port PORT", Integer, "port to serve on (default #{options[:port]})") { |v| options[:port] = v }
|
|
203
432
|
o.on("--bind ADDR", "address to bind (default #{options[:bind]})") { |v| options[:bind] = v }
|
|
204
|
-
o.on("-t", "--title TITLE", "graph title (default: parent/bundle dir name)") { |v| options[:title] = v }
|
|
205
|
-
o.on("-l", "--link URL", "source URL shown in the header") { |v| options[:link] = v }
|
|
206
|
-
o.on("--layout NAME", OKF::
|
|
433
|
+
o.on("-t", "--title TITLE", "graph title, single bundle only (default: parent/bundle dir name)") { |v| options[:title] = v }
|
|
434
|
+
o.on("-l", "--link URL", "source URL shown in the header, single bundle only") { |v| options[:link] = v }
|
|
435
|
+
o.on("--layout NAME", OKF::Render::Graph::LAYOUTS, "initial layout (#{OKF::Render::Graph::LAYOUTS.join(", ")})") { |v| options[:layout] = v }
|
|
436
|
+
help_flag(o)
|
|
207
437
|
end
|
|
208
|
-
|
|
438
|
+
dirs = positional_dirs(parser, argv) or return 2
|
|
209
439
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
440
|
+
# A flag that will have no effect in this mode gets a note, not silence.
|
|
441
|
+
@err.puts "note: --title/--link apply to a single-bundle server; ignored" if dirs.size != 1 && (options[:title] || options[:link])
|
|
442
|
+
|
|
443
|
+
# One dir keeps the historical single-bundle server at `/`; zero (the
|
|
444
|
+
# persistent registry) or many (ephemeral) fan out behind a hub.
|
|
445
|
+
if dirs.size == 1
|
|
446
|
+
folder = OKF::Bundle::Folder.load(dirs.first)
|
|
447
|
+
report_skipped(folder)
|
|
448
|
+
run_server(folder, options)
|
|
449
|
+
else
|
|
450
|
+
run_hub(dirs, options)
|
|
451
|
+
end
|
|
213
452
|
0
|
|
453
|
+
rescue OKF::Error => e
|
|
454
|
+
usage_error(e.message)
|
|
214
455
|
end
|
|
215
456
|
|
|
216
|
-
# Build the Rack app and hand it to the runner (WEBrick by
|
|
217
|
-
# tests drive this without a socket).
|
|
457
|
+
# Build the single-bundle Rack app and hand it to the runner (WEBrick by
|
|
458
|
+
# default, injected so tests drive this without a socket).
|
|
218
459
|
def run_server(folder, options)
|
|
219
460
|
app = OKF::Server::App.new(folder, title: options[:title] || folder.name, link: options[:link], layout: options[:layout])
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
461
|
+
# minimal: the banner wants a count, not bodies — and Folder#graph is not
|
|
462
|
+
# memoized, so a full build here parses every concept a second time (the
|
|
463
|
+
# App builds its own) purely to print one number.
|
|
464
|
+
count = folder.graph(minimal: true).nodes.size
|
|
465
|
+
@out.puts "serving #{count} #{pluralize(count, "concept")} at http://#{options[:bind]}:#{options[:port]} (Ctrl-C to stop)"
|
|
466
|
+
serve(app, options)
|
|
467
|
+
end
|
|
468
|
+
|
|
469
|
+
# Build the multi-bundle hub and hand it to the runner. With dirs it serves
|
|
470
|
+
# those ephemerally; with none it serves the persistent registry. Either way
|
|
471
|
+
# the first bundle is the one `/` opens — for the registry that is its own
|
|
472
|
+
# order, and a first entry whose directory has vanished drops out here, so
|
|
473
|
+
# `/` lands on the next one that is actually there.
|
|
474
|
+
def run_hub(dirs, options)
|
|
475
|
+
require "okf/server/hub"
|
|
476
|
+
require "okf/registry"
|
|
477
|
+
if dirs.empty?
|
|
478
|
+
# A malformed registry raises OKF::Error, which `server` rescues into a
|
|
479
|
+
# usage error — no guarded load needed on this path.
|
|
480
|
+
reg = OKF::Registry.load
|
|
481
|
+
bundles = reg.map { |entry| load_registered(entry) }.compact
|
|
482
|
+
else
|
|
483
|
+
bundles = ephemeral_bundles(dirs)
|
|
484
|
+
end
|
|
485
|
+
hub = OKF::Server::Hub.new(bundles, layout: options[:layout])
|
|
486
|
+
concepts = bundles.inject(0) { |sum, bundle| sum + bundle.folder.graph(minimal: true).nodes.size }
|
|
487
|
+
@out.puts "serving #{bundles.size} #{pluralize(bundles.size,
|
|
488
|
+
"bundle")}, #{concepts} #{pluralize(concepts, "concept")} at http://#{options[:bind]}:#{options[:port]} (Ctrl-C to stop)"
|
|
489
|
+
print_mounts(hub)
|
|
490
|
+
serve(hub, options)
|
|
491
|
+
end
|
|
492
|
+
|
|
493
|
+
# The one boot seam every served app passes through, so a hub gzips exactly
|
|
494
|
+
# like a single bundle — the wrap belongs to booting a server, not to either
|
|
495
|
+
# mode, and a mode added later gets it for free. Deliberately not inside the
|
|
496
|
+
# runner: an embedding app mounting OKF::Server::App brings its own middleware.
|
|
497
|
+
def serve(app, options)
|
|
498
|
+
# gzip responses when the client accepts it — transparent, no new dependency
|
|
499
|
+
@runner.call(Rack::Deflater.new(app), options[:bind], options[:port])
|
|
500
|
+
end
|
|
501
|
+
|
|
502
|
+
# The mount table — which dir landed on which /b/<slug>/ and where `/` goes.
|
|
503
|
+
# Mirrors the Hub's own default resolution (explicit slug, else first).
|
|
504
|
+
# Ask the hub which bundle it chose rather than re-deriving the
|
|
505
|
+
# explicit-else-first rule, and mount at its own prefix: two copies of a
|
|
506
|
+
# rule is two answers waiting to disagree.
|
|
507
|
+
def print_mounts(hub)
|
|
508
|
+
hub.bundles.each do |bundle|
|
|
509
|
+
marker = bundle.equal?(hub.default) ? "*" : " "
|
|
510
|
+
@out.puts " #{marker} #{OKF::Server::Hub::MOUNT}/#{bundle.slug}/ #{bundle.title}"
|
|
511
|
+
end
|
|
512
|
+
end
|
|
513
|
+
|
|
514
|
+
# Load the given directories as unregistered bundles, slugged by basename and
|
|
515
|
+
# deduped within the run. The same directory listed twice mounts once — two
|
|
516
|
+
# windows on one bundle would just burn a slug on a URL that vanishes next run.
|
|
517
|
+
def ephemeral_bundles(dirs)
|
|
518
|
+
roots = []
|
|
519
|
+
dirs.each do |dir|
|
|
520
|
+
root = File.expand_path(dir)
|
|
521
|
+
roots << root unless roots.include?(root)
|
|
522
|
+
end
|
|
523
|
+
|
|
524
|
+
# A registered slug owns its mount outright: reserve every ref's slug
|
|
525
|
+
# before any basename is deduped. Otherwise argv order decides, and
|
|
526
|
+
# `server ./two @two` mounts the *unregistered* ./two at /b/two/ while
|
|
527
|
+
# pushing the ref — the bundle whose slug that is — to /b/two-2/, so a
|
|
528
|
+
# bookmark from a bundle-less run silently opens the wrong graph.
|
|
529
|
+
taken = roots.map { |root| ref_slugs[root] }.compact
|
|
530
|
+
roots.each_with_object([]) do |root, bundles|
|
|
531
|
+
folder = OKF::Bundle::Folder.load(root)
|
|
532
|
+
report_skipped(folder)
|
|
533
|
+
slug = ref_slugs[root]
|
|
534
|
+
unless slug
|
|
535
|
+
slug = OKF::Registry.dedupe(File.basename(root), taken)
|
|
536
|
+
taken << slug
|
|
537
|
+
end
|
|
538
|
+
bundles << OKF::Server::Hub::Bundle.new(slug, folder, folder.name)
|
|
539
|
+
end
|
|
540
|
+
end
|
|
541
|
+
|
|
542
|
+
# Load one registered bundle; a path that has gone missing or no longer reads
|
|
543
|
+
# drops to nil with a note (to stderr) rather than sinking the whole run. The
|
|
544
|
+
# directory check is explicit — the Reader maps a nonexistent directory to an
|
|
545
|
+
# empty bundle, so nothing would raise for the common "dir was deleted" case.
|
|
546
|
+
# Method-level rescue (not a `do…end`-block rescue — a 2.6 feature).
|
|
547
|
+
def load_registered(entry)
|
|
548
|
+
return skip_registered(entry) unless File.directory?(entry.path)
|
|
549
|
+
|
|
550
|
+
folder = OKF::Bundle::Folder.load(entry.path)
|
|
551
|
+
report_skipped(folder)
|
|
552
|
+
OKF::Server::Hub::Bundle.new(entry.slug, folder, entry.title)
|
|
553
|
+
rescue SystemCallError, OKF::Error
|
|
554
|
+
skip_registered(entry)
|
|
555
|
+
end
|
|
556
|
+
|
|
557
|
+
def skip_registered(entry)
|
|
558
|
+
@err.puts "note: skipping #{entry.slug} — cannot read #{entry.path}"
|
|
559
|
+
nil
|
|
223
560
|
end
|
|
224
561
|
|
|
225
562
|
# The static counterpart to `server`: bake the whole bundle into one
|
|
226
563
|
# self-contained HTML file (bodies, catalog, index, logs baked in, no server
|
|
227
564
|
# needed — e.g. hosting on GitHub Pages). Prints to stdout unless -o is given.
|
|
228
565
|
def render(argv)
|
|
229
|
-
require "okf/
|
|
566
|
+
require "okf/render/graph"
|
|
230
567
|
|
|
231
568
|
options = { output: nil, title: nil, link: nil, layout: "cose" }
|
|
232
569
|
parser = OptionParser.new do |o|
|
|
233
|
-
o.banner = "Usage: okf render <
|
|
570
|
+
o.banner = "Usage: okf render <dir|@slug> [-o FILE] [--layout NAME] [-t title] [-l url]"
|
|
234
571
|
o.on("-o", "--output FILE", "write to FILE instead of stdout") { |v| options[:output] = v }
|
|
235
572
|
o.on("-t", "--title TITLE", "graph title (default: parent/bundle dir name)") { |v| options[:title] = v }
|
|
236
573
|
o.on("-l", "--link URL", "source URL shown in the header") { |v| options[:link] = v }
|
|
237
|
-
o.on("--layout NAME", OKF::
|
|
574
|
+
o.on("--layout NAME", OKF::Render::Graph::LAYOUTS, "initial layout (#{OKF::Render::Graph::LAYOUTS.join(", ")})") { |v| options[:layout] = v }
|
|
575
|
+
help_flag(o)
|
|
238
576
|
end
|
|
239
577
|
dir = positional_dir(parser, argv) or return 2
|
|
240
578
|
|
|
241
579
|
folder = OKF::Bundle::Folder.load(dir)
|
|
242
580
|
report_skipped(folder)
|
|
243
|
-
html = OKF::
|
|
581
|
+
html = OKF::Render::Graph.static(folder, title: options[:title], link: options[:link], layout: options[:layout])
|
|
244
582
|
if options[:output]
|
|
245
|
-
|
|
246
|
-
|
|
583
|
+
# A bad -o path (a missing directory, a permission denial) is a bad
|
|
584
|
+
# *argument*: exit 2 with the reason, never a backtrace and an exit code
|
|
585
|
+
# that means "failing bundle".
|
|
586
|
+
begin
|
|
587
|
+
File.write(options[:output], html)
|
|
588
|
+
rescue SystemCallError => e
|
|
589
|
+
return usage_error("cannot write #{options[:output]}: #{e.message}")
|
|
590
|
+
end
|
|
591
|
+
count = folder.graph(minimal: true).nodes.size
|
|
592
|
+
@out.puts "wrote #{count} #{pluralize(count, "concept")} to #{options[:output]}"
|
|
247
593
|
else
|
|
248
594
|
@out.print html
|
|
249
595
|
end
|
|
250
596
|
0
|
|
251
597
|
end
|
|
252
598
|
|
|
599
|
+
# The registry umbrella, split by what each verb keys on. `set`/`del`/`list`
|
|
600
|
+
# act on entries — `set` keys on the bundle's path, so --as means one thing
|
|
601
|
+
# ("the slug this entry has") whether it adds or renames. `default`/`rename`
|
|
602
|
+
# act on slugs, the names actually to hand once a bundle is registered. Every
|
|
603
|
+
# positional stays unambiguous, and `config` is left free for real settings.
|
|
604
|
+
def registry(argv)
|
|
605
|
+
require "okf/registry"
|
|
606
|
+
|
|
607
|
+
sub = argv.first
|
|
608
|
+
case sub
|
|
609
|
+
when "set" then registry_set(argv.drop(1))
|
|
610
|
+
when "del" then registry_del(argv.drop(1))
|
|
611
|
+
when "list" then registry_list(argv.drop(1))
|
|
612
|
+
when "default" then registry_default(argv.drop(1))
|
|
613
|
+
when "rename" then registry_rename(argv.drop(1))
|
|
614
|
+
else
|
|
615
|
+
# A bare word that isn't a known subcommand is a typo (`registry remove x`
|
|
616
|
+
# must not silently render the list and read as success).
|
|
617
|
+
return usage_error("unknown registry subcommand '#{sub}' (expected: #{SUBCOMMANDS.join(", ")})") if sub && !sub.start_with?("-")
|
|
618
|
+
|
|
619
|
+
# Same rule for a subcommand hiding behind a flag: `registry --json set
|
|
620
|
+
# dir` would otherwise list an empty registry and exit 0, having written
|
|
621
|
+
# nothing the user asked for. It cannot just be dispatched from wherever
|
|
622
|
+
# it turns up — the word may be a flag's value (`registry --as set <dir>`
|
|
623
|
+
# asks for the slug "set"), and a grammar where that reading depends on
|
|
624
|
+
# which flag precedes it is a trapdoor. So the subcommand must lead, and
|
|
625
|
+
# the error says which one was found rather than guessing at the intent.
|
|
626
|
+
stray = argv.find { |arg| SUBCOMMANDS.include?(arg) }
|
|
627
|
+
return usage_error("put the subcommand first: okf registry #{stray} … (flags follow it)") if stray
|
|
628
|
+
|
|
629
|
+
registry_list(argv)
|
|
630
|
+
end
|
|
631
|
+
end
|
|
632
|
+
|
|
633
|
+
# Add a bundle to the persistent registry (so a later bare `okf server` finds
|
|
634
|
+
# it), or update one already there. The entry is keyed by the bundle's path: a
|
|
635
|
+
# path already registered refreshes its title in place, and --as renames it. A
|
|
636
|
+
# new path is added, slugged by directory basename unless --as says otherwise.
|
|
637
|
+
def registry_set(argv)
|
|
638
|
+
options = { as: nil, default: false }
|
|
639
|
+
parser = OptionParser.new do |o|
|
|
640
|
+
o.banner = "Usage: okf registry set <dir|@slug> [--as SLUG] [--default]"
|
|
641
|
+
o.on("--as SLUG", "slug to register under (default: directory basename)") { |v| options[:as] = v }
|
|
642
|
+
o.on("--default", "put it first — the bundle a bare `okf server` opens") { options[:default] = true }
|
|
643
|
+
help_flag(o)
|
|
644
|
+
end
|
|
645
|
+
dir = positional_dir(parser, argv) or return 2
|
|
646
|
+
no_extras?(argv) or return 2
|
|
647
|
+
|
|
648
|
+
reg = OKF::Registry.load
|
|
649
|
+
# Said before the upsert: after it, an update is indistinguishable from an
|
|
650
|
+
# add, and "registered" for what was a rename reads as a duplicate entry.
|
|
651
|
+
known = reg.listing.any? { |row| row[:dir] == File.expand_path(dir) }
|
|
652
|
+
entry = reg.add(dir, as: options[:as], default: options[:default])
|
|
653
|
+
# Through report_skipped like every other bundle-reading verb: the reader
|
|
654
|
+
# tolerates a file it cannot open, so a count taken straight off the graph
|
|
655
|
+
# reports "0 concepts" for a bundle whose files are simply unreadable.
|
|
656
|
+
folder = OKF::Bundle::Folder.load(entry.path)
|
|
657
|
+
report_skipped(folder)
|
|
658
|
+
count = folder.graph(minimal: true).nodes.size
|
|
659
|
+
@out.puts "#{known ? "updated" : "registered"} #{entry.slug} → #{entry.path} (#{count} #{pluralize(count, "concept")})"
|
|
660
|
+
0
|
|
661
|
+
rescue OKF::Error => e
|
|
662
|
+
usage_error(e.message)
|
|
663
|
+
end
|
|
664
|
+
|
|
665
|
+
# Remove a bundle from the persistent registry by slug or by its directory.
|
|
666
|
+
def registry_del(argv)
|
|
667
|
+
parser = OptionParser.new do |o|
|
|
668
|
+
o.banner = "Usage: okf registry del <dir|@slug>"
|
|
669
|
+
help_flag(o)
|
|
670
|
+
end
|
|
671
|
+
slug = positional(parser, argv) or return 2
|
|
672
|
+
no_extras?(argv) or return 2
|
|
673
|
+
|
|
674
|
+
reg = OKF::Registry.load
|
|
675
|
+
slug = registry_slug(slug, reg) or return 2
|
|
676
|
+
removed = reg.remove(slug)
|
|
677
|
+
return usage_error("no such bundle: #{slug}") unless removed
|
|
678
|
+
|
|
679
|
+
@out.puts "removed #{removed.slug}"
|
|
680
|
+
0
|
|
681
|
+
rescue OKF::Error => e
|
|
682
|
+
usage_error(e.message)
|
|
683
|
+
end
|
|
684
|
+
|
|
685
|
+
def registry_list(argv)
|
|
686
|
+
options = { json: false }
|
|
687
|
+
parser = OptionParser.new do |o|
|
|
688
|
+
o.banner = "Usage: okf registry list [--json] [--pretty]\n " \
|
|
689
|
+
"okf registry set <dir|@slug> | del <dir|@slug> | default <@slug> | rename <@slug> <new>"
|
|
690
|
+
json_flags(o, options, "emit the registry as JSON")
|
|
691
|
+
help_flag(o)
|
|
692
|
+
end
|
|
693
|
+
begin
|
|
694
|
+
parser.parse!(argv)
|
|
695
|
+
rescue OptionParser::ParseError => e
|
|
696
|
+
@err.puts e.message
|
|
697
|
+
return 2
|
|
698
|
+
end
|
|
699
|
+
no_extras?(argv) or return 2
|
|
700
|
+
|
|
701
|
+
reg = OKF::Registry.load
|
|
702
|
+
return emit_list_json({ "registry" => reg.path }, "bundles", reg.listing.map { |row| stringify(row) }, options) if options[:json]
|
|
703
|
+
|
|
704
|
+
print_registry(reg)
|
|
705
|
+
0
|
|
706
|
+
rescue OKF::Error => e
|
|
707
|
+
usage_error(e.message)
|
|
708
|
+
end
|
|
709
|
+
|
|
710
|
+
# Choose which registered bundle a bare `okf server` opens at `/`, by moving
|
|
711
|
+
# it to the front of the registry. The listing is ordered and the JSON is
|
|
712
|
+
# meant to be hand-editable, so the move is stated rather than left to be
|
|
713
|
+
# discovered from a reordered file.
|
|
714
|
+
def registry_default(argv)
|
|
715
|
+
parser = OptionParser.new do |o|
|
|
716
|
+
o.banner = "Usage: okf registry default <@slug>\n " \
|
|
717
|
+
"moves it to the front — the first registered bundle is the default until you do"
|
|
718
|
+
help_flag(o)
|
|
719
|
+
end
|
|
720
|
+
slug = positional(parser, argv) or return 2
|
|
721
|
+
no_extras?(argv) or return 2
|
|
722
|
+
|
|
723
|
+
reg = OKF::Registry.load
|
|
724
|
+
slug = registry_slug(slug, reg) or return 2
|
|
725
|
+
reg.default = slug
|
|
726
|
+
@out.puts "default bundle → #{reg.default.slug} (now first)"
|
|
727
|
+
0
|
|
728
|
+
rescue OKF::Error => e
|
|
729
|
+
usage_error(e.message)
|
|
730
|
+
end
|
|
731
|
+
|
|
732
|
+
# The @ref grammar for a verb that takes a *slug*, read by name. These three
|
|
733
|
+
# must reach an entry whose directory is gone — that is the one worth
|
|
734
|
+
# deleting or renaming — so they cannot go through resolve_ref, which
|
|
735
|
+
# insists the directory exist. Without this the refs only appeared to work:
|
|
736
|
+
# `normalize` strips the `@` off `@slug`, so `default @slug` resolved by
|
|
737
|
+
# accident while a bare `@` normalized to "" and failed. Returns the slug,
|
|
738
|
+
# or nil after reporting.
|
|
739
|
+
def registry_slug(arg, registry)
|
|
740
|
+
return arg unless arg.start_with?("@")
|
|
741
|
+
|
|
742
|
+
asked = arg[1..-1]
|
|
743
|
+
return asked unless asked.empty?
|
|
744
|
+
|
|
745
|
+
default = registry.default
|
|
746
|
+
return default.slug if default
|
|
747
|
+
|
|
748
|
+
@err.puts "error: no bundle is registered, so `@` names nothing (okf registry set <dir>)"
|
|
749
|
+
nil
|
|
750
|
+
end
|
|
751
|
+
|
|
752
|
+
# Rename a registered bundle's slug — its mount path and switcher name.
|
|
753
|
+
def registry_rename(argv)
|
|
754
|
+
parser = OptionParser.new do |o|
|
|
755
|
+
o.banner = "Usage: okf registry rename <@slug> <new>"
|
|
756
|
+
help_flag(o)
|
|
757
|
+
end
|
|
758
|
+
parser.parse!(argv)
|
|
759
|
+
old_slug, new_slug = argv.shift(2)
|
|
760
|
+
if old_slug.nil? || new_slug.nil?
|
|
761
|
+
@err.puts parser.banner
|
|
762
|
+
return 2
|
|
763
|
+
end
|
|
764
|
+
no_extras?(argv) or return 2
|
|
765
|
+
|
|
766
|
+
reg = OKF::Registry.load
|
|
767
|
+
# The old name may be a ref; the new one is a name being minted, never one.
|
|
768
|
+
old_slug = registry_slug(old_slug, reg) or return 2
|
|
769
|
+
entry = reg.rename(old_slug, new_slug)
|
|
770
|
+
# The slug it *found*, not the argv that found it: rename normalizes to look
|
|
771
|
+
# the entry up, so echoing the raw ask names a bundle that never existed.
|
|
772
|
+
@out.puts "renamed #{OKF::Registry.normalize(old_slug)} → #{entry.slug}"
|
|
773
|
+
0
|
|
774
|
+
rescue OptionParser::ParseError => e
|
|
775
|
+
@err.puts e.message
|
|
776
|
+
2
|
|
777
|
+
rescue OKF::Error => e
|
|
778
|
+
usage_error(e.message)
|
|
779
|
+
end
|
|
780
|
+
|
|
781
|
+
# The registry verbs take an exact number of positionals — a leftover argument
|
|
782
|
+
# is a typo'd invocation, not something to drop silently.
|
|
783
|
+
def no_extras?(argv)
|
|
784
|
+
return true if argv.empty?
|
|
785
|
+
|
|
786
|
+
@err.puts "error: unexpected argument '#{argv.first}'"
|
|
787
|
+
false
|
|
788
|
+
end
|
|
789
|
+
|
|
790
|
+
def print_registry(reg)
|
|
791
|
+
return @out.puts "no bundles registered — okf registry set <dir>" if reg.empty?
|
|
792
|
+
|
|
793
|
+
rows = reg.listing
|
|
794
|
+
width = rows.map { |row| row[:slug].length }.max
|
|
795
|
+
rows.each do |row|
|
|
796
|
+
marker = row[:default] ? "*" : " "
|
|
797
|
+
missing = row[:missing] ? " (missing)" : ""
|
|
798
|
+
@out.puts "#{marker} #{row[:slug].ljust(width)} #{row[:title]} (#{row[:dir]})#{missing}"
|
|
799
|
+
end
|
|
800
|
+
end
|
|
801
|
+
|
|
253
802
|
def graph(argv)
|
|
254
803
|
options = { json: false, minimal: false, body: true }
|
|
255
804
|
parser = OptionParser.new do |o|
|
|
256
|
-
o.banner = "Usage: okf graph <
|
|
805
|
+
o.banner = "Usage: okf graph <dir|@slug> [--json] [--minimal] [--no-body]"
|
|
257
806
|
json_flags(o, options, "emit nodes and edges as JSON")
|
|
258
807
|
o.on("--minimal", "leanest nodes (id + title); adds type/tag indexes") { options[:minimal] = true }
|
|
259
808
|
o.on("--[no-]body", "include each concept's body (default: yes)") { |v| options[:body] = v }
|
|
809
|
+
help_flag(o)
|
|
260
810
|
end
|
|
261
811
|
dir = positional_dir(parser, argv) or return 2
|
|
262
812
|
|
|
@@ -264,11 +814,15 @@ module OKF
|
|
|
264
814
|
graph = folder.graph(minimal: options[:minimal], body: options[:body])
|
|
265
815
|
report_skipped(folder)
|
|
266
816
|
if options[:json]
|
|
267
|
-
payload
|
|
817
|
+
# The head every view carries: a payload of nodes and edges that never
|
|
818
|
+
# says which bundle they came from is exactly what an agent holding
|
|
819
|
+
# several bundles has to guess at.
|
|
820
|
+
payload = bundle_head(dir).merge(graph.to_h)
|
|
268
821
|
payload = payload.merge(types: graph.type_index, tags: graph.tag_index) if options[:minimal]
|
|
269
822
|
emit_json(payload)
|
|
270
823
|
else
|
|
271
|
-
@out.puts "#{graph.nodes.size}
|
|
824
|
+
@out.puts "Graph — #{bundle_label(dir)} (#{graph.nodes.size} #{pluralize(graph.nodes.size, "concept")}, " \
|
|
825
|
+
"#{graph.edges.size} #{pluralize(graph.edges.size, "link")})"
|
|
272
826
|
end
|
|
273
827
|
0
|
|
274
828
|
end
|
|
@@ -282,11 +836,12 @@ module OKF
|
|
|
282
836
|
def index(argv)
|
|
283
837
|
options = { json: false, body: true, areas: nil }
|
|
284
838
|
parser = OptionParser.new do |o|
|
|
285
|
-
o.banner = "Usage: okf index <
|
|
839
|
+
o.banner = "Usage: okf index <dir|@slug> [--area AREA] [--no-body] [--json]"
|
|
286
840
|
json_flags(o, options, "emit the index map as JSON")
|
|
287
841
|
projection_flags(o, options)
|
|
288
842
|
o.on("--area AREA", "only this directory/area (repeatable; `root` for the bundle root)") { |v| (options[:areas] ||= []) << v }
|
|
289
843
|
o.on("--[no-]body", "include each index's prose body (default: yes)") { |v| options[:body] = v }
|
|
844
|
+
help_flag(o)
|
|
290
845
|
end
|
|
291
846
|
dir = positional_dir(parser, argv) or return 2
|
|
292
847
|
|
|
@@ -295,6 +850,13 @@ module OKF
|
|
|
295
850
|
entries = folder.directory_index
|
|
296
851
|
selected = select_directories(entries, options[:areas])
|
|
297
852
|
if options[:json]
|
|
853
|
+
# --no-body is shorthand for --except body, so asking for the body by
|
|
854
|
+
# name in the same breath is a contradiction. Letting --fields quietly
|
|
855
|
+
# win would hand back the very thing the other flag was there to drop.
|
|
856
|
+
if !options[:body] && Array(options[:fields]).map(&:downcase).include?("body")
|
|
857
|
+
return usage_error("--no-body and --fields body contradict each other: drop one")
|
|
858
|
+
end
|
|
859
|
+
|
|
298
860
|
options[:except] = Array(options[:except]) + [ "body" ] unless options[:body] || options[:fields]
|
|
299
861
|
return print_index_map_json(dir, selected, options)
|
|
300
862
|
end
|
|
@@ -314,7 +876,7 @@ module OKF
|
|
|
314
876
|
|
|
315
877
|
def print_index_map(dir, entries, body)
|
|
316
878
|
noun = entries.size == 1 ? "directory" : "directories"
|
|
317
|
-
@out.puts "Index map — #{dir} (#{entries.size} #{noun})"
|
|
879
|
+
@out.puts "Index map — #{bundle_label(dir)} (#{entries.size} #{noun})"
|
|
318
880
|
entries.each do |entry|
|
|
319
881
|
@out.puts
|
|
320
882
|
@out.puts " #{index_dir_label(entry)}#{index_dir_meta(entry)}"
|
|
@@ -334,8 +896,8 @@ module OKF
|
|
|
334
896
|
end
|
|
335
897
|
|
|
336
898
|
def index_dir_meta(entry)
|
|
337
|
-
count = "#{entry[:count]} #{entry[:count]
|
|
338
|
-
types = entry[:types].map { |type, n| "#{
|
|
899
|
+
count = "#{entry[:count]} #{pluralize(entry[:count], "concept")}"
|
|
900
|
+
types = entry[:types].map { |type, n| "#{OKF.blank?(type) ? "Untyped" : type} #{n}" }.join(", ")
|
|
339
901
|
types.empty? ? " · #{count}" : " · #{count} · #{types}"
|
|
340
902
|
end
|
|
341
903
|
|
|
@@ -377,10 +939,11 @@ module OKF
|
|
|
377
939
|
def catalog(argv)
|
|
378
940
|
options = { json: false }
|
|
379
941
|
parser = OptionParser.new do |o|
|
|
380
|
-
o.banner = "Usage: okf catalog <
|
|
942
|
+
o.banner = "Usage: okf catalog <dir|@slug> [--type T] [--area A] [--tag T] [--json]"
|
|
381
943
|
json_flags(o, options, "emit the catalog as JSON")
|
|
382
944
|
projection_flags(o, options)
|
|
383
945
|
filter_flags(o, options, :type, :area, :tag)
|
|
946
|
+
help_flag(o)
|
|
384
947
|
end
|
|
385
948
|
dir = positional_dir(parser, argv) or return 2
|
|
386
949
|
|
|
@@ -397,10 +960,11 @@ module OKF
|
|
|
397
960
|
def files(argv)
|
|
398
961
|
options = { json: false }
|
|
399
962
|
parser = OptionParser.new do |o|
|
|
400
|
-
o.banner = "Usage: okf files <
|
|
963
|
+
o.banner = "Usage: okf files <dir|@slug> [--type T] [--area A] [--tag T] [--json]"
|
|
401
964
|
json_flags(o, options, "emit the file tree as JSON")
|
|
402
965
|
projection_flags(o, options)
|
|
403
966
|
filter_flags(o, options, :type, :area, :tag)
|
|
967
|
+
help_flag(o)
|
|
404
968
|
end
|
|
405
969
|
dir = positional_dir(parser, argv) or return 2
|
|
406
970
|
|
|
@@ -417,10 +981,11 @@ module OKF
|
|
|
417
981
|
def tags(argv)
|
|
418
982
|
options = { json: false, by: nil }
|
|
419
983
|
parser = OptionParser.new do |o|
|
|
420
|
-
o.banner = "Usage: okf tags <
|
|
984
|
+
o.banner = "Usage: okf tags <dir|@slug> [--by type|area] [--type T] [--area A] [--json]"
|
|
421
985
|
json_flags(o, options, "emit the tag index as JSON")
|
|
422
986
|
o.on("--by DIM", %w[type area], "group the tags by a concept dimension (type | area)") { |v| options[:by] = v.to_sym }
|
|
423
987
|
filter_flags(o, options, :type, :area)
|
|
988
|
+
help_flag(o)
|
|
424
989
|
end
|
|
425
990
|
dir = positional_dir(parser, argv) or return 2
|
|
426
991
|
|
|
@@ -432,9 +997,10 @@ module OKF
|
|
|
432
997
|
def types(argv)
|
|
433
998
|
options = { json: false }
|
|
434
999
|
parser = OptionParser.new do |o|
|
|
435
|
-
o.banner = "Usage: okf types <
|
|
1000
|
+
o.banner = "Usage: okf types <dir|@slug> [--area A] [--tag T] [--json]"
|
|
436
1001
|
json_flags(o, options, "emit the type index as JSON")
|
|
437
1002
|
filter_flags(o, options, :area, :tag)
|
|
1003
|
+
help_flag(o)
|
|
438
1004
|
end
|
|
439
1005
|
dir = positional_dir(parser, argv) or return 2
|
|
440
1006
|
|
|
@@ -494,15 +1060,15 @@ module OKF
|
|
|
494
1060
|
|
|
495
1061
|
# A catalog entry's type for display — "Untyped" when blank, matching the graph.
|
|
496
1062
|
def entry_type(entry)
|
|
497
|
-
entry[:type]
|
|
1063
|
+
OKF.blank?(entry[:type]) ? "Untyped" : entry[:type]
|
|
498
1064
|
end
|
|
499
1065
|
|
|
500
1066
|
def print_grouped_tags(dir, dim, groups, titles)
|
|
501
|
-
@out.puts "Tags — #{dir} (#{distinct_tags(groups)} distinct, by #{dim})"
|
|
1067
|
+
@out.puts "Tags — #{bundle_label(dir)} (#{distinct_tags(groups)} distinct, by #{dim})"
|
|
502
1068
|
groups.each do |key, rows|
|
|
503
1069
|
label = dim == :area && key != "(root)" ? "#{key}/" : key
|
|
504
1070
|
@out.puts
|
|
505
|
-
@out.puts " #{label} (#{rows.size}
|
|
1071
|
+
@out.puts " #{label} (#{rows.size} #{pluralize(rows.size, "tag")})"
|
|
506
1072
|
width = rows.map { |row| row[:tag].length }.max || 0
|
|
507
1073
|
rows.each do |row|
|
|
508
1074
|
names = row[:concepts].map { |id| titles[id] || id }.join(", ")
|
|
@@ -512,12 +1078,10 @@ module OKF
|
|
|
512
1078
|
end
|
|
513
1079
|
|
|
514
1080
|
def print_grouped_tags_json(dir, dim, groups)
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
end
|
|
520
|
-
)
|
|
1081
|
+
groups_json = groups.map do |key, rows|
|
|
1082
|
+
{ dim.to_s => key, "count" => rows.size, "tags" => index_rows_json(:tag, rows) }
|
|
1083
|
+
end
|
|
1084
|
+
emit_json(bundle_head(dir).merge("count" => distinct_tags(groups), "by" => dim.to_s, "groups" => groups_json))
|
|
521
1085
|
end
|
|
522
1086
|
|
|
523
1087
|
def distinct_tags(groups)
|
|
@@ -527,8 +1091,9 @@ module OKF
|
|
|
527
1091
|
def stats(argv)
|
|
528
1092
|
options = { json: false }
|
|
529
1093
|
parser = OptionParser.new do |o|
|
|
530
|
-
o.banner = "Usage: okf stats <
|
|
1094
|
+
o.banner = "Usage: okf stats <dir|@slug> [--json]"
|
|
531
1095
|
json_flags(o, options, "emit the stats as JSON")
|
|
1096
|
+
help_flag(o)
|
|
532
1097
|
end
|
|
533
1098
|
dir = positional_dir(parser, argv) or return 2
|
|
534
1099
|
|
|
@@ -569,6 +1134,58 @@ module OKF
|
|
|
569
1134
|
parser.on("--pretty", "indent the JSON for reading (implies --json)") { options[:json] = true; @pretty = true }
|
|
570
1135
|
end
|
|
571
1136
|
|
|
1137
|
+
# Every parser answers its own -h/--help, so no parser inherits
|
|
1138
|
+
# OptionParser's officious one: that prints to the process's $stdout rather
|
|
1139
|
+
# than @out (an embedding app that injects streams never sees it) and ends
|
|
1140
|
+
# the process with `exit` rather than returning a status (a test that asks a
|
|
1141
|
+
# command for help takes the whole runner down with it). Thrown, not
|
|
1142
|
+
# returned — #run catches it — because a parser is parsed inside
|
|
1143
|
+
# positional_dir, where every other early exit means "exit 2".
|
|
1144
|
+
# on_tail, so help sorts last in the list it is printing.
|
|
1145
|
+
def help_flag(parser)
|
|
1146
|
+
parser.on_tail("-h", "--help", "print this message") do
|
|
1147
|
+
@out.puts parser.help
|
|
1148
|
+
throw :help, 0
|
|
1149
|
+
end
|
|
1150
|
+
end
|
|
1151
|
+
|
|
1152
|
+
# The registered engines, read at parse time so an addon that registers one
|
|
1153
|
+
# shows up in `--help` without the CLI knowing it exists.
|
|
1154
|
+
def engine_names
|
|
1155
|
+
OKF::Bundle::Search.engines.map(&:id).join(" | ")
|
|
1156
|
+
end
|
|
1157
|
+
|
|
1158
|
+
def unsupported_query_message(error)
|
|
1159
|
+
wanted = error.missing.map { |name| CAPABILITY_FLAGS.fetch(name, ":#{name}") }.join(", ")
|
|
1160
|
+
return "no available search engine offers #{wanted}" if error.engine.nil?
|
|
1161
|
+
|
|
1162
|
+
able = OKF::Bundle::Search.engines.select { |engine| (error.missing - engine.capabilities).empty? }
|
|
1163
|
+
message = "--engine #{error.engine} does not support #{wanted}"
|
|
1164
|
+
message += " (try --engine #{able.map(&:id).join(" or ")})" unless able.empty?
|
|
1165
|
+
message
|
|
1166
|
+
end
|
|
1167
|
+
|
|
1168
|
+
# The engine story, told once, in the only place there is to tell it. `search`
|
|
1169
|
+
# routes on what the query needs — a pattern needs the scan, --fuzzy needs the
|
|
1170
|
+
# index — and says nothing about it at runtime: no note on stderr, nothing in
|
|
1171
|
+
# the header, and deliberately no --engine flag. So this is where a user learns
|
|
1172
|
+
# that the exactness a token index gives up is still reachable, and that -e is
|
|
1173
|
+
# how. Without it that capability is present but undiscoverable.
|
|
1174
|
+
#
|
|
1175
|
+
# It leads rather than trails because #help_flag registers -h with `on_tail`,
|
|
1176
|
+
# which OptionParser renders after every separator: a closing paragraph would
|
|
1177
|
+
# print *above* the -h line and split the option list in half. Stating the
|
|
1178
|
+
# matching model before the flags reads better anyway.
|
|
1179
|
+
def search_engine_note(parser)
|
|
1180
|
+
parser.separator ""
|
|
1181
|
+
parser.separator "Terms match raw text, so a phrase (\"dedup key\"), a dotted identifier (7.2.0,"
|
|
1182
|
+
parser.separator "customer_id) and a word inside `backticks` all match literally — the scan engine."
|
|
1183
|
+
parser.separator "--engine index matches whole tokens and the tokens they prefix, ranked by BM25+:"
|
|
1184
|
+
parser.separator "better ranking and the engine the browser page runs, at the cost of that"
|
|
1185
|
+
parser.separator "exactness. --fuzzy implies it. Add -e to read the terms as regular expressions."
|
|
1186
|
+
parser.separator ""
|
|
1187
|
+
end
|
|
1188
|
+
|
|
572
1189
|
# --fields/--except project the JSON down to the properties an agent wants, so it
|
|
573
1190
|
# never pays tokens for fields it will not read. --fields is an allowlist,
|
|
574
1191
|
# --except a denylist (mutually exclusive); both imply --json and apply per item
|
|
@@ -633,6 +1250,7 @@ module OKF
|
|
|
633
1250
|
o.banner = "Usage: okf skill <dest-dir> [--here] [--force]"
|
|
634
1251
|
o.on("--here", "install straight into <dest-dir>, wherever it is (no skills/okf nesting)") { options[:nest] = false }
|
|
635
1252
|
o.on("--force", "overwrite a non-empty destination") { options[:force] = true }
|
|
1253
|
+
help_flag(o)
|
|
636
1254
|
end
|
|
637
1255
|
parser.parse!(argv)
|
|
638
1256
|
dest = argv.shift
|
|
@@ -662,10 +1280,14 @@ module OKF
|
|
|
662
1280
|
note_skipped(folder.bundle.unparseable.size)
|
|
663
1281
|
end
|
|
664
1282
|
|
|
1283
|
+
# The bucket holds two kinds now — frontmatter that would not parse, and a
|
|
1284
|
+
# file that would not open — so the note names neither and points at the verb
|
|
1285
|
+
# that names both. "invalid frontmatter" was a guess the summary had no need
|
|
1286
|
+
# to make: `validate` prints the file and the reason for every one of them.
|
|
665
1287
|
def note_skipped(count)
|
|
666
1288
|
return if count.nil? || count <= 0
|
|
667
1289
|
|
|
668
|
-
@err.puts "note: skipped #{count} file(s)
|
|
1290
|
+
@err.puts "note: skipped #{count} unusable file(s) (run `okf validate` for details)"
|
|
669
1291
|
end
|
|
670
1292
|
|
|
671
1293
|
# Turn a --stale-after value (90d, 12w, or an ISO date) into an absolute cutoff
|
|
@@ -684,8 +1306,9 @@ module OKF
|
|
|
684
1306
|
:invalid
|
|
685
1307
|
end
|
|
686
1308
|
|
|
687
|
-
# Parse options, then require a single
|
|
688
|
-
# Returns the directory, or nil (after
|
|
1309
|
+
# Parse options, then require a single bundle positional — a directory, or an
|
|
1310
|
+
# @ref into the registry. Returns the bundle's directory, or nil (after
|
|
1311
|
+
# reporting) so the caller returns 2.
|
|
689
1312
|
def positional_dir(parser, argv)
|
|
690
1313
|
parser.parse!(argv)
|
|
691
1314
|
dir = argv.shift
|
|
@@ -693,11 +1316,143 @@ module OKF
|
|
|
693
1316
|
@err.puts parser.banner
|
|
694
1317
|
return nil
|
|
695
1318
|
end
|
|
696
|
-
|
|
697
|
-
|
|
1319
|
+
# A second bundle is a question this verb cannot answer: only `search`
|
|
1320
|
+
# merges across bundles and only `server` mounts several. Reading the
|
|
1321
|
+
# first and dropping the rest would answer confidently about a bundle the
|
|
1322
|
+
# user never asked about — the silent-wrong-answer shape, so: exit 2.
|
|
1323
|
+
return nil unless no_extras?(argv)
|
|
1324
|
+
|
|
1325
|
+
resolve_ref(dir)
|
|
1326
|
+
rescue OptionParser::ParseError => e
|
|
1327
|
+
@err.puts e.message
|
|
1328
|
+
nil
|
|
1329
|
+
end
|
|
1330
|
+
|
|
1331
|
+
# Parse options, then take zero or more bundle positionals (the multi-bundle
|
|
1332
|
+
# server) — directories or @refs. Returns the resolved array (possibly
|
|
1333
|
+
# empty), or nil (after reporting) so the caller returns 2.
|
|
1334
|
+
def positional_dirs(parser, argv)
|
|
1335
|
+
parser.parse!(argv)
|
|
1336
|
+
dirs = argv.map { |dir| resolve_ref(dir) }
|
|
1337
|
+
dirs.include?(nil) ? nil : dirs
|
|
1338
|
+
rescue OptionParser::ParseError => e
|
|
1339
|
+
@err.puts e.message
|
|
1340
|
+
nil
|
|
1341
|
+
end
|
|
1342
|
+
|
|
1343
|
+
# "@slug" — or bare "@", the registry's default — names a registered bundle
|
|
1344
|
+
# wherever a <dir> goes; anything else must be a directory on disk. A
|
|
1345
|
+
# leading @ always means the registry (a directory literally named that way
|
|
1346
|
+
# stays reachable as ./@name), and the registry loads only when a ref
|
|
1347
|
+
# appears, so plain-dir invocations never pay for it. Returns the bundle's
|
|
1348
|
+
# directory, or nil after reporting.
|
|
1349
|
+
def resolve_ref(arg)
|
|
1350
|
+
return resolve_registered(arg) if arg.start_with?("@")
|
|
1351
|
+
|
|
1352
|
+
unless File.directory?(arg)
|
|
1353
|
+
@err.puts "error: #{arg} is not a directory or a registry ref " \
|
|
1354
|
+
"(@slug names a registered bundle, @ the default; okf registry list)"
|
|
698
1355
|
return nil
|
|
699
1356
|
end
|
|
700
|
-
|
|
1357
|
+
arg
|
|
1358
|
+
end
|
|
1359
|
+
|
|
1360
|
+
# Load the registry, turning a malformed file into a reported usage error
|
|
1361
|
+
# instead of an OKF::Error escaping through whatever verb took an @ref —
|
|
1362
|
+
# only `server` and the `registry` verbs rescue one. Returns nil after
|
|
1363
|
+
# reporting, so every caller returns 2.
|
|
1364
|
+
def load_registry
|
|
1365
|
+
require "okf/registry"
|
|
1366
|
+
OKF::Registry.load
|
|
1367
|
+
rescue OKF::Error => e
|
|
1368
|
+
@err.puts "error: #{e.message}"
|
|
1369
|
+
nil
|
|
1370
|
+
end
|
|
1371
|
+
|
|
1372
|
+
# Resolve one @ref through the registry under $OKF_HOME (default ~/.okf).
|
|
1373
|
+
# The slug part is normalized
|
|
1374
|
+
# exactly as registration normalized it, so @One finds the bundle
|
|
1375
|
+
# registered from dir One — but never through #slugify's mint-a-name
|
|
1376
|
+
# placeholder, so "@***" is a bad ref rather than whatever is slugged
|
|
1377
|
+
# "bundle". An explicit ask fails hard: an unknown slug or a
|
|
1378
|
+
# registered-but-gone directory is a usage error naming the registry file
|
|
1379
|
+
# and the next move, never a silent skip.
|
|
1380
|
+
#
|
|
1381
|
+
# @all never resolves here. `search` expands it before this point; every
|
|
1382
|
+
# other verb takes exactly one bundle, so letting it through would mean
|
|
1383
|
+
# @all lints when one bundle is registered and exits 2 when two are —
|
|
1384
|
+
# behavior that varies with the size of the registry, which is the
|
|
1385
|
+
# silent-wrong-answer shape the second-bundle rule exists to stop. Say what
|
|
1386
|
+
# @all is instead of calling it a bundle nobody registered ("all" cannot be
|
|
1387
|
+
# registered — Registry::RESERVED_SLUGS sees to that).
|
|
1388
|
+
def resolve_registered(ref)
|
|
1389
|
+
@ref_failure = :registry
|
|
1390
|
+
if all_ref?(ref)
|
|
1391
|
+
@err.puts "error: #{ALL_REF} is only supported by `okf search` (it names every registered bundle)"
|
|
1392
|
+
return nil
|
|
1393
|
+
end
|
|
1394
|
+
registry = load_registry
|
|
1395
|
+
return nil unless registry
|
|
1396
|
+
|
|
1397
|
+
asked = ref[1..-1]
|
|
1398
|
+
slug = OKF::Registry.normalize(asked)
|
|
1399
|
+
entry = if asked.empty?
|
|
1400
|
+
registry.default # bare "@"
|
|
1401
|
+
elsif slug.empty?
|
|
1402
|
+
nil # "@***" — nothing to look up, and no placeholder to fall back on
|
|
1403
|
+
else
|
|
1404
|
+
registry.get(slug)
|
|
1405
|
+
end
|
|
1406
|
+
if entry.nil?
|
|
1407
|
+
@ref_failure = :unknown
|
|
1408
|
+
hint = registry.empty? ? "okf registry set <dir>" : "okf registry list"
|
|
1409
|
+
@err.puts "error: not a registered bundle: #{ref} in #{registry.path} (#{hint})"
|
|
1410
|
+
return nil
|
|
1411
|
+
end
|
|
1412
|
+
unless File.directory?(entry.path)
|
|
1413
|
+
@ref_failure = :missing
|
|
1414
|
+
@err.puts "error: #{ref} points to #{entry.path}, which is not a directory (okf registry del #{entry.slug}, or restore it)"
|
|
1415
|
+
return nil
|
|
1416
|
+
end
|
|
1417
|
+
ref_slugs[entry.path] = entry.slug
|
|
1418
|
+
entry.path
|
|
1419
|
+
end
|
|
1420
|
+
|
|
1421
|
+
# Which slug each @ref resolved to, by absolute path — so a hub built from
|
|
1422
|
+
# refs mounts each bundle under its registered slug, not its dir basename.
|
|
1423
|
+
# Reset by every run; never memoized here, or a stale run would seed it.
|
|
1424
|
+
attr_reader :ref_slugs
|
|
1425
|
+
|
|
1426
|
+
# Every bundle-scoped output names its bundle in the identity the caller
|
|
1427
|
+
# used: `@handbook (/path)` when they named a registered bundle, the plain
|
|
1428
|
+
# path otherwise. A dir named by path stays a path — inventing a slug for it
|
|
1429
|
+
# would imply a registration that does not exist, and looking one up would
|
|
1430
|
+
# cost a registry read on every plain-dir run.
|
|
1431
|
+
def bundle_label(dir)
|
|
1432
|
+
slug = ref_slugs[dir]
|
|
1433
|
+
slug ? "@#{slug} (#{dir})" : dir.to_s
|
|
1434
|
+
end
|
|
1435
|
+
|
|
1436
|
+
# The JSON head for one bundle. `bundle` is always its directory and `slug`
|
|
1437
|
+
# always a registry slug — never the same key meaning two things — so a
|
|
1438
|
+
# consumer resolves a row to a file without a second lookup.
|
|
1439
|
+
def bundle_head(dir)
|
|
1440
|
+
head = { "bundle" => dir }
|
|
1441
|
+
slug = ref_slugs[dir]
|
|
1442
|
+
head["slug"] = slug if slug
|
|
1443
|
+
head
|
|
1444
|
+
end
|
|
1445
|
+
|
|
1446
|
+
# Parse options, then require a single non-directory positional (e.g. a slug).
|
|
1447
|
+
# Returns it, or nil (after reporting the banner) so the caller returns 2.
|
|
1448
|
+
def positional(parser, argv)
|
|
1449
|
+
parser.parse!(argv)
|
|
1450
|
+
value = argv.shift
|
|
1451
|
+
if value.nil?
|
|
1452
|
+
@err.puts parser.banner
|
|
1453
|
+
return nil
|
|
1454
|
+
end
|
|
1455
|
+
value
|
|
701
1456
|
rescue OptionParser::ParseError => e
|
|
702
1457
|
@err.puts e.message
|
|
703
1458
|
nil
|
|
@@ -705,7 +1460,7 @@ module OKF
|
|
|
705
1460
|
|
|
706
1461
|
def print_validation(dir, result)
|
|
707
1462
|
counts = result.counts
|
|
708
|
-
@out.puts "OKF v0.1 conformance — #{dir}"
|
|
1463
|
+
@out.puts "OKF v0.1 conformance — #{bundle_label(dir)}"
|
|
709
1464
|
@out.puts " concepts: #{counts[:concepts]} index.md: #{counts[:indexes]} log.md: #{counts[:logs]}"
|
|
710
1465
|
result.errors.each { |e| @out.puts " #{paint("✗ ERROR", 31)} #{e[:path]}: #{e[:message]}" }
|
|
711
1466
|
result.warnings.each { |w| @out.puts " #{paint("! warn", 33)} #{w[:path]}: #{w[:message]}" }
|
|
@@ -719,18 +1474,17 @@ module OKF
|
|
|
719
1474
|
end
|
|
720
1475
|
|
|
721
1476
|
def print_validation_json(dir, result)
|
|
722
|
-
emit_json(
|
|
723
|
-
"bundle" => dir,
|
|
1477
|
+
emit_json(bundle_head(dir).merge(
|
|
724
1478
|
"conformant" => result.valid?,
|
|
725
1479
|
"counts" => result.counts,
|
|
726
1480
|
"errors" => result.errors,
|
|
727
1481
|
"warnings" => result.warnings
|
|
728
|
-
)
|
|
1482
|
+
))
|
|
729
1483
|
end
|
|
730
1484
|
|
|
731
1485
|
def print_lint(dir, report)
|
|
732
1486
|
stats = report.stats
|
|
733
|
-
@out.puts "OKF lint — #{dir}"
|
|
1487
|
+
@out.puts "OKF lint — #{bundle_label(dir)}"
|
|
734
1488
|
@out.puts " concepts: #{stats[:concepts]} edges: #{stats[:edges]} index.md: #{stats[:indexes]} log.md: #{stats[:logs]}"
|
|
735
1489
|
summary = lint_summary(stats)
|
|
736
1490
|
@out.puts " #{summary}" unless summary.empty?
|
|
@@ -751,12 +1505,11 @@ module OKF
|
|
|
751
1505
|
end
|
|
752
1506
|
|
|
753
1507
|
def print_lint_json(dir, report)
|
|
754
|
-
emit_json(
|
|
755
|
-
"bundle" => dir,
|
|
1508
|
+
emit_json(bundle_head(dir).merge(
|
|
756
1509
|
"healthy" => report.healthy?,
|
|
757
1510
|
"stats" => report.stats,
|
|
758
1511
|
"findings" => report.findings
|
|
759
|
-
)
|
|
1512
|
+
))
|
|
760
1513
|
end
|
|
761
1514
|
|
|
762
1515
|
# Degree-0 nodes as { id:, title:, dir: }, sorted by path — the same set lint's
|
|
@@ -769,7 +1522,7 @@ module OKF
|
|
|
769
1522
|
end
|
|
770
1523
|
|
|
771
1524
|
def print_loose(dir, files)
|
|
772
|
-
@out.puts "Loose files — #{dir} (#{files.size})"
|
|
1525
|
+
@out.puts "Loose files — #{bundle_label(dir)} (#{files.size})"
|
|
773
1526
|
if files.empty?
|
|
774
1527
|
@out.puts " #{paint("✓ none — every concept links or is linked", 32)}"
|
|
775
1528
|
return
|
|
@@ -786,15 +1539,14 @@ module OKF
|
|
|
786
1539
|
end
|
|
787
1540
|
|
|
788
1541
|
def print_loose_json(dir, files)
|
|
789
|
-
emit_json(
|
|
790
|
-
"bundle" => dir,
|
|
1542
|
+
emit_json(bundle_head(dir).merge(
|
|
791
1543
|
"count" => files.size,
|
|
792
1544
|
"loose" => files.map { |file| stringify(file) }
|
|
793
|
-
)
|
|
1545
|
+
))
|
|
794
1546
|
end
|
|
795
1547
|
|
|
796
1548
|
def print_catalog(dir, entries, total)
|
|
797
|
-
@out.puts "Catalog — #{dir} (#{counted(entries.size, total, "
|
|
1549
|
+
@out.puts "Catalog — #{bundle_label(dir)} (#{counted(entries.size, total, "concept")})"
|
|
798
1550
|
entries.group_by { |entry| entry[:area] }.sort_by(&:first).each do |area, group|
|
|
799
1551
|
@out.puts
|
|
800
1552
|
@out.puts " #{area == "(root)" ? "(root)" : "#{area}/"} (#{group.size})"
|
|
@@ -812,7 +1564,7 @@ module OKF
|
|
|
812
1564
|
end
|
|
813
1565
|
|
|
814
1566
|
def print_files(dir, entries, total)
|
|
815
|
-
@out.puts "Files — #{dir} (#{counted(entries.size, total, "
|
|
1567
|
+
@out.puts "Files — #{bundle_label(dir)} (#{counted(entries.size, total, "file")})"
|
|
816
1568
|
entries.group_by { |entry| entry[:dir] }.sort_by(&:first).each do |folder, group|
|
|
817
1569
|
width = group.map { |entry| File.basename("#{entry[:id]}.md").length }.max
|
|
818
1570
|
@out.puts
|
|
@@ -832,7 +1584,7 @@ module OKF
|
|
|
832
1584
|
end
|
|
833
1585
|
|
|
834
1586
|
def print_index(dir, label, key, rows, titles)
|
|
835
|
-
@out.puts "#{label} — #{dir} (#{rows.size} distinct)"
|
|
1587
|
+
@out.puts "#{label} — #{bundle_label(dir)} (#{rows.size} distinct)"
|
|
836
1588
|
@out.puts
|
|
837
1589
|
width = rows.map { |row| row[key].length }.max || 0
|
|
838
1590
|
rows.each do |row|
|
|
@@ -842,19 +1594,29 @@ module OKF
|
|
|
842
1594
|
end
|
|
843
1595
|
|
|
844
1596
|
def print_index_json(dir, plural, key, rows)
|
|
845
|
-
emit_json(
|
|
1597
|
+
emit_json(bundle_head(dir).merge("count" => rows.size, plural => index_rows_json(key, rows)))
|
|
846
1598
|
end
|
|
847
1599
|
|
|
848
1600
|
def index_rows_json(key, rows)
|
|
849
1601
|
rows.map { |row| { key.to_s => row[key], "count" => row[:count], "concepts" => row[:concepts] } }
|
|
850
1602
|
end
|
|
851
1603
|
|
|
1604
|
+
# "3 concepts", "1 concept", "1 of 7 concepts" — the noun agrees with the
|
|
1605
|
+
# number it follows: the size when that is all we show, the total otherwise.
|
|
852
1606
|
def counted(size, total, noun)
|
|
853
|
-
|
|
1607
|
+
return "#{size} #{pluralize(size, noun)}" if size == total
|
|
1608
|
+
|
|
1609
|
+
"#{size} of #{total} #{pluralize(total, noun)}"
|
|
1610
|
+
end
|
|
1611
|
+
|
|
1612
|
+
# The gem's whole vocabulary is regular, so a naive +s is not a shortcut —
|
|
1613
|
+
# it is the rule. Callers pass the singular.
|
|
1614
|
+
def pluralize(count, noun)
|
|
1615
|
+
count == 1 ? noun : "#{noun}s"
|
|
854
1616
|
end
|
|
855
1617
|
|
|
856
1618
|
def print_stats(dir, stats)
|
|
857
|
-
@out.puts "Stats — #{dir}"
|
|
1619
|
+
@out.puts "Stats — #{bundle_label(dir)}"
|
|
858
1620
|
@out.puts
|
|
859
1621
|
@out.puts " concepts #{stats[:concepts]}"
|
|
860
1622
|
@out.puts " areas #{stats[:areas]}"
|
|
@@ -875,11 +1637,11 @@ module OKF
|
|
|
875
1637
|
end
|
|
876
1638
|
|
|
877
1639
|
def print_stats_json(dir, stats)
|
|
878
|
-
emit_json(
|
|
879
|
-
"
|
|
1640
|
+
emit_json(bundle_head(dir).merge(
|
|
1641
|
+
"concepts" => stats[:concepts], "areas" => stats[:areas],
|
|
880
1642
|
"concept_types" => stats[:types], "cross_links" => stats[:cross_links], "distinct_tags" => stats[:tags],
|
|
881
1643
|
"by_type" => stats[:by_type], "by_area" => stats[:by_area]
|
|
882
|
-
)
|
|
1644
|
+
))
|
|
883
1645
|
end
|
|
884
1646
|
|
|
885
1647
|
# The single JSON writer. Compact by default — the token-efficient substrate an
|
|
@@ -892,13 +1654,18 @@ module OKF
|
|
|
892
1654
|
# Emit a list view's JSON envelope with --fields/--except projection applied to
|
|
893
1655
|
# each item. Returns the verb's exit code (0, or 2 on a bad projection request —
|
|
894
1656
|
# both flags at once, or a field name no item carries).
|
|
895
|
-
|
|
1657
|
+
# +dir+ is the bundle's directory — or a ready-made head Hash when the
|
|
1658
|
+
# payload spans bundles (multi-bundle search's "bundles" key).
|
|
1659
|
+
# +key+ names the JSON property the rows land under; +shape+ names the row
|
|
1660
|
+
# shape to check --fields/--except against. They are the same for every view
|
|
1661
|
+
# but search, whose two modes emit the same property from different rows.
|
|
1662
|
+
def emit_list_json(dir, key, items, options, extra = {}, shape = key)
|
|
896
1663
|
return usage_error("--fields and --except are mutually exclusive") if options[:fields] && options[:except]
|
|
897
1664
|
|
|
898
|
-
unknown = unknown_fields(items, options)
|
|
899
|
-
return usage_error("unknown field(s): #{unknown.join(", ")} (available: #{available_fields(items).join(", ")})") unless unknown.empty?
|
|
1665
|
+
unknown = unknown_fields(items, options, shape)
|
|
1666
|
+
return usage_error("unknown field(s): #{unknown.join(", ")} (available: #{available_fields(items, shape).join(", ")})") unless unknown.empty?
|
|
900
1667
|
|
|
901
|
-
payload =
|
|
1668
|
+
payload = (dir.is_a?(Hash) ? dir.dup : bundle_head(dir)).merge(extra)
|
|
902
1669
|
payload["count"] = items.size
|
|
903
1670
|
payload[key] = project(items, options)
|
|
904
1671
|
emit_json(payload)
|
|
@@ -917,17 +1684,22 @@ module OKF
|
|
|
917
1684
|
end
|
|
918
1685
|
end
|
|
919
1686
|
|
|
920
|
-
|
|
921
|
-
|
|
1687
|
+
# The declared shape wins over the data's, so the same typo gets the same
|
|
1688
|
+
# answer whether or not the result happened to have rows; a view with no
|
|
1689
|
+
# declared shape falls back to what it actually emitted.
|
|
1690
|
+
def available_fields(items, key = nil)
|
|
1691
|
+
ROW_FIELDS[key] || (items.first ? items.first.keys.map(&:to_s) : [])
|
|
922
1692
|
end
|
|
923
1693
|
|
|
924
1694
|
# Requested field names that no item actually carries — a typo guard (exit 2),
|
|
925
1695
|
# matching how lint rejects unknown check names.
|
|
926
|
-
def unknown_fields(items, options)
|
|
1696
|
+
def unknown_fields(items, options, key = nil)
|
|
927
1697
|
requested = (Array(options[:fields]) + Array(options[:except])).map(&:downcase)
|
|
928
|
-
return [] if requested.empty?
|
|
1698
|
+
return [] if requested.empty?
|
|
1699
|
+
|
|
1700
|
+
known = available_fields(items, key).map(&:downcase)
|
|
1701
|
+
return [] if known.empty? # an unknown view: no shape to check against, so accept
|
|
929
1702
|
|
|
930
|
-
known = available_fields(items).map(&:downcase)
|
|
931
1703
|
requested.reject { |field| known.include?(field) }.uniq
|
|
932
1704
|
end
|
|
933
1705
|
|
|
@@ -976,23 +1748,37 @@ module OKF
|
|
|
976
1748
|
io.puts <<~USAGE
|
|
977
1749
|
okf <command> [options]
|
|
978
1750
|
|
|
979
|
-
skill <dest> [--here] [--force]
|
|
980
|
-
server
|
|
981
|
-
render <dir> [-o FILE] [--layout NAME] [...] write a static, self-contained HTML graph
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
1751
|
+
skill <dest> [--here] [--force] install the companion agent skill
|
|
1752
|
+
server [DIR|@slug…] [-p PORT] [--bind ADDR] [...] serve one bundle, or many behind a hub
|
|
1753
|
+
render <dir|@slug> [-o FILE] [--layout NAME] [...] write a static, self-contained HTML graph
|
|
1754
|
+
|
|
1755
|
+
registry list [--json] list registered bundles (* marks the default)
|
|
1756
|
+
registry set <dir|@slug> [--as SLUG] [--default] add or update a bundle (a bare `server` serves them)
|
|
1757
|
+
registry del <dir|@slug> remove a bundle from the registry
|
|
1758
|
+
registry default <@slug> move a bundle to the front (the default)
|
|
1759
|
+
registry rename <@slug> <new> rename a registered bundle (<new> is a new name, not a ref)
|
|
1760
|
+
|
|
1761
|
+
lint <dir|@slug> [--json] [--fail-on warn] [...] report curation-quality issues
|
|
1762
|
+
loose <dir|@slug> [--json] list files with no graph links, by folder
|
|
1763
|
+
validate <dir|@slug> [--json] check OKF v0.1 conformance
|
|
1764
|
+
|
|
1765
|
+
search <dir|@slug…|@all> <term…> [-e|--fuzzy] [...] find concepts by text or regexp, ranked (@all: every bundle)
|
|
1766
|
+
index <dir|@slug> [--json] [--area A] [--no-body] the index map: dirs, their listings and rollups
|
|
1767
|
+
stats <dir|@slug> [--json] bundle rollups (concepts, types, areas, links, tags)
|
|
1768
|
+
types <dir|@slug> [--json] [filters] list types with their concepts, by count
|
|
1769
|
+
tags <dir|@slug> [--json] [--by DIM] [filters] list tags with their concepts, by count
|
|
1770
|
+
files <dir|@slug> [--json] [filters] list files with titles, by folder
|
|
1771
|
+
catalog <dir|@slug> [--json] [filters] list concepts with metadata, by area
|
|
1772
|
+
|
|
1773
|
+
graph <dir|@slug> [--json] [--minimal] [--no-body] print the knowledge graph
|
|
1774
|
+
|
|
1775
|
+
@slug names a registered bundle instead of a path — the slug from
|
|
1776
|
+
`okf registry set`, or bare @ for the registry default. Anywhere a <dir>
|
|
1777
|
+
goes, an @slug goes: `okf lint @handbook`, `okf render @ -o graph.html`.
|
|
1778
|
+
The registry lives under $OKF_HOME (default ~/.okf); set it to point
|
|
1779
|
+
every verb at another one.
|
|
1780
|
+
search spans bundles: several leading @slugs, or @all for every registered one
|
|
1781
|
+
(@all skips a bundle whose directory is gone; a named @slug insists on it).
|
|
996
1782
|
|
|
997
1783
|
[filters] narrow a view to matching concepts: --type TYPE, --area AREA, --tag TAG
|
|
998
1784
|
(each view takes the ones orthogonal to it; matching is case-insensitive).
|