Class: OKF::CLI

Inherits:
Object
  • Object
show all
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 search expands 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/--except can be checked against a name even when the result is empty. Without it the typo guard keyed off the data: --fields bogus was 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 server without opening a socket; the runner loads here (not at require time) so require "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 puts okf/plugin.rb on 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. --engine already 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 the okf/plugin.rb path. It guards a little too, since require runs 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 names paragraph 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

Class Method Summary collapse

Instance Method Summary collapse

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_errorObject (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_errorObject (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

.builtinsObject

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

.commandsObject

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

.declinedObject

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

Returns:

  • (Boolean)


337
338
339
# File 'lib/okf/cli.rb', line 337

def extension?(command)
  !builtins.include?(command)
end

.gem_indexObject

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_pluginsObject

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_pathsObject

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.

Raises:

  • (ArgumentError)


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 { |message| command.respond_to?(message) }
  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.

Returns:

  • (Boolean)


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_pluginsObject

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