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
|
@@ -0,0 +1,495 @@
|
|
|
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/--area/--tag filters the
|
|
122
|
+
# server UI offers, so browser and CLI can answer the same questions.
|
|
123
|
+
#
|
|
124
|
+
# ── their shared --type/--area/--tag narrowing ──
|
|
125
|
+
# Each view takes the filters orthogonal to it (tags can't filter by tag).
|
|
126
|
+
# Matching is case-insensitive and exact; a concept at the bundle root lives in
|
|
127
|
+
# the "(root)" area, which --area also accepts as plain `root` (no shell quoting).
|
|
128
|
+
|
|
129
|
+
# The shared back half of `tags` and `types`: load, narrow, print.
|
|
130
|
+
def print_inverted_index(dir, label, key, plural, options)
|
|
131
|
+
folder = OKF::Bundle::Folder.load(dir)
|
|
132
|
+
report_skipped(folder)
|
|
133
|
+
graph = folder.graph(minimal: true)
|
|
134
|
+
index = key == :tag ? graph.tag_index : graph.type_index
|
|
135
|
+
rows = index_rows(index, key, folder, options)
|
|
136
|
+
if options[:json]
|
|
137
|
+
print_index_json(dir, plural, key, rows)
|
|
138
|
+
else
|
|
139
|
+
titles = graph.nodes.map { |node| [ node[:id], node[:title] ] }.to_h
|
|
140
|
+
print_index(dir, label, key, rows, titles)
|
|
141
|
+
end
|
|
142
|
+
0
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# The --json / --pretty pair every emitting verb shares. --json is the compact
|
|
146
|
+
# machine substrate (the default JSON form, aligned with the server); --pretty
|
|
147
|
+
# indents it for a human and implies --json. Both route through emit_json.
|
|
148
|
+
def json_flags(parser, options, desc)
|
|
149
|
+
parser.on("--json", desc) { options[:json] = true }
|
|
150
|
+
parser.on("--pretty", "indent the JSON for reading (implies --json)") { options[:json] = true; @pretty = true }
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Every parser answers its own -h/--help, so no parser inherits
|
|
154
|
+
# OptionParser's officious one: that prints to the process's $stdout rather
|
|
155
|
+
# than @out (an embedding app that injects streams never sees it) and ends
|
|
156
|
+
# the process with `exit` rather than returning a status (a test that asks a
|
|
157
|
+
# command for help takes the whole runner down with it). Thrown, not
|
|
158
|
+
# returned — #run catches it — because a parser is parsed inside
|
|
159
|
+
# positional_dir, where every other early exit means "exit 2".
|
|
160
|
+
# on_tail, so help sorts last in the list it is printing.
|
|
161
|
+
def help_flag(parser)
|
|
162
|
+
parser.on_tail("-h", "--help", "print this message") do
|
|
163
|
+
@out.puts parser.help
|
|
164
|
+
throw :help, 0
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# --fields/--except project the JSON down to the properties an agent wants, so it
|
|
169
|
+
# never pays tokens for fields it will not read. --fields is an allowlist,
|
|
170
|
+
# --except a denylist (mutually exclusive); both imply --json and apply per item
|
|
171
|
+
# in a list view (catalog, files, index). Names are the JSON keys, matched
|
|
172
|
+
# case-insensitively.
|
|
173
|
+
def projection_flags(parser, options)
|
|
174
|
+
parser.on("--fields LIST", Array, "emit only these JSON properties (comma-separated)") { |v| options[:json] = true; options[:fields] = v }
|
|
175
|
+
parser.on("--except LIST", Array, "emit every JSON property but these") { |v| options[:json] = true; options[:except] = v }
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def filter_flags(parser, options, *keys)
|
|
179
|
+
parser.on("--type TYPE", "only concepts of this type") { |v| options[:type] = v } if keys.include?(:type)
|
|
180
|
+
parser.on("--area AREA", "only concepts in this top-level area") { |v| options[:area] = v } if keys.include?(:area)
|
|
181
|
+
parser.on("--tag TAG", "only concepts carrying this tag") { |v| options[:tag] = v } if keys.include?(:tag)
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def filter_entries(entries, options)
|
|
185
|
+
entries.select do |entry|
|
|
186
|
+
(options[:type].nil? || fold(entry[:type]) == fold(options[:type])) &&
|
|
187
|
+
(options[:area].nil? || fold(entry[:area]) == fold_area(options[:area])) &&
|
|
188
|
+
(options[:tag].nil? || entry[:tags].any? { |tag| fold(tag) == fold(options[:tag]) })
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def fold(value)
|
|
193
|
+
value.to_s.downcase
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def fold_area(value)
|
|
197
|
+
folded = fold(value)
|
|
198
|
+
folded == "root" ? "(root)" : folded
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# Turn an inverted index ({ value => [id, …] }) into display rows ordered by
|
|
202
|
+
# count, narrowed to the concepts the active filters select; rows the narrowing
|
|
203
|
+
# empties drop. With no filters the index passes through whole.
|
|
204
|
+
def index_rows(index, key, folder, options)
|
|
205
|
+
keep = filter_ids(folder, options)
|
|
206
|
+
index.each_with_object([]) do |(value, ids), rows|
|
|
207
|
+
ids = ids.select { |id| keep.include?(id) } unless keep.nil?
|
|
208
|
+
rows << { key => value, count: ids.length, concepts: ids } unless ids.empty?
|
|
209
|
+
end.sort_by { |row| [ -row[:count], row[key] ] }
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# The ids the filters select, resolved through the catalog metadata — or nil
|
|
213
|
+
# when no filter is active, meaning keep everything.
|
|
214
|
+
def filter_ids(folder, options)
|
|
215
|
+
return nil if options[:type].nil? && options[:area].nil? && options[:tag].nil?
|
|
216
|
+
|
|
217
|
+
filter_entries(folder.catalog, options).map { |entry| entry[:id] }
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# §9 best-effort: the graph is built from concepts that parse. Surface any that
|
|
221
|
+
# the reader could not parse (to stderr, so JSON on stdout stays clean) rather
|
|
222
|
+
# than dropping them silently.
|
|
223
|
+
def report_skipped(folder)
|
|
224
|
+
note_skipped(folder.bundle.unparseable.size)
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# The bucket holds two kinds now — frontmatter that would not parse, and a
|
|
228
|
+
# file that would not open — so the note names neither and points at the verb
|
|
229
|
+
# that names both. "invalid frontmatter" was a guess the summary had no need
|
|
230
|
+
# to make: `validate` prints the file and the reason for every one of them.
|
|
231
|
+
def note_skipped(count)
|
|
232
|
+
return if count.nil? || count <= 0
|
|
233
|
+
|
|
234
|
+
@err.puts "note: skipped #{count} unusable file(s) (run `okf validate` for details)"
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# Parse options, then require a single bundle positional — a directory, or an
|
|
238
|
+
# @ref into the registry. Returns the bundle's directory, or nil (after
|
|
239
|
+
# reporting) so the caller returns 2.
|
|
240
|
+
def positional_dir(parser, argv)
|
|
241
|
+
parser.parse!(argv)
|
|
242
|
+
dir = argv.shift
|
|
243
|
+
if dir.nil?
|
|
244
|
+
@err.puts parser.banner
|
|
245
|
+
return nil
|
|
246
|
+
end
|
|
247
|
+
# A second bundle is a question this verb cannot answer: only `search`
|
|
248
|
+
# merges across bundles and only `server` mounts several. Reading the
|
|
249
|
+
# first and dropping the rest would answer confidently about a bundle the
|
|
250
|
+
# user never asked about — the silent-wrong-answer shape, so: exit 2.
|
|
251
|
+
return nil unless no_extras?(argv)
|
|
252
|
+
|
|
253
|
+
resolve_ref(dir)
|
|
254
|
+
rescue OptionParser::ParseError => e
|
|
255
|
+
@err.puts e.message
|
|
256
|
+
nil
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
# Parse options, then take zero or more bundle positionals (the multi-bundle
|
|
260
|
+
# server) — directories or @refs. Returns the resolved array (possibly
|
|
261
|
+
# empty), or nil (after reporting) so the caller returns 2.
|
|
262
|
+
def positional_dirs(parser, argv)
|
|
263
|
+
parser.parse!(argv)
|
|
264
|
+
dirs = argv.map { |dir| resolve_ref(dir) }
|
|
265
|
+
dirs.include?(nil) ? nil : dirs
|
|
266
|
+
rescue OptionParser::ParseError => e
|
|
267
|
+
@err.puts e.message
|
|
268
|
+
nil
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
# "@slug" — or bare "@", the registry's default — names a registered bundle
|
|
272
|
+
# wherever a <dir> goes; anything else must be a directory on disk. A
|
|
273
|
+
# leading @ always means the registry (a directory literally named that way
|
|
274
|
+
# stays reachable as ./@name), and the registry loads only when a ref
|
|
275
|
+
# appears, so plain-dir invocations never pay for it. Returns the bundle's
|
|
276
|
+
# directory, or nil after reporting.
|
|
277
|
+
def resolve_ref(arg)
|
|
278
|
+
return resolve_registered(arg) if arg.start_with?("@")
|
|
279
|
+
|
|
280
|
+
unless File.directory?(arg)
|
|
281
|
+
@err.puts "error: #{arg} is not a directory or a registry ref " \
|
|
282
|
+
"(@slug names a registered bundle, @ the default; okf registry list)"
|
|
283
|
+
return nil
|
|
284
|
+
end
|
|
285
|
+
arg
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
# Load the registry, turning a malformed file into a reported usage error
|
|
289
|
+
# instead of an OKF::Error escaping through whatever verb took an @ref —
|
|
290
|
+
# only `server` and the `registry` verbs rescue one. Returns nil after
|
|
291
|
+
# reporting, so every caller returns 2.
|
|
292
|
+
def load_registry
|
|
293
|
+
require "okf/registry"
|
|
294
|
+
OKF::Registry.load
|
|
295
|
+
rescue OKF::Error => e
|
|
296
|
+
@err.puts "error: #{e.message}"
|
|
297
|
+
nil
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
# Resolve one @ref through the registry under $OKF_HOME (default ~/.okf).
|
|
301
|
+
# The slug part is normalized
|
|
302
|
+
# exactly as registration normalized it, so @One finds the bundle
|
|
303
|
+
# registered from dir One — but never through #slugify's mint-a-name
|
|
304
|
+
# placeholder, so "@***" is a bad ref rather than whatever is slugged
|
|
305
|
+
# "bundle". An explicit ask fails hard: an unknown slug or a
|
|
306
|
+
# registered-but-gone directory is a usage error naming the registry file
|
|
307
|
+
# and the next move, never a silent skip.
|
|
308
|
+
#
|
|
309
|
+
# @all never resolves here. `search` expands it before this point; every
|
|
310
|
+
# other verb takes exactly one bundle, so letting it through would mean
|
|
311
|
+
# @all lints when one bundle is registered and exits 2 when two are —
|
|
312
|
+
# behavior that varies with the size of the registry, which is the
|
|
313
|
+
# silent-wrong-answer shape the second-bundle rule exists to stop. Say what
|
|
314
|
+
# @all is instead of calling it a bundle nobody registered ("all" cannot be
|
|
315
|
+
# registered — Registry::RESERVED_SLUGS sees to that).
|
|
316
|
+
def resolve_registered(ref)
|
|
317
|
+
@ref_failure = :registry
|
|
318
|
+
if all_ref?(ref)
|
|
319
|
+
@err.puts "error: #{ALL_REF} is only supported by `okf search` (it names every registered bundle)"
|
|
320
|
+
return nil
|
|
321
|
+
end
|
|
322
|
+
registry = load_registry
|
|
323
|
+
return nil unless registry
|
|
324
|
+
|
|
325
|
+
asked = ref[1..-1]
|
|
326
|
+
slug = OKF::Registry.normalize(asked)
|
|
327
|
+
entry = if asked.empty?
|
|
328
|
+
registry.default # bare "@"
|
|
329
|
+
elsif slug.empty?
|
|
330
|
+
nil # "@***" — nothing to look up, and no placeholder to fall back on
|
|
331
|
+
else
|
|
332
|
+
registry.get(slug)
|
|
333
|
+
end
|
|
334
|
+
if entry.nil?
|
|
335
|
+
@ref_failure = :unknown
|
|
336
|
+
hint = registry.empty? ? "okf registry set <dir>" : "okf registry list"
|
|
337
|
+
@err.puts "error: not a registered bundle: #{ref} in #{registry.path} (#{hint})"
|
|
338
|
+
return nil
|
|
339
|
+
end
|
|
340
|
+
unless File.directory?(entry.path)
|
|
341
|
+
@ref_failure = :missing
|
|
342
|
+
@err.puts "error: #{ref} points to #{entry.path}, which is not a directory (okf registry del #{entry.slug}, or restore it)"
|
|
343
|
+
return nil
|
|
344
|
+
end
|
|
345
|
+
ref_slugs[entry.path] = entry.slug
|
|
346
|
+
entry.path
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
# Every bundle-scoped output names its bundle in the identity the caller
|
|
350
|
+
# used: `@handbook (/path)` when they named a registered bundle, the plain
|
|
351
|
+
# path otherwise. A dir named by path stays a path — inventing a slug for it
|
|
352
|
+
# would imply a registration that does not exist, and looking one up would
|
|
353
|
+
# cost a registry read on every plain-dir run.
|
|
354
|
+
def bundle_label(dir)
|
|
355
|
+
slug = ref_slugs[dir]
|
|
356
|
+
slug ? "@#{slug} (#{dir})" : dir.to_s
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
# The JSON head for one bundle. `bundle` is always its directory and `slug`
|
|
360
|
+
# always a registry slug — never the same key meaning two things — so a
|
|
361
|
+
# consumer resolves a row to a file without a second lookup.
|
|
362
|
+
def bundle_head(dir)
|
|
363
|
+
head = { "bundle" => dir }
|
|
364
|
+
slug = ref_slugs[dir]
|
|
365
|
+
head["slug"] = slug if slug
|
|
366
|
+
head
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
# Parse options, then require a single non-directory positional (e.g. a slug).
|
|
370
|
+
# Returns it, or nil (after reporting the banner) so the caller returns 2.
|
|
371
|
+
def positional(parser, argv)
|
|
372
|
+
parser.parse!(argv)
|
|
373
|
+
value = argv.shift
|
|
374
|
+
if value.nil?
|
|
375
|
+
@err.puts parser.banner
|
|
376
|
+
return nil
|
|
377
|
+
end
|
|
378
|
+
value
|
|
379
|
+
rescue OptionParser::ParseError => e
|
|
380
|
+
@err.puts e.message
|
|
381
|
+
nil
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
def print_index(dir, label, key, rows, titles)
|
|
385
|
+
@out.puts "#{label} — #{bundle_label(dir)} (#{rows.size} distinct)"
|
|
386
|
+
@out.puts
|
|
387
|
+
width = rows.map { |row| row[key].length }.max || 0
|
|
388
|
+
rows.each do |row|
|
|
389
|
+
names = row[:concepts].map { |id| titles[id] || id }.join(", ")
|
|
390
|
+
@out.puts " #{row[key].ljust(width)} #{row[:count].to_s.rjust(3)} #{truncate(names, 78)}"
|
|
391
|
+
end
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
def print_index_json(dir, plural, key, rows)
|
|
395
|
+
emit_json(bundle_head(dir).merge("count" => rows.size, plural => index_rows_json(key, rows)))
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
def index_rows_json(key, rows)
|
|
399
|
+
rows.map { |row| { key.to_s => row[key], "count" => row[:count], "concepts" => row[:concepts] } }
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
# "3 concepts", "1 concept", "1 of 7 concepts" — the noun agrees with the
|
|
403
|
+
# number it follows: the size when that is all we show, the total otherwise.
|
|
404
|
+
def counted(size, total, noun)
|
|
405
|
+
return "#{size} #{pluralize(size, noun)}" if size == total
|
|
406
|
+
|
|
407
|
+
"#{size} of #{total} #{pluralize(total, noun)}"
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
# The gem's whole vocabulary is regular, so a naive +s is not a shortcut —
|
|
411
|
+
# it is the rule. Callers pass the singular.
|
|
412
|
+
def pluralize(count, noun)
|
|
413
|
+
count == 1 ? noun : "#{noun}s"
|
|
414
|
+
end
|
|
415
|
+
|
|
416
|
+
# The single JSON writer. Compact by default — the token-efficient substrate an
|
|
417
|
+
# agent consumes; --pretty indents it for a human. JSON semantics are identical
|
|
418
|
+
# either way, so a parser never cares which was emitted.
|
|
419
|
+
def emit_json(payload)
|
|
420
|
+
@out.puts(@pretty ? JSON.pretty_generate(payload) : JSON.generate(payload))
|
|
421
|
+
end
|
|
422
|
+
|
|
423
|
+
# Emit a list view's JSON envelope with --fields/--except projection applied to
|
|
424
|
+
# each item. Returns the verb's exit code (0, or 2 on a bad projection request —
|
|
425
|
+
# both flags at once, or a field name no item carries).
|
|
426
|
+
# +dir+ is the bundle's directory — or a ready-made head Hash when the
|
|
427
|
+
# payload spans bundles (multi-bundle search's "bundles" key).
|
|
428
|
+
# +key+ names the JSON property the rows land under; +shape+ names the row
|
|
429
|
+
# shape to check --fields/--except against. They are the same for every view
|
|
430
|
+
# but search, whose two modes emit the same property from different rows.
|
|
431
|
+
def emit_list_json(dir, key, items, options, extra = {}, shape = key)
|
|
432
|
+
return usage_error("--fields and --except are mutually exclusive") if options[:fields] && options[:except]
|
|
433
|
+
|
|
434
|
+
unknown = unknown_fields(items, options, shape)
|
|
435
|
+
return usage_error("unknown field(s): #{unknown.join(", ")} (available: #{available_fields(items, shape).join(", ")})") unless unknown.empty?
|
|
436
|
+
|
|
437
|
+
payload = (dir.is_a?(Hash) ? dir.dup : bundle_head(dir)).merge(extra)
|
|
438
|
+
payload["count"] = items.size
|
|
439
|
+
payload[key] = project(items, options)
|
|
440
|
+
emit_json(payload)
|
|
441
|
+
0
|
|
442
|
+
end
|
|
443
|
+
|
|
444
|
+
# Keep only --fields (allowlist) or drop --except (denylist) from each item's
|
|
445
|
+
# top-level properties; unset flags pass the items through whole.
|
|
446
|
+
def project(items, options)
|
|
447
|
+
return items if options[:fields].nil? && options[:except].nil?
|
|
448
|
+
|
|
449
|
+
fields = options[:fields]&.map(&:downcase)
|
|
450
|
+
except = options[:except]&.map(&:downcase)
|
|
451
|
+
items.map do |item|
|
|
452
|
+
fields ? item.select { |k, _| fields.include?(k.to_s.downcase) } : item.reject { |k, _| except.include?(k.to_s.downcase) }
|
|
453
|
+
end
|
|
454
|
+
end
|
|
455
|
+
|
|
456
|
+
# The declared shape wins over the data's, so the same typo gets the same
|
|
457
|
+
# answer whether or not the result happened to have rows; a view with no
|
|
458
|
+
# declared shape falls back to what it actually emitted.
|
|
459
|
+
def available_fields(items, key = nil)
|
|
460
|
+
ROW_FIELDS[key] || (items.first ? items.first.keys.map(&:to_s) : [])
|
|
461
|
+
end
|
|
462
|
+
|
|
463
|
+
# Requested field names that no item actually carries — a typo guard (exit 2),
|
|
464
|
+
# matching how lint rejects unknown check names.
|
|
465
|
+
def unknown_fields(items, options, key = nil)
|
|
466
|
+
requested = (Array(options[:fields]) + Array(options[:except])).map(&:downcase)
|
|
467
|
+
return [] if requested.empty?
|
|
468
|
+
|
|
469
|
+
known = available_fields(items, key).map(&:downcase)
|
|
470
|
+
return [] if known.empty? # an unknown view: no shape to check against, so accept
|
|
471
|
+
|
|
472
|
+
requested.reject { |field| known.include?(field) }.uniq
|
|
473
|
+
end
|
|
474
|
+
|
|
475
|
+
def usage_error(message)
|
|
476
|
+
@err.puts "error: #{message}"
|
|
477
|
+
2
|
|
478
|
+
end
|
|
479
|
+
|
|
480
|
+
def stringify(hash)
|
|
481
|
+
hash.map { |key, value| [ key.to_s, value ] }.to_h
|
|
482
|
+
end
|
|
483
|
+
|
|
484
|
+
def truncate(str, max)
|
|
485
|
+
str.length > max ? "#{str[0, max - 1]}…" : str
|
|
486
|
+
end
|
|
487
|
+
|
|
488
|
+
def paint(text, code)
|
|
489
|
+
return text unless @out.respond_to?(:tty?) && @out.tty?
|
|
490
|
+
|
|
491
|
+
"\e[#{code}m#{text}\e[0m"
|
|
492
|
+
end
|
|
493
|
+
end
|
|
494
|
+
end
|
|
495
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OKF
|
|
4
|
+
class CLI
|
|
5
|
+
# Every file with its title, grouped by folder — the view for "what is on disk"
|
|
6
|
+
# rather than "what is modelled".
|
|
7
|
+
class Files < Command
|
|
8
|
+
def self.id
|
|
9
|
+
:files
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def self.group
|
|
13
|
+
:read
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def self.help_rows
|
|
17
|
+
[
|
|
18
|
+
[ "files <dir|@slug> [--json] [filters]", "list files with titles, by folder" ]
|
|
19
|
+
]
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def call(argv)
|
|
23
|
+
options = { json: false }
|
|
24
|
+
parser = OptionParser.new do |o|
|
|
25
|
+
o.banner = "Usage: okf files <dir|@slug> [--type T] [--area A] [--tag T] [--json]"
|
|
26
|
+
json_flags(o, options, "emit the file tree as JSON")
|
|
27
|
+
projection_flags(o, options)
|
|
28
|
+
filter_flags(o, options, :type, :area, :tag)
|
|
29
|
+
help_flag(o)
|
|
30
|
+
end
|
|
31
|
+
dir = positional_dir(parser, argv) or return 2
|
|
32
|
+
|
|
33
|
+
folder = OKF::Bundle::Folder.load(dir)
|
|
34
|
+
report_skipped(folder)
|
|
35
|
+
entries = folder.catalog
|
|
36
|
+
selected = filter_entries(entries, options)
|
|
37
|
+
return print_files_json(dir, selected, options) if options[:json]
|
|
38
|
+
|
|
39
|
+
print_files(dir, selected, entries.size)
|
|
40
|
+
0
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def print_files(dir, entries, total)
|
|
46
|
+
@out.puts "Files — #{bundle_label(dir)} (#{counted(entries.size, total, "file")})"
|
|
47
|
+
entries.group_by { |entry| entry[:dir] }.sort_by(&:first).each do |folder, group|
|
|
48
|
+
width = group.map { |entry| File.basename("#{entry[:id]}.md").length }.max
|
|
49
|
+
@out.puts
|
|
50
|
+
@out.puts " #{folder == "." ? "(root)" : "#{folder}/"}"
|
|
51
|
+
group.each do |entry|
|
|
52
|
+
@out.puts " #{File.basename("#{entry[:id]}.md").ljust(width)} #{entry[:title]}"
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def print_files_json(dir, entries, options)
|
|
58
|
+
files = entries.map do |entry|
|
|
59
|
+
{ "path" => "#{entry[:id]}.md", "id" => entry[:id], "dir" => entry[:dir], "type" => entry[:type], "title" => entry[:title],
|
|
60
|
+
"description" => entry[:description] }
|
|
61
|
+
end
|
|
62
|
+
emit_list_json(dir, "files", files, options)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
register(Files)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OKF
|
|
4
|
+
class CLI
|
|
5
|
+
# The knowledge graph as text — nodes, edges and the rollups the browser page
|
|
6
|
+
# draws, for a reader that has no browser.
|
|
7
|
+
class Graph < Command
|
|
8
|
+
def self.id
|
|
9
|
+
:graph
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def self.group
|
|
13
|
+
:graph
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def self.help_rows
|
|
17
|
+
[
|
|
18
|
+
[ "graph <dir|@slug> [--json] [--minimal] [--hubs]", "print the knowledge graph" ]
|
|
19
|
+
]
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def call(argv)
|
|
23
|
+
options = { json: false, minimal: false, body: true, hubs: false }
|
|
24
|
+
parser = OptionParser.new do |o|
|
|
25
|
+
o.banner = "Usage: okf graph <dir|@slug> [--json] [--minimal] [--no-body] [--hubs]"
|
|
26
|
+
json_flags(o, options, "emit nodes and edges as JSON")
|
|
27
|
+
o.on("--minimal", "leanest nodes (id + title); adds type/tag indexes") { options[:minimal] = true }
|
|
28
|
+
o.on("--[no-]body", "include each concept's body (default: yes)") { |v| options[:body] = v }
|
|
29
|
+
o.on("--hubs", "rank concepts by inbound links, with the source areas") { options[:hubs] = true }
|
|
30
|
+
help_flag(o)
|
|
31
|
+
end
|
|
32
|
+
dir = positional_dir(parser, argv) or return 2
|
|
33
|
+
|
|
34
|
+
return print_hubs(dir, options) if options[:hubs]
|
|
35
|
+
|
|
36
|
+
folder = OKF::Bundle::Folder.load(dir)
|
|
37
|
+
graph = folder.graph(minimal: options[:minimal], body: options[:body])
|
|
38
|
+
report_skipped(folder)
|
|
39
|
+
if options[:json]
|
|
40
|
+
# The head every view carries: a payload of nodes and edges that never
|
|
41
|
+
# says which bundle they came from is exactly what an agent holding
|
|
42
|
+
# several bundles has to guess at.
|
|
43
|
+
payload = bundle_head(dir).merge(graph.to_h)
|
|
44
|
+
payload = payload.merge(types: graph.type_index, tags: graph.tag_index) if options[:minimal]
|
|
45
|
+
emit_json(payload)
|
|
46
|
+
else
|
|
47
|
+
@out.puts "Graph — #{bundle_label(dir)} (#{graph.nodes.size} #{pluralize(graph.nodes.size, "concept")}, " \
|
|
48
|
+
"#{graph.edges.size} #{pluralize(graph.edges.size, "link")})"
|
|
49
|
+
end
|
|
50
|
+
0
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
# `graph --hubs`: the inbound ranking with each hub's links grouped by
|
|
56
|
+
# source area — the "is this hub well-homed?" evidence. A hub whose
|
|
57
|
+
# inbound majority comes from outside its own area is a move candidate;
|
|
58
|
+
# --minimal/--no-body shape node payloads and change nothing here.
|
|
59
|
+
def print_hubs(dir, options)
|
|
60
|
+
folder = OKF::Bundle::Folder.load(dir)
|
|
61
|
+
hubs = folder.hubs
|
|
62
|
+
report_skipped(folder)
|
|
63
|
+
if options[:json]
|
|
64
|
+
rows = hubs.map { |row| { "id" => row[:id], "area" => row[:area], "inbound" => row[:inbound], "by_area" => row[:by_area] } }
|
|
65
|
+
emit_json(bundle_head(dir).merge("count" => hubs.size, "hubs" => rows))
|
|
66
|
+
else
|
|
67
|
+
@out.puts "Hubs — #{bundle_label(dir)} (#{counted(hubs.size, folder.concepts.size, "concept")} with inbound links)"
|
|
68
|
+
@out.puts
|
|
69
|
+
width = hubs.map { |row| row[:id].length }.max || 0
|
|
70
|
+
dwidth = hubs.map { |row| row[:inbound].to_s.length }.max || 0
|
|
71
|
+
hubs.each do |row|
|
|
72
|
+
sources = row[:by_area].map { |area, count| "#{area} #{count}" }.join(", ")
|
|
73
|
+
@out.puts " #{row[:id].ljust(width)} ×#{row[:inbound].to_s.rjust(dwidth)} #{sources}"
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
0
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
register(Graph)
|
|
81
|
+
end
|
|
82
|
+
end
|