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.
Files changed (42) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +696 -133
  3. data/README.md +250 -334
  4. data/lib/okf/bundle/folder.rb +24 -5
  5. data/lib/okf/bundle/linter.rb +1 -1
  6. data/lib/okf/bundle/search/index.rb +13 -3
  7. data/lib/okf/bundle/search.rb +91 -11
  8. data/lib/okf/bundle.rb +26 -2
  9. data/lib/okf/cli/catalog.rb +66 -0
  10. data/lib/okf/cli/command.rb +657 -0
  11. data/lib/okf/cli/dirs.rb +118 -0
  12. data/lib/okf/cli/files.rb +68 -0
  13. data/lib/okf/cli/graph.rb +82 -0
  14. data/lib/okf/cli/index.rb +169 -0
  15. data/lib/okf/cli/lint.rb +139 -0
  16. data/lib/okf/cli/loose.rb +78 -0
  17. data/lib/okf/cli/registry.rb +229 -0
  18. data/lib/okf/cli/render.rb +66 -0
  19. data/lib/okf/cli/search.rb +285 -0
  20. data/lib/okf/cli/server.rb +186 -0
  21. data/lib/okf/cli/skill.rb +57 -0
  22. data/lib/okf/cli/stats.rb +113 -0
  23. data/lib/okf/cli/tags.rb +144 -0
  24. data/lib/okf/cli/types.rb +37 -0
  25. data/lib/okf/cli/validate.rb +66 -0
  26. data/lib/okf/cli.rb +425 -1706
  27. data/lib/okf/render/graph/template.html.erb +1285 -129
  28. data/lib/okf/render/graph.rb +46 -2
  29. data/lib/okf/server/app.rb +71 -4
  30. data/lib/okf/server/hub/not_found.rb +663 -0
  31. data/lib/okf/server/hub.rb +512 -38
  32. data/lib/okf/skill/SKILL.md +26 -19
  33. data/lib/okf/skill/playbooks/consume.md +3 -3
  34. data/lib/okf/skill/playbooks/curate.md +3 -1
  35. data/lib/okf/skill/playbooks/maintain.md +7 -5
  36. data/lib/okf/skill/playbooks/menu.md +5 -0
  37. data/lib/okf/skill/playbooks/refine.md +93 -0
  38. data/lib/okf/skill/playbooks/search.md +7 -7
  39. data/lib/okf/skill/reference/cli.md +122 -22
  40. data/lib/okf/version.rb +1 -1
  41. data/lib/okf.rb +9 -0
  42. metadata +38 -8
