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,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OKF
4
+ class CLI
5
+ # The progressive-disclosure map (spec §6): every directory that holds concepts
6
+ # or carries an index.md, with its authored index body, a type/tag rollup, its
7
+ # child directories, and — for a directory with no index.md — the listing
8
+ # synthesized from the concepts there. The "orient before you read" view. `--area`
9
+ # is repeatable (one or many directories; `root` is the bundle root); `--no-body`
10
+ # drops the prose to a skeleton; advisory, exit 0.
11
+ class Index < Command
12
+ def self.id
13
+ :index
14
+ end
15
+
16
+ def self.group
17
+ :read
18
+ end
19
+
20
+ def self.help_rows
21
+ [
22
+ [ "index <dir|@slug> [--json] [--area A] [--no-body]", "the index map: dirs, their listings and rollups" ]
23
+ ]
24
+ end
25
+
26
+ def call(argv)
27
+ options = { json: false, body: true, areas: nil }
28
+ parser = OptionParser.new do |o|
29
+ o.banner = "Usage: okf index <dir|@slug> [--area AREA] [--no-body] [--json]"
30
+ json_flags(o, options, "emit the index map as JSON")
31
+ projection_flags(o, options)
32
+ o.on("--area AREA", "only this directory/area (repeatable; `root` for the bundle root)") { |v| (options[:areas] ||= []) << v }
33
+ o.on("--[no-]body", "include each index's prose body (default: yes)") { |v| options[:body] = v }
34
+ help_flag(o)
35
+ end
36
+ dir = positional_dir(parser, argv) or return 2
37
+
38
+ folder = OKF::Bundle::Folder.load(dir)
39
+ report_skipped(folder)
40
+ entries = folder.directory_index
41
+ selected = select_directories(entries, options[:areas])
42
+ if options[:json]
43
+ # --no-body is shorthand for --except body, so asking for the body by
44
+ # name in the same breath is a contradiction. Letting --fields quietly
45
+ # win would hand back the very thing the other flag was there to drop.
46
+ if !options[:body] && Array(options[:fields]).map(&:downcase).include?("body")
47
+ return usage_error("--no-body and --fields body contradict each other: drop one")
48
+ end
49
+
50
+ options[:except] = Array(options[:except]) + [ "body" ] unless options[:body] || options[:fields]
51
+ return print_index_map_json(dir, selected, options)
52
+ end
53
+ print_index_map(dir, selected, options[:body])
54
+ 0
55
+ end
56
+
57
+ private
58
+
59
+ # Narrow the map to the named directories/areas — case-insensitive, `root`
60
+ # matching the bundle root (".") so no shell quoting is needed. No --area passed
61
+ # keeps the whole map.
62
+ def select_directories(entries, areas)
63
+ return entries if areas.nil? || areas.empty?
64
+
65
+ wanted = areas.map { |area| area.downcase == "root" ? "." : area.downcase }
66
+ entries.select { |entry| wanted.include?(entry[:dir].downcase) }
67
+ end
68
+
69
+ def print_index_map(dir, entries, body)
70
+ noun = entries.size == 1 ? "directory" : "directories"
71
+ @out.puts "Index map — #{bundle_label(dir)} (#{entries.size} #{noun})"
72
+ entries.each do |entry|
73
+ @out.puts
74
+ @out.puts " #{index_dir_label(entry)}#{index_dir_meta(entry)}"
75
+ subdirs = entry[:subdirs]
76
+ @out.puts " → #{subdirs.map { |sub| "#{File.basename(sub)}/" }.join(" ")}" unless subdirs.empty?
77
+ if entry[:present]
78
+ print_index_body(entry[:body]) if body
79
+ else
80
+ print_synthesized_listing(entry[:listing])
81
+ end
82
+ end
83
+ end
84
+
85
+ def index_dir_label(entry)
86
+ base = entry[:dir] == "." ? "(root)" : "#{entry[:dir]}/"
87
+ entry[:present] ? base : "#{base} (no index.md)"
88
+ end
89
+
90
+ def index_dir_meta(entry)
91
+ count = "#{entry[:count]} #{pluralize(entry[:count], "concept")}"
92
+ types = entry[:types].map { |type, n| "#{OKF.blank?(type) ? "Untyped" : type} #{n}" }.join(", ")
93
+ types.empty? ? " · #{count}" : " · #{count} · #{types}"
94
+ end
95
+
96
+ def print_index_body(body)
97
+ text = body.to_s.strip
98
+ return if text.empty?
99
+
100
+ text.each_line { |line| @out.puts " #{line.chomp}" }
101
+ end
102
+
103
+ def print_synthesized_listing(listing)
104
+ listing.each do |item|
105
+ suffix = item[:description].empty? ? "" : " — #{truncate(item[:description], 72)}"
106
+ @out.puts " • #{item[:title]}#{suffix}"
107
+ end
108
+ end
109
+
110
+ def print_index_map_json(dir, entries, options)
111
+ emit_list_json(dir, "directories", entries.map { |entry| index_map_entry_json(entry) }, options)
112
+ end
113
+
114
+ def index_map_entry_json(entry)
115
+ {
116
+ "dir" => entry[:dir], "index_path" => entry[:index_path],
117
+ "present" => entry[:present], "synthesized" => entry[:synthesized],
118
+ "count" => entry[:count], "types" => entry[:types], "tags" => entry[:tags],
119
+ "subdirs" => entry[:subdirs], "body" => entry[:body],
120
+ "listing" => entry[:listing].map { |item| stringify(item) }
121
+ }
122
+ end
123
+ end
124
+
125
+ register(Index)
126
+ end
127
+ end
@@ -0,0 +1,139 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OKF
4
+ class CLI
5
+ # The curation judge: is this bundle *good*? Advisory by design — findings
6
+ # never change the exit code unless --fail-on says so, because a stub or a
7
+ # loose leaf can be deliberate. Freshness is off unless --stale-after asks.
8
+ class Lint < Command
9
+ # Lint findings grouped for display, in category order.
10
+ LINT_CATEGORIES = {
11
+ "Reachability" => %i[orphan not_in_index disconnected_component unlinked],
12
+ "Backlog" => %i[missing_concept broken_index_entry],
13
+ "Completeness" => %i[stub missing_title missing_description missing_timestamp],
14
+ "Freshness" => %i[stale],
15
+ "Provenance" => %i[uncited_external broken_citation],
16
+ "Hygiene" => %i[duplicate_title unused_reference_def undefined_reference self_link]
17
+ }.freeze
18
+
19
+ def self.id
20
+ :lint
21
+ end
22
+
23
+ def self.group
24
+ :judge
25
+ end
26
+
27
+ def self.help_rows
28
+ [
29
+ [ "lint <dir|@slug> [--json] [--fail-on warn] [...]", "report curation-quality issues" ]
30
+ ]
31
+ end
32
+
33
+ def call(argv)
34
+ options = { json: false, min_body: OKF::Bundle::Linter::DEFAULT_MIN_BODY, stale_after: nil, only: nil, except: nil, fail_on: :never }
35
+ parser = OptionParser.new do |o|
36
+ o.banner = "Usage: okf lint <dir|@slug> [--json] [--min-body N] [--stale-after DUR] [--only a,b] [--except a,b] [--fail-on warn]"
37
+ json_flags(o, options, "emit a JSON report")
38
+ o.on("--min-body N", Integer, "stub threshold in body characters (default #{OKF::Bundle::Linter::DEFAULT_MIN_BODY})") { |v| options[:min_body] = v }
39
+ o.on("--stale-after DUR", "flag concepts older than DUR (e.g. 90d, 12w, 2026-01-01)") { |v| options[:stale_after] = v }
40
+ o.on("--only LIST", Array, "run only these checks (comma-separated)") { |v| options[:only] = v.map(&:to_sym) }
41
+ o.on("--except LIST", Array, "skip these checks (comma-separated)") { |v| options[:except] = v.map(&:to_sym) }
42
+ o.on("--fail-on LEVEL", %w[never warn], "exit 1 when a finding at LEVEL exists (never | warn)") { |v| options[:fail_on] = v.to_sym }
43
+ help_flag(o)
44
+ end
45
+ dir = positional_dir(parser, argv) or return 2
46
+
47
+ unknown = ((options[:only] || []) + (options[:except] || [])) - OKF::Bundle::Linter::CHECKS
48
+ unless unknown.empty?
49
+ @err.puts "error: unknown check(s): #{unknown.uniq.join(", ")}"
50
+ return 2
51
+ end
52
+
53
+ stale_before = parse_stale_after(options[:stale_after])
54
+ if stale_before == :invalid
55
+ @err.puts "error: invalid --stale-after `#{options[:stale_after]}` (use 90d, 12w, or an ISO date like 2026-01-01)"
56
+ return 2
57
+ end
58
+
59
+ folder = OKF::Bundle::Folder.load(dir)
60
+ report = folder.lint(min_body: options[:min_body], stale_before: stale_before, only: options[:only], except: options[:except])
61
+ note_skipped(report.stats[:skipped])
62
+ options[:json] ? print_lint_json(dir, report) : print_lint(dir, report)
63
+ options[:fail_on] == :warn && report.warnings.any? ? 1 : 0
64
+ end
65
+
66
+ private
67
+
68
+ # Turn a --stale-after value (90d, 12w, or an ISO date) into an absolute cutoff
69
+ # Time so the pure Linter never reads the clock. nil when unset, :invalid on a
70
+ # bad value.
71
+ def parse_stale_after(value)
72
+ return nil if value.nil?
73
+
74
+ if (match = value.match(/\A(\d+)([dw])\z/))
75
+ days = match[1].to_i * (match[2] == "w" ? 7 : 1)
76
+ Time.now - (days * 86_400)
77
+ else
78
+ Date.iso8601(value).to_time
79
+ end
80
+ rescue ArgumentError
81
+ :invalid
82
+ end
83
+
84
+ def print_lint(dir, report)
85
+ stats = report.stats
86
+ @out.puts "OKF lint — #{bundle_label(dir)}"
87
+ @out.puts " concepts: #{stats[:concepts]} edges: #{stats[:edges]} index.md: #{stats[:indexes]} log.md: #{stats[:logs]}"
88
+ summary = lint_summary(stats)
89
+ @out.puts " #{summary}" unless summary.empty?
90
+
91
+ LINT_CATEGORIES.each do |name, checks|
92
+ findings = report.findings.select { |finding| checks.include?(finding[:check]) }
93
+ next if findings.empty?
94
+
95
+ @out.puts
96
+ @out.puts " #{name}"
97
+ findings.each do |finding|
98
+ @out.puts " #{lint_glyph(finding)} #{[ finding[:path], finding[:message] ].compact.join(": ")}"
99
+ end
100
+ end
101
+
102
+ @out.puts
103
+ @out.puts " #{lint_verdict(report)}"
104
+ end
105
+
106
+ def print_lint_json(dir, report)
107
+ emit_json(bundle_head(dir).merge(
108
+ "healthy" => report.healthy?,
109
+ "stats" => report.stats,
110
+ "findings" => report.findings
111
+ ))
112
+ end
113
+
114
+ def lint_summary(stats)
115
+ parts = []
116
+ hubs = stats[:hubs].map { |hub| "#{hub[:id]} (×#{hub[:in_degree]})" }.join(", ")
117
+ types = stats[:types].map { |type, count| "#{type} #{count}" }.join(", ")
118
+ parts << "hubs: #{hubs}" unless hubs.empty?
119
+ parts << "types: #{types}" unless types.empty?
120
+ parts.join(" ")
121
+ end
122
+
123
+ def lint_glyph(finding)
124
+ finding[:severity] == :warn ? paint("! warn", 33) : "· info"
125
+ end
126
+
127
+ def lint_verdict(report)
128
+ warnings = report.warnings.size
129
+ infos = report.info.size
130
+ return paint("✓ healthy — no issues", 32) if warnings.zero? && infos.zero?
131
+
132
+ marker = warnings.zero? ? paint("✓", 32) : paint("⚠", 33)
133
+ "#{marker} #{warnings} warn, #{infos} info"
134
+ end
135
+ end
136
+
137
+ register(Lint)
138
+ end
139
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OKF
4
+ class CLI
5
+ # List the "loose" files — concepts with graph degree 0 (no cross-links in or
6
+ # out), grouped by folder. A folder-grouped view over lint's `unlinked` check,
7
+ # for the common "which files float in the graph?" question. Advisory (exit 0):
8
+ # a terminal leaf can be loose by design. `--json` for a machine substrate.
9
+ class Loose < Command
10
+ def self.id
11
+ :loose
12
+ end
13
+
14
+ def self.group
15
+ :judge
16
+ end
17
+
18
+ def self.help_rows
19
+ [
20
+ [ "loose <dir|@slug> [--json]", "list files with no graph links, by folder" ]
21
+ ]
22
+ end
23
+
24
+ def call(argv)
25
+ options = { json: false }
26
+ parser = OptionParser.new do |o|
27
+ o.banner = "Usage: okf loose <dir|@slug> [--json]"
28
+ json_flags(o, options, "emit the loose files as JSON")
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
+ files = loose_files(folder.graph(minimal: true))
36
+ options[:json] ? print_loose_json(dir, files) : print_loose(dir, files)
37
+ 0
38
+ end
39
+
40
+ private
41
+
42
+ # Degree-0 nodes as { id:, title:, dir: }, sorted by path — the same set lint's
43
+ # `unlinked` check reports, resolved to titles/folders for display.
44
+ def loose_files(graph)
45
+ titles = graph.nodes.map { |node| [ node[:id], node[:title] ] }.to_h
46
+ graph.unlinked_ids
47
+ .map { |id| { id: id, title: titles[id], dir: File.dirname("#{id}.md") } }
48
+ .sort_by { |file| file[:id] }
49
+ end
50
+
51
+ def print_loose(dir, files)
52
+ @out.puts "Loose files — #{bundle_label(dir)} (#{files.size})"
53
+ if files.empty?
54
+ @out.puts " #{paint("✓ none — every concept links or is linked", 32)}"
55
+ return
56
+ end
57
+
58
+ files.group_by { |file| file[:dir] }.sort_by(&:first).each do |folder, group|
59
+ width = group.map { |file| File.basename("#{file[:id]}.md").length }.max
60
+ @out.puts
61
+ @out.puts " #{folder == "." ? "(root)" : "#{folder}/"}"
62
+ group.each do |file|
63
+ @out.puts " #{File.basename("#{file[:id]}.md").ljust(width)} #{file[:title]}"
64
+ end
65
+ end
66
+ end
67
+
68
+ def print_loose_json(dir, files)
69
+ emit_json(bundle_head(dir).merge(
70
+ "count" => files.size,
71
+ "loose" => files.map { |file| stringify(file) }
72
+ ))
73
+ end
74
+ end
75
+
76
+ register(Loose)
77
+ end
78
+ end
@@ -0,0 +1,229 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OKF
4
+ class CLI
5
+ # The registry umbrella, split by what each verb keys on. `set`/`del`/`list`
6
+ # act on entries — `set` keys on the bundle's path, so --as means one thing
7
+ # ("the slug this entry has") whether it adds or renames. `default`/`rename`
8
+ # act on slugs, the names actually to hand once a bundle is registered. Every
9
+ # positional stays unambiguous, and `config` is left free for real settings.
10
+ class Registry < Command
11
+ # The `registry` umbrella's subcommands — the dispatch, and the words a
12
+ # flag-first invocation is checked against.
13
+ SUBCOMMANDS = %w[set del list default rename].freeze
14
+
15
+ def self.id
16
+ :registry
17
+ end
18
+
19
+ def self.group
20
+ :registry
21
+ end
22
+
23
+ def self.help_rows
24
+ [
25
+ [ "registry list [--json]", "list registered bundles (* marks the default)" ],
26
+ [ "registry set <dir|@slug> [--as SLUG] [--default]", "add or update a bundle (a bare `server` serves them)" ],
27
+ [ "registry del <dir|@slug>", "remove a bundle from the registry" ],
28
+ [ "registry default <@slug>", "move a bundle to the front (the default)" ],
29
+ [ "registry rename <@slug> <new>", "rename a registered bundle (<new> is a new name, not a ref)" ]
30
+ ]
31
+ end
32
+
33
+ def call(argv)
34
+ require "okf/registry"
35
+
36
+ sub = argv.first
37
+ case sub
38
+ when "set" then registry_set(argv.drop(1))
39
+ when "del" then registry_del(argv.drop(1))
40
+ when "list" then registry_list(argv.drop(1))
41
+ when "default" then registry_default(argv.drop(1))
42
+ when "rename" then registry_rename(argv.drop(1))
43
+ else
44
+ # A bare word that isn't a known subcommand is a typo (`registry remove x`
45
+ # must not silently render the list and read as success).
46
+ return usage_error("unknown registry subcommand '#{sub}' (expected: #{SUBCOMMANDS.join(", ")})") if sub && !sub.start_with?("-")
47
+
48
+ # Same rule for a subcommand hiding behind a flag: `registry --json set
49
+ # dir` would otherwise list an empty registry and exit 0, having written
50
+ # nothing the user asked for. It cannot just be dispatched from wherever
51
+ # it turns up — the word may be a flag's value (`registry --as set <dir>`
52
+ # asks for the slug "set"), and a grammar where that reading depends on
53
+ # which flag precedes it is a trapdoor. So the subcommand must lead, and
54
+ # the error says which one was found rather than guessing at the intent.
55
+ stray = argv.find { |arg| SUBCOMMANDS.include?(arg) }
56
+ return usage_error("put the subcommand first: okf registry #{stray} … (flags follow it)") if stray
57
+
58
+ registry_list(argv)
59
+ end
60
+ end
61
+
62
+ private
63
+
64
+ # Add a bundle to the persistent registry (so a later bare `okf server` finds
65
+ # it), or update one already there. The entry is keyed by the bundle's path: a
66
+ # path already registered refreshes its title in place, and --as renames it. A
67
+ # new path is added, slugged by directory basename unless --as says otherwise.
68
+ def registry_set(argv)
69
+ options = { as: nil, default: false }
70
+ parser = OptionParser.new do |o|
71
+ o.banner = "Usage: okf registry set <dir|@slug> [--as SLUG] [--default]"
72
+ o.on("--as SLUG", "slug to register under (default: directory basename)") { |v| options[:as] = v }
73
+ o.on("--default", "put it first — the bundle a bare `okf server` opens") { options[:default] = true }
74
+ help_flag(o)
75
+ end
76
+ # No no_extras? here: positional_dir has already refused a trailing
77
+ # argument. The sibling subcommands need the call because they take their
78
+ # positional through `positional`, which does not check.
79
+ dir = positional_dir(parser, argv) or return 2
80
+
81
+ reg = OKF::Registry.load
82
+ # Said before the upsert: after it, an update is indistinguishable from an
83
+ # add, and "registered" for what was a rename reads as a duplicate entry.
84
+ known = reg.listing.any? { |row| row[:dir] == File.expand_path(dir) }
85
+ entry = reg.add(dir, as: options[:as], default: options[:default])
86
+ # Through report_skipped like every other bundle-reading verb: the reader
87
+ # tolerates a file it cannot open, so a count taken straight off the graph
88
+ # reports "0 concepts" for a bundle whose files are simply unreadable.
89
+ folder = OKF::Bundle::Folder.load(entry.path)
90
+ report_skipped(folder)
91
+ count = folder.graph(minimal: true).nodes.size
92
+ @out.puts "#{known ? "updated" : "registered"} #{entry.slug} → #{entry.path} (#{count} #{pluralize(count, "concept")})"
93
+ 0
94
+ rescue OKF::Error => e
95
+ usage_error(e.message)
96
+ end
97
+
98
+ # Remove a bundle from the persistent registry by slug or by its directory.
99
+ def registry_del(argv)
100
+ parser = OptionParser.new do |o|
101
+ o.banner = "Usage: okf registry del <dir|@slug>"
102
+ help_flag(o)
103
+ end
104
+ slug = positional(parser, argv) or return 2
105
+ no_extras?(argv) or return 2
106
+
107
+ reg = OKF::Registry.load
108
+ slug = registry_slug(slug, reg) or return 2
109
+ removed = reg.remove(slug)
110
+ return usage_error("no such bundle: #{slug}") unless removed
111
+
112
+ @out.puts "removed #{removed.slug}"
113
+ 0
114
+ rescue OKF::Error => e
115
+ usage_error(e.message)
116
+ end
117
+
118
+ def registry_list(argv)
119
+ options = { json: false }
120
+ parser = OptionParser.new do |o|
121
+ o.banner = "Usage: okf registry list [--json] [--pretty]\n " \
122
+ "okf registry set <dir|@slug> | del <dir|@slug> | default <@slug> | rename <@slug> <new>"
123
+ json_flags(o, options, "emit the registry as JSON")
124
+ help_flag(o)
125
+ end
126
+ begin
127
+ parser.parse!(argv)
128
+ rescue OptionParser::ParseError => e
129
+ @err.puts e.message
130
+ return 2
131
+ end
132
+ no_extras?(argv) or return 2
133
+
134
+ reg = OKF::Registry.load
135
+ return emit_list_json({ "registry" => reg.path }, "bundles", reg.listing.map { |row| stringify(row) }, options) if options[:json]
136
+
137
+ print_registry(reg)
138
+ 0
139
+ rescue OKF::Error => e
140
+ usage_error(e.message)
141
+ end
142
+
143
+ # Choose which registered bundle a bare `okf server` opens at `/`, by moving
144
+ # it to the front of the registry. The listing is ordered and the JSON is
145
+ # meant to be hand-editable, so the move is stated rather than left to be
146
+ # discovered from a reordered file.
147
+ def registry_default(argv)
148
+ parser = OptionParser.new do |o|
149
+ o.banner = "Usage: okf registry default <@slug>\n " \
150
+ "moves it to the front — the first registered bundle is the default until you do"
151
+ help_flag(o)
152
+ end
153
+ slug = positional(parser, argv) or return 2
154
+ no_extras?(argv) or return 2
155
+
156
+ reg = OKF::Registry.load
157
+ slug = registry_slug(slug, reg) or return 2
158
+ reg.default = slug
159
+ @out.puts "default bundle → #{reg.default.slug} (now first)"
160
+ 0
161
+ rescue OKF::Error => e
162
+ usage_error(e.message)
163
+ end
164
+
165
+ # The @ref grammar for a verb that takes a *slug*, read by name. These three
166
+ # must reach an entry whose directory is gone — that is the one worth
167
+ # deleting or renaming — so they cannot go through resolve_ref, which
168
+ # insists the directory exist. Without this the refs only appeared to work:
169
+ # `normalize` strips the `@` off `@slug`, so `default @slug` resolved by
170
+ # accident while a bare `@` normalized to "" and failed. Returns the slug,
171
+ # or nil after reporting.
172
+ def registry_slug(arg, registry)
173
+ return arg unless arg.start_with?("@")
174
+
175
+ asked = arg[1..-1]
176
+ return asked unless asked.empty?
177
+
178
+ default = registry.default
179
+ return default.slug if default
180
+
181
+ @err.puts "error: no bundle is registered, so `@` names nothing (okf registry set <dir>)"
182
+ nil
183
+ end
184
+
185
+ # Rename a registered bundle's slug — its mount path and switcher name.
186
+ def registry_rename(argv)
187
+ parser = OptionParser.new do |o|
188
+ o.banner = "Usage: okf registry rename <@slug> <new>"
189
+ help_flag(o)
190
+ end
191
+ parser.parse!(argv)
192
+ old_slug, new_slug = argv.shift(2)
193
+ if old_slug.nil? || new_slug.nil?
194
+ @err.puts parser.banner
195
+ return 2
196
+ end
197
+ no_extras?(argv) or return 2
198
+
199
+ reg = OKF::Registry.load
200
+ # The old name may be a ref; the new one is a name being minted, never one.
201
+ old_slug = registry_slug(old_slug, reg) or return 2
202
+ entry = reg.rename(old_slug, new_slug)
203
+ # The slug it *found*, not the argv that found it: rename normalizes to look
204
+ # the entry up, so echoing the raw ask names a bundle that never existed.
205
+ @out.puts "renamed #{OKF::Registry.normalize(old_slug)} → #{entry.slug}"
206
+ 0
207
+ rescue OptionParser::ParseError => e
208
+ @err.puts e.message
209
+ 2
210
+ rescue OKF::Error => e
211
+ usage_error(e.message)
212
+ end
213
+
214
+ def print_registry(reg)
215
+ return @out.puts "no bundles registered — okf registry set <dir>" if reg.empty?
216
+
217
+ rows = reg.listing
218
+ width = rows.map { |row| row[:slug].length }.max
219
+ rows.each do |row|
220
+ marker = row[:default] ? "*" : " "
221
+ missing = row[:missing] ? " (missing)" : ""
222
+ @out.puts "#{marker} #{row[:slug].ljust(width)} #{row[:title]} (#{row[:dir]})#{missing}"
223
+ end
224
+ end
225
+ end
226
+
227
+ register(Registry)
228
+ end
229
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OKF
4
+ class CLI
5
+ # The static counterpart to `server`: bake the whole bundle into one
6
+ # self-contained HTML file (bodies, catalog, index, logs baked in, no server
7
+ # needed — e.g. hosting on GitHub Pages). Prints to stdout unless -o is given.
8
+ class Render < Command
9
+ def self.id
10
+ :render
11
+ end
12
+
13
+ def self.group
14
+ :act
15
+ end
16
+
17
+ def self.help_rows
18
+ [
19
+ [ "render <dir|@slug> [-o FILE] [--layout NAME] [...]", "write a static, self-contained HTML graph" ]
20
+ ]
21
+ end
22
+
23
+ def call(argv)
24
+ require "okf/render/graph"
25
+
26
+ options = { output: nil, title: nil, link: nil, layout: "cose" }
27
+ parser = OptionParser.new do |o|
28
+ o.banner = "Usage: okf render <dir|@slug> [-o FILE] [--layout NAME] [-t title] [-l url]"
29
+ o.on("-o", "--output FILE", "write to FILE instead of stdout") { |v| options[:output] = v }
30
+ o.on("-t", "--title TITLE", "graph title (default: parent/bundle dir name)") { |v| options[:title] = v }
31
+ o.on("-l", "--link URL", "source URL shown in the header") { |v| options[:link] = v }
32
+ o.on("--layout NAME", OKF::Render::Graph::LAYOUTS, "initial layout (#{OKF::Render::Graph::LAYOUTS.join(", ")})") { |v| options[:layout] = v }
33
+ help_flag(o)
34
+ end
35
+ dir = positional_dir(parser, argv) or return 2
36
+
37
+ folder = OKF::Bundle::Folder.load(dir)
38
+ report_skipped(folder)
39
+ html = OKF::Render::Graph.static(folder, title: options[:title], link: options[:link], layout: options[:layout])
40
+ if options[:output]
41
+ # A bad -o path (a missing directory, a permission denial) is a bad
42
+ # *argument*: exit 2 with the reason, never a backtrace and an exit code
43
+ # that means "failing bundle".
44
+ begin
45
+ File.write(options[:output], html)
46
+ rescue SystemCallError => e
47
+ return usage_error("cannot write #{options[:output]}: #{e.message}")
48
+ end
49
+ # Off the bundle, not a second graph: Graph.build maps one node per
50
+ # concept, so the counts are identical — and Folder#graph is not
51
+ # memoized, so asking for one here would build a whole second graph
52
+ # (Render::Graph.static already built one) to print one number. Only
53
+ # the graph, to be exact: the concepts are parsed once at Folder.load
54
+ # and Graph.build reads them from memory, so this costs no disk.
55
+ count = folder.bundle.concepts.size
56
+ @out.puts "wrote #{count} #{pluralize(count, "concept")} to #{options[:output]}"
57
+ else
58
+ @out.print html
59
+ end
60
+ 0
61
+ end
62
+ end
63
+
64
+ register(Render)
65
+ end
66
+ end