pikuri-skills 0.0.7 → 0.1.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7f8ffca294daaa6b3ecdf2b24aa4a61cabcb447181067aa937a793def7f158a8
4
- data.tar.gz: 61bc4b765ad51cfab254e89086bcd6ac8bd26410caa5707afb42acc98d696ec5
3
+ metadata.gz: 9c98e45fc109b71c03ed61bcc841eda71bbb74454bab297087fe100401ce7185
4
+ data.tar.gz: 18d24a2ac73b760da78df70fe18d551ff7d24ca9c99b4cf0e63f364cd36beabf
5
5
  SHA512:
6
- metadata.gz: 0be0e990da5370c7863544ddf60f3137ce1a4170a8047b25950a344dfbf3fd08a9b493dcc204f9e2c976b634c13a5df48c8f909592afa5a633b1d57937d3c117
7
- data.tar.gz: 1689554961b21d618bd7507ca48cb8c36bf1c0ddecc11d85311a8b45d67eaab5c241dbbbddf1bf98c09cd8825ee40a6b1d21bb0bf5fcfc95a872f21bccdc0113
6
+ metadata.gz: 9aed0f05b6f15478060c7ea4dee97e2dbb3f64f973a00f89118b4d12c328899b2f6115b7388234db8ac45a0842dcba633e68e2db766d7d9a314b36baf7daa66a
7
+ data.tar.gz: 18b79412748c6883e01b1d994349c2c5874173d94ec682dbe5cfac7a772bb4c82910d0226fcff174ea4c5c31bf01c7fdf2cb96c9397f5ba5525afe15d0bdc6d1
data/README.md CHANGED
@@ -2,7 +2,8 @@
2
2
 
