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,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
|
data/lib/okf/cli/lint.rb
ADDED
|
@@ -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
|