okf 1.9.0 → 1.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +493 -133
- data/README.md +101 -10
- data/lib/okf/bundle/folder.rb +4 -0
- data/lib/okf/bundle.rb +25 -1
- data/lib/okf/cli/catalog.rb +66 -0
- data/lib/okf/cli/command.rb +495 -0
- data/lib/okf/cli/files.rb +68 -0
- data/lib/okf/cli/graph.rb +82 -0
- data/lib/okf/cli/index.rb +127 -0
- data/lib/okf/cli/lint.rb +139 -0
- data/lib/okf/cli/loose.rb +78 -0
- data/lib/okf/cli/registry.rb +229 -0
- data/lib/okf/cli/render.rb +66 -0
- data/lib/okf/cli/search.rb +285 -0
- data/lib/okf/cli/server.rb +179 -0
- data/lib/okf/cli/skill.rb +57 -0
- data/lib/okf/cli/stats.rb +88 -0
- data/lib/okf/cli/tags.rb +122 -0
- data/lib/okf/cli/types.rb +37 -0
- data/lib/okf/cli/validate.rb +66 -0
- data/lib/okf/cli.rb +418 -1703
- data/lib/okf/render/graph/template.html.erb +1020 -61
- data/lib/okf/render/graph.rb +46 -2
- data/lib/okf/server/app.rb +10 -4
- data/lib/okf/server/hub/not_found.rb +663 -0
- data/lib/okf/server/hub.rb +504 -38
- data/lib/okf/skill/SKILL.md +14 -10
- data/lib/okf/skill/playbooks/curate.md +3 -1
- data/lib/okf/skill/playbooks/maintain.md +3 -2
- data/lib/okf/skill/playbooks/menu.md +5 -0
- data/lib/okf/skill/playbooks/refine.md +92 -0
- data/lib/okf/skill/reference/cli.md +22 -4
- data/lib/okf/version.rb +1 -1
- metadata +19 -1
data/lib/okf/cli.rb
CHANGED
|
@@ -3,85 +3,16 @@
|
|
|
3
3
|
require "optparse"
|
|
4
4
|
|
|
5
5
|
module OKF
|
|
6
|
-
# Command-line front end: `okf
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
6
|
+
# Command-line front end: `okf <command> [options]`.
|
|
7
|
+
#
|
|
8
|
+
# This file is the dispatcher and the registry; the verbs themselves live one
|
|
9
|
+
# per file under `okf/cli/`, each a Command subclass that registers itself at
|
|
10
|
+
# load. It is still the only layer that parses argv, prints, writes files and
|
|
11
|
+
# decides exit codes — the lib classes below it just return data. Streams are
|
|
12
|
+
# injectable for testing.
|
|
10
13
|
#
|
|
11
14
|
# Exit codes: 0 success, 1 non-conformant / failing bundle, 2 usage error.
|
|
12
15
|
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
|
-
|
|
17
|
-
# Lint findings grouped for display, in category order.
|
|
18
|
-
LINT_CATEGORIES = {
|
|
19
|
-
"Reachability" => %i[orphan not_in_index disconnected_component unlinked],
|
|
20
|
-
"Backlog" => %i[missing_concept broken_index_entry],
|
|
21
|
-
"Completeness" => %i[stub missing_title missing_description missing_timestamp],
|
|
22
|
-
"Freshness" => %i[stale],
|
|
23
|
-
"Provenance" => %i[uncited_external broken_citation],
|
|
24
|
-
"Hygiene" => %i[duplicate_title unused_reference_def undefined_reference self_link]
|
|
25
|
-
}.freeze
|
|
26
|
-
|
|
27
|
-
# Runs a Rack app under WEBrick until interrupted. Injected into the CLI so
|
|
28
|
-
# tests can drive `server` without opening a socket; the runner loads here
|
|
29
|
-
# (not at require time) so `require "okf"` and a Rails mount of the server stay
|
|
30
|
-
# light.
|
|
31
|
-
WEBRICK = lambda do |app, host, port|
|
|
32
|
-
require "okf/server/runner"
|
|
33
|
-
OKF::Server::Runner.run(app, host: host, port: port)
|
|
34
|
-
end
|
|
35
|
-
|
|
36
|
-
def self.start(argv, out: $stdout, err: $stderr)
|
|
37
|
-
new(out: out, err: err).run(argv)
|
|
38
|
-
end
|
|
39
|
-
|
|
40
|
-
def initialize(out: $stdout, err: $stderr, runner: WEBRICK)
|
|
41
|
-
@out = out
|
|
42
|
-
@err = err
|
|
43
|
-
@runner = runner
|
|
44
|
-
@pretty = false
|
|
45
|
-
end
|
|
46
|
-
|
|
47
|
-
def run(argv)
|
|
48
|
-
argv = argv.dup
|
|
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
|
|
82
|
-
end
|
|
83
|
-
end
|
|
84
|
-
|
|
85
16
|
# "every registered bundle" as a ref, in its canonical spelling — what the
|
|
86
17
|
# messages say, and (normalized) what #all_ref? recognizes. Only `search`
|
|
87
18
|
# expands it: it is the one verb that merges across bundles, so it is the one
|
|
@@ -92,12 +23,6 @@ module OKF
|
|
|
92
23
|
# together.
|
|
93
24
|
ALL_REF = "@all"
|
|
94
25
|
|
|
95
|
-
# The core raises `:regexp`; a user typed `--regexp`. Translating here keeps
|
|
96
|
-
# the flag vocabulary in the shell, where it belongs, and lets the message end
|
|
97
|
-
# with the fix rather than only the complaint: an engine that *can* do what was
|
|
98
|
-
# asked is named, so the next command is obvious.
|
|
99
|
-
CAPABILITY_FLAGS = { regexp: "--regexp", fuzzy: "--fuzzy" }.freeze
|
|
100
|
-
|
|
101
26
|
# The row shape each list view emits, so `--fields`/`--except` can be checked
|
|
102
27
|
# against a name even when the result is empty. Without it the typo guard
|
|
103
28
|
# keyed off the data: `--fields bogus` was a usage error against a bundle
|
|
@@ -120,1675 +45,465 @@ module OKF
|
|
|
120
45
|
"bundles" => %w[slug title dir mount default missing]
|
|
121
46
|
}.freeze
|
|
122
47
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
help_flag(o)
|
|
131
|
-
end
|
|
132
|
-
dir = positional_dir(parser, argv) or return 2
|
|
133
|
-
|
|
134
|
-
result = OKF::Bundle::Folder.load(dir).validate
|
|
135
|
-
options[:json] ? print_validation_json(dir, result) : print_validation(dir, result)
|
|
136
|
-
result.valid? ? 0 : 1
|
|
137
|
-
end
|
|
138
|
-
|
|
139
|
-
def lint(argv)
|
|
140
|
-
options = { json: false, min_body: OKF::Bundle::Linter::DEFAULT_MIN_BODY, stale_after: nil, only: nil, except: nil, fail_on: :never }
|
|
141
|
-
parser = OptionParser.new do |o|
|
|
142
|
-
o.banner = "Usage: okf lint <dir|@slug> [--json] [--min-body N] [--stale-after DUR] [--only a,b] [--except a,b] [--fail-on warn]"
|
|
143
|
-
json_flags(o, options, "emit a JSON report")
|
|
144
|
-
o.on("--min-body N", Integer, "stub threshold in body characters (default #{OKF::Bundle::Linter::DEFAULT_MIN_BODY})") { |v| options[:min_body] = v }
|
|
145
|
-
o.on("--stale-after DUR", "flag concepts older than DUR (e.g. 90d, 12w, 2026-01-01)") { |v| options[:stale_after] = v }
|
|
146
|
-
o.on("--only LIST", Array, "run only these checks (comma-separated)") { |v| options[:only] = v.map(&:to_sym) }
|
|
147
|
-
o.on("--except LIST", Array, "skip these checks (comma-separated)") { |v| options[:except] = v.map(&:to_sym) }
|
|
148
|
-
o.on("--fail-on LEVEL", %w[never warn], "exit 1 when a finding at LEVEL exists (never | warn)") { |v| options[:fail_on] = v.to_sym }
|
|
149
|
-
help_flag(o)
|
|
150
|
-
end
|
|
151
|
-
dir = positional_dir(parser, argv) or return 2
|
|
152
|
-
|
|
153
|
-
unknown = ((options[:only] || []) + (options[:except] || [])) - OKF::Bundle::Linter::CHECKS
|
|
154
|
-
unless unknown.empty?
|
|
155
|
-
@err.puts "error: unknown check(s): #{unknown.uniq.join(", ")}"
|
|
156
|
-
return 2
|
|
157
|
-
end
|
|
158
|
-
|
|
159
|
-
stale_before = parse_stale_after(options[:stale_after])
|
|
160
|
-
if stale_before == :invalid
|
|
161
|
-
@err.puts "error: invalid --stale-after `#{options[:stale_after]}` (use 90d, 12w, or an ISO date like 2026-01-01)"
|
|
162
|
-
return 2
|
|
163
|
-
end
|
|
164
|
-
|
|
165
|
-
folder = OKF::Bundle::Folder.load(dir)
|
|
166
|
-
report = folder.lint(min_body: options[:min_body], stale_before: stale_before, only: options[:only], except: options[:except])
|
|
167
|
-
note_skipped(report.stats[:skipped])
|
|
168
|
-
options[:json] ? print_lint_json(dir, report) : print_lint(dir, report)
|
|
169
|
-
options[:fail_on] == :warn && report.warnings.any? ? 1 : 0
|
|
170
|
-
end
|
|
171
|
-
|
|
172
|
-
# List the "loose" files — concepts with graph degree 0 (no cross-links in or
|
|
173
|
-
# out), grouped by folder. A folder-grouped view over lint's `unlinked` check,
|
|
174
|
-
# for the common "which files float in the graph?" question. Advisory (exit 0):
|
|
175
|
-
# a terminal leaf can be loose by design. `--json` for a machine substrate.
|
|
176
|
-
def loose(argv)
|
|
177
|
-
options = { json: false }
|
|
178
|
-
parser = OptionParser.new do |o|
|
|
179
|
-
o.banner = "Usage: okf loose <dir|@slug> [--json]"
|
|
180
|
-
json_flags(o, options, "emit the loose files as JSON")
|
|
181
|
-
help_flag(o)
|
|
182
|
-
end
|
|
183
|
-
dir = positional_dir(parser, argv) or return 2
|
|
184
|
-
|
|
185
|
-
folder = OKF::Bundle::Folder.load(dir)
|
|
186
|
-
report_skipped(folder)
|
|
187
|
-
files = loose_files(folder.graph(minimal: true))
|
|
188
|
-
options[:json] ? print_loose_json(dir, files) : print_loose(dir, files)
|
|
189
|
-
0
|
|
190
|
-
end
|
|
191
|
-
|
|
192
|
-
# Ranked text retrieval — the browser page's search brought to the CLI on the
|
|
193
|
-
# same engine (a MiniFTS index) and extended to bodies. Terms after the
|
|
194
|
-
# directory are ANDed tokens, matched whole or by prefix (Ruby regexps with
|
|
195
|
-
# --regexp, typo tolerance with --fuzzy); rows rank by BM25+ weighted toward
|
|
196
|
-
# where they hit (title > id > tags > type/description > body) and carry one
|
|
197
|
-
# bounded context snippet, so "which concept covers X?" costs a few rows, not
|
|
198
|
-
# a body read. Advisory read: exit 0 even with no matches. Exact by default —
|
|
199
|
-
# the consuming agent is the fuzzy layer, until it asks not to be.
|
|
200
|
-
def search(argv)
|
|
201
|
-
options = { json: false, regexp: false, fuzzy: false, engine: nil }
|
|
202
|
-
parser = OptionParser.new do |o|
|
|
203
|
-
o.banner = "Usage: okf search <dir|@slug…|@all> <term…> [--engine NAME] [--regexp|--fuzzy] [--in FIELDS] [--type T] [--area A] [--tag T] [--json]"
|
|
204
|
-
search_engine_note(o)
|
|
205
|
-
json_flags(o, options, "emit the matches as JSON")
|
|
206
|
-
projection_flags(o, options)
|
|
207
|
-
o.on("-e", "--regexp", "read each term as a Ruby regular expression rather",
|
|
208
|
-
"than literal text — case-insensitive (scan engine)") { options[:regexp] = true }
|
|
209
|
-
o.on("--fuzzy",
|
|
210
|
-
"tolerate typos, edit distance #{OKF::Bundle::Search::FUZZY_DISTANCE} × term length (index engine)") { options[:fuzzy] = true }
|
|
211
|
-
o.on("--engine NAME", "match with this engine instead of the default",
|
|
212
|
-
"(#{engine_names}) — index is BM25+ ranked, token-based") { |v| options[:engine] = v }
|
|
213
|
-
o.on("--in LIST", Array, "search only these fields (#{OKF::Bundle::Search::FIELDS.join(", ")})") { |v| options[:in] = v.map(&:downcase) }
|
|
214
|
-
filter_flags(o, options, :type, :area, :tag)
|
|
215
|
-
help_flag(o)
|
|
216
|
-
end
|
|
217
|
-
begin
|
|
218
|
-
parser.parse!(argv)
|
|
219
|
-
rescue OptionParser::ParseError => e
|
|
220
|
-
@err.puts e.message
|
|
221
|
-
return 2
|
|
222
|
-
end
|
|
223
|
-
|
|
224
|
-
# Registry mode — leading @refs, @all among them — searches several bundles
|
|
225
|
-
# and labels every match; a plain dir keeps the classic single-bundle output.
|
|
226
|
-
if argv.first&.start_with?("@")
|
|
227
|
-
pairs = ref_targets(argv) or return 2
|
|
228
|
-
dir = nil
|
|
229
|
-
else
|
|
230
|
-
dir = argv.shift
|
|
231
|
-
if dir.nil?
|
|
232
|
-
@err.puts parser.banner
|
|
233
|
-
return 2
|
|
234
|
-
end
|
|
235
|
-
dir = resolve_ref(dir) or return 2
|
|
236
|
-
end
|
|
237
|
-
|
|
238
|
-
terms = argv
|
|
239
|
-
if terms.empty?
|
|
240
|
-
@err.puts parser.banner
|
|
241
|
-
return 2
|
|
242
|
-
end
|
|
243
|
-
|
|
244
|
-
# A non-leading @arg is a literal term by the grammar — say so, since the
|
|
245
|
-
# user may have meant a ref (refs must lead) and would otherwise see only
|
|
246
|
-
# a silent zero-match.
|
|
247
|
-
stray = terms.find { |term| term.start_with?("@") }
|
|
248
|
-
@err.puts "note: '#{stray}' searches as a literal term — an @slug or @all must lead" if stray
|
|
249
|
-
|
|
250
|
-
unknown = Array(options[:in]) - OKF::Bundle::Search::FIELDS
|
|
251
|
-
return usage_error("unknown field(s): #{unknown.join(", ")} (searchable: #{OKF::Bundle::Search::FIELDS.join(", ")})") unless unknown.empty?
|
|
252
|
-
|
|
253
|
-
# Two query languages, not two dials on one: a regexp is matched against raw
|
|
254
|
-
# text, --fuzzy is an edit distance over indexed tokens. Silently honouring
|
|
255
|
-
# one and dropping the other would answer a question nobody asked.
|
|
256
|
-
if options[:regexp] && options[:fuzzy]
|
|
257
|
-
return usage_error("--regexp and --fuzzy are mutually exclusive (a pattern is matched literally, not by edit distance)")
|
|
258
|
-
end
|
|
259
|
-
|
|
260
|
-
return multi_search(pairs, terms, options) if pairs
|
|
261
|
-
|
|
262
|
-
folder = OKF::Bundle::Folder.load(dir)
|
|
263
|
-
report_skipped(folder)
|
|
264
|
-
rows = OKF::Bundle::Search.call(folder.bundle, terms, fields: options[:in], regexp: options[:regexp],
|
|
265
|
-
fuzzy: options[:fuzzy], engine: options[:engine])
|
|
266
|
-
keep = filter_ids(folder, options)
|
|
267
|
-
rows = rows.select { |row| keep.include?(row[:id]) } unless keep.nil?
|
|
268
|
-
return print_search_json(dir, terms, rows, options) if options[:json]
|
|
269
|
-
|
|
270
|
-
print_search(dir, terms, rows, folder.bundle.concepts.size)
|
|
271
|
-
0
|
|
272
|
-
rescue RegexpError => e
|
|
273
|
-
usage_error("invalid pattern: #{e.message}")
|
|
274
|
-
rescue OKF::Bundle::Search::UnknownEngine => e
|
|
275
|
-
usage_error(e.message)
|
|
276
|
-
rescue OKF::Bundle::Search::UnsupportedQuery => e
|
|
277
|
-
usage_error(unsupported_query_message(e))
|
|
278
|
-
end
|
|
279
|
-
|
|
280
|
-
# Every registered bundle, as [slug, dir] pairs — what @all expands to.
|
|
281
|
-
# Asking for everything tolerates gaps: a registered directory that has since
|
|
282
|
-
# vanished is skipped with a note, the same forgiveness the hub shows a stale
|
|
283
|
-
# entry. Naming one bundle demands it, so a plain @slug still fails hard.
|
|
284
|
-
def all_targets
|
|
285
|
-
registry = load_registry
|
|
286
|
-
return nil unless registry
|
|
287
|
-
|
|
288
|
-
if registry.empty?
|
|
289
|
-
@err.puts "error: no bundles registered (okf registry set <dir>)"
|
|
290
|
-
return nil
|
|
291
|
-
end
|
|
292
|
-
pairs = []
|
|
293
|
-
registry.each do |entry|
|
|
294
|
-
if File.directory?(entry.path)
|
|
295
|
-
pairs << [ entry.slug, entry.path ]
|
|
296
|
-
else
|
|
297
|
-
skip_registered(entry)
|
|
298
|
-
end
|
|
299
|
-
end
|
|
300
|
-
if pairs.empty?
|
|
301
|
-
@err.puts "error: every registered bundle is missing on disk (okf registry list)"
|
|
302
|
-
return nil
|
|
303
|
-
end
|
|
304
|
-
pairs
|
|
305
|
-
end
|
|
306
|
-
|
|
307
|
-
# Dedupe by resolved path, not ref spelling — `@ @one` is one bundle when
|
|
308
|
-
# "one" is the default, and must be searched once. `@all @one` is the same
|
|
309
|
-
# story with a wider first ref: all ⊇ one, so the result is right and the
|
|
310
|
-
# duplicate simply drops. No error branch, because there is no wrong answer
|
|
311
|
-
# to warn about.
|
|
312
|
-
def ref_targets(argv)
|
|
313
|
-
refs = []
|
|
314
|
-
refs << argv.shift while argv.first&.start_with?("@")
|
|
315
|
-
pairs = []
|
|
316
|
-
refs.each do |ref|
|
|
317
|
-
found = all_ref?(ref) ? all_targets : ref_pair(ref)
|
|
318
|
-
return nil unless found
|
|
319
|
-
|
|
320
|
-
found.each { |slug, path| pairs << [ slug, path ] unless pairs.any? { |_, seen| seen == path } }
|
|
321
|
-
end
|
|
322
|
-
pairs
|
|
323
|
-
end
|
|
324
|
-
|
|
325
|
-
# Does this @ref name every registered bundle? Takes a ref, sigil and all —
|
|
326
|
-
# both callers reach it only past a start_with?("@") of their own, so a
|
|
327
|
-
# third check here would be a branch no run can take.
|
|
328
|
-
#
|
|
329
|
-
# Compared *normalized*, because the ref grammar has exactly one
|
|
330
|
-
# normalization and a ref exempt from it is a trapdoor: `@ALL` has to reach
|
|
331
|
-
# `@all` for the same reason `@One` reaches the bundle registered from dir
|
|
332
|
-
# `One`. It normalizes through Registry.normalize — the very call the slug
|
|
333
|
-
# lookup makes — rather than a second downcase that could be forgotten while
|
|
334
|
-
# the first was maintained.
|
|
335
|
-
def all_ref?(ref)
|
|
336
|
-
require "okf/registry"
|
|
337
|
-
OKF::Registry.normalize(ref[1..-1]) == ALL_REF[1..-1]
|
|
338
|
-
end
|
|
339
|
-
|
|
340
|
-
# One @ref as a single-element [[slug, dir]], or nil after reporting.
|
|
341
|
-
def ref_pair(ref)
|
|
342
|
-
path = resolve_registered(ref)
|
|
343
|
-
unless path
|
|
344
|
-
# Only an unknown slug is plausibly a mistyped term — a broken registry
|
|
345
|
-
# or a gone directory has nothing to do with the grammar.
|
|
346
|
-
@err.puts "note: searching for a literal @-term? put a non-@ term first, or use -e '\\@term'" if @ref_failure == :unknown
|
|
347
|
-
return nil
|
|
348
|
-
end
|
|
349
|
-
[ [ ref_slugs[path], path ] ]
|
|
48
|
+
# Runs a Rack app under WEBrick until interrupted. Injected into the CLI so
|
|
49
|
+
# tests can drive `server` without opening a socket; the runner loads here
|
|
50
|
+
# (not at require time) so `require "okf"` and a Rails mount of the server stay
|
|
51
|
+
# light.
|
|
52
|
+
WEBRICK = lambda do |app, host, port|
|
|
53
|
+
require "okf/server/runner"
|
|
54
|
+
OKF::Server::Runner.run(app, host: host, port: port)
|
|
350
55
|
end
|
|
351
56
|
|
|
352
|
-
#
|
|
353
|
-
#
|
|
354
|
-
#
|
|
355
|
-
# statistics and then interleaving the lists would let the same match score
|
|
356
|
-
# differently for no reason a reader could see. One index, one ranking.
|
|
57
|
+
# The file a gem ships to add verbs to `okf`. Everything about the seam is in
|
|
58
|
+
# this one constant: a gem that wants to extend the CLI puts `okf/plugin.rb`
|
|
59
|
+
# on its load path and registers from it.
|
|
357
60
|
#
|
|
358
|
-
#
|
|
359
|
-
# the
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
@
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
#
|
|
462
|
-
#
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
require "okf/registry"
|
|
477
|
-
if dirs.empty?
|
|
478
|
-
# A malformed registry raises OKF::Error, which `server` rescues into a
|
|
479
|
-
# usage error — no guarded load needed on this path.
|
|
480
|
-
reg = OKF::Registry.load
|
|
481
|
-
bundles = reg.map { |entry| load_registered(entry) }.compact
|
|
482
|
-
else
|
|
483
|
-
bundles = ephemeral_bundles(dirs)
|
|
484
|
-
end
|
|
485
|
-
hub = OKF::Server::Hub.new(bundles, layout: options[:layout])
|
|
486
|
-
concepts = bundles.inject(0) { |sum, bundle| sum + bundle.folder.graph(minimal: true).nodes.size }
|
|
487
|
-
@out.puts "serving #{bundles.size} #{pluralize(bundles.size,
|
|
488
|
-
"bundle")}, #{concepts} #{pluralize(concepts, "concept")} at http://#{options[:bind]}:#{options[:port]} (Ctrl-C to stop)"
|
|
489
|
-
print_mounts(hub)
|
|
490
|
-
serve(hub, options)
|
|
491
|
-
end
|
|
492
|
-
|
|
493
|
-
# The one boot seam every served app passes through, so a hub gzips exactly
|
|
494
|
-
# like a single bundle — the wrap belongs to booting a server, not to either
|
|
495
|
-
# mode, and a mode added later gets it for free. Deliberately not inside the
|
|
496
|
-
# runner: an embedding app mounting OKF::Server::App brings its own middleware.
|
|
497
|
-
def serve(app, options)
|
|
498
|
-
# gzip responses when the client accepts it — transparent, no new dependency
|
|
499
|
-
@runner.call(Rack::Deflater.new(app), options[:bind], options[:port])
|
|
500
|
-
end
|
|
501
|
-
|
|
502
|
-
# The mount table — which dir landed on which /b/<slug>/ and where `/` goes.
|
|
503
|
-
# Mirrors the Hub's own default resolution (explicit slug, else first).
|
|
504
|
-
# Ask the hub which bundle it chose rather than re-deriving the
|
|
505
|
-
# explicit-else-first rule, and mount at its own prefix: two copies of a
|
|
506
|
-
# rule is two answers waiting to disagree.
|
|
507
|
-
def print_mounts(hub)
|
|
508
|
-
hub.bundles.each do |bundle|
|
|
509
|
-
marker = bundle.equal?(hub.default) ? "*" : " "
|
|
510
|
-
@out.puts " #{marker} #{OKF::Server::Hub::MOUNT}/#{bundle.slug}/ #{bundle.title}"
|
|
511
|
-
end
|
|
512
|
-
end
|
|
513
|
-
|
|
514
|
-
# Load the given directories as unregistered bundles, slugged by basename and
|
|
515
|
-
# deduped within the run. The same directory listed twice mounts once — two
|
|
516
|
-
# windows on one bundle would just burn a slug on a URL that vanishes next run.
|
|
517
|
-
def ephemeral_bundles(dirs)
|
|
518
|
-
roots = []
|
|
519
|
-
dirs.each do |dir|
|
|
520
|
-
root = File.expand_path(dir)
|
|
521
|
-
roots << root unless roots.include?(root)
|
|
522
|
-
end
|
|
523
|
-
|
|
524
|
-
# A registered slug owns its mount outright: reserve every ref's slug
|
|
525
|
-
# before any basename is deduped. Otherwise argv order decides, and
|
|
526
|
-
# `server ./two @two` mounts the *unregistered* ./two at /b/two/ while
|
|
527
|
-
# pushing the ref — the bundle whose slug that is — to /b/two-2/, so a
|
|
528
|
-
# bookmark from a bundle-less run silently opens the wrong graph.
|
|
529
|
-
taken = roots.map { |root| ref_slugs[root] }.compact
|
|
530
|
-
roots.each_with_object([]) do |root, bundles|
|
|
531
|
-
folder = OKF::Bundle::Folder.load(root)
|
|
532
|
-
report_skipped(folder)
|
|
533
|
-
slug = ref_slugs[root]
|
|
534
|
-
unless slug
|
|
535
|
-
slug = OKF::Registry.dedupe(File.basename(root), taken)
|
|
536
|
-
taken << slug
|
|
61
|
+
# A convention rather than a list the base gem keeps, because the alternative
|
|
62
|
+
# is this gem naming its own addons — and the moment it does, adding an addon
|
|
63
|
+
# means editing okf. `--engine` already set the precedent on the search side:
|
|
64
|
+
# an addon shows up in help *without the CLI knowing it exists*.
|
|
65
|
+
PLUGIN_FILE = "okf/plugin.rb"
|
|
66
|
+
|
|
67
|
+
# Only gems named `okf-*` are loaded — the namespacing convention Jekyll
|
|
68
|
+
# (`jekyll-*`) and Vagrant (`vagrant-*`) use, which is the reason the rule is
|
|
69
|
+
# here: it makes what counts as an okf extension explicit and stops an
|
|
70
|
+
# unrelated gem claiming the `okf/plugin.rb` path. It guards a little too,
|
|
71
|
+
# since `require` runs what it loads, but that window is nearly empty and
|
|
72
|
+
# overselling it would be worse than having no rule. The argument in full is
|
|
73
|
+
# at #plugin_paths.
|
|
74
|
+
PLUGIN_GEM_PREFIX = "okf-"
|
|
75
|
+
|
|
76
|
+
# What #plugin_gem_name answers when it cannot work out a path's owning gem
|
|
77
|
+
# at all — deliberately distinct from nil, which means "belongs to no gem"
|
|
78
|
+
# and is trusted. See #plugin_gem_name for why the two must not merge.
|
|
79
|
+
UNKNOWN_GEM = :unknown
|
|
80
|
+
|
|
81
|
+
# The map's shape: the order the groups print in, and the heading each one
|
|
82
|
+
# carries. Only extensions get a heading — the built-in groups are separated
|
|
83
|
+
# by a blank line and their verbs speak for themselves, which is how this map
|
|
84
|
+
# has always read. A plugin's verbs are labelled because "where did this come
|
|
85
|
+
# from?" is a question only an installed extension raises.
|
|
86
|
+
GROUPS = [
|
|
87
|
+
[ :act, nil ],
|
|
88
|
+
[ :registry, nil ],
|
|
89
|
+
[ :judge, nil ],
|
|
90
|
+
[ :read, nil ],
|
|
91
|
+
[ :graph, nil ],
|
|
92
|
+
[ :extension, " installed extensions:" ]
|
|
93
|
+
].freeze
|
|
94
|
+
|
|
95
|
+
# Everything the map's grammar column cannot say for itself. A test finds the
|
|
96
|
+
# `@slug names` paragraph by its opening words, so the wording is load-bearing.
|
|
97
|
+
NOTE = <<~NOTE
|
|
98
|
+
@slug names a registered bundle instead of a path — the slug from
|
|
99
|
+
`okf registry set`, or bare @ for the registry default. Anywhere a <dir>
|
|
100
|
+
goes, an @slug goes: `okf lint @handbook`, `okf render @ -o graph.html`.
|
|
101
|
+
The registry lives under $OKF_HOME (default ~/.okf); set it to point
|
|
102
|
+
every verb at another one.
|
|
103
|
+
search spans bundles: several leading @slugs, or @all for every registered one
|
|
104
|
+
(@all skips a bundle whose directory is gone; a named @slug insists on it).
|
|
105
|
+
|
|
106
|
+
[filters] narrow a view to matching concepts: --type TYPE, --area AREA, --tag TAG
|
|
107
|
+
(each view takes the ones orthogonal to it; matching is case-insensitive).
|
|
108
|
+
tags --by DIM regroups the tags per concept dimension — type or area — with
|
|
109
|
+
within-group counts, the view for curating a tag vocabulary.
|
|
110
|
+
--json emits compact JSON (the machine substrate); add --pretty to indent it.
|
|
111
|
+
--fields / --except project the JSON to the properties you want (search/index/catalog/files).
|
|
112
|
+
|
|
113
|
+
okf --version
|
|
114
|
+
NOTE
|
|
115
|
+
|
|
116
|
+
class << self
|
|
117
|
+
# Append-only and idempotent by id: a second registration of an id already
|
|
118
|
+
# present is a no-op, so a double `require` cannot double the registry and
|
|
119
|
+
# **an addon cannot quietly displace a built-in**. Deliberately the same
|
|
120
|
+
# shape as Search.register — three extension points, one idiom.
|
|
121
|
+
#
|
|
122
|
+
# The duck type is checked here rather than at dispatch, so a malformed
|
|
123
|
+
# command fails where it is installed instead of the first time somebody
|
|
124
|
+
# types its verb.
|
|
125
|
+
def register(command)
|
|
126
|
+
missing = Command::DUCK_TYPE.reject { |message| command.respond_to?(message) }
|
|
127
|
+
raise ArgumentError, "#{command} cannot be a command: it does not answer #{missing.join(", ")}" unless missing.empty?
|
|
128
|
+
|
|
129
|
+
@commands ||= []
|
|
130
|
+
existing = @commands.find { |registered| registered.id == command.id }
|
|
131
|
+
return register_declined(command, existing) if existing
|
|
132
|
+
|
|
133
|
+
@commands << command
|
|
134
|
+
command
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# A frozen snapshot in registration order — which, for the built-ins, is
|
|
138
|
+
# the order this file requires them in at the bottom, and therefore the
|
|
139
|
+
# order `okf help` lists them in.
|
|
140
|
+
def commands
|
|
141
|
+
(@commands ||= []).dup.freeze
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def lookup(name)
|
|
145
|
+
return nil if OKF.blank?(name)
|
|
146
|
+
|
|
147
|
+
commands.find { |command| command.id.to_s == name.to_s }
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Registrations refused because the id was taken. Kept so the refusal can
|
|
151
|
+
# be *reported* — Search can no-op in silence because an engine nobody
|
|
152
|
+
# selected is invisible either way, but a verb that silently does nothing
|
|
153
|
+
# is a bug report waiting to happen.
|
|
154
|
+
def declined
|
|
155
|
+
(@declined ||= []).dup.freeze
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Load every installed extension, once. Returns the failures as
|
|
159
|
+
# [ path, error ] pairs rather than printing them: this is a class method
|
|
160
|
+
# with no streams, and the CLI's whole contract is that nothing writes
|
|
161
|
+
# anywhere but the streams it was handed.
|
|
162
|
+
#
|
|
163
|
+
# A plugin that raises is *skipped and reported*, never fatal — the same
|
|
164
|
+
# best-effort posture the reader takes with an unparseable file. One broken
|
|
165
|
+
# addon must not cost a user their `okf lint`.
|
|
166
|
+
def load_plugins
|
|
167
|
+
return @plugin_failures if @plugins_loaded
|
|
168
|
+
|
|
169
|
+
@plugins_loaded = true
|
|
170
|
+
@plugin_failures = []
|
|
171
|
+
@loaded_plugins = []
|
|
172
|
+
plugin_paths.each do |path|
|
|
173
|
+
begin
|
|
174
|
+
require path
|
|
175
|
+
@loaded_plugins << path
|
|
176
|
+
rescue ::LoadError, ::StandardError => e
|
|
177
|
+
@plugin_failures << [ path, e ]
|
|
178
|
+
end
|
|
537
179
|
end
|
|
538
|
-
|
|
539
|
-
end
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
# A bad -o path (a missing directory, a permission denial) is a bad
|
|
584
|
-
# *argument*: exit 2 with the reason, never a backtrace and an exit code
|
|
585
|
-
# that means "failing bundle".
|
|
586
|
-
begin
|
|
587
|
-
File.write(options[:output], html)
|
|
588
|
-
rescue SystemCallError => e
|
|
589
|
-
return usage_error("cannot write #{options[:output]}: #{e.message}")
|
|
180
|
+
@plugin_failures
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# Latest-version-only where RubyGems offers it, so two installed versions
|
|
184
|
+
# of the same addon cannot both register. The fallback keeps the floor:
|
|
185
|
+
# find_latest_files has been there since RubyGems 1.8, but the guard costs
|
|
186
|
+
# nothing and says which method the behaviour depends on.
|
|
187
|
+
#
|
|
188
|
+
# Narrowed to gems named `okf-*` — the convention Jekyll (`jekyll-*`) and
|
|
189
|
+
# Vagrant (`vagrant-*`) use for the same job, and the reason the rule is
|
|
190
|
+
# here: it makes what counts as an okf extension explicit, and stops an
|
|
191
|
+
# unrelated gem claiming the `okf/plugin.rb` path by accident.
|
|
192
|
+
#
|
|
193
|
+
# It is a mild guard as well, since `require` runs whatever it loads, but
|
|
194
|
+
# the window it closes is nearly empty and calling it a **defence** would
|
|
195
|
+
# invite the false confidence that is worse than having no rule at all. A
|
|
196
|
+
# transitive dependency is required by its parent in normal use, so
|
|
197
|
+
# `require "foo"` already runs foo's; under Bundler, discovery is
|
|
198
|
+
# bundle-scoped, so the Gemfile is an allowlist already. What is left is a
|
|
199
|
+
# pure-Ruby gem installed globally and then used by nothing — and nothing
|
|
200
|
+
# here saves anyone from a package deliberately installed under an `okf-`
|
|
201
|
+
# name, because `gem install` has already run on it.
|
|
202
|
+
#
|
|
203
|
+
# The rule underneath this one *is* load-bearing: naming a gem must never
|
|
204
|
+
# load it. See `plugin_gem_name` below, and
|
|
205
|
+
# .okf/design/extension-points.md for the argument in full.
|
|
206
|
+
def plugin_paths
|
|
207
|
+
# Cleared first, so the rescue below cannot return "found nothing" while
|
|
208
|
+
# leaving an earlier call's refusals standing to be reported again.
|
|
209
|
+
@untrusted_plugins = []
|
|
210
|
+
@plugin_gem_error = nil
|
|
211
|
+
@plugin_discovery_error = nil
|
|
212
|
+
@gem_index = nil
|
|
213
|
+
found = if Gem.respond_to?(:find_latest_files)
|
|
214
|
+
Gem.find_latest_files(PLUGIN_FILE)
|
|
215
|
+
else
|
|
216
|
+
Gem.find_files(PLUGIN_FILE)
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
found.select do |path|
|
|
220
|
+
name = plugin_gem_name(path)
|
|
221
|
+
next true if trusted_gem?(name)
|
|
222
|
+
|
|
223
|
+
@untrusted_plugins << [ path, name ]
|
|
224
|
+
false
|
|
590
225
|
end
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
when
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
when
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
#
|
|
650
|
-
#
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
#
|
|
654
|
-
#
|
|
655
|
-
#
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
help_flag(o)
|
|
670
|
-
end
|
|
671
|
-
slug = positional(parser, argv) or return 2
|
|
672
|
-
no_extras?(argv) or return 2
|
|
673
|
-
|
|
674
|
-
reg = OKF::Registry.load
|
|
675
|
-
slug = registry_slug(slug, reg) or return 2
|
|
676
|
-
removed = reg.remove(slug)
|
|
677
|
-
return usage_error("no such bundle: #{slug}") unless removed
|
|
678
|
-
|
|
679
|
-
@out.puts "removed #{removed.slug}"
|
|
680
|
-
0
|
|
681
|
-
rescue OKF::Error => e
|
|
682
|
-
usage_error(e.message)
|
|
683
|
-
end
|
|
684
|
-
|
|
685
|
-
def registry_list(argv)
|
|
686
|
-
options = { json: false }
|
|
687
|
-
parser = OptionParser.new do |o|
|
|
688
|
-
o.banner = "Usage: okf registry list [--json] [--pretty]\n " \
|
|
689
|
-
"okf registry set <dir|@slug> | del <dir|@slug> | default <@slug> | rename <@slug> <new>"
|
|
690
|
-
json_flags(o, options, "emit the registry as JSON")
|
|
691
|
-
help_flag(o)
|
|
692
|
-
end
|
|
693
|
-
begin
|
|
694
|
-
parser.parse!(argv)
|
|
695
|
-
rescue OptionParser::ParseError => e
|
|
696
|
-
@err.puts e.message
|
|
697
|
-
return 2
|
|
698
|
-
end
|
|
699
|
-
no_extras?(argv) or return 2
|
|
700
|
-
|
|
701
|
-
reg = OKF::Registry.load
|
|
702
|
-
return emit_list_json({ "registry" => reg.path }, "bundles", reg.listing.map { |row| stringify(row) }, options) if options[:json]
|
|
703
|
-
|
|
704
|
-
print_registry(reg)
|
|
705
|
-
0
|
|
706
|
-
rescue OKF::Error => e
|
|
707
|
-
usage_error(e.message)
|
|
708
|
-
end
|
|
709
|
-
|
|
710
|
-
# Choose which registered bundle a bare `okf server` opens at `/`, by moving
|
|
711
|
-
# it to the front of the registry. The listing is ordered and the JSON is
|
|
712
|
-
# meant to be hand-editable, so the move is stated rather than left to be
|
|
713
|
-
# discovered from a reordered file.
|
|
714
|
-
def registry_default(argv)
|
|
715
|
-
parser = OptionParser.new do |o|
|
|
716
|
-
o.banner = "Usage: okf registry default <@slug>\n " \
|
|
717
|
-
"moves it to the front — the first registered bundle is the default until you do"
|
|
718
|
-
help_flag(o)
|
|
719
|
-
end
|
|
720
|
-
slug = positional(parser, argv) or return 2
|
|
721
|
-
no_extras?(argv) or return 2
|
|
722
|
-
|
|
723
|
-
reg = OKF::Registry.load
|
|
724
|
-
slug = registry_slug(slug, reg) or return 2
|
|
725
|
-
reg.default = slug
|
|
726
|
-
@out.puts "default bundle → #{reg.default.slug} (now first)"
|
|
727
|
-
0
|
|
728
|
-
rescue OKF::Error => e
|
|
729
|
-
usage_error(e.message)
|
|
730
|
-
end
|
|
731
|
-
|
|
732
|
-
# The @ref grammar for a verb that takes a *slug*, read by name. These three
|
|
733
|
-
# must reach an entry whose directory is gone — that is the one worth
|
|
734
|
-
# deleting or renaming — so they cannot go through resolve_ref, which
|
|
735
|
-
# insists the directory exist. Without this the refs only appeared to work:
|
|
736
|
-
# `normalize` strips the `@` off `@slug`, so `default @slug` resolved by
|
|
737
|
-
# accident while a bare `@` normalized to "" and failed. Returns the slug,
|
|
738
|
-
# or nil after reporting.
|
|
739
|
-
def registry_slug(arg, registry)
|
|
740
|
-
return arg unless arg.start_with?("@")
|
|
741
|
-
|
|
742
|
-
asked = arg[1..-1]
|
|
743
|
-
return asked unless asked.empty?
|
|
744
|
-
|
|
745
|
-
default = registry.default
|
|
746
|
-
return default.slug if default
|
|
747
|
-
|
|
748
|
-
@err.puts "error: no bundle is registered, so `@` names nothing (okf registry set <dir>)"
|
|
749
|
-
nil
|
|
750
|
-
end
|
|
751
|
-
|
|
752
|
-
# Rename a registered bundle's slug — its mount path and switcher name.
|
|
753
|
-
def registry_rename(argv)
|
|
754
|
-
parser = OptionParser.new do |o|
|
|
755
|
-
o.banner = "Usage: okf registry rename <@slug> <new>"
|
|
756
|
-
help_flag(o)
|
|
757
|
-
end
|
|
758
|
-
parser.parse!(argv)
|
|
759
|
-
old_slug, new_slug = argv.shift(2)
|
|
760
|
-
if old_slug.nil? || new_slug.nil?
|
|
761
|
-
@err.puts parser.banner
|
|
762
|
-
return 2
|
|
763
|
-
end
|
|
764
|
-
no_extras?(argv) or return 2
|
|
765
|
-
|
|
766
|
-
reg = OKF::Registry.load
|
|
767
|
-
# The old name may be a ref; the new one is a name being minted, never one.
|
|
768
|
-
old_slug = registry_slug(old_slug, reg) or return 2
|
|
769
|
-
entry = reg.rename(old_slug, new_slug)
|
|
770
|
-
# The slug it *found*, not the argv that found it: rename normalizes to look
|
|
771
|
-
# the entry up, so echoing the raw ask names a bundle that never existed.
|
|
772
|
-
@out.puts "renamed #{OKF::Registry.normalize(old_slug)} → #{entry.slug}"
|
|
773
|
-
0
|
|
774
|
-
rescue OptionParser::ParseError => e
|
|
775
|
-
@err.puts e.message
|
|
776
|
-
2
|
|
777
|
-
rescue OKF::Error => e
|
|
778
|
-
usage_error(e.message)
|
|
779
|
-
end
|
|
780
|
-
|
|
781
|
-
# The registry verbs take an exact number of positionals — a leftover argument
|
|
782
|
-
# is a typo'd invocation, not something to drop silently.
|
|
783
|
-
def no_extras?(argv)
|
|
784
|
-
return true if argv.empty?
|
|
785
|
-
|
|
786
|
-
@err.puts "error: unexpected argument '#{argv.first}'"
|
|
787
|
-
false
|
|
788
|
-
end
|
|
789
|
-
|
|
790
|
-
def print_registry(reg)
|
|
791
|
-
return @out.puts "no bundles registered — okf registry set <dir>" if reg.empty?
|
|
792
|
-
|
|
793
|
-
rows = reg.listing
|
|
794
|
-
width = rows.map { |row| row[:slug].length }.max
|
|
795
|
-
rows.each do |row|
|
|
796
|
-
marker = row[:default] ? "*" : " "
|
|
797
|
-
missing = row[:missing] ? " (missing)" : ""
|
|
798
|
-
@out.puts "#{marker} #{row[:slug].ljust(width)} #{row[:title]} (#{row[:dir]})#{missing}"
|
|
799
|
-
end
|
|
800
|
-
end
|
|
801
|
-
|
|
802
|
-
def graph(argv)
|
|
803
|
-
options = { json: false, minimal: false, body: true }
|
|
804
|
-
parser = OptionParser.new do |o|
|
|
805
|
-
o.banner = "Usage: okf graph <dir|@slug> [--json] [--minimal] [--no-body]"
|
|
806
|
-
json_flags(o, options, "emit nodes and edges as JSON")
|
|
807
|
-
o.on("--minimal", "leanest nodes (id + title); adds type/tag indexes") { options[:minimal] = true }
|
|
808
|
-
o.on("--[no-]body", "include each concept's body (default: yes)") { |v| options[:body] = v }
|
|
809
|
-
help_flag(o)
|
|
810
|
-
end
|
|
811
|
-
dir = positional_dir(parser, argv) or return 2
|
|
812
|
-
|
|
813
|
-
folder = OKF::Bundle::Folder.load(dir)
|
|
814
|
-
graph = folder.graph(minimal: options[:minimal], body: options[:body])
|
|
815
|
-
report_skipped(folder)
|
|
816
|
-
if options[:json]
|
|
817
|
-
# The head every view carries: a payload of nodes and edges that never
|
|
818
|
-
# says which bundle they came from is exactly what an agent holding
|
|
819
|
-
# several bundles has to guess at.
|
|
820
|
-
payload = bundle_head(dir).merge(graph.to_h)
|
|
821
|
-
payload = payload.merge(types: graph.type_index, tags: graph.tag_index) if options[:minimal]
|
|
822
|
-
emit_json(payload)
|
|
823
|
-
else
|
|
824
|
-
@out.puts "Graph — #{bundle_label(dir)} (#{graph.nodes.size} #{pluralize(graph.nodes.size, "concept")}, " \
|
|
825
|
-
"#{graph.edges.size} #{pluralize(graph.edges.size, "link")})"
|
|
826
|
-
end
|
|
827
|
-
0
|
|
828
|
-
end
|
|
829
|
-
|
|
830
|
-
# The progressive-disclosure map (spec §6): every directory that holds concepts
|
|
831
|
-
# or carries an index.md, with its authored index body, a type/tag rollup, its
|
|
832
|
-
# child directories, and — for a directory with no index.md — the listing
|
|
833
|
-
# synthesized from the concepts there. The "orient before you read" view. `--area`
|
|
834
|
-
# is repeatable (one or many directories; `root` is the bundle root); `--no-body`
|
|
835
|
-
# drops the prose to a skeleton; advisory, exit 0.
|
|
836
|
-
def index(argv)
|
|
837
|
-
options = { json: false, body: true, areas: nil }
|
|
838
|
-
parser = OptionParser.new do |o|
|
|
839
|
-
o.banner = "Usage: okf index <dir|@slug> [--area AREA] [--no-body] [--json]"
|
|
840
|
-
json_flags(o, options, "emit the index map as JSON")
|
|
841
|
-
projection_flags(o, options)
|
|
842
|
-
o.on("--area AREA", "only this directory/area (repeatable; `root` for the bundle root)") { |v| (options[:areas] ||= []) << v }
|
|
843
|
-
o.on("--[no-]body", "include each index's prose body (default: yes)") { |v| options[:body] = v }
|
|
844
|
-
help_flag(o)
|
|
845
|
-
end
|
|
846
|
-
dir = positional_dir(parser, argv) or return 2
|
|
847
|
-
|
|
848
|
-
folder = OKF::Bundle::Folder.load(dir)
|
|
849
|
-
report_skipped(folder)
|
|
850
|
-
entries = folder.directory_index
|
|
851
|
-
selected = select_directories(entries, options[:areas])
|
|
852
|
-
if options[:json]
|
|
853
|
-
# --no-body is shorthand for --except body, so asking for the body by
|
|
854
|
-
# name in the same breath is a contradiction. Letting --fields quietly
|
|
855
|
-
# win would hand back the very thing the other flag was there to drop.
|
|
856
|
-
if !options[:body] && Array(options[:fields]).map(&:downcase).include?("body")
|
|
857
|
-
return usage_error("--no-body and --fields body contradict each other: drop one")
|
|
226
|
+
rescue ::StandardError => e
|
|
227
|
+
# The *search* failing is its own report, and the reason is the one
|
|
228
|
+
# `plugin_gem_name` already answers to one frame down: an empty list and
|
|
229
|
+
# no message is indistinguishable from a machine with nothing installed.
|
|
230
|
+
# There is no path left to hang a refusal on here, so the failure has to
|
|
231
|
+
# carry itself or it is not reported at all.
|
|
232
|
+
@plugin_discovery_error = e
|
|
233
|
+
[]
|
|
234
|
+
ensure
|
|
235
|
+
# Only a snapshot for the length of one discovery — a long-lived copy of
|
|
236
|
+
# every installed spec's path would outlive its usefulness and go stale.
|
|
237
|
+
@gem_index = nil
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# Three answers, and the third has to stay distinct from the second: a gem
|
|
241
|
+
# name; nil when the path belongs to no gem at all — a bare $LOAD_PATH
|
|
242
|
+
# entry, which is how a checkout, `ruby -I`, a Gemfile `path:` and the
|
|
243
|
+
# suite's own fixtures appear, and which stays trusted because someone put
|
|
244
|
+
# it there; and UNKNOWN_GEM when the lookup itself failed.
|
|
245
|
+
#
|
|
246
|
+
# Answering nil for that last case is fail-open, and worth spelling out
|
|
247
|
+
# because it reads as harmless: enumerating the installed specs is what
|
|
248
|
+
# raises when one gemspec anywhere on the machine is corrupt — and every
|
|
249
|
+
# discovered path would come back "belongs to no gem" and load. A rule that
|
|
250
|
+
# quietly switches itself off under failure is the false confidence this
|
|
251
|
+
# one is deliberately modest to avoid, so a name that cannot be read is
|
|
252
|
+
# refused — and the cause is kept, because refusing every extension on the
|
|
253
|
+
# machine while naming no reason leaves the user nothing to act on.
|
|
254
|
+
#
|
|
255
|
+
# Resolved from the spec's full_gem_path rather than by loading anything:
|
|
256
|
+
# naming an extension must never mean running it.
|
|
257
|
+
def plugin_gem_name(path)
|
|
258
|
+
index = gem_index
|
|
259
|
+
return UNKNOWN_GEM if index.equal?(UNKNOWN_GEM)
|
|
260
|
+
|
|
261
|
+
found = index.find { |prefix, _name| path.start_with?(prefix) }
|
|
262
|
+
found&.last
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
# Whether a name clears the prefix rule. Three answers in, two out, and
|
|
266
|
+
# the middle one is the whole point: nil belongs to no gem — a checkout,
|
|
267
|
+
# `ruby -I`, a Gemfile `path:` — and stays trusted because someone put it
|
|
268
|
+
# there deliberately, while UNKNOWN_GEM is the lookup itself failing and is
|
|
269
|
+
# refused, because a rule that switches itself off when it cannot get an
|
|
270
|
+
# answer is the false confidence this one is deliberately modest to avoid.
|
|
271
|
+
def trusted_gem?(name)
|
|
272
|
+
return true if name.nil?
|
|
273
|
+
return false if name.equal?(UNKNOWN_GEM)
|
|
274
|
+
|
|
275
|
+
name.start_with?(PLUGIN_GEM_PREFIX)
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
# Every installed gem's path with the name that owns it, or UNKNOWN_GEM if
|
|
279
|
+
# the specs could not be walked. Not a saving — it replaces a `find` that
|
|
280
|
+
# short-circuited on the first match with a `map` over all of them, so on
|
|
281
|
+
# the ordinary one-path discovery it is strictly more work (1.0ms for 282
|
|
282
|
+
# specs). It buys a shape instead: **one pass has one outcome**, so every
|
|
283
|
+
# path in a discovery gets the same answer. Per-path enumeration made that
|
|
284
|
+
# a lottery — a failure that cleared between paths, a gemspec rewritten by
|
|
285
|
+
# a concurrent `gem install`, would refuse one path and trust the next in
|
|
286
|
+
# the same run.
|
|
287
|
+
#
|
|
288
|
+
# Which is why the failure memoizes too. `||=` over a raising expression
|
|
289
|
+
# caches nothing, so the second path would try again and could get a
|
|
290
|
+
# different answer — the lottery back, in the branch the whole thing was
|
|
291
|
+
# written for.
|
|
292
|
+
#
|
|
293
|
+
# Built lazily, so the ordinary run — nothing discovered — never walks the
|
|
294
|
+
# specs at all.
|
|
295
|
+
def gem_index
|
|
296
|
+
@gem_index ||= begin
|
|
297
|
+
Gem::Specification.map do |spec|
|
|
298
|
+
full = spec.full_gem_path
|
|
299
|
+
[ full.end_with?(File::SEPARATOR) ? full : "#{full}#{File::SEPARATOR}", spec.name ]
|
|
300
|
+
end
|
|
301
|
+
rescue ::StandardError => e
|
|
302
|
+
@plugin_gem_error = e
|
|
303
|
+
UNKNOWN_GEM
|
|
858
304
|
end
|
|
859
|
-
|
|
860
|
-
options[:except] = Array(options[:except]) + [ "body" ] unless options[:body] || options[:fields]
|
|
861
|
-
return print_index_map_json(dir, selected, options)
|
|
862
305
|
end
|
|
863
|
-
print_index_map(dir, selected, options[:body])
|
|
864
|
-
0
|
|
865
|
-
end
|
|
866
306
|
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
return entries if areas.nil? || areas.empty?
|
|
307
|
+
# Why a name could not be read, when one could not. Reported with the
|
|
308
|
+
# refusal it caused: "could not be determined" on its own names no gem to
|
|
309
|
+
# fix and no reason to look.
|
|
310
|
+
attr_reader :plugin_gem_error
|
|
872
311
|
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
312
|
+
# Why the search for extensions could not run at all, when it could not.
|
|
313
|
+
# Distinct from the above: that one refuses paths it found, this one found
|
|
314
|
+
# none — so there is nothing to refuse and the error is the only witness.
|
|
315
|
+
attr_reader :plugin_discovery_error
|
|
876
316
|
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
@
|
|
882
|
-
@out.puts " #{index_dir_label(entry)}#{index_dir_meta(entry)}"
|
|
883
|
-
subdirs = entry[:subdirs]
|
|
884
|
-
@out.puts " → #{subdirs.map { |sub| "#{File.basename(sub)}/" }.join(" ")}" unless subdirs.empty?
|
|
885
|
-
if entry[:present]
|
|
886
|
-
print_index_body(entry[:body]) if body
|
|
887
|
-
else
|
|
888
|
-
print_synthesized_listing(entry[:listing])
|
|
889
|
-
end
|
|
317
|
+
# Paths discovered but refused for their gem's name, as [ path, gem ]
|
|
318
|
+
# pairs. Kept so the refusal can be *reported*: an extension that is
|
|
319
|
+
# present and deliberately not run is exactly the thing a user needs told.
|
|
320
|
+
def untrusted_plugins
|
|
321
|
+
(@untrusted_plugins ||= []).dup.freeze
|
|
890
322
|
end
|
|
891
|
-
end
|
|
892
|
-
|
|
893
|
-
def index_dir_label(entry)
|
|
894
|
-
base = entry[:dir] == "." ? "(root)" : "#{entry[:dir]}/"
|
|
895
|
-
entry[:present] ? base : "#{base} (no index.md)"
|
|
896
|
-
end
|
|
897
|
-
|
|
898
|
-
def index_dir_meta(entry)
|
|
899
|
-
count = "#{entry[:count]} #{pluralize(entry[:count], "concept")}"
|
|
900
|
-
types = entry[:types].map { |type, n| "#{OKF.blank?(type) ? "Untyped" : type} #{n}" }.join(", ")
|
|
901
|
-
types.empty? ? " · #{count}" : " · #{count} · #{types}"
|
|
902
|
-
end
|
|
903
|
-
|
|
904
|
-
def print_index_body(body)
|
|
905
|
-
text = body.to_s.strip
|
|
906
|
-
return if text.empty?
|
|
907
|
-
|
|
908
|
-
text.each_line { |line| @out.puts " #{line.chomp}" }
|
|
909
|
-
end
|
|
910
323
|
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
324
|
+
# Called once at the bottom of this file, after the built-ins have
|
|
325
|
+
# registered. Everything registered after it is an extension — which makes
|
|
326
|
+
# "built-in" a fact the CLI knows rather than a group a command claims,
|
|
327
|
+
# and gives a test somewhere to roll back to.
|
|
328
|
+
def seal_builtins!
|
|
329
|
+
@builtins = commands
|
|
915
330
|
end
|
|
916
|
-
end
|
|
917
|
-
|
|
918
|
-
def print_index_map_json(dir, entries, options)
|
|
919
|
-
emit_list_json(dir, "directories", entries.map { |entry| index_map_entry_json(entry) }, options)
|
|
920
|
-
end
|
|
921
331
|
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
"present" => entry[:present], "synthesized" => entry[:synthesized],
|
|
926
|
-
"count" => entry[:count], "types" => entry[:types], "tags" => entry[:tags],
|
|
927
|
-
"subdirs" => entry[:subdirs], "body" => entry[:body],
|
|
928
|
-
"listing" => entry[:listing].map { |item| stringify(item) }
|
|
929
|
-
}
|
|
930
|
-
end
|
|
931
|
-
|
|
932
|
-
# The Catalog / Files / Tags / Stats views the server renders in the browser,
|
|
933
|
-
# reproduced on the CLI so an agent can read the same knowledge without one.
|
|
934
|
-
# Each prints a scannable human view by default and machine JSON with --json;
|
|
935
|
-
# all are advisory reads (exit 0). They share OKF::Bundle#catalog for their data,
|
|
936
|
-
# and (with `types`) narrow through the same --type/--area/--tag filters the
|
|
937
|
-
# server UI offers, so browser and CLI can answer the same questions.
|
|
938
|
-
|
|
939
|
-
def catalog(argv)
|
|
940
|
-
options = { json: false }
|
|
941
|
-
parser = OptionParser.new do |o|
|
|
942
|
-
o.banner = "Usage: okf catalog <dir|@slug> [--type T] [--area A] [--tag T] [--json]"
|
|
943
|
-
json_flags(o, options, "emit the catalog as JSON")
|
|
944
|
-
projection_flags(o, options)
|
|
945
|
-
filter_flags(o, options, :type, :area, :tag)
|
|
946
|
-
help_flag(o)
|
|
332
|
+
# The verbs this gem ships, frozen at seal time.
|
|
333
|
+
def builtins
|
|
334
|
+
(@builtins ||= []).dup.freeze
|
|
947
335
|
end
|
|
948
|
-
dir = positional_dir(parser, argv) or return 2
|
|
949
|
-
|
|
950
|
-
folder = OKF::Bundle::Folder.load(dir)
|
|
951
|
-
report_skipped(folder)
|
|
952
|
-
entries = folder.catalog
|
|
953
|
-
selected = filter_entries(entries, options)
|
|
954
|
-
return print_catalog_json(dir, selected, options) if options[:json]
|
|
955
|
-
|
|
956
|
-
print_catalog(dir, selected, entries.size)
|
|
957
|
-
0
|
|
958
|
-
end
|
|
959
|
-
|
|
960
|
-
def files(argv)
|
|
961
|
-
options = { json: false }
|
|
962
|
-
parser = OptionParser.new do |o|
|
|
963
|
-
o.banner = "Usage: okf files <dir|@slug> [--type T] [--area A] [--tag T] [--json]"
|
|
964
|
-
json_flags(o, options, "emit the file tree as JSON")
|
|
965
|
-
projection_flags(o, options)
|
|
966
|
-
filter_flags(o, options, :type, :area, :tag)
|
|
967
|
-
help_flag(o)
|
|
968
|
-
end
|
|
969
|
-
dir = positional_dir(parser, argv) or return 2
|
|
970
|
-
|
|
971
|
-
folder = OKF::Bundle::Folder.load(dir)
|
|
972
|
-
report_skipped(folder)
|
|
973
|
-
entries = folder.catalog
|
|
974
|
-
selected = filter_entries(entries, options)
|
|
975
|
-
return print_files_json(dir, selected, options) if options[:json]
|
|
976
|
-
|
|
977
|
-
print_files(dir, selected, entries.size)
|
|
978
|
-
0
|
|
979
|
-
end
|
|
980
336
|
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
parser = OptionParser.new do |o|
|
|
984
|
-
o.banner = "Usage: okf tags <dir|@slug> [--by type|area] [--type T] [--area A] [--json]"
|
|
985
|
-
json_flags(o, options, "emit the tag index as JSON")
|
|
986
|
-
o.on("--by DIM", %w[type area], "group the tags by a concept dimension (type | area)") { |v| options[:by] = v.to_sym }
|
|
987
|
-
filter_flags(o, options, :type, :area)
|
|
988
|
-
help_flag(o)
|
|
337
|
+
def extension?(command)
|
|
338
|
+
!builtins.include?(command)
|
|
989
339
|
end
|
|
990
|
-
dir = positional_dir(parser, argv) or return 2
|
|
991
|
-
|
|
992
|
-
return grouped_tags(dir, options) if options[:by]
|
|
993
|
-
|
|
994
|
-
print_inverted_index(dir, "Tags", :tag, "tags", options)
|
|
995
|
-
end
|
|
996
340
|
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
341
|
+
# Test seam: put the registry back to what shipped and forget the load
|
|
342
|
+
# latch. Registration happens at require time, so without this a test that
|
|
343
|
+
# installs a fake plugin would leak it into every test that runs after it.
|
|
344
|
+
#
|
|
345
|
+
# Dropping the files from $LOADED_FEATURES is not optional, and the reason
|
|
346
|
+
# is worth stating: `require` is idempotent, so clearing the registry
|
|
347
|
+
# alone leaves a plugin *unregistered and unloadable* — the next
|
|
348
|
+
# load_plugins would find the file, require it, get `false`, and register
|
|
349
|
+
# nothing. That only stays hidden while each test writes its plugin to a
|
|
350
|
+
# fresh tmpdir; the moment one points at a real gem's lib/, the verb
|
|
351
|
+
# vanishes after the first reset.
|
|
352
|
+
def reset_plugins!
|
|
353
|
+
Array(@loaded_plugins).each { |path| $LOADED_FEATURES.delete(path) }
|
|
354
|
+
@loaded_plugins = []
|
|
355
|
+
# Unconditional, because there is always a seal to roll back to:
|
|
356
|
+
# `seal_builtins!` runs at the bottom of this file, so a caller that can
|
|
357
|
+
# name this method has already loaded it. An earlier version guarded on
|
|
358
|
+
# `@builtins.nil?` to cover a pre-seal call — a state that cannot occur,
|
|
359
|
+
# and a test that could not have detected it either way, since `builtins`
|
|
360
|
+
# memoizes `@builtins ||= []` and so stops being nil on its first read.
|
|
361
|
+
@commands = builtins.dup
|
|
362
|
+
@plugins_loaded = false
|
|
363
|
+
@plugin_failures = []
|
|
364
|
+
@plugin_gem_error = nil
|
|
365
|
+
@plugin_discovery_error = nil
|
|
366
|
+
@untrusted_plugins = []
|
|
367
|
+
@declined = []
|
|
1004
368
|
end
|
|
1005
|
-
dir = positional_dir(parser, argv) or return 2
|
|
1006
369
|
|
|
1007
|
-
|
|
1008
|
-
end
|
|
370
|
+
private
|
|
1009
371
|
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
report_skipped(folder)
|
|
1014
|
-
graph = folder.graph(minimal: true)
|
|
1015
|
-
index = key == :tag ? graph.tag_index : graph.type_index
|
|
1016
|
-
rows = index_rows(index, key, folder, options)
|
|
1017
|
-
if options[:json]
|
|
1018
|
-
print_index_json(dir, plural, key, rows)
|
|
1019
|
-
else
|
|
1020
|
-
titles = graph.nodes.map { |node| [ node[:id], node[:title] ] }.to_h
|
|
1021
|
-
print_index(dir, label, key, rows, titles)
|
|
372
|
+
def register_declined(command, existing)
|
|
373
|
+
(@declined ||= []) << [ command, existing ] unless existing.equal?(command)
|
|
374
|
+
existing
|
|
1022
375
|
end
|
|
1023
|
-
0
|
|
1024
376
|
end
|
|
1025
377
|
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
# group at count 1 is scattered; one recurring across groups is connective.
|
|
1029
|
-
# The --type/--area filters narrow the concepts first, then the grouping cuts.
|
|
1030
|
-
def grouped_tags(dir, options)
|
|
1031
|
-
folder = OKF::Bundle::Folder.load(dir)
|
|
1032
|
-
report_skipped(folder)
|
|
1033
|
-
graph = folder.graph(minimal: true)
|
|
1034
|
-
titles = graph.nodes.map { |node| [ node[:id], node[:title] ] }.to_h
|
|
1035
|
-
groups = tag_groups(graph.tag_index, folder, options)
|
|
1036
|
-
options[:json] ? print_grouped_tags_json(dir, options[:by], groups) : print_grouped_tags(dir, options[:by], groups, titles)
|
|
1037
|
-
0
|
|
378
|
+
def self.start(argv, out: $stdout, err: $stderr, input: $stdin)
|
|
379
|
+
new(out: out, err: err, input: input).run(argv)
|
|
1038
380
|
end
|
|
1039
381
|
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
ids.each do |id|
|
|
1047
|
-
entry = by_id[id]
|
|
1048
|
-
next if entry.nil?
|
|
1049
|
-
|
|
1050
|
-
key = options[:by] == :type ? entry_type(entry) : entry[:area]
|
|
1051
|
-
((groups[key] ||= {})[tag] ||= []) << id
|
|
1052
|
-
end
|
|
1053
|
-
end
|
|
1054
|
-
groups.map do |key, tags|
|
|
1055
|
-
rows = tags.map { |tag, ids| { tag: tag, count: ids.length, concepts: ids } }
|
|
1056
|
-
.sort_by { |row| [ -row[:count], row[:tag] ] }
|
|
1057
|
-
[ key, rows ]
|
|
1058
|
-
end.sort_by(&:first)
|
|
1059
|
-
end
|
|
1060
|
-
|
|
1061
|
-
# A catalog entry's type for display — "Untyped" when blank, matching the graph.
|
|
1062
|
-
def entry_type(entry)
|
|
1063
|
-
OKF.blank?(entry[:type]) ? "Untyped" : entry[:type]
|
|
382
|
+
def initialize(out: $stdout, err: $stderr, runner: WEBRICK, input: $stdin)
|
|
383
|
+
@out = out
|
|
384
|
+
@err = err
|
|
385
|
+
@runner = runner
|
|
386
|
+
@input = input
|
|
387
|
+
@plugin_notes_reported = false
|
|
1064
388
|
end
|
|
1065
389
|
|
|
1066
|
-
def
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
390
|
+
def run(argv)
|
|
391
|
+
argv = argv.dup
|
|
392
|
+
# -h/--help is answered wherever a parser sees it — deep inside
|
|
393
|
+
# positional_dir, where returning would only mean "usage error, exit 2".
|
|
394
|
+
# Thrown here instead, so help keeps the contract every other path keeps:
|
|
395
|
+
# a status this method returns. See Command#help_flag.
|
|
396
|
+
catch(:help) do
|
|
397
|
+
case (name = argv.shift)
|
|
398
|
+
when "version", "--version", "-v" then @out.puts(OKF::VERSION); 0
|
|
399
|
+
when "help", "--help", "-h" then usage(@out); 0
|
|
400
|
+
when nil then usage(@err); 2
|
|
401
|
+
else dispatch(name, argv)
|
|
1076
402
|
end
|
|
1077
403
|
end
|
|
1078
404
|
end
|
|
1079
405
|
|
|
1080
|
-
|
|
1081
|
-
groups_json = groups.map do |key, rows|
|
|
1082
|
-
{ dim.to_s => key, "count" => rows.size, "tags" => index_rows_json(:tag, rows) }
|
|
1083
|
-
end
|
|
1084
|
-
emit_json(bundle_head(dir).merge("count" => distinct_tags(groups), "by" => dim.to_s, "groups" => groups_json))
|
|
1085
|
-
end
|
|
1086
|
-
|
|
1087
|
-
def distinct_tags(groups)
|
|
1088
|
-
groups.flat_map { |_, rows| rows.map { |row| row[:tag] } }.uniq.size
|
|
1089
|
-
end
|
|
1090
|
-
|
|
1091
|
-
def stats(argv)
|
|
1092
|
-
options = { json: false }
|
|
1093
|
-
parser = OptionParser.new do |o|
|
|
1094
|
-
o.banner = "Usage: okf stats <dir|@slug> [--json]"
|
|
1095
|
-
json_flags(o, options, "emit the stats as JSON")
|
|
1096
|
-
help_flag(o)
|
|
1097
|
-
end
|
|
1098
|
-
dir = positional_dir(parser, argv) or return 2
|
|
1099
|
-
|
|
1100
|
-
folder = OKF::Bundle::Folder.load(dir)
|
|
1101
|
-
report_skipped(folder)
|
|
1102
|
-
stats = bundle_stats(folder)
|
|
1103
|
-
options[:json] ? print_stats_json(dir, stats) : print_stats(dir, stats)
|
|
1104
|
-
0
|
|
1105
|
-
end
|
|
1106
|
-
|
|
1107
|
-
# Bundle-level rollups derived from the catalog and the graph indexes.
|
|
1108
|
-
def bundle_stats(folder)
|
|
1109
|
-
graph = folder.graph(minimal: true)
|
|
1110
|
-
entries = folder.catalog
|
|
1111
|
-
by_type = graph.type_index.transform_values(&:size).sort_by { |_, n| -n }.to_h
|
|
1112
|
-
by_area = entries.group_by { |entry| entry[:area] }.transform_values(&:size).sort_by { |_, n| -n }.to_h
|
|
1113
|
-
{
|
|
1114
|
-
concepts: entries.size,
|
|
1115
|
-
areas: by_area.size,
|
|
1116
|
-
types: by_type.size,
|
|
1117
|
-
cross_links: graph.edges.size,
|
|
1118
|
-
tags: graph.tag_index.size,
|
|
1119
|
-
by_type: by_type,
|
|
1120
|
-
by_area: by_area
|
|
1121
|
-
}
|
|
1122
|
-
end
|
|
1123
|
-
|
|
1124
|
-
# ── the read views' shared --type/--area/--tag narrowing ──
|
|
1125
|
-
# Each view takes the filters orthogonal to it (tags can't filter by tag).
|
|
1126
|
-
# Matching is case-insensitive and exact; a concept at the bundle root lives in
|
|
1127
|
-
# the "(root)" area, which --area also accepts as plain `root` (no shell quoting).
|
|
1128
|
-
|
|
1129
|
-
# The --json / --pretty pair every emitting verb shares. --json is the compact
|
|
1130
|
-
# machine substrate (the default JSON form, aligned with the server); --pretty
|
|
1131
|
-
# indents it for a human and implies --json. Both route through emit_json.
|
|
1132
|
-
def json_flags(parser, options, desc)
|
|
1133
|
-
parser.on("--json", desc) { options[:json] = true }
|
|
1134
|
-
parser.on("--pretty", "indent the JSON for reading (implies --json)") { options[:json] = true; @pretty = true }
|
|
1135
|
-
end
|
|
1136
|
-
|
|
1137
|
-
# Every parser answers its own -h/--help, so no parser inherits
|
|
1138
|
-
# OptionParser's officious one: that prints to the process's $stdout rather
|
|
1139
|
-
# than @out (an embedding app that injects streams never sees it) and ends
|
|
1140
|
-
# the process with `exit` rather than returning a status (a test that asks a
|
|
1141
|
-
# command for help takes the whole runner down with it). Thrown, not
|
|
1142
|
-
# returned — #run catches it — because a parser is parsed inside
|
|
1143
|
-
# positional_dir, where every other early exit means "exit 2".
|
|
1144
|
-
# on_tail, so help sorts last in the list it is printing.
|
|
1145
|
-
def help_flag(parser)
|
|
1146
|
-
parser.on_tail("-h", "--help", "print this message") do
|
|
1147
|
-
@out.puts parser.help
|
|
1148
|
-
throw :help, 0
|
|
1149
|
-
end
|
|
1150
|
-
end
|
|
1151
|
-
|
|
1152
|
-
# The registered engines, read at parse time so an addon that registers one
|
|
1153
|
-
# shows up in `--help` without the CLI knowing it exists.
|
|
1154
|
-
def engine_names
|
|
1155
|
-
OKF::Bundle::Search.engines.map(&:id).join(" | ")
|
|
1156
|
-
end
|
|
1157
|
-
|
|
1158
|
-
def unsupported_query_message(error)
|
|
1159
|
-
wanted = error.missing.map { |name| CAPABILITY_FLAGS.fetch(name, ":#{name}") }.join(", ")
|
|
1160
|
-
return "no available search engine offers #{wanted}" if error.engine.nil?
|
|
1161
|
-
|
|
1162
|
-
able = OKF::Bundle::Search.engines.select { |engine| (error.missing - engine.capabilities).empty? }
|
|
1163
|
-
message = "--engine #{error.engine} does not support #{wanted}"
|
|
1164
|
-
message += " (try --engine #{able.map(&:id).join(" or ")})" unless able.empty?
|
|
1165
|
-
message
|
|
1166
|
-
end
|
|
1167
|
-
|
|
1168
|
-
# The engine story, told once, in the only place there is to tell it. `search`
|
|
1169
|
-
# routes on what the query needs — a pattern needs the scan, --fuzzy needs the
|
|
1170
|
-
# index — and says nothing about it at runtime: no note on stderr, nothing in
|
|
1171
|
-
# the header, and deliberately no --engine flag. So this is where a user learns
|
|
1172
|
-
# that the exactness a token index gives up is still reachable, and that -e is
|
|
1173
|
-
# how. Without it that capability is present but undiscoverable.
|
|
1174
|
-
#
|
|
1175
|
-
# It leads rather than trails because #help_flag registers -h with `on_tail`,
|
|
1176
|
-
# which OptionParser renders after every separator: a closing paragraph would
|
|
1177
|
-
# print *above* the -h line and split the option list in half. Stating the
|
|
1178
|
-
# matching model before the flags reads better anyway.
|
|
1179
|
-
def search_engine_note(parser)
|
|
1180
|
-
parser.separator ""
|
|
1181
|
-
parser.separator "Terms match raw text, so a phrase (\"dedup key\"), a dotted identifier (7.2.0,"
|
|
1182
|
-
parser.separator "customer_id) and a word inside `backticks` all match literally — the scan engine."
|
|
1183
|
-
parser.separator "--engine index matches whole tokens and the tokens they prefix, ranked by BM25+:"
|
|
1184
|
-
parser.separator "better ranking and the engine the browser page runs, at the cost of that"
|
|
1185
|
-
parser.separator "exactness. --fuzzy implies it. Add -e to read the terms as regular expressions."
|
|
1186
|
-
parser.separator ""
|
|
1187
|
-
end
|
|
1188
|
-
|
|
1189
|
-
# --fields/--except project the JSON down to the properties an agent wants, so it
|
|
1190
|
-
# never pays tokens for fields it will not read. --fields is an allowlist,
|
|
1191
|
-
# --except a denylist (mutually exclusive); both imply --json and apply per item
|
|
1192
|
-
# in a list view (catalog, files, index). Names are the JSON keys, matched
|
|
1193
|
-
# case-insensitively.
|
|
1194
|
-
def projection_flags(parser, options)
|
|
1195
|
-
parser.on("--fields LIST", Array, "emit only these JSON properties (comma-separated)") { |v| options[:json] = true; options[:fields] = v }
|
|
1196
|
-
parser.on("--except LIST", Array, "emit every JSON property but these") { |v| options[:json] = true; options[:except] = v }
|
|
1197
|
-
end
|
|
1198
|
-
|
|
1199
|
-
def filter_flags(parser, options, *keys)
|
|
1200
|
-
parser.on("--type TYPE", "only concepts of this type") { |v| options[:type] = v } if keys.include?(:type)
|
|
1201
|
-
parser.on("--area AREA", "only concepts in this top-level area") { |v| options[:area] = v } if keys.include?(:area)
|
|
1202
|
-
parser.on("--tag TAG", "only concepts carrying this tag") { |v| options[:tag] = v } if keys.include?(:tag)
|
|
1203
|
-
end
|
|
406
|
+
private
|
|
1204
407
|
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
408
|
+
# Built-ins answer without a plugin ever being loaded — the scan only
|
|
409
|
+
# happens once a name misses, which is every run of `okf lint` and no run
|
|
410
|
+
# of `okf tui`. Discovery is cheap (about 11ms on the 2.4 floor) but not
|
|
411
|
+
# free, and a one-shot CLI that already refuses to build a search index for
|
|
412
|
+
# a single query should not pay it to answer a verb it shipped with.
|
|
413
|
+
def dispatch(name, argv)
|
|
414
|
+
command = self.class.lookup(name) || begin
|
|
415
|
+
report_plugin_failures(self.class.load_plugins)
|
|
416
|
+
self.class.lookup(name)
|
|
1210
417
|
end
|
|
1211
|
-
|
|
418
|
+
return unknown(name) if command.nil?
|
|
1212
419
|
|
|
1213
|
-
|
|
1214
|
-
value.to_s.downcase
|
|
420
|
+
command.new(out: @out, err: @err, runner: @runner, input: @input).call(argv)
|
|
1215
421
|
end
|
|
1216
422
|
|
|
1217
|
-
def
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
end
|
|
1221
|
-
|
|
1222
|
-
# Turn an inverted index ({ value => [id, …] }) into display rows ordered by
|
|
1223
|
-
# count, narrowed to the concepts the active filters select; rows the narrowing
|
|
1224
|
-
# empties drop. With no filters the index passes through whole.
|
|
1225
|
-
def index_rows(index, key, folder, options)
|
|
1226
|
-
keep = filter_ids(folder, options)
|
|
1227
|
-
index.each_with_object([]) do |(value, ids), rows|
|
|
1228
|
-
ids = ids.select { |id| keep.include?(id) } unless keep.nil?
|
|
1229
|
-
rows << { key => value, count: ids.length, concepts: ids } unless ids.empty?
|
|
1230
|
-
end.sort_by { |row| [ -row[:count], row[key] ] }
|
|
1231
|
-
end
|
|
1232
|
-
|
|
1233
|
-
# The ids the filters select, resolved through the catalog metadata — or nil
|
|
1234
|
-
# when no filter is active, meaning keep everything.
|
|
1235
|
-
def filter_ids(folder, options)
|
|
1236
|
-
return nil if options[:type].nil? && options[:area].nil? && options[:tag].nil?
|
|
1237
|
-
|
|
1238
|
-
filter_entries(folder.catalog, options).map { |entry| entry[:id] }
|
|
1239
|
-
end
|
|
1240
|
-
|
|
1241
|
-
# Install this gem's companion agent skill into a destination directory. The
|
|
1242
|
-
# destination is required (no magic default) so the user always decides where
|
|
1243
|
-
# their agent picks the skill up. By default the skill lands in a skills/okf/
|
|
1244
|
-
# folder under it — point at a project or skills dir (.claude, .agents/skills)
|
|
1245
|
-
# and it settles in its own folder, never loose among the others — so the
|
|
1246
|
-
# resolved path is echoed back. --here installs straight into <dest-dir>.
|
|
1247
|
-
def skill(argv)
|
|
1248
|
-
options = { force: false, nest: true }
|
|
1249
|
-
parser = OptionParser.new do |o|
|
|
1250
|
-
o.banner = "Usage: okf skill <dest-dir> [--here] [--force]"
|
|
1251
|
-
o.on("--here", "install straight into <dest-dir>, wherever it is (no skills/okf nesting)") { options[:nest] = false }
|
|
1252
|
-
o.on("--force", "overwrite a non-empty destination") { options[:force] = true }
|
|
1253
|
-
help_flag(o)
|
|
1254
|
-
end
|
|
1255
|
-
parser.parse!(argv)
|
|
1256
|
-
dest = argv.shift
|
|
1257
|
-
if dest.nil?
|
|
1258
|
-
@err.puts parser.banner
|
|
1259
|
-
return 2
|
|
1260
|
-
end
|
|
1261
|
-
|
|
1262
|
-
skill = OKF::Skill.new(dest, force: options[:force], nest: options[:nest])
|
|
1263
|
-
files = skill.install
|
|
1264
|
-
@out.puts "installed the okf skill (#{files.size} files) -> #{skill.dest}"
|
|
1265
|
-
files.each { |f| @out.puts " #{f}" }
|
|
1266
|
-
@out.puts "your agent picks it up from #{skill.dest} (needs the `okf` CLI, which you already have)."
|
|
1267
|
-
0
|
|
1268
|
-
rescue OptionParser::ParseError => e
|
|
1269
|
-
@err.puts e.message
|
|
423
|
+
def unknown(name)
|
|
424
|
+
@err.puts "okf: unknown command '#{name}'"
|
|
425
|
+
usage(@err)
|
|
1270
426
|
2
|
|
1271
|
-
rescue OKF::Skill::Error => e
|
|
1272
|
-
@err.puts "error: #{e.message}"
|
|
1273
|
-
2
|
|
1274
|
-
end
|
|
1275
|
-
|
|
1276
|
-
# §9 best-effort: the graph is built from concepts that parse. Surface any that
|
|
1277
|
-
# the reader could not parse (to stderr, so JSON on stdout stays clean) rather
|
|
1278
|
-
# than dropping them silently.
|
|
1279
|
-
def report_skipped(folder)
|
|
1280
|
-
note_skipped(folder.bundle.unparseable.size)
|
|
1281
427
|
end
|
|
1282
428
|
|
|
1283
|
-
#
|
|
1284
|
-
#
|
|
1285
|
-
# that names both. "invalid frontmatter" was a guess the summary had no need
|
|
1286
|
-
# to make: `validate` prints the file and the reason for every one of them.
|
|
1287
|
-
def note_skipped(count)
|
|
1288
|
-
return if count.nil? || count <= 0
|
|
1289
|
-
|
|
1290
|
-
@err.puts "note: skipped #{count} unusable file(s) (run `okf validate` for details)"
|
|
1291
|
-
end
|
|
1292
|
-
|
|
1293
|
-
# Turn a --stale-after value (90d, 12w, or an ISO date) into an absolute cutoff
|
|
1294
|
-
# Time so the pure Linter never reads the clock. nil when unset, :invalid on a
|
|
1295
|
-
# bad value.
|
|
1296
|
-
def parse_stale_after(value)
|
|
1297
|
-
return nil if value.nil?
|
|
1298
|
-
|
|
1299
|
-
if (match = value.match(/\A(\d+)([dw])\z/))
|
|
1300
|
-
days = match[1].to_i * (match[2] == "w" ? 7 : 1)
|
|
1301
|
-
Time.now - (days * 86_400)
|
|
1302
|
-
else
|
|
1303
|
-
Date.iso8601(value).to_time
|
|
1304
|
-
end
|
|
1305
|
-
rescue ArgumentError
|
|
1306
|
-
:invalid
|
|
1307
|
-
end
|
|
1308
|
-
|
|
1309
|
-
# Parse options, then require a single bundle positional — a directory, or an
|
|
1310
|
-
# @ref into the registry. Returns the bundle's directory, or nil (after
|
|
1311
|
-
# reporting) so the caller returns 2.
|
|
1312
|
-
def positional_dir(parser, argv)
|
|
1313
|
-
parser.parse!(argv)
|
|
1314
|
-
dir = argv.shift
|
|
1315
|
-
if dir.nil?
|
|
1316
|
-
@err.puts parser.banner
|
|
1317
|
-
return nil
|
|
1318
|
-
end
|
|
1319
|
-
# A second bundle is a question this verb cannot answer: only `search`
|
|
1320
|
-
# merges across bundles and only `server` mounts several. Reading the
|
|
1321
|
-
# first and dropping the rest would answer confidently about a bundle the
|
|
1322
|
-
# user never asked about — the silent-wrong-answer shape, so: exit 2.
|
|
1323
|
-
return nil unless no_extras?(argv)
|
|
1324
|
-
|
|
1325
|
-
resolve_ref(dir)
|
|
1326
|
-
rescue OptionParser::ParseError => e
|
|
1327
|
-
@err.puts e.message
|
|
1328
|
-
nil
|
|
1329
|
-
end
|
|
1330
|
-
|
|
1331
|
-
# Parse options, then take zero or more bundle positionals (the multi-bundle
|
|
1332
|
-
# server) — directories or @refs. Returns the resolved array (possibly
|
|
1333
|
-
# empty), or nil (after reporting) so the caller returns 2.
|
|
1334
|
-
def positional_dirs(parser, argv)
|
|
1335
|
-
parser.parse!(argv)
|
|
1336
|
-
dirs = argv.map { |dir| resolve_ref(dir) }
|
|
1337
|
-
dirs.include?(nil) ? nil : dirs
|
|
1338
|
-
rescue OptionParser::ParseError => e
|
|
1339
|
-
@err.puts e.message
|
|
1340
|
-
nil
|
|
1341
|
-
end
|
|
1342
|
-
|
|
1343
|
-
# "@slug" — or bare "@", the registry's default — names a registered bundle
|
|
1344
|
-
# wherever a <dir> goes; anything else must be a directory on disk. A
|
|
1345
|
-
# leading @ always means the registry (a directory literally named that way
|
|
1346
|
-
# stays reachable as ./@name), and the registry loads only when a ref
|
|
1347
|
-
# appears, so plain-dir invocations never pay for it. Returns the bundle's
|
|
1348
|
-
# directory, or nil after reporting.
|
|
1349
|
-
def resolve_ref(arg)
|
|
1350
|
-
return resolve_registered(arg) if arg.start_with?("@")
|
|
1351
|
-
|
|
1352
|
-
unless File.directory?(arg)
|
|
1353
|
-
@err.puts "error: #{arg} is not a directory or a registry ref " \
|
|
1354
|
-
"(@slug names a registered bundle, @ the default; okf registry list)"
|
|
1355
|
-
return nil
|
|
1356
|
-
end
|
|
1357
|
-
arg
|
|
1358
|
-
end
|
|
1359
|
-
|
|
1360
|
-
# Load the registry, turning a malformed file into a reported usage error
|
|
1361
|
-
# instead of an OKF::Error escaping through whatever verb took an @ref —
|
|
1362
|
-
# only `server` and the `registry` verbs rescue one. Returns nil after
|
|
1363
|
-
# reporting, so every caller returns 2.
|
|
1364
|
-
def load_registry
|
|
1365
|
-
require "okf/registry"
|
|
1366
|
-
OKF::Registry.load
|
|
1367
|
-
rescue OKF::Error => e
|
|
1368
|
-
@err.puts "error: #{e.message}"
|
|
1369
|
-
nil
|
|
1370
|
-
end
|
|
1371
|
-
|
|
1372
|
-
# Resolve one @ref through the registry under $OKF_HOME (default ~/.okf).
|
|
1373
|
-
# The slug part is normalized
|
|
1374
|
-
# exactly as registration normalized it, so @One finds the bundle
|
|
1375
|
-
# registered from dir One — but never through #slugify's mint-a-name
|
|
1376
|
-
# placeholder, so "@***" is a bad ref rather than whatever is slugged
|
|
1377
|
-
# "bundle". An explicit ask fails hard: an unknown slug or a
|
|
1378
|
-
# registered-but-gone directory is a usage error naming the registry file
|
|
1379
|
-
# and the next move, never a silent skip.
|
|
429
|
+
# On stderr, so a `--json` run's stdout stays a clean machine substrate even
|
|
430
|
+
# when an addon is broken — or when one was deliberately not run.
|
|
1380
431
|
#
|
|
1381
|
-
#
|
|
1382
|
-
#
|
|
1383
|
-
#
|
|
1384
|
-
#
|
|
1385
|
-
#
|
|
1386
|
-
#
|
|
1387
|
-
#
|
|
1388
|
-
def
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
hint = registry.empty? ? "okf registry set <dir>" : "okf registry list"
|
|
1409
|
-
@err.puts "error: not a registered bundle: #{ref} in #{registry.path} (#{hint})"
|
|
1410
|
-
return nil
|
|
1411
|
-
end
|
|
1412
|
-
unless File.directory?(entry.path)
|
|
1413
|
-
@ref_failure = :missing
|
|
1414
|
-
@err.puts "error: #{ref} points to #{entry.path}, which is not a directory (okf registry del #{entry.slug}, or restore it)"
|
|
1415
|
-
return nil
|
|
1416
|
-
end
|
|
1417
|
-
ref_slugs[entry.path] = entry.slug
|
|
1418
|
-
entry.path
|
|
1419
|
-
end
|
|
1420
|
-
|
|
1421
|
-
# Which slug each @ref resolved to, by absolute path — so a hub built from
|
|
1422
|
-
# refs mounts each bundle under its registered slug, not its dir basename.
|
|
1423
|
-
# Reset by every run; never memoized here, or a stale run would seed it.
|
|
1424
|
-
attr_reader :ref_slugs
|
|
1425
|
-
|
|
1426
|
-
# Every bundle-scoped output names its bundle in the identity the caller
|
|
1427
|
-
# used: `@handbook (/path)` when they named a registered bundle, the plain
|
|
1428
|
-
# path otherwise. A dir named by path stays a path — inventing a slug for it
|
|
1429
|
-
# would imply a registration that does not exist, and looking one up would
|
|
1430
|
-
# cost a registry read on every plain-dir run.
|
|
1431
|
-
def bundle_label(dir)
|
|
1432
|
-
slug = ref_slugs[dir]
|
|
1433
|
-
slug ? "@#{slug} (#{dir})" : dir.to_s
|
|
1434
|
-
end
|
|
1435
|
-
|
|
1436
|
-
# The JSON head for one bundle. `bundle` is always its directory and `slug`
|
|
1437
|
-
# always a registry slug — never the same key meaning two things — so a
|
|
1438
|
-
# consumer resolves a row to a file without a second lookup.
|
|
1439
|
-
def bundle_head(dir)
|
|
1440
|
-
head = { "bundle" => dir }
|
|
1441
|
-
slug = ref_slugs[dir]
|
|
1442
|
-
head["slug"] = slug if slug
|
|
1443
|
-
head
|
|
1444
|
-
end
|
|
1445
|
-
|
|
1446
|
-
# Parse options, then require a single non-directory positional (e.g. a slug).
|
|
1447
|
-
# Returns it, or nil (after reporting the banner) so the caller returns 2.
|
|
1448
|
-
def positional(parser, argv)
|
|
1449
|
-
parser.parse!(argv)
|
|
1450
|
-
value = argv.shift
|
|
1451
|
-
if value.nil?
|
|
1452
|
-
@err.puts parser.banner
|
|
1453
|
-
return nil
|
|
1454
|
-
end
|
|
1455
|
-
value
|
|
1456
|
-
rescue OptionParser::ParseError => e
|
|
1457
|
-
@err.puts e.message
|
|
1458
|
-
nil
|
|
1459
|
-
end
|
|
1460
|
-
|
|
1461
|
-
def print_validation(dir, result)
|
|
1462
|
-
counts = result.counts
|
|
1463
|
-
@out.puts "OKF v0.1 conformance — #{bundle_label(dir)}"
|
|
1464
|
-
@out.puts " concepts: #{counts[:concepts]} index.md: #{counts[:indexes]} log.md: #{counts[:logs]}"
|
|
1465
|
-
result.errors.each { |e| @out.puts " #{paint("✗ ERROR", 31)} #{e[:path]}: #{e[:message]}" }
|
|
1466
|
-
result.warnings.each { |w| @out.puts " #{paint("! warn", 33)} #{w[:path]}: #{w[:message]}" }
|
|
1467
|
-
if result.valid? && result.warnings.empty?
|
|
1468
|
-
@out.puts " #{paint("✓ conformant — no issues", 32)}"
|
|
1469
|
-
elsif result.valid?
|
|
1470
|
-
@out.puts " #{paint("✓ conformant", 32)} (#{result.warnings.size} warning(s))"
|
|
1471
|
-
else
|
|
1472
|
-
@out.puts " #{paint("✗ non-conformant", 31)} (#{result.errors.size} error(s))"
|
|
1473
|
-
end
|
|
1474
|
-
end
|
|
1475
|
-
|
|
1476
|
-
def print_validation_json(dir, result)
|
|
1477
|
-
emit_json(bundle_head(dir).merge(
|
|
1478
|
-
"conformant" => result.valid?,
|
|
1479
|
-
"counts" => result.counts,
|
|
1480
|
-
"errors" => result.errors,
|
|
1481
|
-
"warnings" => result.warnings
|
|
1482
|
-
))
|
|
1483
|
-
end
|
|
1484
|
-
|
|
1485
|
-
def print_lint(dir, report)
|
|
1486
|
-
stats = report.stats
|
|
1487
|
-
@out.puts "OKF lint — #{bundle_label(dir)}"
|
|
1488
|
-
@out.puts " concepts: #{stats[:concepts]} edges: #{stats[:edges]} index.md: #{stats[:indexes]} log.md: #{stats[:logs]}"
|
|
1489
|
-
summary = lint_summary(stats)
|
|
1490
|
-
@out.puts " #{summary}" unless summary.empty?
|
|
1491
|
-
|
|
1492
|
-
LINT_CATEGORIES.each do |name, checks|
|
|
1493
|
-
findings = report.findings.select { |finding| checks.include?(finding[:check]) }
|
|
1494
|
-
next if findings.empty?
|
|
1495
|
-
|
|
1496
|
-
@out.puts
|
|
1497
|
-
@out.puts " #{name}"
|
|
1498
|
-
findings.each do |finding|
|
|
1499
|
-
@out.puts " #{lint_glyph(finding)} #{[ finding[:path], finding[:message] ].compact.join(": ")}"
|
|
1500
|
-
end
|
|
1501
|
-
end
|
|
1502
|
-
|
|
1503
|
-
@out.puts
|
|
1504
|
-
@out.puts " #{lint_verdict(report)}"
|
|
1505
|
-
end
|
|
1506
|
-
|
|
1507
|
-
def print_lint_json(dir, report)
|
|
1508
|
-
emit_json(bundle_head(dir).merge(
|
|
1509
|
-
"healthy" => report.healthy?,
|
|
1510
|
-
"stats" => report.stats,
|
|
1511
|
-
"findings" => report.findings
|
|
1512
|
-
))
|
|
1513
|
-
end
|
|
1514
|
-
|
|
1515
|
-
# Degree-0 nodes as { id:, title:, dir: }, sorted by path — the same set lint's
|
|
1516
|
-
# `unlinked` check reports, resolved to titles/folders for display.
|
|
1517
|
-
def loose_files(graph)
|
|
1518
|
-
titles = graph.nodes.map { |node| [ node[:id], node[:title] ] }.to_h
|
|
1519
|
-
graph.unlinked_ids
|
|
1520
|
-
.map { |id| { id: id, title: titles[id], dir: File.dirname("#{id}.md") } }
|
|
1521
|
-
.sort_by { |file| file[:id] }
|
|
1522
|
-
end
|
|
1523
|
-
|
|
1524
|
-
def print_loose(dir, files)
|
|
1525
|
-
@out.puts "Loose files — #{bundle_label(dir)} (#{files.size})"
|
|
1526
|
-
if files.empty?
|
|
1527
|
-
@out.puts " #{paint("✓ none — every concept links or is linked", 32)}"
|
|
1528
|
-
return
|
|
1529
|
-
end
|
|
1530
|
-
|
|
1531
|
-
files.group_by { |file| file[:dir] }.sort_by(&:first).each do |folder, group|
|
|
1532
|
-
width = group.map { |file| File.basename("#{file[:id]}.md").length }.max
|
|
1533
|
-
@out.puts
|
|
1534
|
-
@out.puts " #{folder == "." ? "(root)" : "#{folder}/"}"
|
|
1535
|
-
group.each do |file|
|
|
1536
|
-
@out.puts " #{File.basename("#{file[:id]}.md").ljust(width)} #{file[:title]}"
|
|
1537
|
-
end
|
|
1538
|
-
end
|
|
1539
|
-
end
|
|
1540
|
-
|
|
1541
|
-
def print_loose_json(dir, files)
|
|
1542
|
-
emit_json(bundle_head(dir).merge(
|
|
1543
|
-
"count" => files.size,
|
|
1544
|
-
"loose" => files.map { |file| stringify(file) }
|
|
1545
|
-
))
|
|
1546
|
-
end
|
|
1547
|
-
|
|
1548
|
-
def print_catalog(dir, entries, total)
|
|
1549
|
-
@out.puts "Catalog — #{bundle_label(dir)} (#{counted(entries.size, total, "concept")})"
|
|
1550
|
-
entries.group_by { |entry| entry[:area] }.sort_by(&:first).each do |area, group|
|
|
1551
|
-
@out.puts
|
|
1552
|
-
@out.puts " #{area == "(root)" ? "(root)" : "#{area}/"} (#{group.size})"
|
|
1553
|
-
group.each do |entry|
|
|
1554
|
-
links = entry[:links_out] + entry[:links_in]
|
|
1555
|
-
meta = [ entry[:type], (links.positive? ? "↳#{links}" : nil), entry[:status] ].compact.join(" · ")
|
|
1556
|
-
@out.puts " #{entry[:title]} · #{meta}"
|
|
1557
|
-
@out.puts " #{truncate(entry[:description], 92)}" unless entry[:description].empty?
|
|
1558
|
-
end
|
|
1559
|
-
end
|
|
1560
|
-
end
|
|
1561
|
-
|
|
1562
|
-
def print_catalog_json(dir, entries, options)
|
|
1563
|
-
emit_list_json(dir, "concepts", entries.map { |entry| stringify(entry) }, options)
|
|
1564
|
-
end
|
|
1565
|
-
|
|
1566
|
-
def print_files(dir, entries, total)
|
|
1567
|
-
@out.puts "Files — #{bundle_label(dir)} (#{counted(entries.size, total, "file")})"
|
|
1568
|
-
entries.group_by { |entry| entry[:dir] }.sort_by(&:first).each do |folder, group|
|
|
1569
|
-
width = group.map { |entry| File.basename("#{entry[:id]}.md").length }.max
|
|
1570
|
-
@out.puts
|
|
1571
|
-
@out.puts " #{folder == "." ? "(root)" : "#{folder}/"}"
|
|
1572
|
-
group.each do |entry|
|
|
1573
|
-
@out.puts " #{File.basename("#{entry[:id]}.md").ljust(width)} #{entry[:title]}"
|
|
432
|
+
# A refused extension is *louder* than a broken one on purpose. A gem that
|
|
433
|
+
# ships okf/plugin.rb under a name outside the okf- prefix is either an
|
|
434
|
+
# honest mistake somebody needs told about, or something that wanted to run
|
|
435
|
+
# code on a machine where nobody asked it to. Both want saying out loud.
|
|
436
|
+
# Once per run, not once per caller. An unknown verb reaches this twice —
|
|
437
|
+
# dispatch looks, misses, and then prints the map, which looks again — and a
|
|
438
|
+
# warning repeated is a warning that reads like two problems.
|
|
439
|
+
def report_plugin_failures(failures)
|
|
440
|
+
return if @plugin_notes_reported
|
|
441
|
+
|
|
442
|
+
@plugin_notes_reported = true
|
|
443
|
+
if (error = self.class.plugin_discovery_error)
|
|
444
|
+
@err.puts "okf: could not look for installed extensions (#{error.class}: #{error.message})"
|
|
445
|
+
@err.puts " none were loaded, so a verb an extension provides will read as unknown"
|
|
446
|
+
end
|
|
447
|
+
Array(failures).each do |path, error|
|
|
448
|
+
@err.puts "okf: extension at #{path} failed to load (#{error.class}: #{error.message})"
|
|
449
|
+
end
|
|
450
|
+
self.class.untrusted_plugins.each do |path, gem_name|
|
|
451
|
+
if gem_name == UNKNOWN_GEM
|
|
452
|
+
cause = self.class.plugin_gem_error
|
|
453
|
+
@err.puts "okf: ignoring the extension at #{path} — its owning gem could not be determined" \
|
|
454
|
+
"#{cause && " (#{cause.class}: #{cause.message})"}"
|
|
455
|
+
@err.puts " extensions are loaded only from gems named #{PLUGIN_GEM_PREFIX}*, and a name that cannot be read cannot be checked"
|
|
456
|
+
else
|
|
457
|
+
@err.puts "okf: ignoring an extension shipped by `#{gem_name}` (#{path})"
|
|
458
|
+
@err.puts " extensions are loaded only from gems named #{PLUGIN_GEM_PREFIX}*, since loading one runs its code"
|
|
1574
459
|
end
|
|
1575
460
|
end
|
|
1576
461
|
end
|
|
1577
462
|
|
|
1578
|
-
def print_files_json(dir, entries, options)
|
|
1579
|
-
files = entries.map do |entry|
|
|
1580
|
-
{ "path" => "#{entry[:id]}.md", "id" => entry[:id], "dir" => entry[:dir], "type" => entry[:type], "title" => entry[:title],
|
|
1581
|
-
"description" => entry[:description] }
|
|
1582
|
-
end
|
|
1583
|
-
emit_list_json(dir, "files", files, options)
|
|
1584
|
-
end
|
|
1585
|
-
|
|
1586
|
-
def print_index(dir, label, key, rows, titles)
|
|
1587
|
-
@out.puts "#{label} — #{bundle_label(dir)} (#{rows.size} distinct)"
|
|
1588
|
-
@out.puts
|
|
1589
|
-
width = rows.map { |row| row[key].length }.max || 0
|
|
1590
|
-
rows.each do |row|
|
|
1591
|
-
names = row[:concepts].map { |id| titles[id] || id }.join(", ")
|
|
1592
|
-
@out.puts " #{row[key].ljust(width)} #{row[:count].to_s.rjust(3)} #{truncate(names, 78)}"
|
|
1593
|
-
end
|
|
1594
|
-
end
|
|
1595
|
-
|
|
1596
|
-
def print_index_json(dir, plural, key, rows)
|
|
1597
|
-
emit_json(bundle_head(dir).merge("count" => rows.size, plural => index_rows_json(key, rows)))
|
|
1598
|
-
end
|
|
1599
|
-
|
|
1600
|
-
def index_rows_json(key, rows)
|
|
1601
|
-
rows.map { |row| { key.to_s => row[key], "count" => row[:count], "concepts" => row[:concepts] } }
|
|
1602
|
-
end
|
|
1603
|
-
|
|
1604
|
-
# "3 concepts", "1 concept", "1 of 7 concepts" — the noun agrees with the
|
|
1605
|
-
# number it follows: the size when that is all we show, the total otherwise.
|
|
1606
|
-
def counted(size, total, noun)
|
|
1607
|
-
return "#{size} #{pluralize(size, noun)}" if size == total
|
|
1608
|
-
|
|
1609
|
-
"#{size} of #{total} #{pluralize(total, noun)}"
|
|
1610
|
-
end
|
|
1611
|
-
|
|
1612
|
-
# The gem's whole vocabulary is regular, so a naive +s is not a shortcut —
|
|
1613
|
-
# it is the rule. Callers pass the singular.
|
|
1614
|
-
def pluralize(count, noun)
|
|
1615
|
-
count == 1 ? noun : "#{noun}s"
|
|
1616
|
-
end
|
|
1617
|
-
|
|
1618
|
-
def print_stats(dir, stats)
|
|
1619
|
-
@out.puts "Stats — #{bundle_label(dir)}"
|
|
1620
|
-
@out.puts
|
|
1621
|
-
@out.puts " concepts #{stats[:concepts]}"
|
|
1622
|
-
@out.puts " areas #{stats[:areas]}"
|
|
1623
|
-
@out.puts " concept types #{stats[:types]}"
|
|
1624
|
-
@out.puts " cross-links #{stats[:cross_links]}"
|
|
1625
|
-
@out.puts " distinct tags #{stats[:tags]}"
|
|
1626
|
-
print_stat_breakdown("By type", stats[:by_type])
|
|
1627
|
-
print_stat_breakdown("By area", stats[:by_area])
|
|
1628
|
-
end
|
|
1629
|
-
|
|
1630
|
-
def print_stat_breakdown(title, counts)
|
|
1631
|
-
return if counts.empty?
|
|
1632
|
-
|
|
1633
|
-
width = counts.keys.map(&:length).max
|
|
1634
|
-
@out.puts
|
|
1635
|
-
@out.puts " #{title}"
|
|
1636
|
-
counts.each { |label, count| @out.puts " #{label.ljust(width)} #{count}" }
|
|
1637
|
-
end
|
|
1638
|
-
|
|
1639
|
-
def print_stats_json(dir, stats)
|
|
1640
|
-
emit_json(bundle_head(dir).merge(
|
|
1641
|
-
"concepts" => stats[:concepts], "areas" => stats[:areas],
|
|
1642
|
-
"concept_types" => stats[:types], "cross_links" => stats[:cross_links], "distinct_tags" => stats[:tags],
|
|
1643
|
-
"by_type" => stats[:by_type], "by_area" => stats[:by_area]
|
|
1644
|
-
))
|
|
1645
|
-
end
|
|
1646
|
-
|
|
1647
|
-
# The single JSON writer. Compact by default — the token-efficient substrate an
|
|
1648
|
-
# agent consumes; --pretty indents it for a human. JSON semantics are identical
|
|
1649
|
-
# either way, so a parser never cares which was emitted.
|
|
1650
|
-
def emit_json(payload)
|
|
1651
|
-
@out.puts(@pretty ? JSON.pretty_generate(payload) : JSON.generate(payload))
|
|
1652
|
-
end
|
|
1653
|
-
|
|
1654
|
-
# Emit a list view's JSON envelope with --fields/--except projection applied to
|
|
1655
|
-
# each item. Returns the verb's exit code (0, or 2 on a bad projection request —
|
|
1656
|
-
# both flags at once, or a field name no item carries).
|
|
1657
|
-
# +dir+ is the bundle's directory — or a ready-made head Hash when the
|
|
1658
|
-
# payload spans bundles (multi-bundle search's "bundles" key).
|
|
1659
|
-
# +key+ names the JSON property the rows land under; +shape+ names the row
|
|
1660
|
-
# shape to check --fields/--except against. They are the same for every view
|
|
1661
|
-
# but search, whose two modes emit the same property from different rows.
|
|
1662
|
-
def emit_list_json(dir, key, items, options, extra = {}, shape = key)
|
|
1663
|
-
return usage_error("--fields and --except are mutually exclusive") if options[:fields] && options[:except]
|
|
1664
|
-
|
|
1665
|
-
unknown = unknown_fields(items, options, shape)
|
|
1666
|
-
return usage_error("unknown field(s): #{unknown.join(", ")} (available: #{available_fields(items, shape).join(", ")})") unless unknown.empty?
|
|
1667
|
-
|
|
1668
|
-
payload = (dir.is_a?(Hash) ? dir.dup : bundle_head(dir)).merge(extra)
|
|
1669
|
-
payload["count"] = items.size
|
|
1670
|
-
payload[key] = project(items, options)
|
|
1671
|
-
emit_json(payload)
|
|
1672
|
-
0
|
|
1673
|
-
end
|
|
1674
|
-
|
|
1675
|
-
# Keep only --fields (allowlist) or drop --except (denylist) from each item's
|
|
1676
|
-
# top-level properties; unset flags pass the items through whole.
|
|
1677
|
-
def project(items, options)
|
|
1678
|
-
return items if options[:fields].nil? && options[:except].nil?
|
|
1679
|
-
|
|
1680
|
-
fields = options[:fields]&.map(&:downcase)
|
|
1681
|
-
except = options[:except]&.map(&:downcase)
|
|
1682
|
-
items.map do |item|
|
|
1683
|
-
fields ? item.select { |k, _| fields.include?(k.to_s.downcase) } : item.reject { |k, _| except.include?(k.to_s.downcase) }
|
|
1684
|
-
end
|
|
1685
|
-
end
|
|
1686
|
-
|
|
1687
|
-
# The declared shape wins over the data's, so the same typo gets the same
|
|
1688
|
-
# answer whether or not the result happened to have rows; a view with no
|
|
1689
|
-
# declared shape falls back to what it actually emitted.
|
|
1690
|
-
def available_fields(items, key = nil)
|
|
1691
|
-
ROW_FIELDS[key] || (items.first ? items.first.keys.map(&:to_s) : [])
|
|
1692
|
-
end
|
|
1693
|
-
|
|
1694
|
-
# Requested field names that no item actually carries — a typo guard (exit 2),
|
|
1695
|
-
# matching how lint rejects unknown check names.
|
|
1696
|
-
def unknown_fields(items, options, key = nil)
|
|
1697
|
-
requested = (Array(options[:fields]) + Array(options[:except])).map(&:downcase)
|
|
1698
|
-
return [] if requested.empty?
|
|
1699
|
-
|
|
1700
|
-
known = available_fields(items, key).map(&:downcase)
|
|
1701
|
-
return [] if known.empty? # an unknown view: no shape to check against, so accept
|
|
1702
|
-
|
|
1703
|
-
requested.reject { |field| known.include?(field) }.uniq
|
|
1704
|
-
end
|
|
1705
|
-
|
|
1706
|
-
def usage_error(message)
|
|
1707
|
-
@err.puts "error: #{message}"
|
|
1708
|
-
2
|
|
1709
|
-
end
|
|
1710
|
-
|
|
1711
|
-
def stringify(hash)
|
|
1712
|
-
hash.map { |key, value| [ key.to_s, value ] }.to_h
|
|
1713
|
-
end
|
|
1714
|
-
|
|
1715
|
-
def truncate(str, max)
|
|
1716
|
-
str.length > max ? "#{str[0, max - 1]}…" : str
|
|
1717
|
-
end
|
|
1718
|
-
|
|
1719
|
-
def lint_summary(stats)
|
|
1720
|
-
parts = []
|
|
1721
|
-
hubs = stats[:hubs].map { |hub| "#{hub[:id]} (×#{hub[:in_degree]})" }.join(", ")
|
|
1722
|
-
types = stats[:types].map { |type, count| "#{type} #{count}" }.join(", ")
|
|
1723
|
-
parts << "hubs: #{hubs}" unless hubs.empty?
|
|
1724
|
-
parts << "types: #{types}" unless types.empty?
|
|
1725
|
-
parts.join(" ")
|
|
1726
|
-
end
|
|
1727
|
-
|
|
1728
|
-
def lint_glyph(finding)
|
|
1729
|
-
finding[:severity] == :warn ? paint("! warn", 33) : "· info"
|
|
1730
|
-
end
|
|
1731
|
-
|
|
1732
|
-
def lint_verdict(report)
|
|
1733
|
-
warnings = report.warnings.size
|
|
1734
|
-
infos = report.info.size
|
|
1735
|
-
return paint("✓ healthy — no issues", 32) if warnings.zero? && infos.zero?
|
|
1736
|
-
|
|
1737
|
-
marker = warnings.zero? ? paint("✓", 32) : paint("⚠", 33)
|
|
1738
|
-
"#{marker} #{warnings} warn, #{infos} info"
|
|
1739
|
-
end
|
|
1740
|
-
|
|
1741
|
-
def paint(text, code)
|
|
1742
|
-
return text unless @out.respond_to?(:tty?) && @out.tty?
|
|
1743
|
-
|
|
1744
|
-
"\e[#{code}m#{text}\e[0m"
|
|
1745
|
-
end
|
|
1746
|
-
|
|
1747
463
|
def usage(io)
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
registry set <dir|@slug> [--as SLUG] [--default] add or update a bundle (a bare `server` serves them)
|
|
1757
|
-
registry del <dir|@slug> remove a bundle from the registry
|
|
1758
|
-
registry default <@slug> move a bundle to the front (the default)
|
|
1759
|
-
registry rename <@slug> <new> rename a registered bundle (<new> is a new name, not a ref)
|
|
1760
|
-
|
|
1761
|
-
lint <dir|@slug> [--json] [--fail-on warn] [...] report curation-quality issues
|
|
1762
|
-
loose <dir|@slug> [--json] list files with no graph links, by folder
|
|
1763
|
-
validate <dir|@slug> [--json] check OKF v0.1 conformance
|
|
1764
|
-
|
|
1765
|
-
search <dir|@slug…|@all> <term…> [-e|--fuzzy] [...] find concepts by text or regexp, ranked (@all: every bundle)
|
|
1766
|
-
index <dir|@slug> [--json] [--area A] [--no-body] the index map: dirs, their listings and rollups
|
|
1767
|
-
stats <dir|@slug> [--json] bundle rollups (concepts, types, areas, links, tags)
|
|
1768
|
-
types <dir|@slug> [--json] [filters] list types with their concepts, by count
|
|
1769
|
-
tags <dir|@slug> [--json] [--by DIM] [filters] list tags with their concepts, by count
|
|
1770
|
-
files <dir|@slug> [--json] [filters] list files with titles, by folder
|
|
1771
|
-
catalog <dir|@slug> [--json] [filters] list concepts with metadata, by area
|
|
1772
|
-
|
|
1773
|
-
graph <dir|@slug> [--json] [--minimal] [--no-body] print the knowledge graph
|
|
1774
|
-
|
|
1775
|
-
@slug names a registered bundle instead of a path — the slug from
|
|
1776
|
-
`okf registry set`, or bare @ for the registry default. Anywhere a <dir>
|
|
1777
|
-
goes, an @slug goes: `okf lint @handbook`, `okf render @ -o graph.html`.
|
|
1778
|
-
The registry lives under $OKF_HOME (default ~/.okf); set it to point
|
|
1779
|
-
every verb at another one.
|
|
1780
|
-
search spans bundles: several leading @slugs, or @all for every registered one
|
|
1781
|
-
(@all skips a bundle whose directory is gone; a named @slug insists on it).
|
|
464
|
+
# Help is the one place that must know about every verb, so it is the one
|
|
465
|
+
# place besides an unknown name that pays for discovery.
|
|
466
|
+
report_plugin_failures(self.class.load_plugins)
|
|
467
|
+
io.puts "okf <command> [options]"
|
|
468
|
+
io.puts
|
|
469
|
+
GROUPS.each { |group, heading| print_group(io, group, heading) }
|
|
470
|
+
io.puts NOTE
|
|
471
|
+
end
|
|
1782
472
|
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
within-group counts, the view for curating a tag vocabulary.
|
|
1787
|
-
--json emits compact JSON (the machine substrate); add --pretty to indent it.
|
|
1788
|
-
--fields / --except project the JSON to the properties you want (search/index/catalog/files).
|
|
473
|
+
def print_group(io, group, heading)
|
|
474
|
+
rows = self.class.commands.reject(&:hidden?).select { |command| command.group == group }.flat_map(&:help_rows)
|
|
475
|
+
return if rows.empty?
|
|
1789
476
|
|
|
1790
|
-
|
|
1791
|
-
|
|
477
|
+
io.puts heading if heading
|
|
478
|
+
rows.each { |left, desc| io.puts " #{left.to_s.ljust(56)}#{desc}" }
|
|
479
|
+
io.puts
|
|
1792
480
|
end
|
|
1793
481
|
end
|
|
1794
482
|
end
|
|
483
|
+
|
|
484
|
+
require "okf/cli/command"
|
|
485
|
+
|
|
486
|
+
# ── These requires ARE the order `okf help` lists the verbs in ──
|
|
487
|
+
# Registration happens at load, `CLI.commands` is registration order, and the
|
|
488
|
+
# map walks the groups in GROUPS order and the verbs within a group in this
|
|
489
|
+
# one. Reordering these reorders the map. A test pins the result so the
|
|
490
|
+
# coupling cannot drift unnoticed, but the coupling is here, not there.
|
|
491
|
+
require "okf/cli/skill"
|
|
492
|
+
require "okf/cli/server"
|
|
493
|
+
require "okf/cli/render"
|
|
494
|
+
require "okf/cli/registry"
|
|
495
|
+
require "okf/cli/lint"
|
|
496
|
+
require "okf/cli/loose"
|
|
497
|
+
require "okf/cli/validate"
|
|
498
|
+
require "okf/cli/search"
|
|
499
|
+
require "okf/cli/index"
|
|
500
|
+
require "okf/cli/stats"
|
|
501
|
+
require "okf/cli/types"
|
|
502
|
+
require "okf/cli/tags"
|
|
503
|
+
require "okf/cli/files"
|
|
504
|
+
require "okf/cli/catalog"
|
|
505
|
+
require "okf/cli/graph"
|
|
506
|
+
|
|
507
|
+
# The line between what ships and what is installed. Everything above is a
|
|
508
|
+
# built-in; everything registered after this point came from a plugin.
|
|
509
|
+
OKF::CLI.seal_builtins!
|