3
3
  [Agent Skills standard](https://agentskills.io/specification)
4
4
  support for the [pikuri](https://codeberg.org/mvysny/pikuri)
5
- AI-assistant toolkit.
5
+ AI-assistant toolkit — plus its human-invoked counterpart, slash
6
+ commands.
6
7
 
7
8
  Provides:
8
9
  - `Pikuri::Skill::Catalog` — discovery + validation of skill folders
@@ -13,6 +14,11 @@ Provides:
13
14
  a skill's body into the conversation on demand.
14
15
  - `Pikuri::Skill::Extension` — wires both into a `Pikuri::Agent` via
15
16
  the `c.add_extension(...)` block API.
17
+ - `Pikuri::Command::Registry` / `Pikuri::Command::Renderer` — slash
18
+ commands: prompt templates *you* invoke by typing `/name`, scanned
19
+ from `.pikuri/commands` and `.claude/commands` under the same search
20
+ bases. Never shown to the model, so they cost nothing in the system
21
+ prompt.
16
22
 
17
23
  ## Install
18
24
 
@@ -36,7 +42,7 @@ catalog = Pikuri::Skill::Catalog::Bundled.new(
36
42
  )
37
43
 
38
44
  agent = Pikuri::Agent.new(transport: ..., system_prompt: ...) do |c|
39
- c.add_extension(Pikuri::Skill::Extension.new(catalog: catalog))
45
+ c.add_extension(Pikuri::Skill::Extension.new(catalog: catalog, root: project_root))
40
46
  end
41
47
  ```
42
48
 
@@ -46,10 +52,79 @@ skill) and registers the `skill` tool. The LLM invokes `skill` with a
46
52
  name; the tool returns the skill's body wrapped with its base
47
53
  directory so the LLM can resolve sidecar files via `read`.
48
54
 
55
+ ## Skills that wait for the right project
56
+
57
+ A skill only some projects need can say so in its frontmatter, and stay
58
+ out of the prompt everywhere else:
59
+
60
+ ```yaml
61
+ ---
62
+ name: writing-rdoc
63
+ description: Writing Ruby doc comments — which level each fact belongs at.
64
+ paths:
65
+ - "**/*.rb"
66
+ - "**/*.gemspec"
67
+ when-to-use: >-
68
+ About to write or edit a # comment, or a @param/@return tag.
69
+ ---
70
+ ```
71
+
72
+ Such a skill is withheld completely — no name, no description, and the
73
+ `skill` tool refuses it exactly as it refuses a name that exists nowhere.
74
+ The moment the agent reads, writes or edits a file matching one of the
75
+ globs, its description arrives in the conversation and it becomes
76
+ loadable. `/clear` hides it again.
77
+
78
+ Patterns are gitignore syntax, so a slash-less pattern matches at any
79
+ depth and a bare directory covers everything beneath it. Two things
80
+ worth knowing before you write one:
81
+
82
+ - **`paths: []` means *always advertised*, not "never"** — as does
83
+ `paths: ["**"]`, and a bare `paths:` with nothing under it.
84
+ - **Give the agent an `interloper:`** (`Pikuri::Agent::Control::Interloper.new`).
85
+ A promotion arrives as a mid-loop injection, so without one there is
86
+ nowhere for it to land — pikuri then leaves every skill advertised and
87
+ logs a warning, rather than hiding one it could never bring back.
88
+
89
+ ## Slash commands
90
+
91
+ A command is one Markdown file. The model is never told it exists —
92
+ you invoke it by name, and what it expands to becomes *your* next
93
+ message:
94
+
95
+ ```ruby
96
+ registry = Pikuri::Command::Registry.new(
97
+ search_bases: [project_root, Dir.home]
98
+ )
99
+
100
+ # ~/.claude/commands/wh.md -> /wh
101
+ text = registry.expand('wh', arguments: 'swingai') # nil if no such command
102
+ agent.run_loop(user_message: text) # it *is* your turn
103
+ ```
104
+
105
+ `$ARGUMENTS` in the body becomes everything you typed after the name;
106
+ `$1`…`$9` are its whitespace-separated words. Both are left alone
107
+ inside backticks and fenced blocks, so a body that *writes about* its
108
+ own arguments survives expansion. If the body mentions neither, what
109
+ you typed is appended as `ARGUMENTS: …` instead of being dropped.
110
+
111
+ A file's path is its name: `commands/frontend/deploy.md` is
112
+ `/frontend:deploy`. Frontmatter is optional; `description` and
113
+ `argument-hint` feed a menu, everything else is ignored — except
114
+ `allowed-tools:`, which is refused, as is a body containing a
115
+ `` !`cmd` `` directive. pikuri will not run a shell command because
116
+ you typed a slash, and it will not silently *widen* a toolset an
117
+ author asked to narrow. Add
118
+ `Pikuri::Command::Extension.new(registry: registry)` to the agent so
119
+ the trifecta report knows the commands are there.
120
+
121
+ Try it in `bin/pikuri-code` or `bin/pikuri-assistant`: both list the
122
+ commands they found at boot and dispatch a typed `/name`.
123
+
49
124
  ## Further reading
50
125
 
51
126
  - **Narrative walkthrough:**
52
- [chapter 12 of the pikuri guide](../docs/guide/12-skills.md) —
127
+ [the skills chapter of the pikuri guide](../book/skills.md) —
53
128
  how skill folders are discovered, the `SKILL.md` format, and a
54
129
  worked drop-in example. (Currently a stub chapter; the API
55
130
  surface lives in the YARD docs below.)
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pikuri
4
+ # Discovery vocabulary shared by pikuri's *bundles* — Markdown files with
5
+ # YAML frontmatter, found by scanning a list of search bases rather than by
6
+ # being registered one at a time.
7
+ module Bundle
8
+ # One search base: a directory to scan, plus whether the bytes under it
9
+ # are the user's own.
10
+ #
11
+ # Bundle::Base.new(dir: project_root, trusted: repo_sources_are_mine)
12
+ # Bundle::Base.new(dir: Dir.home) # trusted: false
13
+ #
14
+ # +trusted+ asks what +Pikuri::Workspace::Filesystem#trusted?+ asks for
15
+ # the file tools — user-authored bytes, or bytes that arrived with a
16
+ # clone? — but about *this root alone*. That is why it is a value the
17
+ # host passes rather than a reach into the workspace: the two objects
18
+ # reach different bytes, so a workspace whose +readable:+ carries
19
+ # +~/.m2/repository+ is honestly untrusted while the project's own
20
+ # +.pikuri/skills+ beside it is honestly the user's. Like +private:+, it
21
+ # is unverifiable, so silence buys +false+.
22
+ #
23
+ # A scanner grades its own untrusted trifecta leg by unioning this flag
24
+ # over the bundles it actually stored, so an untrusted base costs nothing
25
+ # while it holds nothing.
26
+ Base = Data.define(:dir, :trusted) do
27
+ # @param dir [String, Pathname] directory to scan for bundle subdirs
28
+ # @param trusted [Boolean] every byte under +dir+ is user-authored
29
+ # @return [Base]
30
+ def initialize(dir:, trusted: false) = super
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yaml'
4
+
5
+ module Pikuri
6
+ module Bundle
7
+ # Splits a bundle file into its YAML frontmatter and its body.
8
+ #
9
+ # Frontmatter.split(File.read(path), path: path)
10
+ # # => [{"description" => "Weekly work log"}, "# Weekly work log\n…"]
11
+ #
12
+ # A file with no +---+ block splits as +[nil, body]+ — the caller decides
13
+ # whether that is a skipped file or an ordinary one, and the two bundle
14
+ # surfaces answer differently. Malformed YAML degrades to +[{}, body]+
15
+ # with a warning rather than raising: a frontmatter typo should cost the
16
+ # keys, not the file.
17
+ module Frontmatter
18
+ LOGGER = Pikuri.logger_for('Bundles')
19
+ private_constant :LOGGER
20
+
21
+ module_function
22
+
23
+ # @param content [String] the whole file
24
+ # @param path [String] used in warnings only
25
+ # @return [Array(Hash{String=>Object}, String)] parsed keys and body
26
+ # @return [Array(nil, String)] when no frontmatter block is present
27
+ def split(content, path:)
28
+ normalized = content.gsub(/\r\n?/, "\n")
29
+ return [nil, normalized] unless normalized.start_with?("---\n")
30
+
31
+ end_marker = normalized.index("\n---", 4)
32
+ return [nil, normalized] unless end_marker
33
+
34
+ body = normalized[(end_marker + 4)..].to_s.sub(/\A\n/, '')
35
+ [parse(normalized[4...end_marker], path: path), body]
36
+ end
37
+
38
+ # @param yaml_str [String]
39
+ # @param path [String]
40
+ # @return [Hash{String=>Object}] +{}+ on a parse error or a non-Hash document
41
+ def parse(yaml_str, path:)
42
+ parsed =
43
+ begin
44
+ YAML.safe_load(yaml_str) || {}
45
+ rescue Psych::SyntaxError => e
46
+ LOGGER.warn("#{path}: frontmatter YAML parse error: #{e.message}")
47
+ {}
48
+ end
49
+ parsed.is_a?(Hash) ? parsed : {}
50
+ end
51
+ private_class_method :parse
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pikuri
4
+ module Command
5
+ # Declares the command surface to {Pikuri::Trifecta}, and does nothing
6
+ # else — it registers no tool, renders no prompt snippet and binds no
7
+ # handle.
8
+ #
9
+ # Pikuri::Agent.new(transport: ..., system_prompt: ...) do |c|
10
+ # c.add_extension Pikuri::Command::Extension.new(registry: registry)
11
+ # end
12
+ #
13
+ # A host wires it purely so the boot banner tells the truth. Expansion
14
+ # needs none of this: {Registry} and {Renderer} are pure functions over
15
+ # disk, and a host dispatches a typed +/name+ itself.
16
+ #
17
+ # == Why an extension for one declaration
18
+ #
19
+ # The detector builds its tree from an agent's *tools* plus what its
20
+ # extensions contribute. A command is neither — so a wired command surface
21
+ # would land in that tree nowhere, and an agent that genuinely holds all
22
+ # three legs would report two. Skills don't have this problem only because
23
+ # their bytes ride {Pikuri::Skill::SkillTool}'s declaration; commands have
24
+ # no tool to ride.
25
+ #
26
+ # An object rather than a kwarg on the host side because the mistake
27
+ # should be unrepresentable, not something each host remembers.
28
+ class Extension
29
+ include Pikuri::Agent::Extension
30
+
31
+ # @param registry [Pikuri::Command::Registry]
32
+ def initialize(registry:)
33
+ @registry = registry
34
+ end
35
+
36
+ # @return [Pikuri::Command::Registry]
37
+ attr_reader :registry
38
+
39
+ # The +untrusted+ leg, graded over the commands the registry stored:
40
+ # +:hard+ unless every one of them came from a base the host vouched
41
+ # for. No +private+ leg (a command reads nothing) and no egress leg (it
42
+ # sends nothing) — a command body is prose that lands in a user turn.
43
+ #
44
+ # @param tools [Array<Pikuri::Tool>] unused; commands contribute no children
45
+ # @return [Pikuri::Trifecta::Contribution, nil] +nil+ when there is
46
+ # nothing to declare, so an empty or fully-vouched registry adds no row
47
+ def trifecta_contribution(tools)
48
+ level = @registry.untrusted_level
49
+ return nil if level == :none
50
+
51
+ Pikuri::Trifecta::Contribution.new(
52
+ label: '(commands)',
53
+ legs: Pikuri::Tool::TrifectaLegs.new(
54
+ private: false, untrusted: level, egress_payload_review: :no_egress
55
+ )
56
+ )
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pikuri
4
+ module Command
5
+ # Raised by {Registry#get} for a command that exists on disk but cannot be
6
+ # run — it declares +allowed-tools:+, or its body carries a
7
+ # <code>!`cmd`</code> directive.
8
+ #
9
+ # Host-facing: it never reaches a tool, so the +"Error: …"+-string
10
+ # convention for model-visible failures does not apply. A REPL rescues it
11
+ # and prints {#message}; the command stays in {Registry#list} so a menu can
12
+ # still show it, greyed out or not.
13
+ #
14
+ # begin
15
+ # text = registry.expand(name, arguments: args)
16
+ # rescue Pikuri::Command::RefusedError => e
17
+ # puts "/#{name} is unavailable — #{e.message}"
18
+ # next
19
+ # end
20
+ class RefusedError < StandardError
21
+ # @return [String] path of the command file
22
+ attr_reader :location
23
+
24
+ # @param location [String] path of the command file
25
+ # @param reason [String] what the file did, phrased as the predicate of
26
+ # "this command …"
27
+ def initialize(location, reason)
28
+ @location = location
29
+ super("#{location}: #{reason}")
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,268 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pikuri
4
+ # Slash commands: prompt templates a *human* invokes by typing +/name+.
5
+ #
6
+ # The counterpart of {Pikuri::Skill}, and deliberately not a variant of it.
7
+ # A skill is advertised to the model and loaded by the model, so it costs
8
+ # prompt tokens on every turn; a command is never advertised, costs nothing
9
+ # resident, and lands as the user's own turn when the human asks for it.
10
+ # {Registry} discovers them, {Renderer} expands one into the text a host
11
+ # then sends.
12
+ module Command
13
+ # On-disk registry of slash commands, over the same *search bases* as the
14
+ # skill catalog: under each base it scans +.pikuri/commands+ and
15
+ # +.claude/commands+ for +.md+ files.
16
+ #
17
+ # registry = Pikuri::Command::Registry.new(search_bases: [
18
+ # Pikuri::Bundle::Base.new(dir: project_root, trusted: repo_sources_are_mine),
19
+ # Pikuri::Bundle::Base.new(dir: Dir.home, trusted: home_bundles_are_mine)
20
+ # ])
21
+ # text = registry.expand('wh', arguments: 'swingai') # nil if no such command
22
+ # agent.run_loop(user_message: text) # it *is* the human's turn
23
+ #
24
+ # == Names are paths
25
+ #
26
+ # A command's name is its file path under the scanned dir, +.md+ dropped
27
+ # and separators +:+-joined: +commands/frontend/deploy.md+ is
28
+ # +/frontend:deploy+. A frontmatter +name:+ is *ignored* (warned about) —
29
+ # for a skill the name is a label the model reads, but for a command it is
30
+ # the address the human types, and an address its location doesn't predict
31
+ # is not an address.
32
+ #
33
+ # == Precedence
34
+ #
35
+ # Bases left to right, +.pikuri/commands+ before +.claude/commands+; the
36
+ # first command seen for a name wins and later ones are dropped with a
37
+ # warning. Pass the project root first for "project beats global".
38
+ #
39
+ # A host resolving a typed +/name+ owes one more rule, since nothing here
40
+ # sees the other two namespaces: **host built-in > command > skill**.
41
+ # Match built-ins first or a cloned repo shipping
42
+ # +.claude/commands/plan.md+ captures a +/plan+ that flips a safety gate.
43
+ #
44
+ # == Validation, and the two refusals
45
+ #
46
+ # Nothing else is fatal. Frontmatter is optional (a bare +.md+ is all
47
+ # body); +description+ falls back to the name; unknown keys are ignored.
48
+ # A command that parses is a command the human can invoke — a menu entry
49
+ # that silently vanished would be the worse failure, since the human is
50
+ # looking straight at the list.
51
+ #
52
+ # Two things are refused, at {#get} rather than at scan time, so typing
53
+ # +/foo+ explains itself instead of the entry being mysteriously absent:
54
+ #
55
+ # * +allowed-tools:+ — a narrowing pikuri cannot honour, since an agent's
56
+ # toolset is fixed at +configure+ time; ignoring the key would *widen*
57
+ # what the author asked to fence.
58
+ # * a <code>!`cmd`</code> body directive — the shell command Claude Code
59
+ # runs before sending the prompt. pikuri never runs it.
60
+ #
61
+ # Detection is over-eager on purpose and does *not* skip fenced blocks —
62
+ # the inverse of what {Renderer} does to the same characters, which is not
63
+ # an inconsistency; see there. Rationale for both refusals:
64
+ # +DECISIONS.md+ +D_command_inlining_legs+.
65
+ #
66
+ # == Trust
67
+ #
68
+ # {#untrusted_level} unions {Pikuri::Bundle::Base#trusted} over the
69
+ # commands actually stored, exactly as the skill catalog does, and
70
+ # {Extension} declares the result. A *refused* command never counts: its
71
+ # bytes cannot reach the context.
72
+ #
73
+ # Immutable.
74
+ class Registry
75
+ LOGGER = Pikuri.logger_for('Commands')
76
+ private_constant :LOGGER
77
+
78
+ # Subdirectory names scanned under each search base, in precedence
79
+ # order. No +.agents/commands+: no harness in +COMPARISON.md+ defines
80
+ # one, and an invented convention is worse than a missing one.
81
+ COMMAND_SUBDIRS = ['.pikuri/commands', '.claude/commands'].freeze
82
+ private_constant :COMMAND_SUBDIRS
83
+
84
+ # A loaded command. Bodies are eager-loaded at scan time; command files
85
+ # are small and expansion stays IO-free.
86
+ #
87
+ # @!attribute [r] name
88
+ # @return [String] the address the human types, +/+ excluded
89
+ # @!attribute [r] description
90
+ # @return [String] menu chrome; the name itself when the file gave none
91
+ # @!attribute [r] argument_hint
92
+ # @return [String, nil] opaque display string, e.g. +"<label> [--since DATE]"+
93
+ # @!attribute [r] location
94
+ # @return [String] absolute path of the +.md+
95
+ # @!attribute [r] body
96
+ # @return [String] everything after the frontmatter, un-expanded
97
+ # @!attribute [r] refusal
98
+ # @return [String, nil] why this cannot run, captured at scan time so
99
+ # {#get} re-reads nothing; +nil+ when it can
100
+ # @!attribute [r] sidecars
101
+ # @return [Boolean] the command's directory holds something the
102
+ # registry did not load as a command — a non-Markdown file, or a
103
+ # subdirectory holding none — which is what earns the expansion its
104
+ # "this command lives in …" footer
105
+ Command = Data.define(:name, :description, :argument_hint, :location, :body, :refusal, :sidecars) do
106
+ # @return [Boolean] true when {#get} would raise instead of returning this
107
+ def refused? = !refusal.nil?
108
+
109
+ # @return [Boolean]
110
+ def sidecars? = sidecars
111
+ end
112
+
113
+ # @param search_bases [Array<Pikuri::Bundle::Base, String, Pathname>]
114
+ # directories under which to look for command subdirs. Typical values:
115
+ # the project root and +Dir.home+. Processed left to right. A bare
116
+ # path means +Bundle::Base.new(dir: path)+ — untrusted, so the unsafe
117
+ # value is the one you get by saying nothing.
118
+ # @return [Registry]
119
+ def initialize(search_bases:)
120
+ @roots = []
121
+ @commands = {}
122
+ @untrusted = false
123
+
124
+ search_bases.each do |entry|
125
+ base = entry.is_a?(Bundle::Base) ? entry : Bundle::Base.new(dir: entry)
126
+ COMMAND_SUBDIRS.each do |sub|
127
+ root = File.join(base.dir.to_s, sub)
128
+ next unless File.directory?(root)
129
+
130
+ @roots << root
131
+ scan_root(root, trusted: base.trusted)
132
+ end
133
+ end
134
+
135
+ @roots.freeze
136
+ @commands.freeze
137
+ @list = @commands.values.freeze
138
+ freeze
139
+ end
140
+
141
+ # @return [Array<Command>] commands in discovery order (which equals
142
+ # precedence order), refused ones included so a menu can show them
143
+ attr_reader :list
144
+
145
+ # @return [Array<String>] absolute paths of the command directories this
146
+ # registry covered, in scan order. Hosts fold these into
147
+ # +Pikuri::Workspace::Filesystem+'s +readable:+ so the model can
148
+ # +read+ a file a command body points at.
149
+ attr_reader :roots
150
+
151
+ # Look a command up by the name the human typed.
152
+ #
153
+ # Two outcomes beyond success, and they are different on purpose:
154
+ # *absent* is an ordinary thing a human causes by typo, while *refused*
155
+ # is a fact about a file on disk that retyping cannot fix.
156
+ #
157
+ # @param name [String] e.g. +"wh"+ or +"frontend:deploy"+
158
+ # @return [Command, nil] +nil+ when no command has that name
159
+ # @raise [RefusedError] when the command exists but cannot be run
160
+ def get(name)
161
+ command = @commands[name]
162
+ return nil if command.nil?
163
+ raise RefusedError.new(command.location, command.refusal) if command.refused?
164
+
165
+ command
166
+ end
167
+
168
+ # {#get} plus {Renderer.expand}, in one call. What comes back is the
169
+ # human's own *user* turn — see {Renderer} for what that rules out.
170
+ #
171
+ # @param name [String]
172
+ # @param arguments [String] everything typed after the command name
173
+ # @return [String, nil] +nil+ when no command has that name
174
+ # @raise [RefusedError] when the command exists but cannot be run
175
+ def expand(name, arguments: '')
176
+ command = get(name)
177
+ return nil if command.nil?
178
+
179
+ Renderer.expand(command, arguments: arguments)
180
+ end
181
+
182
+ # @return [Boolean] true when no base held a command; {Extension} then
183
+ # declares nothing
184
+ def empty? = @list.empty?
185
+
186
+ # @return [Symbol] +:none+ when every runnable command came from a
187
+ # +trusted+ base, else +:hard+
188
+ def untrusted_level = @untrusted ? :hard : :none
189
+
190
+ private
191
+
192
+ def scan_root(root, trusted:)
193
+ Dir.glob('**/*.md', base: root).sort.each do |relative|
194
+ path = File.join(root, relative)
195
+ next unless File.file?(path)
196
+
197
+ command = parse_command(path, name: relative.delete_suffix('.md').tr(File::SEPARATOR, ':'))
198
+ next if command.nil?
199
+
200
+ existing = @commands[command.name]
201
+ if existing
202
+ LOGGER.warn("command name '#{command.name}' at #{path} is shadowed by " \
203
+ "earlier entry at #{existing.location}")
204
+ else
205
+ @commands[command.name] = command
206
+ @untrusted ||= !trusted && !command.refused?
207
+ end
208
+ end
209
+ end
210
+
211
+ def parse_command(path, name:)
212
+ frontmatter, body = Bundle::Frontmatter.split(File.read(path), path: path)
213
+ frontmatter ||= {}
214
+
215
+ declared = frontmatter['name'].to_s.strip
216
+ LOGGER.warn("#{path}: frontmatter name '#{declared}' ignored; a command is addressed " \
217
+ "by its path, so this one is '#{name}'") if !declared.empty? && declared != name
218
+
219
+ description = frontmatter['description'].to_s.strip
220
+ LOGGER.warn("#{path}: no 'description'; the menu will show the name") if description.empty?
221
+
222
+ refusal = refusal_for(frontmatter, body)
223
+ LOGGER.warn("#{path}: #{refusal}; /#{name} will refuse to run") if refusal
224
+
225
+ Command.new(
226
+ name: name,
227
+ description: description.empty? ? name : description,
228
+ argument_hint: frontmatter['argument-hint']&.to_s,
229
+ location: path,
230
+ body: body,
231
+ refusal: refusal,
232
+ sidecars: sidecars?(File.dirname(path))
233
+ )
234
+ rescue Errno::ENOENT, Errno::EACCES => e
235
+ LOGGER.warn("#{path}: #{e.class}: #{e.message}; skipped")
236
+ nil
237
+ end
238
+
239
+ # Both spellings of the key: the documented +allowed-tools+ and the
240
+ # underscore form an author is as likely to write.
241
+ def refusal_for(frontmatter, body)
242
+ if frontmatter.key?('allowed-tools') || frontmatter.key?('allowed_tools')
243
+ 'declares allowed-tools:, which pikuri cannot honour (an agent\'s toolset ' \
244
+ 'is fixed at configure time, so ignoring the key would *widen* it)'
245
+ elsif body.match?(/!`/)
246
+ 'contains a !`…` bash pre-execution directive, which pikuri never runs'
247
+ end
248
+ end
249
+
250
+ # True when +dir+ holds anything the registry did not load as a command.
251
+ # A subdirectory full of commands is a *namespace*, not a sidecar, so it
252
+ # does not count; a directory of nothing but commands — the common case
253
+ # — gets no footer and stays quiet.
254
+ def sidecars?(dir)
255
+ Dir.children(dir).any? do |entry|
256
+ child = File.join(dir, entry)
257
+ if File.directory?(child)
258
+ Dir.glob('**/*.md', base: child).empty?
259
+ else
260
+ !entry.end_with?('.md')
261
+ end
262
+ end
263
+ rescue Errno::ENOENT, Errno::EACCES
264
+ false
265
+ end
266
+ end
267
+ end
268
+ end