okf 1.9.0 → 1.11.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 +696 -133
- data/README.md +250 -334
- data/lib/okf/bundle/folder.rb +24 -5
- data/lib/okf/bundle/linter.rb +1 -1
- data/lib/okf/bundle/search/index.rb +13 -3
- data/lib/okf/bundle/search.rb +91 -11
- data/lib/okf/bundle.rb +26 -2
- data/lib/okf/cli/catalog.rb +66 -0
- data/lib/okf/cli/command.rb +657 -0
- data/lib/okf/cli/dirs.rb +118 -0
- data/lib/okf/cli/files.rb +68 -0
- data/lib/okf/cli/graph.rb +82 -0
- data/lib/okf/cli/index.rb +169 -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 +186 -0
- data/lib/okf/cli/skill.rb +57 -0
- data/lib/okf/cli/stats.rb +113 -0
- data/lib/okf/cli/tags.rb +144 -0
- data/lib/okf/cli/types.rb +37 -0
- data/lib/okf/cli/validate.rb +66 -0
- data/lib/okf/cli.rb +425 -1706
- data/lib/okf/render/graph/template.html.erb +1285 -129
- data/lib/okf/render/graph.rb +46 -2
- data/lib/okf/server/app.rb +71 -4
- data/lib/okf/server/hub/not_found.rb +663 -0
- data/lib/okf/server/hub.rb +512 -38
- data/lib/okf/skill/SKILL.md +26 -19
- data/lib/okf/skill/playbooks/consume.md +3 -3
- data/lib/okf/skill/playbooks/curate.md +3 -1
- data/lib/okf/skill/playbooks/maintain.md +7 -5
- data/lib/okf/skill/playbooks/menu.md +5 -0
- data/lib/okf/skill/playbooks/refine.md +93 -0
- data/lib/okf/skill/playbooks/search.md +7 -7
- data/lib/okf/skill/reference/cli.md +122 -22
- data/lib/okf/version.rb +1 -1
- data/lib/okf.rb +9 -0
- metadata +38 -8
|
@@ -0,0 +1,657 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optparse"
|
|
4
|
+
|
|
5
|
+
module OKF
|
|
6
|
+
class CLI
|
|
7
|
+
# What every command inherits: the injected streams, and the shared surface
|
|
8
|
+
# a verb leans on — ref resolution, the flags several of them offer, the
|
|
9
|
+
# JSON emitters, the list-view printers.
|
|
10
|
+
#
|
|
11
|
+
# A command answers four questions about *itself* (.id, .group, .help_rows,
|
|
12
|
+
# .hidden?) and one about a *run* (#call, returning an exit status). That is
|
|
13
|
+
# the whole contract, and it is the same one a plugin implements — there is
|
|
14
|
+
# no second, lesser interface for an addon, because a seam only the base gem
|
|
15
|
+
# can use is not a seam.
|
|
16
|
+
#
|
|
17
|
+
# Privacy is the boundary, the one idea worth taking from Thor without
|
|
18
|
+
# taking Thor: #call is the entire public surface, so a helper added below
|
|
19
|
+
# can never become a verb by accident.
|
|
20
|
+
class Command
|
|
21
|
+
# What .register checks before admitting a command. Checked at
|
|
22
|
+
# registration rather than at dispatch, so a malformed addon fails where
|
|
23
|
+
# it is installed instead of the first time a user types its verb.
|
|
24
|
+
DUCK_TYPE = %i[id group help_rows hidden? new].freeze
|
|
25
|
+
|
|
26
|
+
class << self
|
|
27
|
+
# The verb this answers to, as a Symbol. The registry is keyed on it.
|
|
28
|
+
def id
|
|
29
|
+
raise NotImplementedError, "#{self}.id must name the verb it answers to"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Where the verb sits in the map `okf help` prints. CLI::GROUPS fixes
|
|
33
|
+
# the order; anything else — which is what a plugin gets by default —
|
|
34
|
+
# falls to the end, under its own heading.
|
|
35
|
+
def group
|
|
36
|
+
:extension
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# [ [ left-column, description ], … ] — one row per line of the map.
|
|
40
|
+
# A list rather than a pair because `registry` is an umbrella: five
|
|
41
|
+
# subcommands under one verb, each of which has to be findable alone.
|
|
42
|
+
def help_rows
|
|
43
|
+
[]
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# A command that works but is not advertised.
|
|
47
|
+
def hidden?
|
|
48
|
+
false
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# `runner:` is the server's injected boot seam and `input:` the terminal
|
|
53
|
+
# a full-screen command needs. Both live here rather than on the two
|
|
54
|
+
# commands that want them, so construction is uniform: a plugin is built
|
|
55
|
+
# exactly the way a built-in is, and the CLI needs to know nothing about
|
|
56
|
+
# which is which.
|
|
57
|
+
def initialize(out:, err:, runner: nil, input: nil)
|
|
58
|
+
@out = out
|
|
59
|
+
@err = err
|
|
60
|
+
@runner = runner
|
|
61
|
+
@input = input
|
|
62
|
+
@pretty = false
|
|
63
|
+
@ref_slugs = {}
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# The run. Returns the exit status; it never calls exit, and never writes
|
|
67
|
+
# anywhere but the injected streams.
|
|
68
|
+
def call(argv)
|
|
69
|
+
raise NotImplementedError, "#{self.class} must implement #call(argv) and return an exit status"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
# The terminal, for a command that needs one. Nothing built in does —
|
|
75
|
+
# `okf` is a one-shot tool — but a full-screen addon cannot work without
|
|
76
|
+
# it, and reaching for $stdin behind the CLI's back would put a command
|
|
77
|
+
# outside the stream injection every test depends on.
|
|
78
|
+
attr_reader :input
|
|
79
|
+
|
|
80
|
+
# Which slug each @ref resolved to, by absolute path — so a hub built from
|
|
81
|
+
# refs mounts each bundle under its registered slug, not its dir basename.
|
|
82
|
+
# Per command instance, which is per run: a command is built fresh for each
|
|
83
|
+
# dispatch, so the memo cannot outlive the argv that filled it. (It used to
|
|
84
|
+
# need clearing by hand at the top of #run; one command object per run is
|
|
85
|
+
# what retired that.)
|
|
86
|
+
attr_reader :ref_slugs
|
|
87
|
+
|
|
88
|
+
# `@all` is a ref, not a flag, and only `search` expands it — but the
|
|
89
|
+
# refusal the other verbs give lives in the shared resolver, so the
|
|
90
|
+
# recognizer has to be shared too.
|
|
91
|
+
def all_ref?(ref)
|
|
92
|
+
require "okf/registry"
|
|
93
|
+
OKF::Registry.normalize(ref[1..-1]) == ALL_REF[1..-1]
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# A registered bundle whose directory cannot be read, noted and skipped.
|
|
97
|
+
# Shared because both the verbs that tolerate a gap — `search @all` and the
|
|
98
|
+
# bundle-less `server` — have to skip it the same way, and say so the same
|
|
99
|
+
# way. A named @slug never lands here: it fails hard instead.
|
|
100
|
+
def skip_registered(entry)
|
|
101
|
+
@err.puts "note: skipping #{entry.slug} — cannot read #{entry.path}"
|
|
102
|
+
nil
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Nothing left over. The registry subcommands each take a fixed number of
|
|
106
|
+
# positionals, and so does every `<dir>` verb through positional_dir — a
|
|
107
|
+
# trailing argument means the command was misunderstood, not that it can be
|
|
108
|
+
# answered anyway.
|
|
109
|
+
def no_extras?(argv)
|
|
110
|
+
return true if argv.empty?
|
|
111
|
+
|
|
112
|
+
@err.puts "error: unexpected argument '#{argv.first}'"
|
|
113
|
+
false
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# ── the read views ──
|
|
117
|
+
# The Catalog / Files / Tags / Stats views the server renders in the browser,
|
|
118
|
+
# reproduced on the CLI so an agent can read the same knowledge without one.
|
|
119
|
+
# Each prints a scannable human view by default and machine JSON with --json;
|
|
120
|
+
# all are advisory reads (exit 0). They share OKF::Bundle#catalog for their data,
|
|
121
|
+
# and (with `types`) narrow through the same --type/--dir/--tag filters the
|
|
122
|
+
# server UI offers, so browser and CLI can answer the same questions.
|
|
123
|
+
#
|
|
124
|
+
# ── their shared --type/--dir/--tag narrowing ──
|
|
125
|
+
# Each view takes the filters orthogonal to it (tags can't filter by tag).
|
|
126
|
+
# Matching is case-insensitive; --type and --tag are exact, --dir is a prefix
|
|
127
|
+
# over the whole path (see #under_dir?). The bundle root is `.`, spellable
|
|
128
|
+
# `root` so no shell quoting is needed. --area is --dir's deprecated
|
|
129
|
+
# predecessor and keeps its old first-segment-only behavior.
|
|
130
|
+
|
|
131
|
+
# The shared back half of `tags` and `types`: load, narrow, print.
|
|
132
|
+
def print_inverted_index(dir, label, key, plural, options)
|
|
133
|
+
folder = OKF::Bundle::Folder.load(dir)
|
|
134
|
+
report_skipped(folder)
|
|
135
|
+
graph = folder.graph(minimal: true)
|
|
136
|
+
index = key == :tag ? graph.tag_index : graph.type_index
|
|
137
|
+
rows = index_rows(index, key, folder, options)
|
|
138
|
+
if options[:json]
|
|
139
|
+
print_index_json(dir, plural, key, rows)
|
|
140
|
+
else
|
|
141
|
+
titles = graph.nodes.map { |node| [ node[:id], node[:title] ] }.to_h
|
|
142
|
+
print_index(dir, label, key, rows, titles)
|
|
143
|
+
end
|
|
144
|
+
0
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# The --json / --pretty pair every emitting verb shares. --json is the compact
|
|
148
|
+
# machine substrate (the default JSON form, aligned with the server); --pretty
|
|
149
|
+
# indents it for a human and implies --json. Both route through emit_json.
|
|
150
|
+
def json_flags(parser, options, desc)
|
|
151
|
+
parser.on("--json", desc) { options[:json] = true }
|
|
152
|
+
parser.on("--pretty", "indent the JSON for reading (implies --json)") { options[:json] = true; @pretty = true }
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Every parser answers its own -h/--help, so no parser inherits
|
|
156
|
+
# OptionParser's officious one: that prints to the process's $stdout rather
|
|
157
|
+
# than @out (an embedding app that injects streams never sees it) and ends
|
|
158
|
+
# the process with `exit` rather than returning a status (a test that asks a
|
|
159
|
+
# command for help takes the whole runner down with it). Thrown, not
|
|
160
|
+
# returned — #run catches it — because a parser is parsed inside
|
|
161
|
+
# positional_dir, where every other early exit means "exit 2".
|
|
162
|
+
# on_tail, so help sorts last in the list it is printing.
|
|
163
|
+
def help_flag(parser)
|
|
164
|
+
parser.on_tail("-h", "--help", "print this message") do
|
|
165
|
+
@out.puts parser.help
|
|
166
|
+
throw :help, 0
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# --fields/--except project the JSON down to the properties an agent wants, so it
|
|
171
|
+
# never pays tokens for fields it will not read. --fields is an allowlist,
|
|
172
|
+
# --except a denylist (mutually exclusive); both imply --json and apply per item
|
|
173
|
+
# in a list view (catalog, files, index). Names are the JSON keys, matched
|
|
174
|
+
# case-insensitively.
|
|
175
|
+
def projection_flags(parser, options)
|
|
176
|
+
parser.on("--fields LIST", Array, "emit only these JSON properties (comma-separated)") { |v| options[:json] = true; options[:fields] = v }
|
|
177
|
+
parser.on("--except LIST", Array, "emit every JSON property but these") { |v| options[:json] = true; options[:except] = v }
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def filter_flags(parser, options, *keys)
|
|
181
|
+
parser.on("--type TYPE", "only concepts of this type") { |v| options[:type] = v } if keys.include?(:type)
|
|
182
|
+
if keys.include?(:area)
|
|
183
|
+
parser.on("--dir PATH", "only concepts in this directory or below it",
|
|
184
|
+
"(`root` — or `.` — for the bundle root)") { |v| options[:dir] = v }
|
|
185
|
+
parser.on("--area AREA", "deprecated: use --dir (matches the first path segment only)") do |v|
|
|
186
|
+
options[:area] = v
|
|
187
|
+
deprecated("--area", "--dir")
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
parser.on("--tag TAG", "only concepts carrying this tag") { |v| options[:tag] = v } if keys.include?(:tag)
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def filter_entries(entries, options)
|
|
194
|
+
entries.select do |entry|
|
|
195
|
+
(options[:type].nil? || fold(entry[:type]) == fold(options[:type])) &&
|
|
196
|
+
(options[:area].nil? || fold(entry[:area]) == fold_area(options[:area])) &&
|
|
197
|
+
(options[:dir].nil? || under_dir?(entry[:dir], options[:dir])) &&
|
|
198
|
+
(options[:tag].nil? || entry[:tags].any? { |tag| fold(tag) == fold(options[:tag]) })
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# The one rule --dir is built on: a dir names itself and everything beneath
|
|
203
|
+
# it. `--dir foo` reaches foo/bar, `--dir foo/bar` narrows, and `--dir .`
|
|
204
|
+
# needs no special case at all — nothing starts with "./", so the root
|
|
205
|
+
# selects only what lives directly in it.
|
|
206
|
+
def under_dir?(entry_dir, wanted)
|
|
207
|
+
entry = fold(entry_dir)
|
|
208
|
+
path = fold_dir(wanted)
|
|
209
|
+
entry == path || entry.start_with?("#{path}/")
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def fold(value)
|
|
213
|
+
value.to_s.downcase
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def fold_area(value)
|
|
217
|
+
folded = trim_slash(fold(value))
|
|
218
|
+
folded == "root" ? "(root)" : folded
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# `.` is the stored spelling of the root everywhere; `root` is the one a
|
|
222
|
+
# shell needs no quoting for, and the only reason the two exist.
|
|
223
|
+
def fold_dir(value)
|
|
224
|
+
folded = trim_slash(fold(value))
|
|
225
|
+
folded.empty? || folded == "root" ? "." : folded
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
# The human views print a directory with the slash that says it is one —
|
|
229
|
+
# `tables/`, `docs/api/` — so the flag has to accept the label the CLI
|
|
230
|
+
# itself just printed. Without this, pasting a row back into --dir matched
|
|
231
|
+
# nothing and exited 0: an empty answer that reads like a real one.
|
|
232
|
+
def trim_slash(value)
|
|
233
|
+
value.sub(%r{/+\z}, "")
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# The inverse of fold_dir: `.` is the stored spelling of the root and
|
|
237
|
+
# "(root)" the human one, and every grouped view keeps that split so a
|
|
238
|
+
# table and its --json never disagree about which spelling is the data.
|
|
239
|
+
# `slash:` adds the trailing slash the listing views use to say "directory"
|
|
240
|
+
# — the same one fold_dir now accepts back.
|
|
241
|
+
def dir_label(dir, slash: false)
|
|
242
|
+
return "(root)" if [ ".", "(root)" ].include?(dir)
|
|
243
|
+
|
|
244
|
+
slash ? "#{dir}/" : dir
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# How many path segments deep a directory sits. The root is 0.
|
|
248
|
+
def dir_depth(dir)
|
|
249
|
+
dir == "." ? 0 : dir.count("/") + 1
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
# --depth N: how many directory levels below the *starting point* to keep,
|
|
253
|
+
# where the starting point is each --dir when one is given and the bundle
|
|
254
|
+
# root otherwise. Relative rather than absolute on purpose: `--dir a/b
|
|
255
|
+
# --depth 1` reads "a/b and one level under it" without the caller first
|
|
256
|
+
# working out how deep a/b already is — and the two flags then compose the
|
|
257
|
+
# way a reader descending a tree actually moves.
|
|
258
|
+
def depth_flag(parser, options)
|
|
259
|
+
parser.on("--depth N", "keep only this many directory levels below the",
|
|
260
|
+
"starting point (--dir when given, else the bundle root)") { |v| options[:depth] = v }
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
# Checked here rather than with OptionParser's Integer coercion, which
|
|
264
|
+
# accepts "-1" and "0x2" and reports in its own words. Returns the exit
|
|
265
|
+
# status to hand back, or nil when the value is fine.
|
|
266
|
+
def depth_error(options)
|
|
267
|
+
raw = options[:depth]
|
|
268
|
+
return nil if raw.nil? || raw.to_s =~ /\A\d+\z/
|
|
269
|
+
|
|
270
|
+
usage_error("--depth takes a whole number of levels (got #{raw.inspect})")
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
# The chain from the bundle root down to each --dir, so a branch is never
|
|
274
|
+
# shown adrift. On by default in the *directory* views (`index`, `dirs`):
|
|
275
|
+
# the map's job there is orientation, and a subtree printed with nothing
|
|
276
|
+
# above it has dropped the authored context that says what it is — the root
|
|
277
|
+
# index.md's prose first among it. Off with --no-ancestors, which restores
|
|
278
|
+
# the subtree alone.
|
|
279
|
+
#
|
|
280
|
+
# Deliberately not offered on the concept filters (search/catalog/files/…):
|
|
281
|
+
# there --dir narrows *concepts*, and a concept in `a/` is simply not in
|
|
282
|
+
# `a/b`. Same flag, one meaning, because it is asked about two different
|
|
283
|
+
# kinds of row.
|
|
284
|
+
def ancestors_flag(parser, options)
|
|
285
|
+
parser.on("--[no-]ancestors", "with --dir, also show the chain up to the root",
|
|
286
|
+
"so the branch is placed (default: yes)") { |v| options[:ancestors] = v }
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
# Every proper ancestor of each --dir, root included. Empty unless --dir
|
|
290
|
+
# named something below the root: with no --dir the whole bundle is already
|
|
291
|
+
# the starting point, and `--dir .` has nothing above it.
|
|
292
|
+
#
|
|
293
|
+
# `known` is the map's own directory list, and a base outside it contributes
|
|
294
|
+
# no chain. Without that check `--dir typo` came back with the root — a
|
|
295
|
+
# chain to a place that does not exist, which reads as a partial answer to
|
|
296
|
+
# a query that in fact matched nothing.
|
|
297
|
+
#
|
|
298
|
+
# The deprecated --area gains no chain either: it is exact, and a deprecated
|
|
299
|
+
# flag that quietly answers with more than it used to is worse than one that
|
|
300
|
+
# is merely old.
|
|
301
|
+
# Matching folds case, but a row is found by its *stored* spelling, so the
|
|
302
|
+
# chain is walked folded and handed back in the map's own words. Returning
|
|
303
|
+
# the folded string instead dropped every ancestor a bundle spelled with a
|
|
304
|
+
# capital — the rows are selected with `include?`, which does not fold.
|
|
305
|
+
def ancestor_dirs(options, known)
|
|
306
|
+
return [] unless options[:ancestors]
|
|
307
|
+
|
|
308
|
+
stored = known.each_with_object({}) { |dir, out| out[fold(dir)] = dir }
|
|
309
|
+
Array(options[:dirs]).each_with_object([]) do |path, out|
|
|
310
|
+
base = fold_dir(path)
|
|
311
|
+
next unless stored.key?(base)
|
|
312
|
+
|
|
313
|
+
current = dir_parent(base)
|
|
314
|
+
while current
|
|
315
|
+
out << stored.fetch(current, current)
|
|
316
|
+
current = dir_parent(current)
|
|
317
|
+
end
|
|
318
|
+
end.uniq
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
# nil above the root, so the walk above terminates on it rather than on ".".
|
|
322
|
+
def dir_parent(dir)
|
|
323
|
+
return nil if dir == "."
|
|
324
|
+
|
|
325
|
+
slash = dir.rindex("/")
|
|
326
|
+
slash ? dir[0, slash] : "."
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
# The directories a --dir/--depth pair selects, out of the map's own
|
|
330
|
+
# ordered list. Neither flag given keeps everything — these narrow a view,
|
|
331
|
+
# they do not define one. The ancestor chain is unioned on top by the
|
|
332
|
+
# caller, which is also what tells a row apart from context.
|
|
333
|
+
def select_dirs(dirs, options)
|
|
334
|
+
bases = Array(options[:dirs]).map { |path| fold_dir(path) }
|
|
335
|
+
depth = options[:depth]&.to_i
|
|
336
|
+
return dirs if bases.empty? && depth.nil?
|
|
337
|
+
# No --dir means the whole bundle is the starting point, which is *not*
|
|
338
|
+
# `--dir .`: that one selects the root alone, by the same prefix rule
|
|
339
|
+
# everything else here uses.
|
|
340
|
+
return dirs.select { |dir| dir_depth(dir) <= depth } if bases.empty?
|
|
341
|
+
|
|
342
|
+
dirs.select do |dir|
|
|
343
|
+
bases.any? do |base|
|
|
344
|
+
# --depth bounds the *descent*; the chain above is the ascent, and the
|
|
345
|
+
# two are separate axes. That is what keeps `--depth 0` meaning "the
|
|
346
|
+
# named directory alone" even while its chain is printed with it.
|
|
347
|
+
under_dir?(dir, base) && (depth.nil? || dir_depth(dir) - dir_depth(base) <= depth)
|
|
348
|
+
end
|
|
349
|
+
end
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
# A deprecated spelling still does what it always did — never silently
|
|
353
|
+
# something else — and says so once per run, on stderr so a --json
|
|
354
|
+
# consumer's stdout stays a clean machine substrate.
|
|
355
|
+
def deprecated(what, instead)
|
|
356
|
+
@deprecated ||= {}
|
|
357
|
+
return if @deprecated[what]
|
|
358
|
+
|
|
359
|
+
@deprecated[what] = true
|
|
360
|
+
@err.puts "warning: #{what} is deprecated, use #{instead}"
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
# Turn an inverted index ({ value => [id, …] }) into display rows ordered by
|
|
364
|
+
# count, narrowed to the concepts the active filters select; rows the narrowing
|
|
365
|
+
# empties drop. With no filters the index passes through whole.
|
|
366
|
+
def index_rows(index, key, folder, options)
|
|
367
|
+
keep = filter_ids(folder, options)
|
|
368
|
+
index.each_with_object([]) do |(value, ids), rows|
|
|
369
|
+
ids = ids.select { |id| keep.include?(id) } unless keep.nil?
|
|
370
|
+
rows << { key => value, count: ids.length, concepts: ids } unless ids.empty?
|
|
371
|
+
end.sort_by { |row| [ -row[:count], row[key] ] }
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
# The ids the filters select, resolved through the catalog metadata — or nil
|
|
375
|
+
# when no filter is active, meaning keep everything.
|
|
376
|
+
def filter_ids(folder, options)
|
|
377
|
+
return nil if options[:type].nil? && options[:area].nil? && options[:dir].nil? && options[:tag].nil?
|
|
378
|
+
|
|
379
|
+
filter_entries(folder.catalog, options).map { |entry| entry[:id] }
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
# §9 best-effort: the graph is built from concepts that parse. Surface any that
|
|
383
|
+
# the reader could not parse (to stderr, so JSON on stdout stays clean) rather
|
|
384
|
+
# than dropping them silently.
|
|
385
|
+
def report_skipped(folder)
|
|
386
|
+
note_skipped(folder.bundle.unparseable.size)
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
# The bucket holds two kinds now — frontmatter that would not parse, and a
|
|
390
|
+
# file that would not open — so the note names neither and points at the verb
|
|
391
|
+
# that names both. "invalid frontmatter" was a guess the summary had no need
|
|
392
|
+
# to make: `validate` prints the file and the reason for every one of them.
|
|
393
|
+
def note_skipped(count)
|
|
394
|
+
return if count.nil? || count <= 0
|
|
395
|
+
|
|
396
|
+
@err.puts "note: skipped #{count} unusable file(s) (run `okf validate` for details)"
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
# Parse options, then require a single bundle positional — a directory, or an
|
|
400
|
+
# @ref into the registry. Returns the bundle's directory, or nil (after
|
|
401
|
+
# reporting) so the caller returns 2.
|
|
402
|
+
def positional_dir(parser, argv)
|
|
403
|
+
parser.parse!(argv)
|
|
404
|
+
dir = argv.shift
|
|
405
|
+
if dir.nil?
|
|
406
|
+
@err.puts parser.banner
|
|
407
|
+
return nil
|
|
408
|
+
end
|
|
409
|
+
# A second bundle is a question this verb cannot answer: only `search`
|
|
410
|
+
# merges across bundles and only `server` mounts several. Reading the
|
|
411
|
+
# first and dropping the rest would answer confidently about a bundle the
|
|
412
|
+
# user never asked about — the silent-wrong-answer shape, so: exit 2.
|
|
413
|
+
return nil unless no_extras?(argv)
|
|
414
|
+
|
|
415
|
+
resolve_ref(dir)
|
|
416
|
+
rescue OptionParser::ParseError => e
|
|
417
|
+
@err.puts e.message
|
|
418
|
+
nil
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
# Parse options, then take zero or more bundle positionals (the multi-bundle
|
|
422
|
+
# server) — directories or @refs. Returns the resolved array (possibly
|
|
423
|
+
# empty), or nil (after reporting) so the caller returns 2.
|
|
424
|
+
def positional_dirs(parser, argv)
|
|
425
|
+
parser.parse!(argv)
|
|
426
|
+
dirs = argv.map { |dir| resolve_ref(dir) }
|
|
427
|
+
dirs.include?(nil) ? nil : dirs
|
|
428
|
+
rescue OptionParser::ParseError => e
|
|
429
|
+
@err.puts e.message
|
|
430
|
+
nil
|
|
431
|
+
end
|
|
432
|
+
|
|
433
|
+
# "@slug" — or bare "@", the registry's default — names a registered bundle
|
|
434
|
+
# wherever a <dir> goes; anything else must be a directory on disk. A
|
|
435
|
+
# leading @ always means the registry (a directory literally named that way
|
|
436
|
+
# stays reachable as ./@name), and the registry loads only when a ref
|
|
437
|
+
# appears, so plain-dir invocations never pay for it. Returns the bundle's
|
|
438
|
+
# directory, or nil after reporting.
|
|
439
|
+
def resolve_ref(arg)
|
|
440
|
+
return resolve_registered(arg) if arg.start_with?("@")
|
|
441
|
+
|
|
442
|
+
unless File.directory?(arg)
|
|
443
|
+
@err.puts "error: #{arg} is not a directory or a registry ref " \
|
|
444
|
+
"(@slug names a registered bundle, @ the default; okf registry list)"
|
|
445
|
+
return nil
|
|
446
|
+
end
|
|
447
|
+
arg
|
|
448
|
+
end
|
|
449
|
+
|
|
450
|
+
# Load the registry, turning a malformed file into a reported usage error
|
|
451
|
+
# instead of an OKF::Error escaping through whatever verb took an @ref —
|
|
452
|
+
# only `server` and the `registry` verbs rescue one. Returns nil after
|
|
453
|
+
# reporting, so every caller returns 2.
|
|
454
|
+
def load_registry
|
|
455
|
+
require "okf/registry"
|
|
456
|
+
OKF::Registry.load
|
|
457
|
+
rescue OKF::Error => e
|
|
458
|
+
@err.puts "error: #{e.message}"
|
|
459
|
+
nil
|
|
460
|
+
end
|
|
461
|
+
|
|
462
|
+
# Resolve one @ref through the registry under $OKF_HOME (default ~/.okf).
|
|
463
|
+
# The slug part is normalized
|
|
464
|
+
# exactly as registration normalized it, so @One finds the bundle
|
|
465
|
+
# registered from dir One — but never through #slugify's mint-a-name
|
|
466
|
+
# placeholder, so "@***" is a bad ref rather than whatever is slugged
|
|
467
|
+
# "bundle". An explicit ask fails hard: an unknown slug or a
|
|
468
|
+
# registered-but-gone directory is a usage error naming the registry file
|
|
469
|
+
# and the next move, never a silent skip.
|
|
470
|
+
#
|
|
471
|
+
# @all never resolves here. `search` expands it before this point; every
|
|
472
|
+
# other verb takes exactly one bundle, so letting it through would mean
|
|
473
|
+
# @all lints when one bundle is registered and exits 2 when two are —
|
|
474
|
+
# behavior that varies with the size of the registry, which is the
|
|
475
|
+
# silent-wrong-answer shape the second-bundle rule exists to stop. Say what
|
|
476
|
+
# @all is instead of calling it a bundle nobody registered ("all" cannot be
|
|
477
|
+
# registered — Registry::RESERVED_SLUGS sees to that).
|
|
478
|
+
def resolve_registered(ref)
|
|
479
|
+
@ref_failure = :registry
|
|
480
|
+
if all_ref?(ref)
|
|
481
|
+
@err.puts "error: #{ALL_REF} is only supported by `okf search` (it names every registered bundle)"
|
|
482
|
+
return nil
|
|
483
|
+
end
|
|
484
|
+
registry = load_registry
|
|
485
|
+
return nil unless registry
|
|
486
|
+
|
|
487
|
+
asked = ref[1..-1]
|
|
488
|
+
slug = OKF::Registry.normalize(asked)
|
|
489
|
+
entry = if asked.empty?
|
|
490
|
+
registry.default # bare "@"
|
|
491
|
+
elsif slug.empty?
|
|
492
|
+
nil # "@***" — nothing to look up, and no placeholder to fall back on
|
|
493
|
+
else
|
|
494
|
+
registry.get(slug)
|
|
495
|
+
end
|
|
496
|
+
if entry.nil?
|
|
497
|
+
@ref_failure = :unknown
|
|
498
|
+
hint = registry.empty? ? "okf registry set <dir>" : "okf registry list"
|
|
499
|
+
@err.puts "error: not a registered bundle: #{ref} in #{registry.path} (#{hint})"
|
|
500
|
+
return nil
|
|
501
|
+
end
|
|
502
|
+
unless File.directory?(entry.path)
|
|
503
|
+
@ref_failure = :missing
|
|
504
|
+
@err.puts "error: #{ref} points to #{entry.path}, which is not a directory (okf registry del #{entry.slug}, or restore it)"
|
|
505
|
+
return nil
|
|
506
|
+
end
|
|
507
|
+
ref_slugs[entry.path] = entry.slug
|
|
508
|
+
entry.path
|
|
509
|
+
end
|
|
510
|
+
|
|
511
|
+
# Every bundle-scoped output names its bundle in the identity the caller
|
|
512
|
+
# used: `@handbook (/path)` when they named a registered bundle, the plain
|
|
513
|
+
# path otherwise. A dir named by path stays a path — inventing a slug for it
|
|
514
|
+
# would imply a registration that does not exist, and looking one up would
|
|
515
|
+
# cost a registry read on every plain-dir run.
|
|
516
|
+
def bundle_label(dir)
|
|
517
|
+
slug = ref_slugs[dir]
|
|
518
|
+
slug ? "@#{slug} (#{dir})" : dir.to_s
|
|
519
|
+
end
|
|
520
|
+
|
|
521
|
+
# The JSON head for one bundle. `bundle` is always its directory and `slug`
|
|
522
|
+
# always a registry slug — never the same key meaning two things — so a
|
|
523
|
+
# consumer resolves a row to a file without a second lookup.
|
|
524
|
+
def bundle_head(dir)
|
|
525
|
+
head = { "bundle" => dir }
|
|
526
|
+
slug = ref_slugs[dir]
|
|
527
|
+
head["slug"] = slug if slug
|
|
528
|
+
head
|
|
529
|
+
end
|
|
530
|
+
|
|
531
|
+
# Parse options, then require a single non-directory positional (e.g. a slug).
|
|
532
|
+
# Returns it, or nil (after reporting the banner) so the caller returns 2.
|
|
533
|
+
def positional(parser, argv)
|
|
534
|
+
parser.parse!(argv)
|
|
535
|
+
value = argv.shift
|
|
536
|
+
if value.nil?
|
|
537
|
+
@err.puts parser.banner
|
|
538
|
+
return nil
|
|
539
|
+
end
|
|
540
|
+
value
|
|
541
|
+
rescue OptionParser::ParseError => e
|
|
542
|
+
@err.puts e.message
|
|
543
|
+
nil
|
|
544
|
+
end
|
|
545
|
+
|
|
546
|
+
def print_index(dir, label, key, rows, titles)
|
|
547
|
+
@out.puts "#{label} — #{bundle_label(dir)} (#{rows.size} distinct)"
|
|
548
|
+
@out.puts
|
|
549
|
+
width = rows.map { |row| row[key].length }.max || 0
|
|
550
|
+
rows.each do |row|
|
|
551
|
+
names = row[:concepts].map { |id| titles[id] || id }.join(", ")
|
|
552
|
+
@out.puts " #{row[key].ljust(width)} #{row[:count].to_s.rjust(3)} #{truncate(names, 78)}"
|
|
553
|
+
end
|
|
554
|
+
end
|
|
555
|
+
|
|
556
|
+
def print_index_json(dir, plural, key, rows)
|
|
557
|
+
emit_json(bundle_head(dir).merge("count" => rows.size, plural => index_rows_json(key, rows)))
|
|
558
|
+
end
|
|
559
|
+
|
|
560
|
+
def index_rows_json(key, rows)
|
|
561
|
+
rows.map { |row| { key.to_s => row[key], "count" => row[:count], "concepts" => row[:concepts] } }
|
|
562
|
+
end
|
|
563
|
+
|
|
564
|
+
# "3 concepts", "1 concept", "1 of 7 concepts" — the noun agrees with the
|
|
565
|
+
# number it follows: the size when that is all we show, the total otherwise.
|
|
566
|
+
def counted(size, total, noun)
|
|
567
|
+
return "#{size} #{pluralize(size, noun)}" if size == total
|
|
568
|
+
|
|
569
|
+
"#{size} of #{total} #{pluralize(total, noun)}"
|
|
570
|
+
end
|
|
571
|
+
|
|
572
|
+
# The gem's whole vocabulary is regular, so a naive +s is not a shortcut —
|
|
573
|
+
# it is the rule. Callers pass the singular.
|
|
574
|
+
def pluralize(count, noun)
|
|
575
|
+
count == 1 ? noun : "#{noun}s"
|
|
576
|
+
end
|
|
577
|
+
|
|
578
|
+
# The single JSON writer. Compact by default — the token-efficient substrate an
|
|
579
|
+
# agent consumes; --pretty indents it for a human. JSON semantics are identical
|
|
580
|
+
# either way, so a parser never cares which was emitted.
|
|
581
|
+
def emit_json(payload)
|
|
582
|
+
@out.puts(@pretty ? JSON.pretty_generate(payload) : JSON.generate(payload))
|
|
583
|
+
end
|
|
584
|
+
|
|
585
|
+
# Emit a list view's JSON envelope with --fields/--except projection applied to
|
|
586
|
+
# each item. Returns the verb's exit code (0, or 2 on a bad projection request —
|
|
587
|
+
# both flags at once, or a field name no item carries).
|
|
588
|
+
# +dir+ is the bundle's directory — or a ready-made head Hash when the
|
|
589
|
+
# payload spans bundles (multi-bundle search's "bundles" key).
|
|
590
|
+
# +key+ names the JSON property the rows land under; +shape+ names the row
|
|
591
|
+
# shape to check --fields/--except against. They are the same for every view
|
|
592
|
+
# but search, whose two modes emit the same property from different rows.
|
|
593
|
+
def emit_list_json(dir, key, items, options, extra = {}, shape = key)
|
|
594
|
+
return usage_error("--fields and --except are mutually exclusive") if options[:fields] && options[:except]
|
|
595
|
+
|
|
596
|
+
unknown = unknown_fields(items, options, shape)
|
|
597
|
+
return usage_error("unknown field(s): #{unknown.join(", ")} (available: #{available_fields(items, shape).join(", ")})") unless unknown.empty?
|
|
598
|
+
|
|
599
|
+
payload = (dir.is_a?(Hash) ? dir.dup : bundle_head(dir)).merge(extra)
|
|
600
|
+
payload["count"] = items.size
|
|
601
|
+
payload[key] = project(items, options)
|
|
602
|
+
emit_json(payload)
|
|
603
|
+
0
|
|
604
|
+
end
|
|
605
|
+
|
|
606
|
+
# Keep only --fields (allowlist) or drop --except (denylist) from each item's
|
|
607
|
+
# top-level properties; unset flags pass the items through whole.
|
|
608
|
+
def project(items, options)
|
|
609
|
+
return items if options[:fields].nil? && options[:except].nil?
|
|
610
|
+
|
|
611
|
+
fields = options[:fields]&.map(&:downcase)
|
|
612
|
+
except = options[:except]&.map(&:downcase)
|
|
613
|
+
items.map do |item|
|
|
614
|
+
fields ? item.select { |k, _| fields.include?(k.to_s.downcase) } : item.reject { |k, _| except.include?(k.to_s.downcase) }
|
|
615
|
+
end
|
|
616
|
+
end
|
|
617
|
+
|
|
618
|
+
# The declared shape wins over the data's, so the same typo gets the same
|
|
619
|
+
# answer whether or not the result happened to have rows; a view with no
|
|
620
|
+
# declared shape falls back to what it actually emitted.
|
|
621
|
+
def available_fields(items, key = nil)
|
|
622
|
+
ROW_FIELDS[key] || (items.first ? items.first.keys.map(&:to_s) : [])
|
|
623
|
+
end
|
|
624
|
+
|
|
625
|
+
# Requested field names that no item actually carries — a typo guard (exit 2),
|
|
626
|
+
# matching how lint rejects unknown check names.
|
|
627
|
+
def unknown_fields(items, options, key = nil)
|
|
628
|
+
requested = (Array(options[:fields]) + Array(options[:except])).map(&:downcase)
|
|
629
|
+
return [] if requested.empty?
|
|
630
|
+
|
|
631
|
+
known = available_fields(items, key).map(&:downcase)
|
|
632
|
+
return [] if known.empty? # an unknown view: no shape to check against, so accept
|
|
633
|
+
|
|
634
|
+
requested.reject { |field| known.include?(field) }.uniq
|
|
635
|
+
end
|
|
636
|
+
|
|
637
|
+
def usage_error(message)
|
|
638
|
+
@err.puts "error: #{message}"
|
|
639
|
+
2
|
|
640
|
+
end
|
|
641
|
+
|
|
642
|
+
def stringify(hash)
|
|
643
|
+
hash.map { |key, value| [ key.to_s, value ] }.to_h
|
|
644
|
+
end
|
|
645
|
+
|
|
646
|
+
def truncate(str, max)
|
|
647
|
+
str.length > max ? "#{str[0, max - 1]}…" : str
|
|
648
|
+
end
|
|
649
|
+
|
|
650
|
+
def paint(text, code)
|
|
651
|
+
return text unless @out.respond_to?(:tty?) && @out.tty?
|
|
652
|
+
|
|
653
|
+
"\e[#{code}m#{text}\e[0m"
|
|
654
|
+
end
|
|
655
|
+
end
|
|
656
|
+
end
|
|
657
|
+
end
|