Class: Pikuri::Command::Registry

Inherits:
Object
  • Object
show all
Defined in:
lib/pikuri/command/registry.rb

Overview

On-disk registry of slash commands, over the same search bases as the skill catalog: under each base it scans .pikuri/commands and .claude/commands for .md files.

registry = Pikuri::Command::Registry.new(search_bases: [
Pikuri::Bundle::Base.new(dir: project_root, trusted: repo_sources_are_mine),
Pikuri::Bundle::Base.new(dir: Dir.home, trusted: home_bundles_are_mine)
])
text = registry.expand('wh', arguments: 'swingai')   # nil if no such command
agent.run_loop(user_message: text)                   # it *is* the human's turn

Names are paths

A command's name is its file path under the scanned dir, .md dropped and separators +:+-joined: commands/frontend/deploy.md is /frontend:deploy. A frontmatter name: is ignored (warned about) — for a skill the name is a label the model reads, but for a command it is the address the human types, and an address its location doesn't predict is not an address.

Precedence

Bases left to right, .pikuri/commands before .claude/commands; the first command seen for a name wins and later ones are dropped with a warning. Pass the project root first for "project beats global".

A host resolving a typed /name owes one more rule, since nothing here sees the other two namespaces: host built-in > command > skill. Match built-ins first or a cloned repo shipping .claude/commands/plan.md captures a /plan that flips a safety gate.

Validation, and the two refusals

Nothing else is fatal. Frontmatter is optional (a bare .md is all body); description falls back to the name; unknown keys are ignored. A command that parses is a command the human can invoke — a menu entry that silently vanished would be the worse failure, since the human is looking straight at the list.

Two things are refused, at #get rather than at scan time, so typing /foo explains itself instead of the entry being mysteriously absent:

  • allowed-tools: — a narrowing pikuri cannot honour, since an agent's toolset is fixed at configure time; ignoring the key would widen what the author asked to fence.
  • a !cmd</code> body directive — the shell command Claude Code runs before sending the prompt. pikuri never runs it.

Detection is over-eager on purpose and does not skip fenced blocks — the inverse of what {Renderer} does to the same characters, which is not an inconsistency; see there. Rationale for both refusals: DECISIONS.md D_command_inlining_legs.

Trust

{#untrusted_level} unions {Pikuri::Bundle::Base#trusted} over the commands actually stored, exactly as the skill catalog does, and {Extension} declares the result. A refused command never counts: its bytes cannot reach the context.

Immutable.

Defined Under Namespace

Classes: Command

Instance Attribute Summary collapse

  • #list ⇒ Array<Command> readonly

    Commands in discovery order (which equals precedence order), refused ones included so a menu can show them.

  • #roots ⇒ Array<String> readonly

    Absolute paths of the command directories this registry covered, in scan order.

Instance Method Summary collapse

Constructor Details

#initialize(search_bases:) ⇒ Registry

Parameters:

  • search_bases (Array<Pikuri::Bundle::Base, String, Pathname>)

    directories under which to look for command subdirs. Typical values: the project root and Dir.home. Processed left to right. A bare path means Bundle::Base.new(dir: path) — untrusted, so the unsafe value is the one you get by saying nothing.



119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
# File 'lib/pikuri/command/registry.rb', line 119

def initialize(search_bases:)
  @roots = []
  @commands = {}
  @untrusted = false

  search_bases.each do |entry|
    base = entry.is_a?(Bundle::Base) ? entry : Bundle::Base.new(dir: entry)
    COMMAND_SUBDIRS.each do |sub|
      root = File.join(base.dir.to_s, sub)
      next unless File.directory?(root)

      @roots << root
      scan_root(root, trusted: base.trusted)
    end
  end

  @roots.freeze
  @commands.freeze
  @list = @commands.values.freeze
  freeze
end

Instance Attribute Details

#listArray<Command> (readonly)

Returns commands in discovery order (which equals precedence order), refused ones included so a menu can show them.

Returns:

  • (Array<Command>)

    commands in discovery order (which equals precedence order), refused ones included so a menu can show them



143
144
145
# File 'lib/pikuri/command/registry.rb', line 143

def list
  @list
end

#rootsArray<String> (readonly)

Returns absolute paths of the command directories this registry covered, in scan order. Hosts fold these into +Pikuri::Workspace::Filesystem+'s readable: so the model can read a file a command body points at.

Returns:

  • (Array<String>)

    absolute paths of the command directories this registry covered, in scan order. Hosts fold these into +Pikuri::Workspace::Filesystem+'s readable: so the model can read a file a command body points at.



149
150
151
# File 'lib/pikuri/command/registry.rb', line 149

def roots
  @roots
end

Instance Method Details

#empty?Boolean

Returns true when no base held a command; Extension then declares nothing.

Returns:

  • (Boolean)

    true when no base held a command; Extension then declares nothing



184
# File 'lib/pikuri/command/registry.rb', line 184

def empty? = @list.empty?

#expand(name, arguments: '') ⇒ String?

#get plus Pikuri::Command::Renderer.expand, in one call. What comes back is the human's own user turn — see Pikuri::Command::Renderer for what that rules out.

Parameters:

  • name (String)
  • arguments (String) (defaults to: '')

    everything typed after the command name

Returns:

  • (String, nil)

    nil when no command has that name

Raises:

  • (RefusedError)

    when the command exists but cannot be run



175
176
177
178
179
180
# File 'lib/pikuri/command/registry.rb', line 175

def expand(name, arguments: '')
  command = get(name)
  return nil if command.nil?

  Renderer.expand(command, arguments: arguments)
end

#get(name) ⇒ Command?

Look a command up by the name the human typed.

Two outcomes beyond success, and they are different on purpose: absent is an ordinary thing a human causes by typo, while refused is a fact about a file on disk that retyping cannot fix.

Parameters:

  • name (String)

    e.g. "wh" or "frontend:deploy"

Returns:

  • (Command, nil)

    nil when no command has that name

Raises:

  • (RefusedError)

    when the command exists but cannot be run



160
161
162
163
164
165
166
# File 'lib/pikuri/command/registry.rb', line 160

def get(name)
  command = @commands[name]
  return nil if command.nil?
  raise RefusedError.new(command.location, command.refusal) if command.refused?

  command
end

#untrusted_levelSymbol

Returns :none when every runnable command came from a trusted base, else :hard.

Returns:

  • (Symbol)

    :none when every runnable command came from a trusted base, else :hard



188
# File 'lib/pikuri/command/registry.rb', line 188

def untrusted_level = @untrusted ? :hard : :none