okf 1.6.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 +177 -0
- data/README.md +111 -21
- 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 +834 -116
- data/lib/okf/registry.rb +370 -0
- data/lib/okf/server/app.rb +19 -4
- data/lib/okf/server/graph/template.html.erb +580 -48
- data/lib/okf/server/graph.rb +28 -2
- data/lib/okf/server/hub.rb +207 -0
- data/lib/okf/skill/SKILL.md +9 -2
- data/lib/okf/skill/playbooks/menu.md +5 -2
- data/lib/okf/skill/playbooks/migrate.md +42 -0
- data/lib/okf/skill/playbooks/produce.md +2 -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 +4 -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,32 +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"
|
|
394
|
+
require "rack/deflater"
|
|
197
395
|
|
|
198
396
|
options = { port: 8808, bind: "127.0.0.1", title: nil, link: nil, layout: "cose" }
|
|
199
397
|
parser = OptionParser.new do |o|
|
|
200
|
-
o.banner = "Usage: okf server
|
|
398
|
+
o.banner = "Usage: okf server [DIR|@slug…] [-p PORT] [--bind ADDR] [--layout NAME] [-t title] [-l url]"
|
|
201
399
|
o.on("-p", "--port PORT", Integer, "port to serve on (default #{options[:port]})") { |v| options[:port] = v }
|
|
202
400
|
o.on("--bind ADDR", "address to bind (default #{options[:bind]})") { |v| options[:bind] = v }
|
|
203
|
-
o.on("-t", "--title TITLE", "graph title (default: parent/bundle dir name)") { |v| options[:title] = v }
|
|
204
|
-
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 }
|
|
205
403
|
o.on("--layout NAME", OKF::Server::Graph::LAYOUTS, "initial layout (#{OKF::Server::Graph::LAYOUTS.join(", ")})") { |v| options[:layout] = v }
|
|
404
|
+
help_flag(o)
|
|
206
405
|
end
|
|
207
|
-
|
|
406
|
+
dirs = positional_dirs(parser, argv) or return 2
|
|
208
407
|
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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
|
|
212
420
|
0
|
|
421
|
+
rescue OKF::Error => e
|
|
422
|
+
usage_error(e.message)
|
|
213
423
|
end
|
|
214
424
|
|
|
215
|
-
# Build the Rack app and hand it to the runner (WEBrick by
|
|
216
|
-
# 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).
|
|
217
427
|
def run_server(folder, options)
|
|
218
428
|
app = OKF::Server::App.new(folder, title: options[:title] || folder.name, link: options[:link], layout: options[:layout])
|
|
219
|
-
|
|
220
|
-
|
|
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
|
|
221
528
|
end
|
|
222
529
|
|
|
223
530
|
# The static counterpart to `server`: bake the whole bundle into one
|
|
@@ -228,11 +535,12 @@ module OKF
|
|
|
228
535
|
|
|
229
536
|
options = { output: nil, title: nil, link: nil, layout: "cose" }
|
|
230
537
|
parser = OptionParser.new do |o|
|
|
231
|
-
o.banner = "Usage: okf render <
|
|
538
|
+
o.banner = "Usage: okf render <dir|@slug> [-o FILE] [--layout NAME] [-t title] [-l url]"
|
|
232
539
|
o.on("-o", "--output FILE", "write to FILE instead of stdout") { |v| options[:output] = v }
|
|
233
540
|
o.on("-t", "--title TITLE", "graph title (default: parent/bundle dir name)") { |v| options[:title] = v }
|
|
234
541
|
o.on("-l", "--link URL", "source URL shown in the header") { |v| options[:link] = v }
|
|
235
542
|
o.on("--layout NAME", OKF::Server::Graph::LAYOUTS, "initial layout (#{OKF::Server::Graph::LAYOUTS.join(", ")})") { |v| options[:layout] = v }
|
|
543
|
+
help_flag(o)
|
|
236
544
|
end
|
|
237
545
|
dir = positional_dir(parser, argv) or return 2
|
|
238
546
|
|
|
@@ -240,21 +548,233 @@ module OKF
|
|
|
240
548
|
report_skipped(folder)
|
|
241
549
|
html = OKF::Server::App.new(folder, title: options[:title] || folder.name, link: options[:link], layout: options[:layout]).render_static
|
|
242
550
|
if options[:output]
|
|
243
|
-
|
|
244
|
-
|
|
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]}"
|
|
245
561
|
else
|
|
246
562
|
@out.print html
|
|
247
563
|
end
|
|
248
564
|
0
|
|
249
565
|
end
|
|
250
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
|
+
|
|
251
770
|
def graph(argv)
|
|
252
771
|
options = { json: false, minimal: false, body: true }
|
|
253
772
|
parser = OptionParser.new do |o|
|
|
254
|
-
o.banner = "Usage: okf graph <
|
|
773
|
+
o.banner = "Usage: okf graph <dir|@slug> [--json] [--minimal] [--no-body]"
|
|
255
774
|
json_flags(o, options, "emit nodes and edges as JSON")
|
|
256
775
|
o.on("--minimal", "leanest nodes (id + title); adds type/tag indexes") { options[:minimal] = true }
|
|
257
776
|
o.on("--[no-]body", "include each concept's body (default: yes)") { |v| options[:body] = v }
|
|
777
|
+
help_flag(o)
|
|
258
778
|
end
|
|
259
779
|
dir = positional_dir(parser, argv) or return 2
|
|
260
780
|
|
|
@@ -262,11 +782,15 @@ module OKF
|
|
|
262
782
|
graph = folder.graph(minimal: options[:minimal], body: options[:body])
|
|
263
783
|
report_skipped(folder)
|
|
264
784
|
if options[:json]
|
|
265
|
-
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)
|
|
266
789
|
payload = payload.merge(types: graph.type_index, tags: graph.tag_index) if options[:minimal]
|
|
267
790
|
emit_json(payload)
|
|
268
791
|
else
|
|
269
|
-
@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")})"
|
|
270
794
|
end
|
|
271
795
|
0
|
|
272
796
|
end
|
|
@@ -280,11 +804,12 @@ module OKF
|
|
|
280
804
|
def index(argv)
|
|
281
805
|
options = { json: false, body: true, areas: nil }
|
|
282
806
|
parser = OptionParser.new do |o|
|
|
283
|
-
o.banner = "Usage: okf index <
|
|
807
|
+
o.banner = "Usage: okf index <dir|@slug> [--area AREA] [--no-body] [--json]"
|
|
284
808
|
json_flags(o, options, "emit the index map as JSON")
|
|
285
809
|
projection_flags(o, options)
|
|
286
810
|
o.on("--area AREA", "only this directory/area (repeatable; `root` for the bundle root)") { |v| (options[:areas] ||= []) << v }
|
|
287
811
|
o.on("--[no-]body", "include each index's prose body (default: yes)") { |v| options[:body] = v }
|
|
812
|
+
help_flag(o)
|
|
288
813
|
end
|
|
289
814
|
dir = positional_dir(parser, argv) or return 2
|
|
290
815
|
|
|
@@ -293,6 +818,13 @@ module OKF
|
|
|
293
818
|
entries = folder.directory_index
|
|
294
819
|
selected = select_directories(entries, options[:areas])
|
|
295
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
|
+
|
|
296
828
|
options[:except] = Array(options[:except]) + [ "body" ] unless options[:body] || options[:fields]
|
|
297
829
|
return print_index_map_json(dir, selected, options)
|
|
298
830
|
end
|
|
@@ -312,7 +844,7 @@ module OKF
|
|
|
312
844
|
|
|
313
845
|
def print_index_map(dir, entries, body)
|
|
314
846
|
noun = entries.size == 1 ? "directory" : "directories"
|
|
315
|
-
@out.puts "Index map — #{dir} (#{entries.size} #{noun})"
|
|
847
|
+
@out.puts "Index map — #{bundle_label(dir)} (#{entries.size} #{noun})"
|
|
316
848
|
entries.each do |entry|
|
|
317
849
|
@out.puts
|
|
318
850
|
@out.puts " #{index_dir_label(entry)}#{index_dir_meta(entry)}"
|
|
@@ -332,8 +864,8 @@ module OKF
|
|
|
332
864
|
end
|
|
333
865
|
|
|
334
866
|
def index_dir_meta(entry)
|
|
335
|
-
count = "#{entry[:count]} #{entry[:count]
|
|
336
|
-
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(", ")
|
|
337
869
|
types.empty? ? " · #{count}" : " · #{count} · #{types}"
|
|
338
870
|
end
|
|
339
871
|
|
|
@@ -375,10 +907,11 @@ module OKF
|
|
|
375
907
|
def catalog(argv)
|
|
376
908
|
options = { json: false }
|
|
377
909
|
parser = OptionParser.new do |o|
|
|
378
|
-
o.banner = "Usage: okf catalog <
|
|
910
|
+
o.banner = "Usage: okf catalog <dir|@slug> [--type T] [--area A] [--tag T] [--json]"
|
|
379
911
|
json_flags(o, options, "emit the catalog as JSON")
|
|
380
912
|
projection_flags(o, options)
|
|
381
913
|
filter_flags(o, options, :type, :area, :tag)
|
|
914
|
+
help_flag(o)
|
|
382
915
|
end
|
|
383
916
|
dir = positional_dir(parser, argv) or return 2
|
|
384
917
|
|
|
@@ -395,10 +928,11 @@ module OKF
|
|
|
395
928
|
def files(argv)
|
|
396
929
|
options = { json: false }
|
|
397
930
|
parser = OptionParser.new do |o|
|
|
398
|
-
o.banner = "Usage: okf files <
|
|
931
|
+
o.banner = "Usage: okf files <dir|@slug> [--type T] [--area A] [--tag T] [--json]"
|
|
399
932
|
json_flags(o, options, "emit the file tree as JSON")
|
|
400
933
|
projection_flags(o, options)
|
|
401
934
|
filter_flags(o, options, :type, :area, :tag)
|
|
935
|
+
help_flag(o)
|
|
402
936
|
end
|
|
403
937
|
dir = positional_dir(parser, argv) or return 2
|
|
404
938
|
|
|
@@ -415,10 +949,11 @@ module OKF
|
|
|
415
949
|
def tags(argv)
|
|
416
950
|
options = { json: false, by: nil }
|
|
417
951
|
parser = OptionParser.new do |o|
|
|
418
|
-
o.banner = "Usage: okf tags <
|
|
952
|
+
o.banner = "Usage: okf tags <dir|@slug> [--by type|area] [--type T] [--area A] [--json]"
|
|
419
953
|
json_flags(o, options, "emit the tag index as JSON")
|
|
420
954
|
o.on("--by DIM", %w[type area], "group the tags by a concept dimension (type | area)") { |v| options[:by] = v.to_sym }
|
|
421
955
|
filter_flags(o, options, :type, :area)
|
|
956
|
+
help_flag(o)
|
|
422
957
|
end
|
|
423
958
|
dir = positional_dir(parser, argv) or return 2
|
|
424
959
|
|
|
@@ -430,9 +965,10 @@ module OKF
|
|
|
430
965
|
def types(argv)
|
|
431
966
|
options = { json: false }
|
|
432
967
|
parser = OptionParser.new do |o|
|
|
433
|
-
o.banner = "Usage: okf types <
|
|
968
|
+
o.banner = "Usage: okf types <dir|@slug> [--area A] [--tag T] [--json]"
|
|
434
969
|
json_flags(o, options, "emit the type index as JSON")
|
|
435
970
|
filter_flags(o, options, :area, :tag)
|
|
971
|
+
help_flag(o)
|
|
436
972
|
end
|
|
437
973
|
dir = positional_dir(parser, argv) or return 2
|
|
438
974
|
|
|
@@ -492,15 +1028,15 @@ module OKF
|
|
|
492
1028
|
|
|
493
1029
|
# A catalog entry's type for display — "Untyped" when blank, matching the graph.
|
|
494
1030
|
def entry_type(entry)
|
|
495
|
-
entry[:type]
|
|
1031
|
+
OKF.blank?(entry[:type]) ? "Untyped" : entry[:type]
|
|
496
1032
|
end
|
|
497
1033
|
|
|
498
1034
|
def print_grouped_tags(dir, dim, groups, titles)
|
|
499
|
-
@out.puts "Tags — #{dir} (#{distinct_tags(groups)} distinct, by #{dim})"
|
|
1035
|
+
@out.puts "Tags — #{bundle_label(dir)} (#{distinct_tags(groups)} distinct, by #{dim})"
|
|
500
1036
|
groups.each do |key, rows|
|
|
501
1037
|
label = dim == :area && key != "(root)" ? "#{key}/" : key
|
|
502
1038
|
@out.puts
|
|
503
|
-
@out.puts " #{label} (#{rows.size}
|
|
1039
|
+
@out.puts " #{label} (#{rows.size} #{pluralize(rows.size, "tag")})"
|
|
504
1040
|
width = rows.map { |row| row[:tag].length }.max || 0
|
|
505
1041
|
rows.each do |row|
|
|
506
1042
|
names = row[:concepts].map { |id| titles[id] || id }.join(", ")
|
|
@@ -510,12 +1046,10 @@ module OKF
|
|
|
510
1046
|
end
|
|
511
1047
|
|
|
512
1048
|
def print_grouped_tags_json(dir, dim, groups)
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
end
|
|
518
|
-
)
|
|
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))
|
|
519
1053
|
end
|
|
520
1054
|
|
|
521
1055
|
def distinct_tags(groups)
|
|
@@ -525,8 +1059,9 @@ module OKF
|
|
|
525
1059
|
def stats(argv)
|
|
526
1060
|
options = { json: false }
|
|
527
1061
|
parser = OptionParser.new do |o|
|
|
528
|
-
o.banner = "Usage: okf stats <
|
|
1062
|
+
o.banner = "Usage: okf stats <dir|@slug> [--json]"
|
|
529
1063
|
json_flags(o, options, "emit the stats as JSON")
|
|
1064
|
+
help_flag(o)
|
|
530
1065
|
end
|
|
531
1066
|
dir = positional_dir(parser, argv) or return 2
|
|
532
1067
|
|
|
@@ -567,6 +1102,21 @@ module OKF
|
|
|
567
1102
|
parser.on("--pretty", "indent the JSON for reading (implies --json)") { options[:json] = true; @pretty = true }
|
|
568
1103
|
end
|
|
569
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
|
+
|
|
570
1120
|
# --fields/--except project the JSON down to the properties an agent wants, so it
|
|
571
1121
|
# never pays tokens for fields it will not read. --fields is an allowlist,
|
|
572
1122
|
# --except a denylist (mutually exclusive); both imply --json and apply per item
|
|
@@ -631,6 +1181,7 @@ module OKF
|
|
|
631
1181
|
o.banner = "Usage: okf skill <dest-dir> [--here] [--force]"
|
|
632
1182
|
o.on("--here", "install straight into <dest-dir>, wherever it is (no skills/okf nesting)") { options[:nest] = false }
|
|
633
1183
|
o.on("--force", "overwrite a non-empty destination") { options[:force] = true }
|
|
1184
|
+
help_flag(o)
|
|
634
1185
|
end
|
|
635
1186
|
parser.parse!(argv)
|
|
636
1187
|
dest = argv.shift
|
|
@@ -660,10 +1211,14 @@ module OKF
|
|
|
660
1211
|
note_skipped(folder.bundle.unparseable.size)
|
|
661
1212
|
end
|
|
662
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.
|
|
663
1218
|
def note_skipped(count)
|
|
664
1219
|
return if count.nil? || count <= 0
|
|
665
1220
|
|
|
666
|
-
@err.puts "note: skipped #{count} file(s)
|
|
1221
|
+
@err.puts "note: skipped #{count} unusable file(s) (run `okf validate` for details)"
|
|
667
1222
|
end
|
|
668
1223
|
|
|
669
1224
|
# Turn a --stale-after value (90d, 12w, or an ISO date) into an absolute cutoff
|
|
@@ -682,8 +1237,9 @@ module OKF
|
|
|
682
1237
|
:invalid
|
|
683
1238
|
end
|
|
684
1239
|
|
|
685
|
-
# Parse options, then require a single
|
|
686
|
-
# 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.
|
|
687
1243
|
def positional_dir(parser, argv)
|
|
688
1244
|
parser.parse!(argv)
|
|
689
1245
|
dir = argv.shift
|
|
@@ -691,11 +1247,142 @@ module OKF
|
|
|
691
1247
|
@err.puts parser.banner
|
|
692
1248
|
return nil
|
|
693
1249
|
end
|
|
694
|
-
|
|
695
|
-
|
|
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"
|
|
696
1285
|
return nil
|
|
697
1286
|
end
|
|
698
|
-
|
|
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
|
|
699
1386
|
rescue OptionParser::ParseError => e
|
|
700
1387
|
@err.puts e.message
|
|
701
1388
|
nil
|
|
@@ -703,7 +1390,7 @@ module OKF
|
|
|
703
1390
|
|
|
704
1391
|
def print_validation(dir, result)
|
|
705
1392
|
counts = result.counts
|
|
706
|
-
@out.puts "OKF v0.1 conformance — #{dir}"
|
|
1393
|
+
@out.puts "OKF v0.1 conformance — #{bundle_label(dir)}"
|
|
707
1394
|
@out.puts " concepts: #{counts[:concepts]} index.md: #{counts[:indexes]} log.md: #{counts[:logs]}"
|
|
708
1395
|
result.errors.each { |e| @out.puts " #{paint("✗ ERROR", 31)} #{e[:path]}: #{e[:message]}" }
|
|
709
1396
|
result.warnings.each { |w| @out.puts " #{paint("! warn", 33)} #{w[:path]}: #{w[:message]}" }
|
|
@@ -717,18 +1404,17 @@ module OKF
|
|
|
717
1404
|
end
|
|
718
1405
|
|
|
719
1406
|
def print_validation_json(dir, result)
|
|
720
|
-
emit_json(
|
|
721
|
-
"bundle" => dir,
|
|
1407
|
+
emit_json(bundle_head(dir).merge(
|
|
722
1408
|
"conformant" => result.valid?,
|
|
723
1409
|
"counts" => result.counts,
|
|
724
1410
|
"errors" => result.errors,
|
|
725
1411
|
"warnings" => result.warnings
|
|
726
|
-
)
|
|
1412
|
+
))
|
|
727
1413
|
end
|
|
728
1414
|
|
|
729
1415
|
def print_lint(dir, report)
|
|
730
1416
|
stats = report.stats
|
|
731
|
-
@out.puts "OKF lint — #{dir}"
|
|
1417
|
+
@out.puts "OKF lint — #{bundle_label(dir)}"
|
|
732
1418
|
@out.puts " concepts: #{stats[:concepts]} edges: #{stats[:edges]} index.md: #{stats[:indexes]} log.md: #{stats[:logs]}"
|
|
733
1419
|
summary = lint_summary(stats)
|
|
734
1420
|
@out.puts " #{summary}" unless summary.empty?
|
|
@@ -749,12 +1435,11 @@ module OKF
|
|
|
749
1435
|
end
|
|
750
1436
|
|
|
751
1437
|
def print_lint_json(dir, report)
|
|
752
|
-
emit_json(
|
|
753
|
-
"bundle" => dir,
|
|
1438
|
+
emit_json(bundle_head(dir).merge(
|
|
754
1439
|
"healthy" => report.healthy?,
|
|
755
1440
|
"stats" => report.stats,
|
|
756
1441
|
"findings" => report.findings
|
|
757
|
-
)
|
|
1442
|
+
))
|
|
758
1443
|
end
|
|
759
1444
|
|
|
760
1445
|
# Degree-0 nodes as { id:, title:, dir: }, sorted by path — the same set lint's
|
|
@@ -767,7 +1452,7 @@ module OKF
|
|
|
767
1452
|
end
|
|
768
1453
|
|
|
769
1454
|
def print_loose(dir, files)
|
|
770
|
-
@out.puts "Loose files — #{dir} (#{files.size})"
|
|
1455
|
+
@out.puts "Loose files — #{bundle_label(dir)} (#{files.size})"
|
|
771
1456
|
if files.empty?
|
|
772
1457
|
@out.puts " #{paint("✓ none — every concept links or is linked", 32)}"
|
|
773
1458
|
return
|
|
@@ -784,15 +1469,14 @@ module OKF
|
|
|
784
1469
|
end
|
|
785
1470
|
|
|
786
1471
|
def print_loose_json(dir, files)
|
|
787
|
-
emit_json(
|
|
788
|
-
"bundle" => dir,
|
|
1472
|
+
emit_json(bundle_head(dir).merge(
|
|
789
1473
|
"count" => files.size,
|
|
790
1474
|
"loose" => files.map { |file| stringify(file) }
|
|
791
|
-
)
|
|
1475
|
+
))
|
|
792
1476
|
end
|
|
793
1477
|
|
|
794
1478
|
def print_catalog(dir, entries, total)
|
|
795
|
-
@out.puts "Catalog — #{dir} (#{counted(entries.size, total, "
|
|
1479
|
+
@out.puts "Catalog — #{bundle_label(dir)} (#{counted(entries.size, total, "concept")})"
|
|
796
1480
|
entries.group_by { |entry| entry[:area] }.sort_by(&:first).each do |area, group|
|
|
797
1481
|
@out.puts
|
|
798
1482
|
@out.puts " #{area == "(root)" ? "(root)" : "#{area}/"} (#{group.size})"
|
|
@@ -810,7 +1494,7 @@ module OKF
|
|
|
810
1494
|
end
|
|
811
1495
|
|
|
812
1496
|
def print_files(dir, entries, total)
|
|
813
|
-
@out.puts "Files — #{dir} (#{counted(entries.size, total, "
|
|
1497
|
+
@out.puts "Files — #{bundle_label(dir)} (#{counted(entries.size, total, "file")})"
|
|
814
1498
|
entries.group_by { |entry| entry[:dir] }.sort_by(&:first).each do |folder, group|
|
|
815
1499
|
width = group.map { |entry| File.basename("#{entry[:id]}.md").length }.max
|
|
816
1500
|
@out.puts
|
|
@@ -830,7 +1514,7 @@ module OKF
|
|
|
830
1514
|
end
|
|
831
1515
|
|
|
832
1516
|
def print_index(dir, label, key, rows, titles)
|
|
833
|
-
@out.puts "#{label} — #{dir} (#{rows.size} distinct)"
|
|
1517
|
+
@out.puts "#{label} — #{bundle_label(dir)} (#{rows.size} distinct)"
|
|
834
1518
|
@out.puts
|
|
835
1519
|
width = rows.map { |row| row[key].length }.max || 0
|
|
836
1520
|
rows.each do |row|
|
|
@@ -840,19 +1524,29 @@ module OKF
|
|
|
840
1524
|
end
|
|
841
1525
|
|
|
842
1526
|
def print_index_json(dir, plural, key, rows)
|
|
843
|
-
emit_json(
|
|
1527
|
+
emit_json(bundle_head(dir).merge("count" => rows.size, plural => index_rows_json(key, rows)))
|
|
844
1528
|
end
|
|
845
1529
|
|
|
846
1530
|
def index_rows_json(key, rows)
|
|
847
1531
|
rows.map { |row| { key.to_s => row[key], "count" => row[:count], "concepts" => row[:concepts] } }
|
|
848
1532
|
end
|
|
849
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.
|
|
850
1536
|
def counted(size, total, noun)
|
|
851
|
-
|
|
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"
|
|
852
1546
|
end
|
|
853
1547
|
|
|
854
1548
|
def print_stats(dir, stats)
|
|
855
|
-
@out.puts "Stats — #{dir}"
|
|
1549
|
+
@out.puts "Stats — #{bundle_label(dir)}"
|
|
856
1550
|
@out.puts
|
|
857
1551
|
@out.puts " concepts #{stats[:concepts]}"
|
|
858
1552
|
@out.puts " areas #{stats[:areas]}"
|
|
@@ -873,11 +1567,11 @@ module OKF
|
|
|
873
1567
|
end
|
|
874
1568
|
|
|
875
1569
|
def print_stats_json(dir, stats)
|
|
876
|
-
emit_json(
|
|
877
|
-
"
|
|
1570
|
+
emit_json(bundle_head(dir).merge(
|
|
1571
|
+
"concepts" => stats[:concepts], "areas" => stats[:areas],
|
|
878
1572
|
"concept_types" => stats[:types], "cross_links" => stats[:cross_links], "distinct_tags" => stats[:tags],
|
|
879
1573
|
"by_type" => stats[:by_type], "by_area" => stats[:by_area]
|
|
880
|
-
)
|
|
1574
|
+
))
|
|
881
1575
|
end
|
|
882
1576
|
|
|
883
1577
|
# The single JSON writer. Compact by default — the token-efficient substrate an
|
|
@@ -890,13 +1584,18 @@ module OKF
|
|
|
890
1584
|
# Emit a list view's JSON envelope with --fields/--except projection applied to
|
|
891
1585
|
# each item. Returns the verb's exit code (0, or 2 on a bad projection request —
|
|
892
1586
|
# both flags at once, or a field name no item carries).
|
|
893
|
-
|
|
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)
|
|
894
1593
|
return usage_error("--fields and --except are mutually exclusive") if options[:fields] && options[:except]
|
|
895
1594
|
|
|
896
|
-
unknown = unknown_fields(items, options)
|
|
897
|
-
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?
|
|
898
1597
|
|
|
899
|
-
payload =
|
|
1598
|
+
payload = (dir.is_a?(Hash) ? dir.dup : bundle_head(dir)).merge(extra)
|
|
900
1599
|
payload["count"] = items.size
|
|
901
1600
|
payload[key] = project(items, options)
|
|
902
1601
|
emit_json(payload)
|
|
@@ -915,17 +1614,22 @@ module OKF
|
|
|
915
1614
|
end
|
|
916
1615
|
end
|
|
917
1616
|
|
|
918
|
-
|
|
919
|
-
|
|
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) : [])
|
|
920
1622
|
end
|
|
921
1623
|
|
|
922
1624
|
# Requested field names that no item actually carries — a typo guard (exit 2),
|
|
923
1625
|
# matching how lint rejects unknown check names.
|
|
924
|
-
def unknown_fields(items, options)
|
|
1626
|
+
def unknown_fields(items, options, key = nil)
|
|
925
1627
|
requested = (Array(options[:fields]) + Array(options[:except])).map(&:downcase)
|
|
926
|
-
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
|
|
927
1632
|
|
|
928
|
-
known = available_fields(items).map(&:downcase)
|
|
929
1633
|
requested.reject { |field| known.include?(field) }.uniq
|
|
930
1634
|
end
|
|
931
1635
|
|
|
@@ -974,23 +1678,37 @@ module OKF
|
|
|
974
1678
|
io.puts <<~USAGE
|
|
975
1679
|
okf <command> [options]
|
|
976
1680
|
|
|
977
|
-
skill <dest> [--here] [--force]
|
|
978
|
-
server
|
|
979
|
-
render <dir> [-o FILE] [--layout NAME] [...] write a static, self-contained HTML graph
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
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).
|
|
994
1712
|
|
|
995
1713
|
[filters] narrow a view to matching concepts: --type TYPE, --area AREA, --tag TAG
|
|
996
1714
|
(each view takes the ones orthogonal to it; matching is case-insensitive).
|