Class: OKF::CLI
- Inherits:
-
Object
- Object
- OKF::CLI
- Defined in:
- lib/okf/cli.rb,
lib/okf/cli/lint.rb,
lib/okf/cli/tags.rb,
lib/okf/cli/files.rb,
lib/okf/cli/graph.rb,
lib/okf/cli/index.rb,
lib/okf/cli/loose.rb,
lib/okf/cli/skill.rb,
lib/okf/cli/stats.rb,
lib/okf/cli/types.rb,
lib/okf/cli/render.rb,
lib/okf/cli/search.rb,
lib/okf/cli/server.rb,
lib/okf/cli/catalog.rb,
lib/okf/cli/command.rb,
lib/okf/cli/registry.rb,
lib/okf/cli/validate.rb
Overview
Command-line front end: okf <command> [options].
This file is the dispatcher and the registry; the verbs themselves live one
per file under okf/cli/, each a Command subclass that registers itself at
load. It is still the only layer that parses argv, prints, writes files and
decides exit codes — the lib classes below it just return data. Streams are
injectable for testing.
Exit codes: 0 success, 1 non-conformant / failing bundle, 2 usage error.
Defined Under Namespace
Classes: Catalog, Command, Files, Graph, Index, Lint, Loose, Registry, Render, Search, Server, Skill, Stats, Tags, Types, Validate
Constant Summary collapse
- ALL_REF =
"every registered bundle" as a ref, in its canonical spelling — what the messages say, and (normalized) what #all_ref? recognizes. Only
searchexpands it: it is the one verb that merges across bundles, so it is the one verb for which "all" names something it can answer about. See resolve_registered for why the others refuse it outright rather than treating it as an unknown slug. Its slug half is reserved by Registry::RESERVED_SLUGS so no bundle can answer to it; a test pins the two together. "@all"- ROW_FIELDS =
The row shape each list view emits, so
--fields/--exceptcan be checked against a name even when the result is empty. Without it the typo guard keyed off the data:--fields boguswas a usage error against a bundle with matches and silently fine against one without, which made a typo's fate depend on whether a filter happened to match. A test asserts each view's real rows carry exactly these, so the two cannot drift. Declared in emission order, so the "available:" list a typo prints reads the same as the rows themselves. { "matches" => %w[id title type area tags matched score snippet], # Registry mode labels every row with the bundle it came from; a plain-dir # search has one bundle and no slug to carry. Two shapes, because the typo # guard checks against the *declared* one — a single shape covering both # would let `--fields slug` pass on a search whose rows have none, and hand # back an empty object per match under a count that says otherwise. "matches_by_ref" => %w[slug id title type area tags matched score snippet], "concepts" => %w[id title type description tags timestamp status backlog_ref dir area links_out links_in], "files" => %w[path id dir type title description], "directories" => %w[dir index_path present synthesized count types tags subdirs body listing], "bundles" => %w[slug title dir mount default missing] }.freeze
- WEBRICK =
Runs a Rack app under WEBrick until interrupted. Injected into the CLI so tests can drive
serverwithout opening a socket; the runner loads here (not at require time) sorequire "okf"and a Rails mount of the server stay light. lambda do |app, host, port| require "okf/server/runner" OKF::Server::Runner.run(app, host: host, port: port) end
- PLUGIN_FILE =
The file a gem ships to add verbs to
okf. Everything about the seam is in this one constant: a gem that wants to extend the CLI putsokf/plugin.rbon its load path and registers from it.A convention rather than a list the base gem keeps, because the alternative is this gem naming its own addons — and the moment it does, adding an addon means editing okf.
--enginealready set the precedent on the search side: an addon shows up in help without the CLI knowing it exists. "okf/plugin.rb"- PLUGIN_GEM_PREFIX =
Only gems named
okf-*are loaded — the namespacing convention Jekyll (jekyll-*) and Vagrant (vagrant-*) use, which is the reason the rule is here: it makes what counts as an okf extension explicit and stops an unrelated gem claiming theokf/plugin.rbpath. It guards a little too, sincerequireruns what it loads, but that window is nearly empty and overselling it would be worse than having no rule. The argument in full is at #plugin_paths. "okf-"- UNKNOWN_GEM =
What #plugin_gem_name answers when it cannot work out a path's owning gem at all — deliberately distinct from nil, which means "belongs to no gem" and is trusted. See #plugin_gem_name for why the two must not merge.
:unknown- GROUPS =
The map's shape: the order the groups print in, and the heading each one carries. Only extensions get a heading — the built-in groups are separated by a blank line and their verbs speak for themselves, which is how this map has always read. A plugin's verbs are labelled because "where did this come from?" is a question only an installed extension raises.
[ [ :act, nil ], [ :registry, nil ], [ :judge, nil ], [ :read, nil ], [ :graph, nil ], [ :extension, " installed extensions:" ] ].freeze
- NOTE =
Everything the map's grammar column cannot say for itself. A test finds the
@slug namesparagraph by its opening words, so the wording is load-bearing. <<~NOTE @slug names a registered bundle instead of a path — the slug from `okf registry set`, or bare @ for the registry default. Anywhere a <dir> goes, an @slug goes: `okf lint @handbook`, `okf render @ -o graph.html`. The registry lives under $OKF_HOME (default ~/.okf); set it to point every verb at another one. search spans bundles: several leading @slugs, or @all for every registered one (@all skips a bundle whose directory is gone; a named @slug insists on it). [filters] narrow a view to matching concepts: --type TYPE, --area AREA, --tag TAG (each view takes the ones orthogonal to it; matching is case-insensitive). tags --by DIM regroups the tags per concept dimension — type or area — with within-group counts, the view for curating a tag vocabulary. --json emits compact JSON (the machine substrate); add --pretty to indent it. --fields / --except project the JSON to the properties you want (search/index/catalog/files). okf --version NOTE
Class Attribute Summary collapse
-
.plugin_discovery_error ⇒ Object
readonly
Why the search for extensions could not run at all, when it could not.
-
.plugin_gem_error ⇒ Object
readonly
Why a name could not be read, when one could not.
Class Method Summary collapse
-
.builtins ⇒ Object
The verbs this gem ships, frozen at seal time.
-
.commands ⇒ Object
A frozen snapshot in registration order — which, for the built-ins, is the order this file requires them in at the bottom, and therefore the order
okf helplists them in. -
.declined ⇒ Object
Registrations refused because the id was taken.
- .extension?(command) ⇒ Boolean
-
.gem_index ⇒ Object
Every installed gem's path with the name that owns it, or UNKNOWN_GEM if the specs could not be walked.
-
.load_plugins ⇒ Object
Load every installed extension, once.
- .lookup(name) ⇒ Object
-
.plugin_gem_name(path) ⇒ Object
Three answers, and the third has to stay distinct from the second: a gem name; nil when the path belongs to no gem at all — a bare $LOAD_PATH entry, which is how a checkout,
ruby -I, a Gemfilepath:and the suite's own fixtures appear, and which stays trusted because someone put it there; and UNKNOWN_GEM when the lookup itself failed. -
.plugin_paths ⇒ Object
Latest-version-only where RubyGems offers it, so two installed versions of the same addon cannot both register.
-
.register(command) ⇒ Object
Append-only and idempotent by id: a second registration of an id already present is a no-op, so a double
requirecannot double the registry and an addon cannot quietly displace a built-in. -
.reset_plugins! ⇒ Object
Test seam: put the registry back to what shipped and forget the load latch.
-
.seal_builtins! ⇒ Object
Called once at the bottom of this file, after the built-ins have registered.
- .start(argv, out: $stdout, err: $stderr, input: $stdin) ⇒ Object
-
.trusted_gem?(name) ⇒ Boolean
Whether a name clears the prefix rule.
-
.untrusted_plugins ⇒ Object
Paths discovered but refused for their gem's name, as [ path, gem ] pairs.
Instance Method Summary collapse
-
#initialize(out: $stdout, err: $stderr, runner: WEBRICK, input: $stdin) ⇒ CLI
constructor
A new instance of CLI.
- #run(argv) ⇒ Object
Constructor Details
#initialize(out: $stdout, err: $stderr, runner: WEBRICK, input: $stdin) ⇒ CLI
Returns a new instance of CLI.
382 383 384 385 386 387 388 |
# File 'lib/okf/cli.rb', line 382 def initialize(out: $stdout, err: $stderr, runner: WEBRICK, input: $stdin) @out = out @err = err @runner = runner @input = input @plugin_notes_reported = false end |
Class Attribute Details
.plugin_discovery_error ⇒ Object (readonly)
Why the search for extensions could not run at all, when it could not. Distinct from the above: that one refuses paths it found, this one found none — so there is nothing to refuse and the error is the only witness.
315 316 317 |
# File 'lib/okf/cli.rb', line 315 def plugin_discovery_error @plugin_discovery_error end |
.plugin_gem_error ⇒ Object (readonly)
Why a name could not be read, when one could not. Reported with the refusal it caused: "could not be determined" on its own names no gem to fix and no reason to look.
310 311 312 |
# File 'lib/okf/cli.rb', line 310 def plugin_gem_error @plugin_gem_error end |
Class Method Details
.builtins ⇒ Object
The verbs this gem ships, frozen at seal time.
333 334 335 |
# File 'lib/okf/cli.rb', line 333 def builtins (@builtins ||= []).dup.freeze end |
.commands ⇒ Object
A frozen snapshot in registration order — which, for the built-ins, is
the order this file requires them in at the bottom, and therefore the
order okf help lists them in.
140 141 142 |
# File 'lib/okf/cli.rb', line 140 def commands (@commands ||= []).dup.freeze end |
.declined ⇒ Object
Registrations refused because the id was taken. Kept so the refusal can be reported — Search can no-op in silence because an engine nobody selected is invisible either way, but a verb that silently does nothing is a bug report waiting to happen.
154 155 156 |
# File 'lib/okf/cli.rb', line 154 def declined (@declined ||= []).dup.freeze end |
.extension?(command) ⇒ Boolean
337 338 339 |
# File 'lib/okf/cli.rb', line 337 def extension?(command) !builtins.include?(command) end |
.gem_index ⇒ Object
Every installed gem's path with the name that owns it, or UNKNOWN_GEM if
the specs could not be walked. Not a saving — it replaces a find that
short-circuited on the first match with a map over all of them, so on
the ordinary one-path discovery it is strictly more work (1.0ms for 282
specs). It buys a shape instead: one pass has one outcome, so every
path in a discovery gets the same answer. Per-path enumeration made that
a lottery — a failure that cleared between paths, a gemspec rewritten by
a concurrent gem install, would refuse one path and trust the next in
the same run.
Which is why the failure memoizes too. ||= over a raising expression
caches nothing, so the second path would try again and could get a
different answer — the lottery back, in the branch the whole thing was
written for.
Built lazily, so the ordinary run — nothing discovered — never walks the specs at all.
295 296 297 298 299 300 301 302 303 304 305 |
# File 'lib/okf/cli.rb', line 295 def gem_index @gem_index ||= begin Gem::Specification.map do |spec| full = spec.full_gem_path [ full.end_with?(File::SEPARATOR) ? full : "#{full}#{File::SEPARATOR}", spec.name ] end rescue ::StandardError => e @plugin_gem_error = e UNKNOWN_GEM end end |
.load_plugins ⇒ Object
Load every installed extension, once. Returns the failures as [ path, error ] pairs rather than printing them: this is a class method with no streams, and the CLI's whole contract is that nothing writes anywhere but the streams it was handed.
A plugin that raises is skipped and reported, never fatal — the same
best-effort posture the reader takes with an unparseable file. One broken
addon must not cost a user their okf lint.
166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 |
# File 'lib/okf/cli.rb', line 166 def load_plugins return @plugin_failures if @plugins_loaded @plugins_loaded = true @plugin_failures = [] @loaded_plugins = [] plugin_paths.each do |path| begin require path @loaded_plugins << path rescue ::LoadError, ::StandardError => e @plugin_failures << [ path, e ] end end @plugin_failures end |
.lookup(name) ⇒ Object
144 145 146 147 148 |
# File 'lib/okf/cli.rb', line 144 def lookup(name) return nil if OKF.blank?(name) commands.find { |command| command.id.to_s == name.to_s } end |
.plugin_gem_name(path) ⇒ Object
Three answers, and the third has to stay distinct from the second: a gem
name; nil when the path belongs to no gem at all — a bare $LOAD_PATH
entry, which is how a checkout, ruby -I, a Gemfile path: and the
suite's own fixtures appear, and which stays trusted because someone put
it there; and UNKNOWN_GEM when the lookup itself failed.
Answering nil for that last case is fail-open, and worth spelling out because it reads as harmless: enumerating the installed specs is what raises when one gemspec anywhere on the machine is corrupt — and every discovered path would come back "belongs to no gem" and load. A rule that quietly switches itself off under failure is the false confidence this one is deliberately modest to avoid, so a name that cannot be read is refused — and the cause is kept, because refusing every extension on the machine while naming no reason leaves the user nothing to act on.
Resolved from the spec's full_gem_path rather than by loading anything: naming an extension must never mean running it.
257 258 259 260 261 262 263 |
# File 'lib/okf/cli.rb', line 257 def plugin_gem_name(path) index = gem_index return UNKNOWN_GEM if index.equal?(UNKNOWN_GEM) found = index.find { |prefix, _name| path.start_with?(prefix) } found&.last end |
.plugin_paths ⇒ Object
Latest-version-only where RubyGems offers it, so two installed versions of the same addon cannot both register. The fallback keeps the floor: find_latest_files has been there since RubyGems 1.8, but the guard costs nothing and says which method the behaviour depends on.
Narrowed to gems named okf-* — the convention Jekyll (jekyll-*) and
Vagrant (vagrant-*) use for the same job, and the reason the rule is
here: it makes what counts as an okf extension explicit, and stops an
unrelated gem claiming the okf/plugin.rb path by accident.
It is a mild guard as well, since require runs whatever it loads, but
the window it closes is nearly empty and calling it a defence would
invite the false confidence that is worse than having no rule at all. A
transitive dependency is required by its parent in normal use, so
require "foo" already runs foo's; under Bundler, discovery is
bundle-scoped, so the Gemfile is an allowlist already. What is left is a
pure-Ruby gem installed globally and then used by nothing — and nothing
here saves anyone from a package deliberately installed under an okf-
name, because gem install has already run on it.
The rule underneath this one is load-bearing: naming a gem must never
load it. See plugin_gem_name below, and
.okf/design/extension-points.md for the argument in full.
206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 |
# File 'lib/okf/cli.rb', line 206 def plugin_paths # Cleared first, so the rescue below cannot return "found nothing" while # leaving an earlier call's refusals standing to be reported again. @untrusted_plugins = [] @plugin_gem_error = nil @plugin_discovery_error = nil @gem_index = nil found = if Gem.respond_to?(:find_latest_files) Gem.find_latest_files(PLUGIN_FILE) else Gem.find_files(PLUGIN_FILE) end found.select do |path| name = plugin_gem_name(path) next true if trusted_gem?(name) @untrusted_plugins << [ path, name ] false end rescue ::StandardError => e # The *search* failing is its own report, and the reason is the one # `plugin_gem_name` already answers to one frame down: an empty list and # no message is indistinguishable from a machine with nothing installed. # There is no path left to hang a refusal on here, so the failure has to # carry itself or it is not reported at all. @plugin_discovery_error = e [] ensure # Only a snapshot for the length of one discovery — a long-lived copy of # every installed spec's path would outlive its usefulness and go stale. @gem_index = nil end |
.register(command) ⇒ Object
Append-only and idempotent by id: a second registration of an id already
present is a no-op, so a double require cannot double the registry and
an addon cannot quietly displace a built-in. Deliberately the same
shape as Search.register — three extension points, one idiom.
The duck type is checked here rather than at dispatch, so a malformed command fails where it is installed instead of the first time somebody types its verb.
125 126 127 128 129 130 131 132 133 134 135 |
# File 'lib/okf/cli.rb', line 125 def register(command) missing = Command::DUCK_TYPE.reject { || command.respond_to?() } raise ArgumentError, "#{command} cannot be a command: it does not answer #{missing.join(", ")}" unless missing.empty? @commands ||= [] existing = @commands.find { |registered| registered.id == command.id } return register_declined(command, existing) if existing @commands << command command end |
.reset_plugins! ⇒ Object
Test seam: put the registry back to what shipped and forget the load latch. Registration happens at require time, so without this a test that installs a fake plugin would leak it into every test that runs after it.
Dropping the files from $LOADED_FEATURES is not optional, and the reason
is worth stating: require is idempotent, so clearing the registry
alone leaves a plugin unregistered and unloadable — the next
load_plugins would find the file, require it, get false, and register
nothing. That only stays hidden while each test writes its plugin to a
fresh tmpdir; the moment one points at a real gem's lib/, the verb
vanishes after the first reset.
352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 |
# File 'lib/okf/cli.rb', line 352 def reset_plugins! Array(@loaded_plugins).each { |path| $LOADED_FEATURES.delete(path) } @loaded_plugins = [] # Unconditional, because there is always a seal to roll back to: # `seal_builtins!` runs at the bottom of this file, so a caller that can # name this method has already loaded it. An earlier version guarded on # `@builtins.nil?` to cover a pre-seal call — a state that cannot occur, # and a test that could not have detected it either way, since `builtins` # memoizes `@builtins ||= []` and so stops being nil on its first read. @commands = builtins.dup @plugins_loaded = false @plugin_failures = [] @plugin_gem_error = nil @plugin_discovery_error = nil @untrusted_plugins = [] @declined = [] end |
.seal_builtins! ⇒ Object
Called once at the bottom of this file, after the built-ins have registered. Everything registered after it is an extension — which makes "built-in" a fact the CLI knows rather than a group a command claims, and gives a test somewhere to roll back to.
328 329 330 |
# File 'lib/okf/cli.rb', line 328 def seal_builtins! @builtins = commands end |
.start(argv, out: $stdout, err: $stderr, input: $stdin) ⇒ Object
378 379 380 |
# File 'lib/okf/cli.rb', line 378 def self.start(argv, out: $stdout, err: $stderr, input: $stdin) new(out: out, err: err, input: input).run(argv) end |
.trusted_gem?(name) ⇒ Boolean
Whether a name clears the prefix rule. Three answers in, two out, and
the middle one is the whole point: nil belongs to no gem — a checkout,
ruby -I, a Gemfile path: — and stays trusted because someone put it
there deliberately, while UNKNOWN_GEM is the lookup itself failing and is
refused, because a rule that switches itself off when it cannot get an
answer is the false confidence this one is deliberately modest to avoid.
271 272 273 274 275 276 |
# File 'lib/okf/cli.rb', line 271 def trusted_gem?(name) return true if name.nil? return false if name.equal?(UNKNOWN_GEM) name.start_with?(PLUGIN_GEM_PREFIX) end |
.untrusted_plugins ⇒ Object
Paths discovered but refused for their gem's name, as [ path, gem ] pairs. Kept so the refusal can be reported: an extension that is present and deliberately not run is exactly the thing a user needs told.
320 321 322 |
# File 'lib/okf/cli.rb', line 320 def untrusted_plugins (@untrusted_plugins ||= []).dup.freeze end |
Instance Method Details
#run(argv) ⇒ Object
390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 |
# File 'lib/okf/cli.rb', line 390 def run(argv) argv = argv.dup # -h/--help is answered wherever a parser sees it — deep inside # positional_dir, where returning would only mean "usage error, exit 2". # Thrown here instead, so help keeps the contract every other path keeps: # a status this method returns. See Command#help_flag. catch(:help) do case (name = argv.shift) when "version", "--version", "-v" then @out.puts(OKF::VERSION); 0 when "help", "--help", "-h" then usage(@out); 0 when nil then usage(@err); 2 else dispatch(name, argv) end end end |