okf 1.7.0 → 1.8.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 +151 -0
- data/README.md +71 -20
- data/lib/okf/bundle/folder.rb +9 -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/cli.rb +833 -117
- data/lib/okf/registry.rb +370 -0
- data/lib/okf/server/app.rb +19 -4
- data/lib/okf/server/graph/template.html.erb +578 -46
- data/lib/okf/server/graph.rb +28 -2
- data/lib/okf/server/hub.rb +207 -0
- data/lib/okf/skill/SKILL.md +1 -1
- data/lib/okf/skill/playbooks/search.md +3 -0
- data/lib/okf/skill/reference/cli.md +126 -8
- data/lib/okf/version.rb +1 -1
- metadata +3 -1
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,82 @@ 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 row shape each list view emits, so `--fields`/`--except` can be checked
|
|
96
|
+
# against a name even when the result is empty. Without it the typo guard
|
|
97
|
+
# keyed off the data: `--fields bogus` was a usage error against a bundle
|
|
98
|
+
# with matches and silently fine against one without, which made a typo's
|
|
99
|
+
# fate depend on whether a filter happened to match. A test asserts each
|
|
100
|
+
# view's real rows carry exactly these, so the two cannot drift.
|
|
101
|
+
# Declared in emission order, so the "available:" list a typo prints reads
|
|
102
|
+
# the same as the rows themselves.
|
|
103
|
+
ROW_FIELDS = {
|
|
104
|
+
"matches" => %w[id title type area tags matched score snippet],
|
|
105
|
+
# Registry mode labels every row with the bundle it came from; a plain-dir
|
|
106
|
+
# search has one bundle and no slug to carry. Two shapes, because the typo
|
|
107
|
+
# guard checks against the *declared* one — a single shape covering both
|
|
108
|
+
# would let `--fields slug` pass on a search whose rows have none, and hand
|
|
109
|
+
# back an empty object per match under a count that says otherwise.
|
|
110
|
+
"matches_by_ref" => %w[slug id title type area tags matched score snippet],
|
|
111
|
+
"concepts" => %w[id title type description tags timestamp status backlog_ref dir area links_out links_in],
|
|
112
|
+
"files" => %w[path id dir type title description],
|
|
113
|
+
"directories" => %w[dir index_path present synthesized count types tags subdirs body listing],
|
|
114
|
+
"bundles" => %w[slug title dir mount default missing]
|
|
115
|
+
}.freeze
|
|
116
|
+
|
|
70
117
|
private
|
|
71
118
|
|
|
72
119
|
def validate(argv)
|
|
73
120
|
options = { json: false }
|
|
74
121
|
parser = OptionParser.new do |o|
|
|
75
|
-
o.banner = "Usage: okf validate <
|
|
122
|
+
o.banner = "Usage: okf validate <dir|@slug> [--json]"
|
|
76
123
|
json_flags(o, options, "emit a JSON report")
|
|
124
|
+
help_flag(o)
|
|
77
125
|
end
|
|
78
126
|
dir = positional_dir(parser, argv) or return 2
|
|
79
127
|
|
|
@@ -85,13 +133,14 @@ module OKF
|
|
|
85
133
|
def lint(argv)
|
|
86
134
|
options = { json: false, min_body: OKF::Bundle::Linter::DEFAULT_MIN_BODY, stale_after: nil, only: nil, except: nil, fail_on: :never }
|
|
87
135
|
parser = OptionParser.new do |o|
|
|
88
|
-
o.banner = "Usage: okf lint <
|
|
136
|
+
o.banner = "Usage: okf lint <dir|@slug> [--json] [--min-body N] [--stale-after DUR] [--only a,b] [--except a,b] [--fail-on warn]"
|
|
89
137
|
json_flags(o, options, "emit a JSON report")
|
|
90
138
|
o.on("--min-body N", Integer, "stub threshold in body characters (default #{OKF::Bundle::Linter::DEFAULT_MIN_BODY})") { |v| options[:min_body] = v }
|
|
91
139
|
o.on("--stale-after DUR", "flag concepts older than DUR (e.g. 90d, 12w, 2026-01-01)") { |v| options[:stale_after] = v }
|
|
92
140
|
o.on("--only LIST", Array, "run only these checks (comma-separated)") { |v| options[:only] = v.map(&:to_sym) }
|
|
93
141
|
o.on("--except LIST", Array, "skip these checks (comma-separated)") { |v| options[:except] = v.map(&:to_sym) }
|
|
94
142
|
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 }
|
|
143
|
+
help_flag(o)
|
|
95
144
|
end
|
|
96
145
|
dir = positional_dir(parser, argv) or return 2
|
|
97
146
|
|
|
@@ -121,8 +170,9 @@ module OKF
|
|
|
121
170
|
def loose(argv)
|
|
122
171
|
options = { json: false }
|
|
123
172
|
parser = OptionParser.new do |o|
|
|
124
|
-
o.banner = "Usage: okf loose <
|
|
173
|
+
o.banner = "Usage: okf loose <dir|@slug> [--json]"
|
|
125
174
|
json_flags(o, options, "emit the loose files as JSON")
|
|
175
|
+
help_flag(o)
|
|
126
176
|
end
|
|
127
177
|
dir = positional_dir(parser, argv) or return 2
|
|
128
178
|
|
|
@@ -143,23 +193,52 @@ module OKF
|
|
|
143
193
|
def search(argv)
|
|
144
194
|
options = { json: false, regexp: false }
|
|
145
195
|
parser = OptionParser.new do |o|
|
|
146
|
-
o.banner = "Usage: okf search <
|
|
196
|
+
o.banner = "Usage: okf search <dir|@slug…|@all> <term> [term ...] [--regexp] [--in FIELDS] [--type T] [--area A] [--tag T] [--json]"
|
|
147
197
|
json_flags(o, options, "emit the matches as JSON")
|
|
148
198
|
projection_flags(o, options)
|
|
149
199
|
o.on("-e", "--regexp", "treat each term as a Ruby regular expression (case-insensitive)") { options[:regexp] = true }
|
|
150
200
|
o.on("--in LIST", Array, "search only these fields (#{OKF::Bundle::Search::FIELDS.join(", ")})") { |v| options[:in] = v.map(&:downcase) }
|
|
151
201
|
filter_flags(o, options, :type, :area, :tag)
|
|
202
|
+
help_flag(o)
|
|
152
203
|
end
|
|
153
|
-
|
|
204
|
+
begin
|
|
205
|
+
parser.parse!(argv)
|
|
206
|
+
rescue OptionParser::ParseError => e
|
|
207
|
+
@err.puts e.message
|
|
208
|
+
return 2
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# Registry mode — leading @refs, @all among them — searches several bundles
|
|
212
|
+
# and labels every match; a plain dir keeps the classic single-bundle output.
|
|
213
|
+
if argv.first&.start_with?("@")
|
|
214
|
+
pairs = ref_targets(argv) or return 2
|
|
215
|
+
dir = nil
|
|
216
|
+
else
|
|
217
|
+
dir = argv.shift
|
|
218
|
+
if dir.nil?
|
|
219
|
+
@err.puts parser.banner
|
|
220
|
+
return 2
|
|
221
|
+
end
|
|
222
|
+
dir = resolve_ref(dir) or return 2
|
|
223
|
+
end
|
|
224
|
+
|
|
154
225
|
terms = argv
|
|
155
226
|
if terms.empty?
|
|
156
227
|
@err.puts parser.banner
|
|
157
228
|
return 2
|
|
158
229
|
end
|
|
159
230
|
|
|
231
|
+
# A non-leading @arg is a literal term by the grammar — say so, since the
|
|
232
|
+
# user may have meant a ref (refs must lead) and would otherwise see only
|
|
233
|
+
# a silent zero-match.
|
|
234
|
+
stray = terms.find { |term| term.start_with?("@") }
|
|
235
|
+
@err.puts "note: '#{stray}' searches as a literal term — an @slug or @all must lead" if stray
|
|
236
|
+
|
|
160
237
|
unknown = Array(options[:in]) - OKF::Bundle::Search::FIELDS
|
|
161
238
|
return usage_error("unknown field(s): #{unknown.join(", ")} (searchable: #{OKF::Bundle::Search::FIELDS.join(", ")})") unless unknown.empty?
|
|
162
239
|
|
|
240
|
+
return multi_search(pairs, terms, options) if pairs
|
|
241
|
+
|
|
163
242
|
folder = OKF::Bundle::Folder.load(dir)
|
|
164
243
|
report_skipped(folder)
|
|
165
244
|
rows = OKF::Bundle::Search.call(folder.bundle, terms, fields: options[:in], regexp: options[:regexp])
|
|
@@ -173,8 +252,102 @@ module OKF
|
|
|
173
252
|
usage_error("invalid pattern: #{e.message}")
|
|
174
253
|
end
|
|
175
254
|
|
|
255
|
+
# Every registered bundle, as [slug, dir] pairs — what @all expands to.
|
|
256
|
+
# Asking for everything tolerates gaps: a registered directory that has since
|
|
257
|
+
# vanished is skipped with a note, the same forgiveness the hub shows a stale
|
|
258
|
+
# entry. Naming one bundle demands it, so a plain @slug still fails hard.
|
|
259
|
+
def all_targets
|
|
260
|
+
registry = load_registry
|
|
261
|
+
return nil unless registry
|
|
262
|
+
|
|
263
|
+
if registry.empty?
|
|
264
|
+
@err.puts "error: no bundles registered (okf registry set <dir>)"
|
|
265
|
+
return nil
|
|
266
|
+
end
|
|
267
|
+
pairs = []
|
|
268
|
+
registry.each do |entry|
|
|
269
|
+
if File.directory?(entry.path)
|
|
270
|
+
pairs << [ entry.slug, entry.path ]
|
|
271
|
+
else
|
|
272
|
+
skip_registered(entry)
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
if pairs.empty?
|
|
276
|
+
@err.puts "error: every registered bundle is missing on disk (okf registry list)"
|
|
277
|
+
return nil
|
|
278
|
+
end
|
|
279
|
+
pairs
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
# Dedupe by resolved path, not ref spelling — `@ @one` is one bundle when
|
|
283
|
+
# "one" is the default, and must be searched once. `@all @one` is the same
|
|
284
|
+
# story with a wider first ref: all ⊇ one, so the result is right and the
|
|
285
|
+
# duplicate simply drops. No error branch, because there is no wrong answer
|
|
286
|
+
# to warn about.
|
|
287
|
+
def ref_targets(argv)
|
|
288
|
+
refs = []
|
|
289
|
+
refs << argv.shift while argv.first&.start_with?("@")
|
|
290
|
+
pairs = []
|
|
291
|
+
refs.each do |ref|
|
|
292
|
+
found = all_ref?(ref) ? all_targets : ref_pair(ref)
|
|
293
|
+
return nil unless found
|
|
294
|
+
|
|
295
|
+
found.each { |slug, path| pairs << [ slug, path ] unless pairs.any? { |_, seen| seen == path } }
|
|
296
|
+
end
|
|
297
|
+
pairs
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
# Does this @ref name every registered bundle? Takes a ref, sigil and all —
|
|
301
|
+
# both callers reach it only past a start_with?("@") of their own, so a
|
|
302
|
+
# third check here would be a branch no run can take.
|
|
303
|
+
#
|
|
304
|
+
# Compared *normalized*, because the ref grammar has exactly one
|
|
305
|
+
# normalization and a ref exempt from it is a trapdoor: `@ALL` has to reach
|
|
306
|
+
# `@all` for the same reason `@One` reaches the bundle registered from dir
|
|
307
|
+
# `One`. It normalizes through Registry.normalize — the very call the slug
|
|
308
|
+
# lookup makes — rather than a second downcase that could be forgotten while
|
|
309
|
+
# the first was maintained.
|
|
310
|
+
def all_ref?(ref)
|
|
311
|
+
require "okf/registry"
|
|
312
|
+
OKF::Registry.normalize(ref[1..-1]) == ALL_REF[1..-1]
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
# One @ref as a single-element [[slug, dir]], or nil after reporting.
|
|
316
|
+
def ref_pair(ref)
|
|
317
|
+
path = resolve_registered(ref)
|
|
318
|
+
unless path
|
|
319
|
+
# Only an unknown slug is plausibly a mistyped term — a broken registry
|
|
320
|
+
# or a gone directory has nothing to do with the grammar.
|
|
321
|
+
@err.puts "note: searching for a literal @-term? put a non-@ term first, or use -e '\\@term'" if @ref_failure == :unknown
|
|
322
|
+
return nil
|
|
323
|
+
end
|
|
324
|
+
[ [ ref_slugs[path], path ] ]
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
# Search each bundle with the same terms and merge the rankings — scores are
|
|
328
|
+
# absolute term weights, so they compare across bundles — every row labeled
|
|
329
|
+
# with its bundle's slug and ties broken deterministically.
|
|
330
|
+
def multi_search(pairs, terms, options)
|
|
331
|
+
rows = []
|
|
332
|
+
total = 0
|
|
333
|
+
pairs.each do |slug, dir|
|
|
334
|
+
folder = OKF::Bundle::Folder.load(dir)
|
|
335
|
+
report_skipped(folder)
|
|
336
|
+
total += folder.bundle.concepts.size
|
|
337
|
+
found = OKF::Bundle::Search.call(folder.bundle, terms, fields: options[:in], regexp: options[:regexp])
|
|
338
|
+
keep = filter_ids(folder, options)
|
|
339
|
+
found = found.select { |row| keep.include?(row[:id]) } unless keep.nil?
|
|
340
|
+
found.each { |row| rows << { slug: slug }.merge(row) }
|
|
341
|
+
end
|
|
342
|
+
rows.sort_by! { |row| [ -row[:score], row[:slug], row[:id] ] }
|
|
343
|
+
return print_multi_search_json(pairs, terms, rows, options) if options[:json]
|
|
344
|
+
|
|
345
|
+
print_multi_search(pairs, terms, rows, total)
|
|
346
|
+
0
|
|
347
|
+
end
|
|
348
|
+
|
|
176
349
|
def print_search(dir, terms, rows, total)
|
|
177
|
-
@out.puts "Search — #{dir} · #{terms.join(" ")} (#{counted(rows.size, total, "
|
|
350
|
+
@out.puts "Search — #{bundle_label(dir)} · #{terms.join(" ")} (#{counted(rows.size, total, "concept")})"
|
|
178
351
|
if rows.empty?
|
|
179
352
|
@out.puts " no matches — fewer or broader terms, or scan `okf tags #{dir}` for the vocabulary"
|
|
180
353
|
return
|
|
@@ -192,34 +365,166 @@ module OKF
|
|
|
192
365
|
emit_list_json(dir, "matches", rows.map { |row| stringify(row) }, options, "query" => terms)
|
|
193
366
|
end
|
|
194
367
|
|
|
368
|
+
def print_multi_search(pairs, terms, rows, total)
|
|
369
|
+
@out.puts "Search — #{pairs.map { |slug, _| "@#{slug}" }.join(" ")} · #{terms.join(" ")} (#{counted(rows.size, total, "concept")})"
|
|
370
|
+
if rows.empty?
|
|
371
|
+
@out.puts " no matches — fewer or broader terms, or scan `okf tags @<slug>` for a bundle's vocabulary"
|
|
372
|
+
return
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
slug_width = rows.map { |row| row[:slug].length }.max + 1
|
|
376
|
+
width = rows.map { |row| row[:id].length }.max
|
|
377
|
+
rows.each do |row|
|
|
378
|
+
@out.puts
|
|
379
|
+
@out.puts " #{"@#{row[:slug]}".ljust(slug_width)} #{row[:id].ljust(width)} #{row[:title]} · #{row[:type]} · #{row[:matched].join("+")}"
|
|
380
|
+
@out.puts " #{truncate(row[:snippet], 100)}" unless row[:snippet].empty?
|
|
381
|
+
end
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
# The head maps every searched slug to its directory once, so a row's
|
|
385
|
+
# `slug` resolves to `<dir>/<id>.md` without a second lookup — and without
|
|
386
|
+
# repeating a long path on every row.
|
|
387
|
+
def print_multi_search_json(pairs, terms, rows, options)
|
|
388
|
+
head = { "bundles" => pairs.map { |slug, dir| { "slug" => slug, "dir" => dir } } }
|
|
389
|
+
emit_list_json(head, "matches", rows.map { |row| stringify(row) }, options, { "query" => terms }, "matches_by_ref")
|
|
390
|
+
end
|
|
391
|
+
|
|
195
392
|
def server(argv)
|
|
196
393
|
require "okf/server/app"
|
|
197
394
|
require "rack/deflater"
|
|
198
395
|
|
|
199
396
|
options = { port: 8808, bind: "127.0.0.1", title: nil, link: nil, layout: "cose" }
|
|
200
397
|
parser = OptionParser.new do |o|
|
|
201
|
-
o.banner = "Usage: okf server
|
|
398
|
+
o.banner = "Usage: okf server [DIR|@slug…] [-p PORT] [--bind ADDR] [--layout NAME] [-t title] [-l url]"
|
|
202
399
|
o.on("-p", "--port PORT", Integer, "port to serve on (default #{options[:port]})") { |v| options[:port] = v }
|
|
203
400
|
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 }
|
|
401
|
+
o.on("-t", "--title TITLE", "graph title, single bundle only (default: parent/bundle dir name)") { |v| options[:title] = v }
|
|
402
|
+
o.on("-l", "--link URL", "source URL shown in the header, single bundle only") { |v| options[:link] = v }
|
|
206
403
|
o.on("--layout NAME", OKF::Server::Graph::LAYOUTS, "initial layout (#{OKF::Server::Graph::LAYOUTS.join(", ")})") { |v| options[:layout] = v }
|
|
404
|
+
help_flag(o)
|
|
207
405
|
end
|
|
208
|
-
|
|
406
|
+
dirs = positional_dirs(parser, argv) or return 2
|
|
209
407
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
408
|
+
# A flag that will have no effect in this mode gets a note, not silence.
|
|
409
|
+
@err.puts "note: --title/--link apply to a single-bundle server; ignored" if dirs.size != 1 && (options[:title] || options[:link])
|
|
410
|
+
|
|
411
|
+
# One dir keeps the historical single-bundle server at `/`; zero (the
|
|
412
|
+
# persistent registry) or many (ephemeral) fan out behind a hub.
|
|
413
|
+
if dirs.size == 1
|
|
414
|
+
folder = OKF::Bundle::Folder.load(dirs.first)
|
|
415
|
+
report_skipped(folder)
|
|
416
|
+
run_server(folder, options)
|
|
417
|
+
else
|
|
418
|
+
run_hub(dirs, options)
|
|
419
|
+
end
|
|
213
420
|
0
|
|
421
|
+
rescue OKF::Error => e
|
|
422
|
+
usage_error(e.message)
|
|
214
423
|
end
|
|
215
424
|
|
|
216
|
-
# Build the Rack app and hand it to the runner (WEBrick by
|
|
217
|
-
# tests drive this without a socket).
|
|
425
|
+
# Build the single-bundle Rack app and hand it to the runner (WEBrick by
|
|
426
|
+
# default, injected so tests drive this without a socket).
|
|
218
427
|
def run_server(folder, options)
|
|
219
428
|
app = OKF::Server::App.new(folder, title: options[:title] || folder.name, link: options[:link], layout: options[:layout])
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
429
|
+
# minimal: the banner wants a count, not bodies — and Folder#graph is not
|
|
430
|
+
# memoized, so a full build here parses every concept a second time (the
|
|
431
|
+
# App builds its own) purely to print one number.
|
|
432
|
+
count = folder.graph(minimal: true).nodes.size
|
|
433
|
+
@out.puts "serving #{count} #{pluralize(count, "concept")} at http://#{options[:bind]}:#{options[:port]} (Ctrl-C to stop)"
|
|
434
|
+
serve(app, options)
|
|
435
|
+
end
|
|
436
|
+
|
|
437
|
+
# Build the multi-bundle hub and hand it to the runner. With dirs it serves
|
|
438
|
+
# those ephemerally; with none it serves the persistent registry. Either way
|
|
439
|
+
# the first bundle is the one `/` opens — for the registry that is its own
|
|
440
|
+
# order, and a first entry whose directory has vanished drops out here, so
|
|
441
|
+
# `/` lands on the next one that is actually there.
|
|
442
|
+
def run_hub(dirs, options)
|
|
443
|
+
require "okf/server/hub"
|
|
444
|
+
require "okf/registry"
|
|
445
|
+
if dirs.empty?
|
|
446
|
+
# A malformed registry raises OKF::Error, which `server` rescues into a
|
|
447
|
+
# usage error — no guarded load needed on this path.
|
|
448
|
+
reg = OKF::Registry.load
|
|
449
|
+
bundles = reg.map { |entry| load_registered(entry) }.compact
|
|
450
|
+
else
|
|
451
|
+
bundles = ephemeral_bundles(dirs)
|
|
452
|
+
end
|
|
453
|
+
hub = OKF::Server::Hub.new(bundles, layout: options[:layout])
|
|
454
|
+
concepts = bundles.inject(0) { |sum, bundle| sum + bundle.folder.graph(minimal: true).nodes.size }
|
|
455
|
+
@out.puts "serving #{bundles.size} #{pluralize(bundles.size,
|
|
456
|
+
"bundle")}, #{concepts} #{pluralize(concepts, "concept")} at http://#{options[:bind]}:#{options[:port]} (Ctrl-C to stop)"
|
|
457
|
+
print_mounts(hub)
|
|
458
|
+
serve(hub, options)
|
|
459
|
+
end
|
|
460
|
+
|
|
461
|
+
# The one boot seam every served app passes through, so a hub gzips exactly
|
|
462
|
+
# like a single bundle — the wrap belongs to booting a server, not to either
|
|
463
|
+
# mode, and a mode added later gets it for free. Deliberately not inside the
|
|
464
|
+
# runner: an embedding app mounting OKF::Server::App brings its own middleware.
|
|
465
|
+
def serve(app, options)
|
|
466
|
+
# gzip responses when the client accepts it — transparent, no new dependency
|
|
467
|
+
@runner.call(Rack::Deflater.new(app), options[:bind], options[:port])
|
|
468
|
+
end
|
|
469
|
+
|
|
470
|
+
# The mount table — which dir landed on which /b/<slug>/ and where `/` goes.
|
|
471
|
+
# Mirrors the Hub's own default resolution (explicit slug, else first).
|
|
472
|
+
# Ask the hub which bundle it chose rather than re-deriving the
|
|
473
|
+
# explicit-else-first rule, and mount at its own prefix: two copies of a
|
|
474
|
+
# rule is two answers waiting to disagree.
|
|
475
|
+
def print_mounts(hub)
|
|
476
|
+
hub.bundles.each do |bundle|
|
|
477
|
+
marker = bundle.equal?(hub.default) ? "*" : " "
|
|
478
|
+
@out.puts " #{marker} #{OKF::Server::Hub::MOUNT}/#{bundle.slug}/ #{bundle.title}"
|
|
479
|
+
end
|
|
480
|
+
end
|
|
481
|
+
|
|
482
|
+
# Load the given directories as unregistered bundles, slugged by basename and
|
|
483
|
+
# deduped within the run. The same directory listed twice mounts once — two
|
|
484
|
+
# windows on one bundle would just burn a slug on a URL that vanishes next run.
|
|
485
|
+
def ephemeral_bundles(dirs)
|
|
486
|
+
roots = []
|
|
487
|
+
dirs.each do |dir|
|
|
488
|
+
root = File.expand_path(dir)
|
|
489
|
+
roots << root unless roots.include?(root)
|
|
490
|
+
end
|
|
491
|
+
|
|
492
|
+
# A registered slug owns its mount outright: reserve every ref's slug
|
|
493
|
+
# before any basename is deduped. Otherwise argv order decides, and
|
|
494
|
+
# `server ./two @two` mounts the *unregistered* ./two at /b/two/ while
|
|
495
|
+
# pushing the ref — the bundle whose slug that is — to /b/two-2/, so a
|
|
496
|
+
# bookmark from a bundle-less run silently opens the wrong graph.
|
|
497
|
+
taken = roots.map { |root| ref_slugs[root] }.compact
|
|
498
|
+
roots.each_with_object([]) do |root, bundles|
|
|
499
|
+
folder = OKF::Bundle::Folder.load(root)
|
|
500
|
+
report_skipped(folder)
|
|
501
|
+
slug = ref_slugs[root]
|
|
502
|
+
unless slug
|
|
503
|
+
slug = OKF::Registry.dedupe(File.basename(root), taken)
|
|
504
|
+
taken << slug
|
|
505
|
+
end
|
|
506
|
+
bundles << OKF::Server::Hub::Bundle.new(slug, folder, folder.name)
|
|
507
|
+
end
|
|
508
|
+
end
|
|
509
|
+
|
|
510
|
+
# Load one registered bundle; a path that has gone missing or no longer reads
|
|
511
|
+
# drops to nil with a note (to stderr) rather than sinking the whole run. The
|
|
512
|
+
# directory check is explicit — the Reader maps a nonexistent directory to an
|
|
513
|
+
# empty bundle, so nothing would raise for the common "dir was deleted" case.
|
|
514
|
+
# Method-level rescue (not a `do…end`-block rescue — a 2.6 feature).
|
|
515
|
+
def load_registered(entry)
|
|
516
|
+
return skip_registered(entry) unless File.directory?(entry.path)
|
|
517
|
+
|
|
518
|
+
folder = OKF::Bundle::Folder.load(entry.path)
|
|
519
|
+
report_skipped(folder)
|
|
520
|
+
OKF::Server::Hub::Bundle.new(entry.slug, folder, entry.title)
|
|
521
|
+
rescue SystemCallError, OKF::Error
|
|
522
|
+
skip_registered(entry)
|
|
523
|
+
end
|
|
524
|
+
|
|
525
|
+
def skip_registered(entry)
|
|
526
|
+
@err.puts "note: skipping #{entry.slug} — cannot read #{entry.path}"
|
|
527
|
+
nil
|
|
223
528
|
end
|
|
224
529
|
|
|
225
530
|
# The static counterpart to `server`: bake the whole bundle into one
|
|
@@ -230,11 +535,12 @@ module OKF
|
|
|
230
535
|
|
|
231
536
|
options = { output: nil, title: nil, link: nil, layout: "cose" }
|
|
232
537
|
parser = OptionParser.new do |o|
|
|
233
|
-
o.banner = "Usage: okf render <
|
|
538
|
+
o.banner = "Usage: okf render <dir|@slug> [-o FILE] [--layout NAME] [-t title] [-l url]"
|
|
234
539
|
o.on("-o", "--output FILE", "write to FILE instead of stdout") { |v| options[:output] = v }
|
|
235
540
|
o.on("-t", "--title TITLE", "graph title (default: parent/bundle dir name)") { |v| options[:title] = v }
|
|
236
541
|
o.on("-l", "--link URL", "source URL shown in the header") { |v| options[:link] = v }
|
|
237
542
|
o.on("--layout NAME", OKF::Server::Graph::LAYOUTS, "initial layout (#{OKF::Server::Graph::LAYOUTS.join(", ")})") { |v| options[:layout] = v }
|
|
543
|
+
help_flag(o)
|
|
238
544
|
end
|
|
239
545
|
dir = positional_dir(parser, argv) or return 2
|
|
240
546
|
|
|
@@ -242,21 +548,233 @@ module OKF
|
|
|
242
548
|
report_skipped(folder)
|
|
243
549
|
html = OKF::Server::App.new(folder, title: options[:title] || folder.name, link: options[:link], layout: options[:layout]).render_static
|
|
244
550
|
if options[:output]
|
|
245
|
-
|
|
246
|
-
|
|
551
|
+
# A bad -o path (a missing directory, a permission denial) is a bad
|
|
552
|
+
# *argument*: exit 2 with the reason, never a backtrace and an exit code
|
|
553
|
+
# that means "failing bundle".
|
|
554
|
+
begin
|
|
555
|
+
File.write(options[:output], html)
|
|
556
|
+
rescue SystemCallError => e
|
|
557
|
+
return usage_error("cannot write #{options[:output]}: #{e.message}")
|
|
558
|
+
end
|
|
559
|
+
count = folder.graph(minimal: true).nodes.size
|
|
560
|
+
@out.puts "wrote #{count} #{pluralize(count, "concept")} to #{options[:output]}"
|
|
247
561
|
else
|
|
248
562
|
@out.print html
|
|
249
563
|
end
|
|
250
564
|
0
|
|
251
565
|
end
|
|
252
566
|
|
|
567
|
+
# The registry umbrella, split by what each verb keys on. `set`/`del`/`list`
|
|
568
|
+
# act on entries — `set` keys on the bundle's path, so --as means one thing
|
|
569
|
+
# ("the slug this entry has") whether it adds or renames. `default`/`rename`
|
|
570
|
+
# act on slugs, the names actually to hand once a bundle is registered. Every
|
|
571
|
+
# positional stays unambiguous, and `config` is left free for real settings.
|
|
572
|
+
def registry(argv)
|
|
573
|
+
require "okf/registry"
|
|
574
|
+
|
|
575
|
+
sub = argv.first
|
|
576
|
+
case sub
|
|
577
|
+
when "set" then registry_set(argv.drop(1))
|
|
578
|
+
when "del" then registry_del(argv.drop(1))
|
|
579
|
+
when "list" then registry_list(argv.drop(1))
|
|
580
|
+
when "default" then registry_default(argv.drop(1))
|
|
581
|
+
when "rename" then registry_rename(argv.drop(1))
|
|
582
|
+
else
|
|
583
|
+
# A bare word that isn't a known subcommand is a typo (`registry remove x`
|
|
584
|
+
# must not silently render the list and read as success).
|
|
585
|
+
return usage_error("unknown registry subcommand '#{sub}' (expected: #{SUBCOMMANDS.join(", ")})") if sub && !sub.start_with?("-")
|
|
586
|
+
|
|
587
|
+
# Same rule for a subcommand hiding behind a flag: `registry --json set
|
|
588
|
+
# dir` would otherwise list an empty registry and exit 0, having written
|
|
589
|
+
# nothing the user asked for. It cannot just be dispatched from wherever
|
|
590
|
+
# it turns up — the word may be a flag's value (`registry --as set <dir>`
|
|
591
|
+
# asks for the slug "set"), and a grammar where that reading depends on
|
|
592
|
+
# which flag precedes it is a trapdoor. So the subcommand must lead, and
|
|
593
|
+
# the error says which one was found rather than guessing at the intent.
|
|
594
|
+
stray = argv.find { |arg| SUBCOMMANDS.include?(arg) }
|
|
595
|
+
return usage_error("put the subcommand first: okf registry #{stray} … (flags follow it)") if stray
|
|
596
|
+
|
|
597
|
+
registry_list(argv)
|
|
598
|
+
end
|
|
599
|
+
end
|
|
600
|
+
|
|
601
|
+
# Add a bundle to the persistent registry (so a later bare `okf server` finds
|
|
602
|
+
# it), or update one already there. The entry is keyed by the bundle's path: a
|
|
603
|
+
# path already registered refreshes its title in place, and --as renames it. A
|
|
604
|
+
# new path is added, slugged by directory basename unless --as says otherwise.
|
|
605
|
+
def registry_set(argv)
|
|
606
|
+
options = { as: nil, default: false }
|
|
607
|
+
parser = OptionParser.new do |o|
|
|
608
|
+
o.banner = "Usage: okf registry set <dir|@slug> [--as SLUG] [--default]"
|
|
609
|
+
o.on("--as SLUG", "slug to register under (default: directory basename)") { |v| options[:as] = v }
|
|
610
|
+
o.on("--default", "put it first — the bundle a bare `okf server` opens") { options[:default] = true }
|
|
611
|
+
help_flag(o)
|
|
612
|
+
end
|
|
613
|
+
dir = positional_dir(parser, argv) or return 2
|
|
614
|
+
no_extras?(argv) or return 2
|
|
615
|
+
|
|
616
|
+
reg = OKF::Registry.load
|
|
617
|
+
# Said before the upsert: after it, an update is indistinguishable from an
|
|
618
|
+
# add, and "registered" for what was a rename reads as a duplicate entry.
|
|
619
|
+
known = reg.listing.any? { |row| row[:dir] == File.expand_path(dir) }
|
|
620
|
+
entry = reg.add(dir, as: options[:as], default: options[:default])
|
|
621
|
+
# Through report_skipped like every other bundle-reading verb: the reader
|
|
622
|
+
# tolerates a file it cannot open, so a count taken straight off the graph
|
|
623
|
+
# reports "0 concepts" for a bundle whose files are simply unreadable.
|
|
624
|
+
folder = OKF::Bundle::Folder.load(entry.path)
|
|
625
|
+
report_skipped(folder)
|
|
626
|
+
count = folder.graph(minimal: true).nodes.size
|
|
627
|
+
@out.puts "#{known ? "updated" : "registered"} #{entry.slug} → #{entry.path} (#{count} #{pluralize(count, "concept")})"
|
|
628
|
+
0
|
|
629
|
+
rescue OKF::Error => e
|
|
630
|
+
usage_error(e.message)
|
|
631
|
+
end
|
|
632
|
+
|
|
633
|
+
# Remove a bundle from the persistent registry by slug or by its directory.
|
|
634
|
+
def registry_del(argv)
|
|
635
|
+
parser = OptionParser.new do |o|
|
|
636
|
+
o.banner = "Usage: okf registry del <dir|@slug>"
|
|
637
|
+
help_flag(o)
|
|
638
|
+
end
|
|
639
|
+
slug = positional(parser, argv) or return 2
|
|
640
|
+
no_extras?(argv) or return 2
|
|
641
|
+
|
|
642
|
+
reg = OKF::Registry.load
|
|
643
|
+
slug = registry_slug(slug, reg) or return 2
|
|
644
|
+
removed = reg.remove(slug)
|
|
645
|
+
return usage_error("no such bundle: #{slug}") unless removed
|
|
646
|
+
|
|
647
|
+
@out.puts "removed #{removed.slug}"
|
|
648
|
+
0
|
|
649
|
+
rescue OKF::Error => e
|
|
650
|
+
usage_error(e.message)
|
|
651
|
+
end
|
|
652
|
+
|
|
653
|
+
def registry_list(argv)
|
|
654
|
+
options = { json: false }
|
|
655
|
+
parser = OptionParser.new do |o|
|
|
656
|
+
o.banner = "Usage: okf registry list [--json] [--pretty]\n " \
|
|
657
|
+
"okf registry set <dir|@slug> | del <dir|@slug> | default <@slug> | rename <@slug> <new>"
|
|
658
|
+
json_flags(o, options, "emit the registry as JSON")
|
|
659
|
+
help_flag(o)
|
|
660
|
+
end
|
|
661
|
+
begin
|
|
662
|
+
parser.parse!(argv)
|
|
663
|
+
rescue OptionParser::ParseError => e
|
|
664
|
+
@err.puts e.message
|
|
665
|
+
return 2
|
|
666
|
+
end
|
|
667
|
+
no_extras?(argv) or return 2
|
|
668
|
+
|
|
669
|
+
reg = OKF::Registry.load
|
|
670
|
+
return emit_list_json({ "registry" => reg.path }, "bundles", reg.listing.map { |row| stringify(row) }, options) if options[:json]
|
|
671
|
+
|
|
672
|
+
print_registry(reg)
|
|
673
|
+
0
|
|
674
|
+
rescue OKF::Error => e
|
|
675
|
+
usage_error(e.message)
|
|
676
|
+
end
|
|
677
|
+
|
|
678
|
+
# Choose which registered bundle a bare `okf server` opens at `/`, by moving
|
|
679
|
+
# it to the front of the registry. The listing is ordered and the JSON is
|
|
680
|
+
# meant to be hand-editable, so the move is stated rather than left to be
|
|
681
|
+
# discovered from a reordered file.
|
|
682
|
+
def registry_default(argv)
|
|
683
|
+
parser = OptionParser.new do |o|
|
|
684
|
+
o.banner = "Usage: okf registry default <@slug>\n " \
|
|
685
|
+
"moves it to the front — the first registered bundle is the default until you do"
|
|
686
|
+
help_flag(o)
|
|
687
|
+
end
|
|
688
|
+
slug = positional(parser, argv) or return 2
|
|
689
|
+
no_extras?(argv) or return 2
|
|
690
|
+
|
|
691
|
+
reg = OKF::Registry.load
|
|
692
|
+
slug = registry_slug(slug, reg) or return 2
|
|
693
|
+
reg.default = slug
|
|
694
|
+
@out.puts "default bundle → #{reg.default.slug} (now first)"
|
|
695
|
+
0
|
|
696
|
+
rescue OKF::Error => e
|
|
697
|
+
usage_error(e.message)
|
|
698
|
+
end
|
|
699
|
+
|
|
700
|
+
# The @ref grammar for a verb that takes a *slug*, read by name. These three
|
|
701
|
+
# must reach an entry whose directory is gone — that is the one worth
|
|
702
|
+
# deleting or renaming — so they cannot go through resolve_ref, which
|
|
703
|
+
# insists the directory exist. Without this the refs only appeared to work:
|
|
704
|
+
# `normalize` strips the `@` off `@slug`, so `default @slug` resolved by
|
|
705
|
+
# accident while a bare `@` normalized to "" and failed. Returns the slug,
|
|
706
|
+
# or nil after reporting.
|
|
707
|
+
def registry_slug(arg, registry)
|
|
708
|
+
return arg unless arg.start_with?("@")
|
|
709
|
+
|
|
710
|
+
asked = arg[1..-1]
|
|
711
|
+
return asked unless asked.empty?
|
|
712
|
+
|
|
713
|
+
default = registry.default
|
|
714
|
+
return default.slug if default
|
|
715
|
+
|
|
716
|
+
@err.puts "error: no bundle is registered, so `@` names nothing (okf registry set <dir>)"
|
|
717
|
+
nil
|
|
718
|
+
end
|
|
719
|
+
|
|
720
|
+
# Rename a registered bundle's slug — its mount path and switcher name.
|
|
721
|
+
def registry_rename(argv)
|
|
722
|
+
parser = OptionParser.new do |o|
|
|
723
|
+
o.banner = "Usage: okf registry rename <@slug> <new>"
|
|
724
|
+
help_flag(o)
|
|
725
|
+
end
|
|
726
|
+
parser.parse!(argv)
|
|
727
|
+
old_slug, new_slug = argv.shift(2)
|
|
728
|
+
if old_slug.nil? || new_slug.nil?
|
|
729
|
+
@err.puts parser.banner
|
|
730
|
+
return 2
|
|
731
|
+
end
|
|
732
|
+
no_extras?(argv) or return 2
|
|
733
|
+
|
|
734
|
+
reg = OKF::Registry.load
|
|
735
|
+
# The old name may be a ref; the new one is a name being minted, never one.
|
|
736
|
+
old_slug = registry_slug(old_slug, reg) or return 2
|
|
737
|
+
entry = reg.rename(old_slug, new_slug)
|
|
738
|
+
# The slug it *found*, not the argv that found it: rename normalizes to look
|
|
739
|
+
# the entry up, so echoing the raw ask names a bundle that never existed.
|
|
740
|
+
@out.puts "renamed #{OKF::Registry.normalize(old_slug)} → #{entry.slug}"
|
|
741
|
+
0
|
|
742
|
+
rescue OptionParser::ParseError => e
|
|
743
|
+
@err.puts e.message
|
|
744
|
+
2
|
|
745
|
+
rescue OKF::Error => e
|
|
746
|
+
usage_error(e.message)
|
|
747
|
+
end
|
|
748
|
+
|
|
749
|
+
# The registry verbs take an exact number of positionals — a leftover argument
|
|
750
|
+
# is a typo'd invocation, not something to drop silently.
|
|
751
|
+
def no_extras?(argv)
|
|
752
|
+
return true if argv.empty?
|
|
753
|
+
|
|
754
|
+
@err.puts "error: unexpected argument '#{argv.first}'"
|
|
755
|
+
false
|
|
756
|
+
end
|
|
757
|
+
|
|
758
|
+
def print_registry(reg)
|
|
759
|
+
return @out.puts "no bundles registered — okf registry set <dir>" if reg.empty?
|
|
760
|
+
|
|
761
|
+
rows = reg.listing
|
|
762
|
+
width = rows.map { |row| row[:slug].length }.max
|
|
763
|
+
rows.each do |row|
|
|
764
|
+
marker = row[:default] ? "*" : " "
|
|
765
|
+
missing = row[:missing] ? " (missing)" : ""
|
|
766
|
+
@out.puts "#{marker} #{row[:slug].ljust(width)} #{row[:title]} (#{row[:dir]})#{missing}"
|
|
767
|
+
end
|
|
768
|
+
end
|
|
769
|
+
|
|
253
770
|
def graph(argv)
|
|
254
771
|
options = { json: false, minimal: false, body: true }
|
|
255
772
|
parser = OptionParser.new do |o|
|
|
256
|
-
o.banner = "Usage: okf graph <
|
|
773
|
+
o.banner = "Usage: okf graph <dir|@slug> [--json] [--minimal] [--no-body]"
|
|
257
774
|
json_flags(o, options, "emit nodes and edges as JSON")
|
|
258
775
|
o.on("--minimal", "leanest nodes (id + title); adds type/tag indexes") { options[:minimal] = true }
|
|
259
776
|
o.on("--[no-]body", "include each concept's body (default: yes)") { |v| options[:body] = v }
|
|
777
|
+
help_flag(o)
|
|
260
778
|
end
|
|
261
779
|
dir = positional_dir(parser, argv) or return 2
|
|
262
780
|
|
|
@@ -264,11 +782,15 @@ module OKF
|
|
|
264
782
|
graph = folder.graph(minimal: options[:minimal], body: options[:body])
|
|
265
783
|
report_skipped(folder)
|
|
266
784
|
if options[:json]
|
|
267
|
-
payload
|
|
785
|
+
# The head every view carries: a payload of nodes and edges that never
|
|
786
|
+
# says which bundle they came from is exactly what an agent holding
|
|
787
|
+
# several bundles has to guess at.
|
|
788
|
+
payload = bundle_head(dir).merge(graph.to_h)
|
|
268
789
|
payload = payload.merge(types: graph.type_index, tags: graph.tag_index) if options[:minimal]
|
|
269
790
|
emit_json(payload)
|
|
270
791
|
else
|
|
271
|
-
@out.puts "#{graph.nodes.size}
|
|
792
|
+
@out.puts "Graph — #{bundle_label(dir)} (#{graph.nodes.size} #{pluralize(graph.nodes.size, "concept")}, " \
|
|
793
|
+
"#{graph.edges.size} #{pluralize(graph.edges.size, "link")})"
|
|
272
794
|
end
|
|
273
795
|
0
|
|
274
796
|
end
|
|
@@ -282,11 +804,12 @@ module OKF
|
|
|
282
804
|
def index(argv)
|
|
283
805
|
options = { json: false, body: true, areas: nil }
|
|
284
806
|
parser = OptionParser.new do |o|
|
|
285
|
-
o.banner = "Usage: okf index <
|
|
807
|
+
o.banner = "Usage: okf index <dir|@slug> [--area AREA] [--no-body] [--json]"
|
|
286
808
|
json_flags(o, options, "emit the index map as JSON")
|
|
287
809
|
projection_flags(o, options)
|
|
288
810
|
o.on("--area AREA", "only this directory/area (repeatable; `root` for the bundle root)") { |v| (options[:areas] ||= []) << v }
|
|
289
811
|
o.on("--[no-]body", "include each index's prose body (default: yes)") { |v| options[:body] = v }
|
|
812
|
+
help_flag(o)
|
|
290
813
|
end
|
|
291
814
|
dir = positional_dir(parser, argv) or return 2
|
|
292
815
|
|
|
@@ -295,6 +818,13 @@ module OKF
|
|
|
295
818
|
entries = folder.directory_index
|
|
296
819
|
selected = select_directories(entries, options[:areas])
|
|
297
820
|
if options[:json]
|
|
821
|
+
# --no-body is shorthand for --except body, so asking for the body by
|
|
822
|
+
# name in the same breath is a contradiction. Letting --fields quietly
|
|
823
|
+
# win would hand back the very thing the other flag was there to drop.
|
|
824
|
+
if !options[:body] && Array(options[:fields]).map(&:downcase).include?("body")
|
|
825
|
+
return usage_error("--no-body and --fields body contradict each other: drop one")
|
|
826
|
+
end
|
|
827
|
+
|
|
298
828
|
options[:except] = Array(options[:except]) + [ "body" ] unless options[:body] || options[:fields]
|
|
299
829
|
return print_index_map_json(dir, selected, options)
|
|
300
830
|
end
|
|
@@ -314,7 +844,7 @@ module OKF
|
|
|
314
844
|
|
|
315
845
|
def print_index_map(dir, entries, body)
|
|
316
846
|
noun = entries.size == 1 ? "directory" : "directories"
|
|
317
|
-
@out.puts "Index map — #{dir} (#{entries.size} #{noun})"
|
|
847
|
+
@out.puts "Index map — #{bundle_label(dir)} (#{entries.size} #{noun})"
|
|
318
848
|
entries.each do |entry|
|
|
319
849
|
@out.puts
|
|
320
850
|
@out.puts " #{index_dir_label(entry)}#{index_dir_meta(entry)}"
|
|
@@ -334,8 +864,8 @@ module OKF
|
|
|
334
864
|
end
|
|
335
865
|
|
|
336
866
|
def index_dir_meta(entry)
|
|
337
|
-
count = "#{entry[:count]} #{entry[:count]
|
|
338
|
-
types = entry[:types].map { |type, n| "#{
|
|
867
|
+
count = "#{entry[:count]} #{pluralize(entry[:count], "concept")}"
|
|
868
|
+
types = entry[:types].map { |type, n| "#{OKF.blank?(type) ? "Untyped" : type} #{n}" }.join(", ")
|
|
339
869
|
types.empty? ? " · #{count}" : " · #{count} · #{types}"
|
|
340
870
|
end
|
|
341
871
|
|
|
@@ -377,10 +907,11 @@ module OKF
|
|
|
377
907
|
def catalog(argv)
|
|
378
908
|
options = { json: false }
|
|
379
909
|
parser = OptionParser.new do |o|
|
|
380
|
-
o.banner = "Usage: okf catalog <
|
|
910
|
+
o.banner = "Usage: okf catalog <dir|@slug> [--type T] [--area A] [--tag T] [--json]"
|
|
381
911
|
json_flags(o, options, "emit the catalog as JSON")
|
|
382
912
|
projection_flags(o, options)
|
|
383
913
|
filter_flags(o, options, :type, :area, :tag)
|
|
914
|
+
help_flag(o)
|
|
384
915
|
end
|
|
385
916
|
dir = positional_dir(parser, argv) or return 2
|
|
386
917
|
|
|
@@ -397,10 +928,11 @@ module OKF
|
|
|
397
928
|
def files(argv)
|
|
398
929
|
options = { json: false }
|
|
399
930
|
parser = OptionParser.new do |o|
|
|
400
|
-
o.banner = "Usage: okf files <
|
|
931
|
+
o.banner = "Usage: okf files <dir|@slug> [--type T] [--area A] [--tag T] [--json]"
|
|
401
932
|
json_flags(o, options, "emit the file tree as JSON")
|
|
402
933
|
projection_flags(o, options)
|
|
403
934
|
filter_flags(o, options, :type, :area, :tag)
|
|
935
|
+
help_flag(o)
|
|
404
936
|
end
|
|
405
937
|
dir = positional_dir(parser, argv) or return 2
|
|
406
938
|
|
|
@@ -417,10 +949,11 @@ module OKF
|
|
|
417
949
|
def tags(argv)
|
|
418
950
|
options = { json: false, by: nil }
|
|
419
951
|
parser = OptionParser.new do |o|
|
|
420
|
-
o.banner = "Usage: okf tags <
|
|
952
|
+
o.banner = "Usage: okf tags <dir|@slug> [--by type|area] [--type T] [--area A] [--json]"
|
|
421
953
|
json_flags(o, options, "emit the tag index as JSON")
|
|
422
954
|
o.on("--by DIM", %w[type area], "group the tags by a concept dimension (type | area)") { |v| options[:by] = v.to_sym }
|
|
423
955
|
filter_flags(o, options, :type, :area)
|
|
956
|
+
help_flag(o)
|
|
424
957
|
end
|
|
425
958
|
dir = positional_dir(parser, argv) or return 2
|
|
426
959
|
|
|
@@ -432,9 +965,10 @@ module OKF
|
|
|
432
965
|
def types(argv)
|
|
433
966
|
options = { json: false }
|
|
434
967
|
parser = OptionParser.new do |o|
|
|
435
|
-
o.banner = "Usage: okf types <
|
|
968
|
+
o.banner = "Usage: okf types <dir|@slug> [--area A] [--tag T] [--json]"
|
|
436
969
|
json_flags(o, options, "emit the type index as JSON")
|
|
437
970
|
filter_flags(o, options, :area, :tag)
|
|
971
|
+
help_flag(o)
|
|
438
972
|
end
|
|
439
973
|
dir = positional_dir(parser, argv) or return 2
|
|
440
974
|
|
|
@@ -494,15 +1028,15 @@ module OKF
|
|
|
494
1028
|
|
|
495
1029
|
# A catalog entry's type for display — "Untyped" when blank, matching the graph.
|
|
496
1030
|
def entry_type(entry)
|
|
497
|
-
entry[:type]
|
|
1031
|
+
OKF.blank?(entry[:type]) ? "Untyped" : entry[:type]
|
|
498
1032
|
end
|
|
499
1033
|
|
|
500
1034
|
def print_grouped_tags(dir, dim, groups, titles)
|
|
501
|
-
@out.puts "Tags — #{dir} (#{distinct_tags(groups)} distinct, by #{dim})"
|
|
1035
|
+
@out.puts "Tags — #{bundle_label(dir)} (#{distinct_tags(groups)} distinct, by #{dim})"
|
|
502
1036
|
groups.each do |key, rows|
|
|
503
1037
|
label = dim == :area && key != "(root)" ? "#{key}/" : key
|
|
504
1038
|
@out.puts
|
|
505
|
-
@out.puts " #{label} (#{rows.size}
|
|
1039
|
+
@out.puts " #{label} (#{rows.size} #{pluralize(rows.size, "tag")})"
|
|
506
1040
|
width = rows.map { |row| row[:tag].length }.max || 0
|
|
507
1041
|
rows.each do |row|
|
|
508
1042
|
names = row[:concepts].map { |id| titles[id] || id }.join(", ")
|
|
@@ -512,12 +1046,10 @@ module OKF
|
|
|
512
1046
|
end
|
|
513
1047
|
|
|
514
1048
|
def print_grouped_tags_json(dir, dim, groups)
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
end
|
|
520
|
-
)
|
|
1049
|
+
groups_json = groups.map do |key, rows|
|
|
1050
|
+
{ dim.to_s => key, "count" => rows.size, "tags" => index_rows_json(:tag, rows) }
|
|
1051
|
+
end
|
|
1052
|
+
emit_json(bundle_head(dir).merge("count" => distinct_tags(groups), "by" => dim.to_s, "groups" => groups_json))
|
|
521
1053
|
end
|
|
522
1054
|
|
|
523
1055
|
def distinct_tags(groups)
|
|
@@ -527,8 +1059,9 @@ module OKF
|
|
|
527
1059
|
def stats(argv)
|
|
528
1060
|
options = { json: false }
|
|
529
1061
|
parser = OptionParser.new do |o|
|
|
530
|
-
o.banner = "Usage: okf stats <
|
|
1062
|
+
o.banner = "Usage: okf stats <dir|@slug> [--json]"
|
|
531
1063
|
json_flags(o, options, "emit the stats as JSON")
|
|
1064
|
+
help_flag(o)
|
|
532
1065
|
end
|
|
533
1066
|
dir = positional_dir(parser, argv) or return 2
|
|
534
1067
|
|
|
@@ -569,6 +1102,21 @@ module OKF
|
|
|
569
1102
|
parser.on("--pretty", "indent the JSON for reading (implies --json)") { options[:json] = true; @pretty = true }
|
|
570
1103
|
end
|
|
571
1104
|
|
|
1105
|
+
# Every parser answers its own -h/--help, so no parser inherits
|
|
1106
|
+
# OptionParser's officious one: that prints to the process's $stdout rather
|
|
1107
|
+
# than @out (an embedding app that injects streams never sees it) and ends
|
|
1108
|
+
# the process with `exit` rather than returning a status (a test that asks a
|
|
1109
|
+
# command for help takes the whole runner down with it). Thrown, not
|
|
1110
|
+
# returned — #run catches it — because a parser is parsed inside
|
|
1111
|
+
# positional_dir, where every other early exit means "exit 2".
|
|
1112
|
+
# on_tail, so help sorts last in the list it is printing.
|
|
1113
|
+
def help_flag(parser)
|
|
1114
|
+
parser.on_tail("-h", "--help", "print this message") do
|
|
1115
|
+
@out.puts parser.help
|
|
1116
|
+
throw :help, 0
|
|
1117
|
+
end
|
|
1118
|
+
end
|
|
1119
|
+
|
|
572
1120
|
# --fields/--except project the JSON down to the properties an agent wants, so it
|
|
573
1121
|
# never pays tokens for fields it will not read. --fields is an allowlist,
|
|
574
1122
|
# --except a denylist (mutually exclusive); both imply --json and apply per item
|
|
@@ -633,6 +1181,7 @@ module OKF
|
|
|
633
1181
|
o.banner = "Usage: okf skill <dest-dir> [--here] [--force]"
|
|
634
1182
|
o.on("--here", "install straight into <dest-dir>, wherever it is (no skills/okf nesting)") { options[:nest] = false }
|
|
635
1183
|
o.on("--force", "overwrite a non-empty destination") { options[:force] = true }
|
|
1184
|
+
help_flag(o)
|
|
636
1185
|
end
|
|
637
1186
|
parser.parse!(argv)
|
|
638
1187
|
dest = argv.shift
|
|
@@ -662,10 +1211,14 @@ module OKF
|
|
|
662
1211
|
note_skipped(folder.bundle.unparseable.size)
|
|
663
1212
|
end
|
|
664
1213
|
|
|
1214
|
+
# The bucket holds two kinds now — frontmatter that would not parse, and a
|
|
1215
|
+
# file that would not open — so the note names neither and points at the verb
|
|
1216
|
+
# that names both. "invalid frontmatter" was a guess the summary had no need
|
|
1217
|
+
# to make: `validate` prints the file and the reason for every one of them.
|
|
665
1218
|
def note_skipped(count)
|
|
666
1219
|
return if count.nil? || count <= 0
|
|
667
1220
|
|
|
668
|
-
@err.puts "note: skipped #{count} file(s)
|
|
1221
|
+
@err.puts "note: skipped #{count} unusable file(s) (run `okf validate` for details)"
|
|
669
1222
|
end
|
|
670
1223
|
|
|
671
1224
|
# Turn a --stale-after value (90d, 12w, or an ISO date) into an absolute cutoff
|
|
@@ -684,8 +1237,9 @@ module OKF
|
|
|
684
1237
|
:invalid
|
|
685
1238
|
end
|
|
686
1239
|
|
|
687
|
-
# Parse options, then require a single
|
|
688
|
-
# Returns the directory, or nil (after
|
|
1240
|
+
# Parse options, then require a single bundle positional — a directory, or an
|
|
1241
|
+
# @ref into the registry. Returns the bundle's directory, or nil (after
|
|
1242
|
+
# reporting) so the caller returns 2.
|
|
689
1243
|
def positional_dir(parser, argv)
|
|
690
1244
|
parser.parse!(argv)
|
|
691
1245
|
dir = argv.shift
|
|
@@ -693,11 +1247,142 @@ module OKF
|
|
|
693
1247
|
@err.puts parser.banner
|
|
694
1248
|
return nil
|
|
695
1249
|
end
|
|
696
|
-
|
|
697
|
-
|
|
1250
|
+
# A second bundle is a question this verb cannot answer: only `search`
|
|
1251
|
+
# merges across bundles and only `server` mounts several. Reading the
|
|
1252
|
+
# first and dropping the rest would answer confidently about a bundle the
|
|
1253
|
+
# user never asked about — the silent-wrong-answer shape, so: exit 2.
|
|
1254
|
+
return nil unless no_extras?(argv)
|
|
1255
|
+
|
|
1256
|
+
resolve_ref(dir)
|
|
1257
|
+
rescue OptionParser::ParseError => e
|
|
1258
|
+
@err.puts e.message
|
|
1259
|
+
nil
|
|
1260
|
+
end
|
|
1261
|
+
|
|
1262
|
+
# Parse options, then take zero or more bundle positionals (the multi-bundle
|
|
1263
|
+
# server) — directories or @refs. Returns the resolved array (possibly
|
|
1264
|
+
# empty), or nil (after reporting) so the caller returns 2.
|
|
1265
|
+
def positional_dirs(parser, argv)
|
|
1266
|
+
parser.parse!(argv)
|
|
1267
|
+
dirs = argv.map { |dir| resolve_ref(dir) }
|
|
1268
|
+
dirs.include?(nil) ? nil : dirs
|
|
1269
|
+
rescue OptionParser::ParseError => e
|
|
1270
|
+
@err.puts e.message
|
|
1271
|
+
nil
|
|
1272
|
+
end
|
|
1273
|
+
|
|
1274
|
+
# "@slug" — or bare "@", the registry's default — names a registered bundle
|
|
1275
|
+
# wherever a <dir> goes; anything else must be a directory on disk. A
|
|
1276
|
+
# leading @ always means the registry (a directory literally named that way
|
|
1277
|
+
# stays reachable as ./@name), and the registry loads only when a ref
|
|
1278
|
+
# appears, so plain-dir invocations never pay for it. Returns the bundle's
|
|
1279
|
+
# directory, or nil after reporting.
|
|
1280
|
+
def resolve_ref(arg)
|
|
1281
|
+
return resolve_registered(arg) if arg.start_with?("@")
|
|
1282
|
+
|
|
1283
|
+
unless File.directory?(arg)
|
|
1284
|
+
@err.puts "error: #{arg} is not a directory"
|
|
698
1285
|
return nil
|
|
699
1286
|
end
|
|
700
|
-
|
|
1287
|
+
arg
|
|
1288
|
+
end
|
|
1289
|
+
|
|
1290
|
+
# Load the registry, turning a malformed file into a reported usage error
|
|
1291
|
+
# instead of an OKF::Error escaping through whatever verb took an @ref —
|
|
1292
|
+
# only `server` and the `registry` verbs rescue one. Returns nil after
|
|
1293
|
+
# reporting, so every caller returns 2.
|
|
1294
|
+
def load_registry
|
|
1295
|
+
require "okf/registry"
|
|
1296
|
+
OKF::Registry.load
|
|
1297
|
+
rescue OKF::Error => e
|
|
1298
|
+
@err.puts "error: #{e.message}"
|
|
1299
|
+
nil
|
|
1300
|
+
end
|
|
1301
|
+
|
|
1302
|
+
# Resolve one @ref through the registry under $OKF_HOME (default ~/.okf).
|
|
1303
|
+
# The slug part is normalized
|
|
1304
|
+
# exactly as registration normalized it, so @One finds the bundle
|
|
1305
|
+
# registered from dir One — but never through #slugify's mint-a-name
|
|
1306
|
+
# placeholder, so "@***" is a bad ref rather than whatever is slugged
|
|
1307
|
+
# "bundle". An explicit ask fails hard: an unknown slug or a
|
|
1308
|
+
# registered-but-gone directory is a usage error naming the registry file
|
|
1309
|
+
# and the next move, never a silent skip.
|
|
1310
|
+
#
|
|
1311
|
+
# @all never resolves here. `search` expands it before this point; every
|
|
1312
|
+
# other verb takes exactly one bundle, so letting it through would mean
|
|
1313
|
+
# @all lints when one bundle is registered and exits 2 when two are —
|
|
1314
|
+
# behavior that varies with the size of the registry, which is the
|
|
1315
|
+
# silent-wrong-answer shape the second-bundle rule exists to stop. Say what
|
|
1316
|
+
# @all is instead of calling it a bundle nobody registered ("all" cannot be
|
|
1317
|
+
# registered — Registry::RESERVED_SLUGS sees to that).
|
|
1318
|
+
def resolve_registered(ref)
|
|
1319
|
+
@ref_failure = :registry
|
|
1320
|
+
if all_ref?(ref)
|
|
1321
|
+
@err.puts "error: #{ALL_REF} is only supported by `okf search` (it names every registered bundle)"
|
|
1322
|
+
return nil
|
|
1323
|
+
end
|
|
1324
|
+
registry = load_registry
|
|
1325
|
+
return nil unless registry
|
|
1326
|
+
|
|
1327
|
+
asked = ref[1..-1]
|
|
1328
|
+
slug = OKF::Registry.normalize(asked)
|
|
1329
|
+
entry = if asked.empty?
|
|
1330
|
+
registry.default # bare "@"
|
|
1331
|
+
elsif slug.empty?
|
|
1332
|
+
nil # "@***" — nothing to look up, and no placeholder to fall back on
|
|
1333
|
+
else
|
|
1334
|
+
registry.get(slug)
|
|
1335
|
+
end
|
|
1336
|
+
if entry.nil?
|
|
1337
|
+
@ref_failure = :unknown
|
|
1338
|
+
hint = registry.empty? ? "okf registry set <dir>" : "okf registry list"
|
|
1339
|
+
@err.puts "error: not a registered bundle: #{ref} in #{registry.path} (#{hint})"
|
|
1340
|
+
return nil
|
|
1341
|
+
end
|
|
1342
|
+
unless File.directory?(entry.path)
|
|
1343
|
+
@ref_failure = :missing
|
|
1344
|
+
@err.puts "error: #{ref} points to #{entry.path}, which is not a directory (okf registry del #{entry.slug}, or restore it)"
|
|
1345
|
+
return nil
|
|
1346
|
+
end
|
|
1347
|
+
ref_slugs[entry.path] = entry.slug
|
|
1348
|
+
entry.path
|
|
1349
|
+
end
|
|
1350
|
+
|
|
1351
|
+
# Which slug each @ref resolved to, by absolute path — so a hub built from
|
|
1352
|
+
# refs mounts each bundle under its registered slug, not its dir basename.
|
|
1353
|
+
# Reset by every run; never memoized here, or a stale run would seed it.
|
|
1354
|
+
attr_reader :ref_slugs
|
|
1355
|
+
|
|
1356
|
+
# Every bundle-scoped output names its bundle in the identity the caller
|
|
1357
|
+
# used: `@handbook (/path)` when they named a registered bundle, the plain
|
|
1358
|
+
# path otherwise. A dir named by path stays a path — inventing a slug for it
|
|
1359
|
+
# would imply a registration that does not exist, and looking one up would
|
|
1360
|
+
# cost a registry read on every plain-dir run.
|
|
1361
|
+
def bundle_label(dir)
|
|
1362
|
+
slug = ref_slugs[dir]
|
|
1363
|
+
slug ? "@#{slug} (#{dir})" : dir.to_s
|
|
1364
|
+
end
|
|
1365
|
+
|
|
1366
|
+
# The JSON head for one bundle. `bundle` is always its directory and `slug`
|
|
1367
|
+
# always a registry slug — never the same key meaning two things — so a
|
|
1368
|
+
# consumer resolves a row to a file without a second lookup.
|
|
1369
|
+
def bundle_head(dir)
|
|
1370
|
+
head = { "bundle" => dir }
|
|
1371
|
+
slug = ref_slugs[dir]
|
|
1372
|
+
head["slug"] = slug if slug
|
|
1373
|
+
head
|
|
1374
|
+
end
|
|
1375
|
+
|
|
1376
|
+
# Parse options, then require a single non-directory positional (e.g. a slug).
|
|
1377
|
+
# Returns it, or nil (after reporting the banner) so the caller returns 2.
|
|
1378
|
+
def positional(parser, argv)
|
|
1379
|
+
parser.parse!(argv)
|
|
1380
|
+
value = argv.shift
|
|
1381
|
+
if value.nil?
|
|
1382
|
+
@err.puts parser.banner
|
|
1383
|
+
return nil
|
|
1384
|
+
end
|
|
1385
|
+
value
|
|
701
1386
|
rescue OptionParser::ParseError => e
|
|
702
1387
|
@err.puts e.message
|
|
703
1388
|
nil
|
|
@@ -705,7 +1390,7 @@ module OKF
|
|
|
705
1390
|
|
|
706
1391
|
def print_validation(dir, result)
|
|
707
1392
|
counts = result.counts
|
|
708
|
-
@out.puts "OKF v0.1 conformance — #{dir}"
|
|
1393
|
+
@out.puts "OKF v0.1 conformance — #{bundle_label(dir)}"
|
|
709
1394
|
@out.puts " concepts: #{counts[:concepts]} index.md: #{counts[:indexes]} log.md: #{counts[:logs]}"
|
|
710
1395
|
result.errors.each { |e| @out.puts " #{paint("✗ ERROR", 31)} #{e[:path]}: #{e[:message]}" }
|
|
711
1396
|
result.warnings.each { |w| @out.puts " #{paint("! warn", 33)} #{w[:path]}: #{w[:message]}" }
|
|
@@ -719,18 +1404,17 @@ module OKF
|
|
|
719
1404
|
end
|
|
720
1405
|
|
|
721
1406
|
def print_validation_json(dir, result)
|
|
722
|
-
emit_json(
|
|
723
|
-
"bundle" => dir,
|
|
1407
|
+
emit_json(bundle_head(dir).merge(
|
|
724
1408
|
"conformant" => result.valid?,
|
|
725
1409
|
"counts" => result.counts,
|
|
726
1410
|
"errors" => result.errors,
|
|
727
1411
|
"warnings" => result.warnings
|
|
728
|
-
)
|
|
1412
|
+
))
|
|
729
1413
|
end
|
|
730
1414
|
|
|
731
1415
|
def print_lint(dir, report)
|
|
732
1416
|
stats = report.stats
|
|
733
|
-
@out.puts "OKF lint — #{dir}"
|
|
1417
|
+
@out.puts "OKF lint — #{bundle_label(dir)}"
|
|
734
1418
|
@out.puts " concepts: #{stats[:concepts]} edges: #{stats[:edges]} index.md: #{stats[:indexes]} log.md: #{stats[:logs]}"
|
|
735
1419
|
summary = lint_summary(stats)
|
|
736
1420
|
@out.puts " #{summary}" unless summary.empty?
|
|
@@ -751,12 +1435,11 @@ module OKF
|
|
|
751
1435
|
end
|
|
752
1436
|
|
|
753
1437
|
def print_lint_json(dir, report)
|
|
754
|
-
emit_json(
|
|
755
|
-
"bundle" => dir,
|
|
1438
|
+
emit_json(bundle_head(dir).merge(
|
|
756
1439
|
"healthy" => report.healthy?,
|
|
757
1440
|
"stats" => report.stats,
|
|
758
1441
|
"findings" => report.findings
|
|
759
|
-
)
|
|
1442
|
+
))
|
|
760
1443
|
end
|
|
761
1444
|
|
|
762
1445
|
# Degree-0 nodes as { id:, title:, dir: }, sorted by path — the same set lint's
|
|
@@ -769,7 +1452,7 @@ module OKF
|
|
|
769
1452
|
end
|
|
770
1453
|
|
|
771
1454
|
def print_loose(dir, files)
|
|
772
|
-
@out.puts "Loose files — #{dir} (#{files.size})"
|
|
1455
|
+
@out.puts "Loose files — #{bundle_label(dir)} (#{files.size})"
|
|
773
1456
|
if files.empty?
|
|
774
1457
|
@out.puts " #{paint("✓ none — every concept links or is linked", 32)}"
|
|
775
1458
|
return
|
|
@@ -786,15 +1469,14 @@ module OKF
|
|
|
786
1469
|
end
|
|
787
1470
|
|
|
788
1471
|
def print_loose_json(dir, files)
|
|
789
|
-
emit_json(
|
|
790
|
-
"bundle" => dir,
|
|
1472
|
+
emit_json(bundle_head(dir).merge(
|
|
791
1473
|
"count" => files.size,
|
|
792
1474
|
"loose" => files.map { |file| stringify(file) }
|
|
793
|
-
)
|
|
1475
|
+
))
|
|
794
1476
|
end
|
|
795
1477
|
|
|
796
1478
|
def print_catalog(dir, entries, total)
|
|
797
|
-
@out.puts "Catalog — #{dir} (#{counted(entries.size, total, "
|
|
1479
|
+
@out.puts "Catalog — #{bundle_label(dir)} (#{counted(entries.size, total, "concept")})"
|
|
798
1480
|
entries.group_by { |entry| entry[:area] }.sort_by(&:first).each do |area, group|
|
|
799
1481
|
@out.puts
|
|
800
1482
|
@out.puts " #{area == "(root)" ? "(root)" : "#{area}/"} (#{group.size})"
|
|
@@ -812,7 +1494,7 @@ module OKF
|
|
|
812
1494
|
end
|
|
813
1495
|
|
|
814
1496
|
def print_files(dir, entries, total)
|
|
815
|
-
@out.puts "Files — #{dir} (#{counted(entries.size, total, "
|
|
1497
|
+
@out.puts "Files — #{bundle_label(dir)} (#{counted(entries.size, total, "file")})"
|
|
816
1498
|
entries.group_by { |entry| entry[:dir] }.sort_by(&:first).each do |folder, group|
|
|
817
1499
|
width = group.map { |entry| File.basename("#{entry[:id]}.md").length }.max
|
|
818
1500
|
@out.puts
|
|
@@ -832,7 +1514,7 @@ module OKF
|
|
|
832
1514
|
end
|
|
833
1515
|
|
|
834
1516
|
def print_index(dir, label, key, rows, titles)
|
|
835
|
-
@out.puts "#{label} — #{dir} (#{rows.size} distinct)"
|
|
1517
|
+
@out.puts "#{label} — #{bundle_label(dir)} (#{rows.size} distinct)"
|
|
836
1518
|
@out.puts
|
|
837
1519
|
width = rows.map { |row| row[key].length }.max || 0
|
|
838
1520
|
rows.each do |row|
|
|
@@ -842,19 +1524,29 @@ module OKF
|
|
|
842
1524
|
end
|
|
843
1525
|
|
|
844
1526
|
def print_index_json(dir, plural, key, rows)
|
|
845
|
-
emit_json(
|
|
1527
|
+
emit_json(bundle_head(dir).merge("count" => rows.size, plural => index_rows_json(key, rows)))
|
|
846
1528
|
end
|
|
847
1529
|
|
|
848
1530
|
def index_rows_json(key, rows)
|
|
849
1531
|
rows.map { |row| { key.to_s => row[key], "count" => row[:count], "concepts" => row[:concepts] } }
|
|
850
1532
|
end
|
|
851
1533
|
|
|
1534
|
+
# "3 concepts", "1 concept", "1 of 7 concepts" — the noun agrees with the
|
|
1535
|
+
# number it follows: the size when that is all we show, the total otherwise.
|
|
852
1536
|
def counted(size, total, noun)
|
|
853
|
-
|
|
1537
|
+
return "#{size} #{pluralize(size, noun)}" if size == total
|
|
1538
|
+
|
|
1539
|
+
"#{size} of #{total} #{pluralize(total, noun)}"
|
|
1540
|
+
end
|
|
1541
|
+
|
|
1542
|
+
# The gem's whole vocabulary is regular, so a naive +s is not a shortcut —
|
|
1543
|
+
# it is the rule. Callers pass the singular.
|
|
1544
|
+
def pluralize(count, noun)
|
|
1545
|
+
count == 1 ? noun : "#{noun}s"
|
|
854
1546
|
end
|
|
855
1547
|
|
|
856
1548
|
def print_stats(dir, stats)
|
|
857
|
-
@out.puts "Stats — #{dir}"
|
|
1549
|
+
@out.puts "Stats — #{bundle_label(dir)}"
|
|
858
1550
|
@out.puts
|
|
859
1551
|
@out.puts " concepts #{stats[:concepts]}"
|
|
860
1552
|
@out.puts " areas #{stats[:areas]}"
|
|
@@ -875,11 +1567,11 @@ module OKF
|
|
|
875
1567
|
end
|
|
876
1568
|
|
|
877
1569
|
def print_stats_json(dir, stats)
|
|
878
|
-
emit_json(
|
|
879
|
-
"
|
|
1570
|
+
emit_json(bundle_head(dir).merge(
|
|
1571
|
+
"concepts" => stats[:concepts], "areas" => stats[:areas],
|
|
880
1572
|
"concept_types" => stats[:types], "cross_links" => stats[:cross_links], "distinct_tags" => stats[:tags],
|
|
881
1573
|
"by_type" => stats[:by_type], "by_area" => stats[:by_area]
|
|
882
|
-
)
|
|
1574
|
+
))
|
|
883
1575
|
end
|
|
884
1576
|
|
|
885
1577
|
# The single JSON writer. Compact by default — the token-efficient substrate an
|
|
@@ -892,13 +1584,18 @@ module OKF
|
|
|
892
1584
|
# Emit a list view's JSON envelope with --fields/--except projection applied to
|
|
893
1585
|
# each item. Returns the verb's exit code (0, or 2 on a bad projection request —
|
|
894
1586
|
# both flags at once, or a field name no item carries).
|
|
895
|
-
|
|
1587
|
+
# +dir+ is the bundle's directory — or a ready-made head Hash when the
|
|
1588
|
+
# payload spans bundles (multi-bundle search's "bundles" key).
|
|
1589
|
+
# +key+ names the JSON property the rows land under; +shape+ names the row
|
|
1590
|
+
# shape to check --fields/--except against. They are the same for every view
|
|
1591
|
+
# but search, whose two modes emit the same property from different rows.
|
|
1592
|
+
def emit_list_json(dir, key, items, options, extra = {}, shape = key)
|
|
896
1593
|
return usage_error("--fields and --except are mutually exclusive") if options[:fields] && options[:except]
|
|
897
1594
|
|
|
898
|
-
unknown = unknown_fields(items, options)
|
|
899
|
-
return usage_error("unknown field(s): #{unknown.join(", ")} (available: #{available_fields(items).join(", ")})") unless unknown.empty?
|
|
1595
|
+
unknown = unknown_fields(items, options, shape)
|
|
1596
|
+
return usage_error("unknown field(s): #{unknown.join(", ")} (available: #{available_fields(items, shape).join(", ")})") unless unknown.empty?
|
|
900
1597
|
|
|
901
|
-
payload =
|
|
1598
|
+
payload = (dir.is_a?(Hash) ? dir.dup : bundle_head(dir)).merge(extra)
|
|
902
1599
|
payload["count"] = items.size
|
|
903
1600
|
payload[key] = project(items, options)
|
|
904
1601
|
emit_json(payload)
|
|
@@ -917,17 +1614,22 @@ module OKF
|
|
|
917
1614
|
end
|
|
918
1615
|
end
|
|
919
1616
|
|
|
920
|
-
|
|
921
|
-
|
|
1617
|
+
# The declared shape wins over the data's, so the same typo gets the same
|
|
1618
|
+
# answer whether or not the result happened to have rows; a view with no
|
|
1619
|
+
# declared shape falls back to what it actually emitted.
|
|
1620
|
+
def available_fields(items, key = nil)
|
|
1621
|
+
ROW_FIELDS[key] || (items.first ? items.first.keys.map(&:to_s) : [])
|
|
922
1622
|
end
|
|
923
1623
|
|
|
924
1624
|
# Requested field names that no item actually carries — a typo guard (exit 2),
|
|
925
1625
|
# matching how lint rejects unknown check names.
|
|
926
|
-
def unknown_fields(items, options)
|
|
1626
|
+
def unknown_fields(items, options, key = nil)
|
|
927
1627
|
requested = (Array(options[:fields]) + Array(options[:except])).map(&:downcase)
|
|
928
|
-
return [] if requested.empty?
|
|
1628
|
+
return [] if requested.empty?
|
|
1629
|
+
|
|
1630
|
+
known = available_fields(items, key).map(&:downcase)
|
|
1631
|
+
return [] if known.empty? # an unknown view: no shape to check against, so accept
|
|
929
1632
|
|
|
930
|
-
known = available_fields(items).map(&:downcase)
|
|
931
1633
|
requested.reject { |field| known.include?(field) }.uniq
|
|
932
1634
|
end
|
|
933
1635
|
|
|
@@ -976,23 +1678,37 @@ module OKF
|
|
|
976
1678
|
io.puts <<~USAGE
|
|
977
1679
|
okf <command> [options]
|
|
978
1680
|
|
|
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
|
-
|
|
1681
|
+
skill <dest> [--here] [--force] install the companion agent skill
|
|
1682
|
+
server [DIR|@slug…] [-p PORT] [--bind ADDR] [...] serve one bundle, or many behind a hub
|
|
1683
|
+
render <dir|@slug> [-o FILE] [--layout NAME] [...] write a static, self-contained HTML graph
|
|
1684
|
+
|
|
1685
|
+
registry list [--json] list registered bundles (* marks the default)
|
|
1686
|
+
registry set <dir|@slug> [--as SLUG] [--default] add or update a bundle (a bare `server` serves them)
|
|
1687
|
+
registry del <dir|@slug> remove a bundle from the registry
|
|
1688
|
+
registry default <@slug> move a bundle to the front (the default)
|
|
1689
|
+
registry rename <@slug> <new> rename a registered bundle (<new> is a new name, not a ref)
|
|
1690
|
+
|
|
1691
|
+
lint <dir|@slug> [--json] [--fail-on warn] [...] report curation-quality issues
|
|
1692
|
+
loose <dir|@slug> [--json] list files with no graph links, by folder
|
|
1693
|
+
validate <dir|@slug> [--json] check OKF v0.1 conformance
|
|
1694
|
+
|
|
1695
|
+
search <dir|@slug…|@all> <term…> [-e] [...] find concepts by text or regexp, ranked (@all: every bundle)
|
|
1696
|
+
index <dir|@slug> [--json] [--area A] [--no-body] the index map: dirs, their listings and rollups
|
|
1697
|
+
stats <dir|@slug> [--json] bundle rollups (concepts, types, areas, links, tags)
|
|
1698
|
+
types <dir|@slug> [--json] [filters] list types with their concepts, by count
|
|
1699
|
+
tags <dir|@slug> [--json] [--by DIM] [filters] list tags with their concepts, by count
|
|
1700
|
+
files <dir|@slug> [--json] [filters] list files with titles, by folder
|
|
1701
|
+
catalog <dir|@slug> [--json] [filters] list concepts with metadata, by area
|
|
1702
|
+
|
|
1703
|
+
graph <dir|@slug> [--json] [--minimal] [--no-body] print the knowledge graph
|
|
1704
|
+
|
|
1705
|
+
@slug names a registered bundle instead of a path — the slug from
|
|
1706
|
+
`okf registry set`, or bare @ for the registry default. Anywhere a <dir>
|
|
1707
|
+
goes, an @slug goes: `okf lint @handbook`, `okf render @ -o graph.html`.
|
|
1708
|
+
The registry lives under $OKF_HOME (default ~/.okf); set it to point
|
|
1709
|
+
every verb at another one.
|
|
1710
|
+
search spans bundles: several leading @slugs, or @all for every registered one
|
|
1711
|
+
(@all skips a bundle whose directory is gone; a named @slug insists on it).
|
|
996
1712
|
|
|
997
1713
|
[filters] narrow a view to matching concepts: --type TYPE, --area AREA, --tag TAG
|
|
998
1714
|
(each view takes the ones orthogonal to it; matching is case-insensitive).
|