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.
@@ -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