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 +4 -4
- data/README.md +78 -3
- data/lib/pikuri/bundle/base.rb +33 -0
- data/lib/pikuri/bundle/frontmatter.rb +54 -0
- data/lib/pikuri/command/extension.rb +60 -0
- data/lib/pikuri/command/refused_error.rb +33 -0
- data/lib/pikuri/command/registry.rb +268 -0
- data/lib/pikuri/command/renderer.rb +156 -0
- data/lib/pikuri/skill/activation/open.rb +51 -0
- data/lib/pikuri/skill/activation/sealed.rb +65 -0
- data/lib/pikuri/skill/activation.rb +83 -0
- data/lib/pikuri/skill/catalog.rb +114 -97
- data/lib/pikuri/skill/extension.rb +179 -31
- data/lib/pikuri/skill/path_activation_listener.rb +94 -0
- data/lib/pikuri/skill/path_matcher.rb +144 -0
- data/lib/pikuri/skill/promoted.rb +26 -0
- data/lib/pikuri/skill/renderer.rb +146 -0
- data/lib/pikuri/skill/skill_tool.rb +83 -43
- data/lib/pikuri/skill/trigger.rb +92 -0
- data/lib/pikuri-skills.rb +4 -10
- metadata +17 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9c98e45fc109b71c03ed61bcc841eda71bbb74454bab297087fe100401ce7185
|
|
4
|
+
data.tar.gz: 18d24a2ac73b760da78df70fe18d551ff7d24ca9c99b4cf0e63f364cd36beabf
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
|