@@ -0,0 +1,186 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OKF
4
+ class CLI
5
+ # Boot the graph server. One verb covers three intentions and the argument
6
+ # count is the whole interface: one dir serves it at /, several serve them
7
+ # behind a hub, none serves the registry. Passing dirs never registers them.
8
+ class Server < Command
9
+ def self.id
10
+ :server
11
+ end
12
+
13
+ def self.group
14
+ :act
15
+ end
16
+
17
+ def self.help_rows
18
+ [
19
+ [ "server [DIR|@slug…] [-p PORT] [--bind ADDR] [...]", "serve one bundle, or many behind a hub" ]
20
+ ]
21
+ end
22
+
23
+ def call(argv)
24
+ require "okf/server/app"
25
+ require "rack/deflater"
26
+
27
+ options = { port: 8808, bind: "127.0.0.1", title: nil, link: nil, layout: "cose", read_only: false }
28
+ parser = OptionParser.new do |o|
29
+ o.banner = "Usage: okf server [DIR|@slug…] [-p PORT] [--bind ADDR] [--layout NAME] [-t title] [-l url]"
30
+ o.on("-p", "--port PORT", Integer, "port to serve on (default #{options[:port]})") { |v| options[:port] = v }
31
+ o.on("--bind ADDR", "address to bind (default #{options[:bind]})") { |v| options[:bind] = v }
32
+ o.on("-t", "--title TITLE", "graph title, single bundle only (default: parent/bundle dir name)") { |v| options[:title] = v }
33
+ o.on("-l", "--link URL", "source URL shown in the header, single bundle only") { |v| options[:link] = v }
34
+ o.on("--layout NAME", OKF::Render::Graph::LAYOUTS, "initial layout (#{OKF::Render::Graph::LAYOUTS.join(", ")})") { |v| options[:layout] = v }
35
+ o.on("--read-only", "serve the bundles list without its registry controls") { options[:read_only] = true }
36
+ help_flag(o)
37
+ end
38
+ dirs = positional_dirs(parser, argv) or return 2
39
+
40
+ # A flag that will have no effect in this mode gets a note, not silence.
41
+ @err.puts "note: --title/--link apply to a single-bundle server; ignored" if dirs.size != 1 && (options[:title] || options[:link])
42
+
43
+ # One dir keeps the historical single-bundle server at `/`; zero (the
44
+ # persistent registry) or many (ephemeral) fan out behind a hub.
45
+ if dirs.size == 1
46
+ folder = OKF::Bundle::Folder.load(dirs.first)
47
+ report_skipped(folder)
48
+ run_server(folder, options)
49
+ else
50
+ run_hub(dirs, options)
51
+ end
52
+ 0
53
+ rescue OKF::Error => e
54
+ usage_error(e.message)
55
+ end
56
+
57
+ private
58
+
59
+ # Build the single-bundle Rack app and hand it to the runner (WEBrick by
60
+ # default, injected so tests drive this without a socket).
61
+ def run_server(folder, options)
62
+ # search_endpoint is named here rather than defaulted in App: the page
63
+ # resolves it against the URL the reader is on, and this is the layer that
64
+ # knows the app is mounted at the root. An embedding host mounting App
65
+ # elsewhere passes its own.
66
+ app = OKF::Server::App.new(folder, title: options[:title] || folder.name, link: options[:link],
67
+ layout: options[:layout], search_endpoint: "search")
68
+ # minimal: the banner wants a count, not bodies — and Folder#graph is not
69
+ # memoized, so a full build here parses every concept a second time (the
70
+ # App builds its own) purely to print one number.
71
+ app.warm_search
72
+ count = folder.graph(minimal: true).nodes.size
73
+ @out.puts "serving #{count} #{pluralize(count, "concept")} at http://#{options[:bind]}:#{options[:port]} (Ctrl-C to stop)"
74
+ serve(app, options)
75
+ end
76
+
77
+ # Build the multi-bundle hub and hand it to the runner. With dirs it serves
78
+ # those ephemerally; with none it serves the persistent registry. Either way
79
+ # the first bundle is the one `/` opens — for the registry that is its own
80
+ # order, and a first entry whose directory has vanished drops out here, so
81
+ # `/` lands on the next one that is actually there.
82
+ def run_hub(dirs, options)
83
+ require "okf/server/hub"
84
+ require "okf/registry"
85
+ reg = nil
86
+ if dirs.empty?
87
+ # A malformed registry raises OKF::Error, which `server` rescues into a
88
+ # usage error — no guarded load needed on this path.
89
+ reg = OKF::Registry.load
90
+ # The hub's own loader, so the set it rebuilds after a browser-side
91
+ # write is built exactly the way this one was.
92
+ bundles = OKF::Server::Hub.bundles_for(reg) { |entry| skip_registered(entry) }
93
+ bundles.each { |bundle| report_skipped(bundle.folder) }
94
+ else
95
+ bundles = ephemeral_bundles(dirs)
96
+ end
97
+ # The hub keeps the registry so its /b/ manager can report on entries it
98
+ # could not host — a folder deleted out from under one is the question
99
+ # "where did my bundle go?", and only the registry can answer it.
100
+ hub = OKF::Server::Hub.new(bundles, layout: options[:layout], registry: reg, writable: writable?(options))
101
+ hub.warm_search
102
+ concepts = bundles.inject(0) { |sum, bundle| sum + bundle.folder.graph(minimal: true).nodes.size }
103
+ @out.puts "serving #{bundles.size} #{pluralize(bundles.size,
104
+ "bundle")}, #{concepts} #{pluralize(concepts, "concept")} at http://#{options[:bind]}:#{options[:port]} (Ctrl-C to stop)"
105
+ print_mounts(hub)
106
+ serve(hub, options)
107
+ end
108
+
109
+ # The one boot seam every served app passes through, so a hub gzips exactly
110
+ # like a single bundle — the wrap belongs to booting a server, not to either
111
+ # mode, and a mode added later gets it for free. Deliberately not inside the
112
+ # runner: an embedding app mounting OKF::Server::App brings its own middleware.
113
+ def serve(app, options)
114
+ # gzip responses when the client accepts it — transparent, no new dependency
115
+ @runner.call(Rack::Deflater.new(app), options[:bind], options[:port])
116
+ end
117
+
118
+ # The mount table — which dir landed on which /b/<slug>/ and where `/` goes.
119
+ # Mirrors the Hub's own default resolution (explicit slug, else first).
120
+ # Ask the hub which bundle it chose rather than re-deriving the
121
+ # explicit-else-first rule, and mount at its own prefix: two copies of a
122
+ # rule is two answers waiting to disagree.
123
+ def print_mounts(hub)
124
+ hub.bundles.each do |bundle|
125
+ marker = bundle.equal?(hub.default) ? "*" : " "
126
+ @out.puts " #{marker} #{OKF::Server::Hub::MOUNT}/#{bundle.slug}/ #{bundle.title}"
127
+ end
128
+ end
129
+
130
+ # Load the given directories as unregistered bundles, slugged by basename and
131
+ # deduped within the run. The same directory listed twice mounts once — two
132
+ # windows on one bundle would just burn a slug on a URL that vanishes next run.
133
+ def ephemeral_bundles(dirs)
134
+ roots = []
135
+ dirs.each do |dir|
136
+ root = File.expand_path(dir)
137
+ roots << root unless roots.include?(root)
138
+ end
139
+
140
+ # A registered slug owns its mount outright: reserve every ref's slug
141
+ # before any basename is deduped. Otherwise argv order decides, and
142
+ # `server ./two @two` mounts the *unregistered* ./two at /b/two/ while
143
+ # pushing the ref — the bundle whose slug that is — to /b/two-2/, so a
144
+ # bookmark from a bundle-less run silently opens the wrong graph.
145
+ taken = roots.map { |root| ref_slugs[root] }.compact
146
+ roots.each_with_object([]) do |root, bundles|
147
+ folder = OKF::Bundle::Folder.load(root)
148
+ report_skipped(folder)
149
+ slug = ref_slugs[root]
150
+ unless slug
151
+ slug = OKF::Registry.dedupe(File.basename(root), taken)
152
+ taken << slug
153
+ end
154
+ bundles << OKF::Server::Hub::Bundle.new(slug, folder, folder.name)
155
+ end
156
+ end
157
+
158
+ # May the browser change the registry? On a loopback bind, yes: the server
159
+ # is reachable only from this machine, and the audience this was built for
160
+ # should not need a flag to use the page they were pointed at. Anywhere
161
+ # else, no — and there is no flag that says otherwise, because the registry
162
+ # is a per-user file and the machine that owns it is the machine that
163
+ # manages it. `--bind 0.0.0.0` is how a personal tool becomes a public one,
164
+ # and 0.0.0.0 is *not* loopback: it is every interface, which is the exact
165
+ # case this guards.
166
+ #
167
+ # So the only flag is the way *out*. It was `--allow-manage`, an opt-in
168
+ # that read as the on switch and was not one — the loopback default had
169
+ # already turned management on, and the flag only widened it to a bind
170
+ # nobody should widen it to. Naming the exception instead means the flag
171
+ # cannot be misread as permission: `--read-only` is the word the hub's own
172
+ # refusal already uses, and this server never writes a reader's markdown
173
+ # anyway, so in context it can only mean the registry.
174
+ def writable?(options)
175
+ !options[:read_only] && loopback?(options[:bind])
176
+ end
177
+
178
+ def loopback?(bind)
179
+ address = bind.to_s
180
+ address == "localhost" || address == "::1" || address.start_with?("127.")
181
+ end
182
+ end
183
+
184
+ register(Server)
185
+ end
186
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OKF
4
+ class CLI
5
+ # Install this gem's companion agent skill into a destination directory. The
6
+ # destination is required (no magic default) so the user always decides where
7
+ # their agent picks the skill up. By default the skill lands in a skills/okf/
8
+ # folder under it — point at a project or skills dir (.claude, .agents/skills)
9
+ # and it settles in its own folder, never loose among the others — so the
10
+ # resolved path is echoed back. --here installs straight into <dest-dir>.
11
+ class Skill < Command
12
+ def self.id
13
+ :skill
14
+ end
15
+
16
+ def self.group
17
+ :act
18
+ end
19
+
20
+ def self.help_rows
21
+ [
22
+ [ "skill <dest> [--here] [--force]", "install the companion agent skill" ]
23
+ ]
24
+ end
25
+
26
+ def call(argv)
27
+ options = { force: false, nest: true }
28
+ parser = OptionParser.new do |o|
29
+ o.banner = "Usage: okf skill <dest-dir> [--here] [--force]"
30
+ o.on("--here", "install straight into <dest-dir>, wherever it is (no skills/okf nesting)") { options[:nest] = false }
31
+ o.on("--force", "overwrite a non-empty destination") { options[:force] = true }
32
+ help_flag(o)
33
+ end
34
+ # Through the shared pair like every other verb that takes one positional:
35
+ # `positional` for the value, `no_extras?` for what must not follow it.
36
+ # Hand-rolling the shift is how this one came to accept a second
37
+ # destination, install into the first and exit 0 — the silent-wrong-answer
38
+ # shape the <dir> verbs are guarded against, on the one verb whose
39
+ # positional is not a <dir>.
40
+ dest = positional(parser, argv) or return 2
41
+ no_extras?(argv) or return 2
42
+
43
+ skill = OKF::Skill.new(dest, force: options[:force], nest: options[:nest])
44
+ files = skill.install
45
+ @out.puts "installed the okf skill (#{files.size} files) -> #{skill.dest}"
46
+ files.each { |f| @out.puts " #{f}" }
47
+ @out.puts "your agent picks it up from #{skill.dest} (needs the `okf` CLI, which you already have)."
48
+ 0
49
+ rescue OKF::Skill::Error => e
50
+ @err.puts "error: #{e.message}"
51
+ 2
52
+ end
53
+ end
54
+
55
+ register(Skill)
56
+ end
57
+ end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OKF
4
+ class CLI
5
+ # Bundle rollups — concepts, dirs, types, links, tags — in one screen.
6
+ class Stats < Command
7
+ def self.id
8
+ :stats
9
+ end
10
+
11
+ def self.group
12
+ :read
13
+ end
14
+
15
+ def self.help_rows
16
+ [
17
+ [ "stats <dir|@slug> [--json]", "bundle rollups (concepts, dirs, types, links, tags)" ]
18
+ ]
19
+ end
20
+
21
+ def call(argv)
22
+ options = { json: false }
23
+ parser = OptionParser.new do |o|
24
+ o.banner = "Usage: okf stats <dir|@slug> [--json]"
25
+ json_flags(o, options, "emit the stats as JSON")
26
+ help_flag(o)
27
+ end
28
+ dir = positional_dir(parser, argv) or return 2
29
+
30
+ folder = OKF::Bundle::Folder.load(dir)
31
+ report_skipped(folder)
32
+ stats = bundle_stats(folder)
33
+ options[:json] ? print_stats_json(dir, stats) : print_stats(dir, stats)
34
+ 0
35
+ end
36
+
37
+ private
38
+
39
+ # Bundle-level rollups derived from the catalog and the graph indexes.
40
+ def bundle_stats(folder)
41
+ graph = folder.graph(minimal: true)
42
+ entries = folder.catalog
43
+ by_type = graph.type_index.transform_values(&:size).sort_by { |_, n| -n }.to_h
44
+ by_area = entries.group_by { |entry| entry[:area] }.transform_values(&:size).sort_by { |_, n| -n }.to_h
45
+ by_dir = directory_counts(folder)
46
+ {
47
+ concepts: entries.size,
48
+ dirs: by_dir.size,
49
+ areas: by_area.size,
50
+ types: by_type.size,
51
+ cross_links: graph.edges.size,
52
+ tags: graph.tag_index.size,
53
+ by_type: by_type,
54
+ by_dir: by_dir,
55
+ by_area: by_area
56
+ }
57
+ end
58
+
59
+ # Every directory the bundle has, with the concepts that live *directly* in
60
+ # it. Read off Bundle#directory_index — the same map `okf dirs` lists and
61
+ # `--dir` is answered against — rather than off the catalog, which knows
62
+ # only the directories that happen to hold a concept. Grouping the catalog
63
+ # made `stats` and `dirs` report different totals for one bundle, and left
64
+ # an addressable directory out of by_dir entirely: `--dir deeply` answers,
65
+ # but nothing in `stats` said `deeply` was there to ask about.
66
+ #
67
+ # A directory holding nothing directly therefore appears at 0. That is the
68
+ # honest reading — it is the same zero `okf dirs` prints in its Concepts
69
+ # column — and it keeps `dirs` equal to `by_dir.size`. Ties break by path so
70
+ # the order is total, not whatever the sort happened to leave.
71
+ def directory_counts(folder)
72
+ folder.directory_index
73
+ .map { |entry| [ entry[:dir], entry[:count] ] }
74
+ .sort_by { |dir, count| [ -count, dir ] }.to_h
75
+ end
76
+
77
+ def print_stats(dir, stats)
78
+ @out.puts "Stats — #{bundle_label(dir)}"
79
+ @out.puts
80
+ @out.puts " concepts #{stats[:concepts]}"
81
+ @out.puts " dirs #{stats[:dirs]}"
82
+ @out.puts " concept types #{stats[:types]}"
83
+ @out.puts " cross-links #{stats[:cross_links]}"
84
+ @out.puts " distinct tags #{stats[:tags]}"
85
+ print_stat_breakdown("By type", stats[:by_type])
86
+ # One grouping word in the human view: `by_area` stays in --json for the
87
+ # deprecation window, but a screen that printed both would be teaching the
88
+ # vocabulary the rest of this change is retiring.
89
+ print_stat_breakdown("By dir", stats[:by_dir]) { |label| dir_label(label) }
90
+ end
91
+
92
+ def print_stat_breakdown(title, counts)
93
+ return if counts.empty?
94
+
95
+ labels = counts.keys.map { |key| block_given? ? yield(key) : key }
96
+ width = labels.map(&:length).max
97
+ @out.puts
98
+ @out.puts " #{title}"
99
+ counts.each_with_index { |(_, count), i| @out.puts " #{labels[i].ljust(width)} #{count}" }
100
+ end
101
+
102
+ def print_stats_json(dir, stats)
103
+ emit_json(bundle_head(dir).merge(
104
+ "concepts" => stats[:concepts], "dirs" => stats[:dirs], "areas" => stats[:areas],
105
+ "concept_types" => stats[:types], "cross_links" => stats[:cross_links], "distinct_tags" => stats[:tags],
106
+ "by_type" => stats[:by_type], "by_dir" => stats[:by_dir], "by_area" => stats[:by_area]
107
+ ))
108
+ end
109
+ end
110
+
111
+ register(Stats)
112
+ end
113
+ end
@@ -0,0 +1,144 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OKF
4
+ class CLI
5
+ # The tag index: which tags exist, how often, and on what. --by regroups them
6
+ # per concept dimension, which is the view for curating a vocabulary rather
7
+ # than reading one.
8
+ class Tags < Command
9
+ def self.id
10
+ :tags
11
+ end
12
+
13
+ def self.group
14
+ :read
15
+ end
16
+
17
+ def self.help_rows
18
+ [
19
+ [ "tags <dir|@slug> [--json] [--by DIM] [filters]", "list tags with their concepts, by count" ]
20
+ ]
21
+ end
22
+
23
+ def call(argv)
24
+ options = { json: false, by: nil }
25
+ parser = OptionParser.new do |o|
26
+ o.banner = "Usage: okf tags <dir|@slug> [--by type|dir] [--type T] [--dir D] [--json]"
27
+ json_flags(o, options, "emit the tag index as JSON")
28
+ o.on("--by DIM", %w[type dir area], "group the tags by a concept dimension (type | dir)") do |v|
29
+ options[:by] = v.to_sym
30
+ deprecated("--by area", "--by dir") if options[:by] == :area
31
+ end
32
+ filter_flags(o, options, :type, :area)
33
+ help_flag(o)
34
+ end
35
+ dir = positional_dir(parser, argv) or return 2
36
+
37
+ return grouped_tags(dir, options) if options[:by]
38
+
39
+ print_inverted_index(dir, "Tags", :tag, "tags", options)
40
+ end
41
+
42
+ private
43
+
44
+ # `tags --by type|dir`: the tag index re-cut per concept type or directory,
45
+ # with within-group counts — the curation view. A tag confined to one
46
+ # group at count 1 is scattered; one recurring across groups is connective.
47
+ # The --type/--dir filters narrow the concepts first, then the grouping cuts.
48
+ def grouped_tags(dir, options)
49
+ folder = OKF::Bundle::Folder.load(dir)
50
+ report_skipped(folder)
51
+ graph = folder.graph(minimal: true)
52
+ titles = graph.nodes.map { |node| [ node[:id], node[:title] ] }.to_h
53
+ groups = tag_groups(graph.tag_index, folder, options)
54
+ options[:json] ? print_grouped_tags_json(dir, options[:by], groups) : print_grouped_tags(dir, options[:by], groups, titles)
55
+ 0
56
+ end
57
+
58
+ # [ [ group, rows ], … ] — groups sorted by name, rows shaped like index_rows'
59
+ # plus each tag's total across the narrowed set. A tag carried in several
60
+ # groups appears in each, counted per group; count/total per row is what
61
+ # makes a tag's spread — local to one group, or cutting across several —
62
+ # readable without cross-referencing the groups by hand.
63
+ def tag_groups(tag_index, folder, options)
64
+ by_id = filter_entries(folder.catalog, options).map { |entry| [ entry[:id], entry ] }.to_h
65
+ groups = {}
66
+ totals = Hash.new(0)
67
+ tag_index.each do |tag, ids|
68
+ ids.each do |id|
69
+ entry = by_id[id]
70
+ next if entry.nil?
71
+
72
+ key = group_key(entry, options[:by])
73
+ ((groups[key] ||= {})[tag] ||= []) << id
74
+ totals[tag] += 1
75
+ end
76
+ end
77
+ groups.map do |key, tags|
78
+ rows = tags.map { |tag, ids| { tag: tag, count: ids.length, total: totals[tag], concepts: ids } }
79
+ .sort_by { |row| [ -row[:count], row[:tag] ] }
80
+ [ key, rows ]
81
+ end.sort_by(&:first)
82
+ end
83
+
84
+ # A catalog entry's type for display — "Untyped" when blank, matching the graph.
85
+ def entry_type(entry)
86
+ OKF.blank?(entry[:type]) ? "Untyped" : entry[:type]
87
+ end
88
+
89
+ # The group a concept falls in, in its *stored* spelling — `.` for the root
90
+ # under --by dir, never "(root)". The human label is applied at print time,
91
+ # so the JSON and the table cannot disagree about which one is the data.
92
+ def group_key(entry, dim)
93
+ case dim
94
+ when :type then entry_type(entry)
95
+ when :dir then entry[:dir]
96
+ else entry[:area]
97
+ end
98
+ end
99
+
100
+ # `.` prints "(root)" bare; every other dir carries the trailing slash that
101
+ # says it is one. The deprecated --by area already stores "(root)" itself.
102
+ def group_label(key, dim)
103
+ return key if dim == :type
104
+
105
+ dir_label(key, slash: true)
106
+ end
107
+
108
+ def print_grouped_tags(dir, dim, groups, titles)
109
+ @out.puts "Tags — #{bundle_label(dir)} (#{distinct_tags(groups)} distinct, by #{dim})"
110
+ groups.each do |key, rows|
111
+ label = group_label(key, dim)
112
+ @out.puts
113
+ @out.puts " #{label} (#{rows.size} #{pluralize(rows.size, "tag")})"
114
+ width = rows.map { |row| row[:tag].length }.max || 0
115
+ cwidth = [ 3, *rows.map { |row| count_cell(row).length } ].max
116
+ rows.each do |row|
117
+ names = row[:concepts].map { |id| titles[id] || id }.join(", ")
118
+ @out.puts " #{row[:tag].ljust(width)} #{count_cell(row).rjust(cwidth)} #{truncate(names, 76)}"
119
+ end
120
+ end
121
+ end
122
+
123
+ # "2/3" when the tag spreads beyond this group, the plain count when it is
124
+ # local — so equality (locality 1.0) reads by absence.
125
+ def count_cell(row)
126
+ row[:count] == row[:total] ? row[:count].to_s : "#{row[:count]}/#{row[:total]}"
127
+ end
128
+
129
+ def print_grouped_tags_json(dir, dim, groups)
130
+ groups_json = groups.map do |key, rows|
131
+ rows_json = rows.map { |row| { "tag" => row[:tag], "count" => row[:count], "total" => row[:total], "concepts" => row[:concepts] } }
132
+ { dim.to_s => key, "count" => rows.size, "tags" => rows_json }
133
+ end
134
+ emit_json(bundle_head(dir).merge("count" => distinct_tags(groups), "by" => dim.to_s, "groups" => groups_json))
135
+ end
136
+
137
+ def distinct_tags(groups)
138
+ groups.flat_map { |_, rows| rows.map { |row| row[:tag] } }.uniq.size
139
+ end
140
+ end
141
+
142
+ register(Tags)
143
+ end
144
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OKF
4
+ class CLI
5
+ # The type index: which types exist, how often, and on what.
6
+ class Types < Command
7
+ def self.id
8
+ :types
9
+ end
10
+
11
+ def self.group
12
+ :read
13
+ end
14
+
15
+ def self.help_rows
16
+ [
17
+ [ "types <dir|@slug> [--json] [filters]", "list types with their concepts, by count" ]
18
+ ]
19
+ end
20
+
21
+ def call(argv)
22
+ options = { json: false }
23
+ parser = OptionParser.new do |o|
24
+ o.banner = "Usage: okf types <dir|@slug> [--dir D] [--tag T] [--json]"
25
+ json_flags(o, options, "emit the type index as JSON")
26
+ filter_flags(o, options, :area, :tag)
27
+ help_flag(o)
28
+ end
29
+ dir = positional_dir(parser, argv) or return 2
30
+
31
+ print_inverted_index(dir, "Types", :type, "types", options)
32
+ end
33
+ end
34
+
35
+ register(Types)
36
+ end
37
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OKF
4
+ class CLI
5
+ # The §9 conformance judge: is this legal OKF? Binary and tolerant — it is
6
+ # forbidden from failing a bundle over a broken link or a missing optional
7
+ # field, which is lint's job. Exit 1 when non-conformant.
8
+ class Validate < Command
9
+ def self.id
10
+ :validate
11
+ end
12
+
13
+ def self.group
14
+ :judge
15
+ end
16
+
17
+ def self.help_rows
18
+ [
19
+ [ "validate <dir|@slug> [--json]", "check OKF v0.1 conformance" ]
20
+ ]
21
+ end
22
+
23
+ def call(argv)
24
+ options = { json: false }
25
+ parser = OptionParser.new do |o|
26
+ o.banner = "Usage: okf validate <dir|@slug> [--json]"
27
+ json_flags(o, options, "emit a JSON report")
28
+ help_flag(o)
29
+ end
30
+ dir = positional_dir(parser, argv) or return 2
31
+
32
+ result = OKF::Bundle::Folder.load(dir).validate
33
+ options[:json] ? print_validation_json(dir, result) : print_validation(dir, result)
34
+ result.valid? ? 0 : 1
35
+ end
36
+
37
+ private
38
+
39
+ def print_validation(dir, result)
40
+ counts = result.counts
41
+ @out.puts "OKF v0.1 conformance — #{bundle_label(dir)}"
42
+ @out.puts " concepts: #{counts[:concepts]} index.md: #{counts[:indexes]} log.md: #{counts[:logs]}"
43
+ result.errors.each { |e| @out.puts " #{paint("✗ ERROR", 31)} #{e[:path]}: #{e[:message]}" }
44
+ result.warnings.each { |w| @out.puts " #{paint("! warn", 33)} #{w[:path]}: #{w[:message]}" }
45
+ if result.valid? && result.warnings.empty?
46
+ @out.puts " #{paint("✓ conformant — no issues", 32)}"
47
+ elsif result.valid?
48
+ @out.puts " #{paint("✓ conformant", 32)} (#{result.warnings.size} warning(s))"
49
+ else
50
+ @out.puts " #{paint("✗ non-conformant", 31)} (#{result.errors.size} error(s))"
51
+ end
52
+ end
53
+
54
+ def print_validation_json(dir, result)
55
+ emit_json(bundle_head(dir).merge(
56
+ "conformant" => result.valid?,
57
+ "counts" => result.counts,
58
+ "errors" => result.errors,
59
+ "warnings" => result.warnings
60
+ ))
61
+ end
62
+ end
63
+
64
+ register(Validate)
65
+ end
66
+ end