pikuri-skills 0.0.3
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 +7 -0
- data/README.md +40 -0
- data/lib/pikuri/skill/catalog.rb +375 -0
- data/lib/pikuri/skill/extension.rb +75 -0
- data/lib/pikuri/skill/skill_tool.rb +79 -0
- data/lib/pikuri-skills.rb +24 -0
- metadata +73 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 3225ec6dbaa280bbb0ca84df6207a4d3c08030b80c0ad6246ed72c164a1bf80d
|
|
4
|
+
data.tar.gz: 4605f1727c2c11223798f6a10dbe6b1e46ccbabcb5d874a4a935fa8897e45ab9
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 7eb4aaf9b73614909760697e9645d290cb9c752205b3cb05a2e6a340cc41281e79f55d4c80f9bbd22beea48f200b8503000801f14f94964b0a974bd2c8cd4f94
|
|
7
|
+
data.tar.gz: 4dca2734c9ac979a6b8f4d9e79c1bc2a5390046cd2fabe647e348e22233391647741d3073a7f4260717051f409599d36b418c69ec7925787353780e80429aa31
|
data/README.md
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# pikuri-skills
|
|
2
|
+
|
|
3
|
+
[Agent Skills standard](https://agentskills.io/specification)
|
|
4
|
+
support for the [pikuri](https://codeberg.org/mvysny/pikuri)
|
|
5
|
+
AI-assistant toolkit.
|
|
6
|
+
|
|
7
|
+
Provides:
|
|
8
|
+
- `Pikuri::Skill::Catalog` — discovery + validation of skill folders
|
|
9
|
+
under `.pikuri/skills`, `.claude/skills`, `.agents/skills` (project
|
|
10
|
+
+ global). Walks from CWD up to the git root.
|
|
11
|
+
- `Pikuri::Skill::SkillTool` — the loading tool the LLM uses to pull
|
|
12
|
+
a skill's body into the conversation on demand.
|
|
13
|
+
- `Pikuri::Skill::Extension` — wires both into a `Pikuri::Agent` via
|
|
14
|
+
the `c.add_extension(...)` block API.
|
|
15
|
+
|
|
16
|
+
## Install
|
|
17
|
+
|
|
18
|
+
```ruby
|
|
19
|
+
# Gemfile
|
|
20
|
+
gem 'pikuri-skills'
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Usage
|
|
24
|
+
|
|
25
|
+
```ruby
|
|
26
|
+
require 'pikuri-core'
|
|
27
|
+
require 'pikuri-skills'
|
|
28
|
+
|
|
29
|
+
catalog = Pikuri::Skill::Catalog::Bundled.discover(cwd: Dir.pwd)
|
|
30
|
+
|
|
31
|
+
agent = Pikuri::Agent.new(transport: ..., system_prompt: ...) do |c|
|
|
32
|
+
c.add_extension(Pikuri::Skill::Extension.new(catalog: catalog))
|
|
33
|
+
end
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
When the catalog is non-empty, the extension's `configure` appends
|
|
37
|
+
`<available_skills>` to the system prompt (listing every discovered
|
|
38
|
+
skill) and registers the `skill` tool. The LLM invokes `skill` with a
|
|
39
|
+
name; the tool returns the skill's body wrapped with its base
|
|
40
|
+
directory so the LLM can resolve sidecar files via `read`.
|
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'yaml'
|
|
4
|
+
|
|
5
|
+
module Pikuri
|
|
6
|
+
module Skill
|
|
7
|
+
# Discovery + validation of *skills* — Markdown files with YAML
|
|
8
|
+
# frontmatter that the agent loads on demand via {Tool}.
|
|
9
|
+
#
|
|
10
|
+
# A skill is a directory containing a +SKILL.md+ file. The frontmatter
|
|
11
|
+
# declares the skill's +name+ and +description+; everything in the
|
|
12
|
+
# directory (helper scripts, reference notes, templates) is treated as
|
|
13
|
+
# sidecar content the LLM can pull in via the regular +read+ tool
|
|
14
|
+
# after loading the skill. Pikuri implements the
|
|
15
|
+
# {https://agentskills.io/specification Agent Skills standard}
|
|
16
|
+
# leniently — invalid frontmatter is warned about but the skill
|
|
17
|
+
# still loads, except a missing +description+ which is fatal (no
|
|
18
|
+
# description means the LLM has no signal for when to invoke it).
|
|
19
|
+
#
|
|
20
|
+
# == Catalog vs. tool
|
|
21
|
+
#
|
|
22
|
+
# {Catalog} owns the *discovery* layer: where skills live, how the
|
|
23
|
+
# frontmatter is parsed, what wins on a name collision. The
|
|
24
|
+
# {Tool} class is the *loading* layer: it receives a catalog at
|
|
25
|
+
# construction and looks names up against it when the LLM invokes
|
|
26
|
+
# +skill+. The two are paired by {Extension} — non-empty catalog ⇒
|
|
27
|
+
# {Tool} is appended to the agent's tool list and the catalog's
|
|
28
|
+
# prompt block is appended to the system prompt.
|
|
29
|
+
#
|
|
30
|
+
# == The seam
|
|
31
|
+
#
|
|
32
|
+
# {Catalog} is an abstract base with two bundled implementations:
|
|
33
|
+
# {Empty} (the +EMPTY+ singleton, used by +pikuri-chat+ and as the
|
|
34
|
+
# default for any caller that doesn't have a filesystem story) and
|
|
35
|
+
# {Bundled} (the on-disk scanner). The seam lets future hosts (e.g.
|
|
36
|
+
# an in-editor pikuri reading skills from a virtual filesystem, or
|
|
37
|
+
# a synthetic test catalog) swap in without touching {Tool} or
|
|
38
|
+
# {Extension}.
|
|
39
|
+
class Catalog
|
|
40
|
+
# Frontmatter +name+ cap per the Agent Skills standard.
|
|
41
|
+
MAX_NAME_LENGTH = 64
|
|
42
|
+
|
|
43
|
+
# Frontmatter +description+ cap per the Agent Skills standard.
|
|
44
|
+
MAX_DESCRIPTION_LENGTH = 1024
|
|
45
|
+
|
|
46
|
+
# The three skill directories pikuri scans, in precedence order.
|
|
47
|
+
# +.pikuri+ first (the user's own pikuri-specific skills),
|
|
48
|
+
# +.claude+ second (so existing Claude Code skills travel along
|
|
49
|
+
# for free), +.agents+ last (the cross-harness convention PI also
|
|
50
|
+
# honors). Same names are used for both global roots
|
|
51
|
+
# (under +~/+) and project roots (relative to CWD, walked up).
|
|
52
|
+
SKILL_DIR_NAMES = ['.pikuri/skills', '.claude/skills', '.agents/skills'].freeze
|
|
53
|
+
|
|
54
|
+
LOGGER = Pikuri.logger_for('Skills')
|
|
55
|
+
private_constant :LOGGER
|
|
56
|
+
|
|
57
|
+
# A loaded skill record. Holds everything the catalog discovers
|
|
58
|
+
# and the tool needs to satisfy a load: +name+ and +description+
|
|
59
|
+
# for the catalog's prompt block, +location+ (absolute path to
|
|
60
|
+
# the +SKILL.md+) so the LLM can reference sidecars relative to
|
|
61
|
+
# +File.dirname(location)+, and +body+ (the SKILL.md content
|
|
62
|
+
# below the frontmatter) returned verbatim when the tool fires.
|
|
63
|
+
#
|
|
64
|
+
# Bodies are eager-loaded at scan time even though only
|
|
65
|
+
# +name+/+description+/+location+ end up in the prompt — skill
|
|
66
|
+
# files are small, eager loading keeps the tool's hot path
|
|
67
|
+
# IO-free, and there is no fault tolerance requirement that
|
|
68
|
+
# warrants the complexity of lazy I/O.
|
|
69
|
+
Skill = Data.define(:name, :description, :location, :body)
|
|
70
|
+
|
|
71
|
+
# Skills available to the LLM, in stable insertion order (which
|
|
72
|
+
# equals the order they were discovered, which equals precedence).
|
|
73
|
+
#
|
|
74
|
+
# @return [Array<Skill>]
|
|
75
|
+
# @raise [NotImplementedError] in the abstract base
|
|
76
|
+
def list
|
|
77
|
+
raise NotImplementedError, "#{self.class}#list must be implemented"
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Look up a skill by name.
|
|
81
|
+
#
|
|
82
|
+
# @param name [String]
|
|
83
|
+
# @return [Skill, nil]
|
|
84
|
+
# @raise [NotImplementedError] in the abstract base
|
|
85
|
+
def get(name)
|
|
86
|
+
raise NotImplementedError, "#{self.class}#get must be implemented"
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# @return [Boolean] true when {#list} is empty. {Extension}
|
|
90
|
+
# keys its auto-wiring (system-prompt augmentation + {Tool}
|
|
91
|
+
# registration) off this — empty catalog ⇒ no surface change.
|
|
92
|
+
def empty?
|
|
93
|
+
list.empty?
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# System-prompt block advertising every loaded skill to the LLM.
|
|
97
|
+
# Follows the Agent Skills standard's XML shape (PI uses the same
|
|
98
|
+
# one) so a skill folder authored against any compliant harness
|
|
99
|
+
# renders consistently here.
|
|
100
|
+
#
|
|
101
|
+
# Returns +""+ for an empty catalog so callers can unconditionally
|
|
102
|
+
# concatenate without an +if+. The leading +\n\n+ separates the
|
|
103
|
+
# block from whatever the caller's static system prompt ends with.
|
|
104
|
+
#
|
|
105
|
+
# @return [String]
|
|
106
|
+
def format_for_prompt
|
|
107
|
+
return '' if empty?
|
|
108
|
+
|
|
109
|
+
lines = [
|
|
110
|
+
'',
|
|
111
|
+
'',
|
|
112
|
+
'The following skills provide specialized instructions for specific tasks.',
|
|
113
|
+
"Use the `skill` tool with the skill's name to load its full instructions.",
|
|
114
|
+
'',
|
|
115
|
+
'<available_skills>'
|
|
116
|
+
]
|
|
117
|
+
list.each do |skill|
|
|
118
|
+
lines << ' <skill>'
|
|
119
|
+
lines << " <name>#{escape_xml(skill.name)}</name>"
|
|
120
|
+
lines << " <description>#{escape_xml(skill.description)}</description>"
|
|
121
|
+
lines << " <location>#{escape_xml(skill.location)}</location>"
|
|
122
|
+
lines << ' </skill>'
|
|
123
|
+
end
|
|
124
|
+
lines << '</available_skills>'
|
|
125
|
+
lines.join("\n")
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
private
|
|
129
|
+
|
|
130
|
+
def escape_xml(str)
|
|
131
|
+
str.gsub('&', '&')
|
|
132
|
+
.gsub('<', '<')
|
|
133
|
+
.gsub('>', '>')
|
|
134
|
+
.gsub('"', '"')
|
|
135
|
+
.gsub("'", ''')
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Null-object catalog. The +EMPTY+ constant is the singleton
|
|
139
|
+
# instance used as the default for {Agent#initialize}; constructing
|
|
140
|
+
# additional instances is harmless but pointless.
|
|
141
|
+
class Empty < Catalog
|
|
142
|
+
# @return [Array<Skill>] always empty
|
|
143
|
+
def list
|
|
144
|
+
[].freeze
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# @return [nil]
|
|
148
|
+
def get(_name)
|
|
149
|
+
nil
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Shared singleton for the no-skills case. Frozen.
|
|
154
|
+
EMPTY = Empty.new.freeze
|
|
155
|
+
|
|
156
|
+
# On-disk catalog: eager-scans the given +roots+ at construction
|
|
157
|
+
# and freezes itself. Use {.discover} to build one against
|
|
158
|
+
# pikuri's conventional locations; pass +roots:+ directly in
|
|
159
|
+
# tests so collision and ordering behavior can be exercised
|
|
160
|
+
# without staging dirs under +~/+.
|
|
161
|
+
#
|
|
162
|
+
# == Precedence
|
|
163
|
+
#
|
|
164
|
+
# +roots+ is processed left to right. The *first* skill seen for
|
|
165
|
+
# a given name wins; later occurrences are dropped with a warning
|
|
166
|
+
# logged through +Pikuri.logger_for('Skills')+. Callers that want
|
|
167
|
+
# "project beats global" pass project roots first.
|
|
168
|
+
#
|
|
169
|
+
# == Validation
|
|
170
|
+
#
|
|
171
|
+
# The Agent Skills standard is enforced leniently. A missing
|
|
172
|
+
# +description+ is the only hard failure (the skill is skipped);
|
|
173
|
+
# name/dir mismatch, oversized name/description, invalid name
|
|
174
|
+
# characters, malformed YAML — all log warnings and the skill
|
|
175
|
+
# still loads with whatever could be salvaged. Skills without
|
|
176
|
+
# a YAML frontmatter block at all are skipped silently (the file
|
|
177
|
+
# is presumably not meant as a skill).
|
|
178
|
+
class Bundled < Catalog
|
|
179
|
+
# Build a catalog from pikuri's conventional discovery roots:
|
|
180
|
+
# project skills (walked from +cwd+ up to the git root or
|
|
181
|
+
# filesystem root) take precedence over global skills (under
|
|
182
|
+
# the user's home directory). Within each tier, the order in
|
|
183
|
+
# {SKILL_DIR_NAMES} applies (+.pikuri+ ➜ +.claude+ ➜ +.agents+).
|
|
184
|
+
#
|
|
185
|
+
# Non-existent directories are skipped silently; the loaded
|
|
186
|
+
# set is whatever existed at construction time.
|
|
187
|
+
#
|
|
188
|
+
# @param cwd [String, Pathname] working directory whose
|
|
189
|
+
# ancestor chain is searched for project skill roots.
|
|
190
|
+
# @return [Bundled]
|
|
191
|
+
def self.discover(cwd: Dir.pwd)
|
|
192
|
+
new(roots: discover_project_roots(cwd: cwd) + discover_global_roots)
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# Project roots walked from +cwd+ upwards, stopping at the
|
|
196
|
+
# nearest ancestor containing +.git+ (inclusive) or at the
|
|
197
|
+
# filesystem root. The deepest directory's roots appear first,
|
|
198
|
+
# so closer-to-CWD skills win on name collisions.
|
|
199
|
+
#
|
|
200
|
+
# @param cwd [String, Pathname]
|
|
201
|
+
# @return [Array<String>] absolute paths to existing dirs
|
|
202
|
+
def self.discover_project_roots(cwd:)
|
|
203
|
+
results = []
|
|
204
|
+
current = File.expand_path(cwd.to_s)
|
|
205
|
+
loop do
|
|
206
|
+
SKILL_DIR_NAMES.each do |sub|
|
|
207
|
+
candidate = File.join(current, sub)
|
|
208
|
+
results << candidate if File.directory?(candidate)
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# Stop *after* processing the git root — git roots are
|
|
212
|
+
# natural project boundaries; ancestors above are someone
|
|
213
|
+
# else's project.
|
|
214
|
+
break if File.directory?(File.join(current, '.git'))
|
|
215
|
+
|
|
216
|
+
parent = File.dirname(current)
|
|
217
|
+
break if parent == current # filesystem root
|
|
218
|
+
|
|
219
|
+
current = parent
|
|
220
|
+
end
|
|
221
|
+
results
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# Global roots under the user's home directory.
|
|
225
|
+
#
|
|
226
|
+
# @return [Array<String>] absolute paths to existing dirs
|
|
227
|
+
def self.discover_global_roots
|
|
228
|
+
home = Dir.home
|
|
229
|
+
SKILL_DIR_NAMES.map { |sub| File.join(home, sub) }
|
|
230
|
+
.select { |dir| File.directory?(dir) }
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# @param roots [Array<String, Pathname>] skill directories in
|
|
234
|
+
# precedence order — first occurrence of a given skill name
|
|
235
|
+
# wins.
|
|
236
|
+
# @return [Bundled]
|
|
237
|
+
def initialize(roots:)
|
|
238
|
+
super()
|
|
239
|
+
@skills = {}
|
|
240
|
+
roots.each { |root| scan_root(root.to_s) }
|
|
241
|
+
@skills.freeze
|
|
242
|
+
@list = @skills.values.freeze
|
|
243
|
+
freeze
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# @return [Array<Skill>]
|
|
247
|
+
def list
|
|
248
|
+
@list
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
# @param name [String]
|
|
252
|
+
# @return [Skill, nil]
|
|
253
|
+
def get(name)
|
|
254
|
+
@skills[name]
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
private
|
|
258
|
+
|
|
259
|
+
def scan_root(root)
|
|
260
|
+
return unless File.directory?(root)
|
|
261
|
+
|
|
262
|
+
Dir.children(root).sort.each do |entry|
|
|
263
|
+
skill_dir = File.join(root, entry)
|
|
264
|
+
next unless File.directory?(skill_dir)
|
|
265
|
+
|
|
266
|
+
skill_md = File.join(skill_dir, 'SKILL.md')
|
|
267
|
+
next unless File.file?(skill_md)
|
|
268
|
+
|
|
269
|
+
skill = parse_skill(skill_md, parent_dir_name: entry)
|
|
270
|
+
next if skill.nil?
|
|
271
|
+
|
|
272
|
+
existing = @skills[skill.name]
|
|
273
|
+
if existing
|
|
274
|
+
LOGGER.warn(
|
|
275
|
+
"skill name '#{skill.name}' at #{skill_md} is shadowed by " \
|
|
276
|
+
"earlier entry at #{existing.location}"
|
|
277
|
+
)
|
|
278
|
+
else
|
|
279
|
+
@skills[skill.name] = skill
|
|
280
|
+
end
|
|
281
|
+
end
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
# Read +path+ and return a {Skill}, or +nil+ if the file is
|
|
285
|
+
# unusable (no frontmatter, no description, unreadable). Warnings
|
|
286
|
+
# for soft violations are logged but do not block loading.
|
|
287
|
+
def parse_skill(path, parent_dir_name:)
|
|
288
|
+
raw = File.read(path)
|
|
289
|
+
frontmatter, body = split_frontmatter(raw)
|
|
290
|
+
|
|
291
|
+
if frontmatter.nil?
|
|
292
|
+
LOGGER.warn("#{path}: no YAML frontmatter; skipped")
|
|
293
|
+
return nil
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
description = frontmatter['description'].to_s.strip
|
|
297
|
+
if description.empty?
|
|
298
|
+
LOGGER.warn("#{path}: missing required 'description'; skipped")
|
|
299
|
+
return nil
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
name = (frontmatter['name'] || parent_dir_name).to_s
|
|
303
|
+
validate_name(name, parent_dir_name: parent_dir_name, path: path)
|
|
304
|
+
validate_description(description, path: path)
|
|
305
|
+
|
|
306
|
+
Skill.new(
|
|
307
|
+
name: name,
|
|
308
|
+
description: description,
|
|
309
|
+
location: path,
|
|
310
|
+
body: body
|
|
311
|
+
)
|
|
312
|
+
rescue Errno::ENOENT, Errno::EACCES => e
|
|
313
|
+
LOGGER.warn("#{path}: #{e.class}: #{e.message}; skipped")
|
|
314
|
+
nil
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
# Split a SKILL.md into +[frontmatter_hash, body_string]+.
|
|
318
|
+
# Returns +[nil, raw]+ when there is no detectable frontmatter
|
|
319
|
+
# block. On YAML parse failure, logs a warning and returns
|
|
320
|
+
# +[{}, body]+ so the caller can still observe an empty
|
|
321
|
+
# frontmatter (which trips the "missing description" path
|
|
322
|
+
# below and discards the skill).
|
|
323
|
+
def split_frontmatter(content)
|
|
324
|
+
normalized = content.gsub(/\r\n?/, "\n")
|
|
325
|
+
return [nil, normalized] unless normalized.start_with?("---\n")
|
|
326
|
+
|
|
327
|
+
end_marker = normalized.index("\n---", 4)
|
|
328
|
+
return [nil, normalized] unless end_marker
|
|
329
|
+
|
|
330
|
+
yaml_str = normalized[4...end_marker]
|
|
331
|
+
body = normalized[(end_marker + 4)..].to_s
|
|
332
|
+
body = body.sub(/\A\n/, '')
|
|
333
|
+
|
|
334
|
+
parsed =
|
|
335
|
+
begin
|
|
336
|
+
YAML.safe_load(yaml_str) || {}
|
|
337
|
+
rescue Psych::SyntaxError => e
|
|
338
|
+
LOGGER.warn("frontmatter YAML parse error: #{e.message}")
|
|
339
|
+
{}
|
|
340
|
+
end
|
|
341
|
+
parsed = {} unless parsed.is_a?(Hash)
|
|
342
|
+
|
|
343
|
+
[parsed, body]
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
def validate_name(name, parent_dir_name:, path:)
|
|
347
|
+
if name != parent_dir_name
|
|
348
|
+
LOGGER.warn("#{path}: skill name '#{name}' does not match parent directory '#{parent_dir_name}'")
|
|
349
|
+
end
|
|
350
|
+
if name.length > MAX_NAME_LENGTH
|
|
351
|
+
LOGGER.warn("#{path}: skill name exceeds #{MAX_NAME_LENGTH} chars (#{name.length})")
|
|
352
|
+
end
|
|
353
|
+
unless /\A[a-z0-9-]+\z/.match?(name)
|
|
354
|
+
LOGGER.warn("#{path}: skill name '#{name}' contains invalid characters " \
|
|
355
|
+
"(allowed: lowercase a-z, 0-9, hyphens)")
|
|
356
|
+
end
|
|
357
|
+
if name.start_with?('-') || name.end_with?('-')
|
|
358
|
+
LOGGER.warn("#{path}: skill name '#{name}' starts or ends with a hyphen")
|
|
359
|
+
end
|
|
360
|
+
return unless name.include?('--')
|
|
361
|
+
|
|
362
|
+
LOGGER.warn("#{path}: skill name '#{name}' contains consecutive hyphens")
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
def validate_description(description, path:)
|
|
366
|
+
return unless description.length > MAX_DESCRIPTION_LENGTH
|
|
367
|
+
|
|
368
|
+
LOGGER.warn(
|
|
369
|
+
"#{path}: description exceeds #{MAX_DESCRIPTION_LENGTH} chars (#{description.length})"
|
|
370
|
+
)
|
|
371
|
+
end
|
|
372
|
+
end
|
|
373
|
+
end
|
|
374
|
+
end
|
|
375
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pikuri
|
|
4
|
+
# Namespace for the Skills feature. Built around the Agent Skills
|
|
5
|
+
# standard implemented in {Pikuri::Skill::Catalog} and
|
|
6
|
+
# {Pikuri::Skill::SkillTool}; this module hosts the *Extension* class
|
|
7
|
+
# ({Extension}) that wires the two into an {Pikuri::Agent}.
|
|
8
|
+
module Skill
|
|
9
|
+
# An {Pikuri::Agent::Extension} that auto-wires the Agent Skills
|
|
10
|
+
# standard onto an agent: appends the catalog's
|
|
11
|
+
# +<available_skills>+ block to the system prompt and registers
|
|
12
|
+
# the +skill+ tool so the LLM can load skill bodies on demand.
|
|
13
|
+
#
|
|
14
|
+
# == Usage
|
|
15
|
+
#
|
|
16
|
+
# Pass the extension via the +Agent.new+ block:
|
|
17
|
+
#
|
|
18
|
+
# catalog = Pikuri::Skill::Catalog::Bundled.discover
|
|
19
|
+
# Pikuri::Agent.new(transport: ..., system_prompt: ...) do |c|
|
|
20
|
+
# c.add_extension Pikuri::Skill::Extension.new(catalog: catalog)
|
|
21
|
+
# end
|
|
22
|
+
#
|
|
23
|
+
# The +configure+ hook is agent-agnostic — it appends a snippet
|
|
24
|
+
# and adds a tool, both of which sub-agents inherit verbatim
|
|
25
|
+
# through the existing snapshot mechanism. There is no per-agent
|
|
26
|
+
# state, so this extension does not implement +bind+ (inherits
|
|
27
|
+
# the empty default from {Pikuri::Agent::Extension}).
|
|
28
|
+
#
|
|
29
|
+
# == Empty catalog
|
|
30
|
+
#
|
|
31
|
+
# When the catalog is {Pikuri::Skill::Catalog#empty?}, the
|
|
32
|
+
# extension is a no-op — no snippet, no tool. Same semantics as
|
|
33
|
+
# the legacy +skill_catalog:+ kwarg on {Pikuri::Agent#initialize},
|
|
34
|
+
# which still routes through this extension as a transition layer
|
|
35
|
+
# until Step 5 of the gem-split refactor (see IDEAS.md).
|
|
36
|
+
class Extension
|
|
37
|
+
include Pikuri::Agent::Extension
|
|
38
|
+
|
|
39
|
+
# @param catalog [Pikuri::Skill::Catalog] the catalog of
|
|
40
|
+
# skills the agent may load. Required.
|
|
41
|
+
def initialize(catalog:)
|
|
42
|
+
@catalog = catalog
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# @return [Pikuri::Skill::Catalog]
|
|
46
|
+
attr_reader :catalog
|
|
47
|
+
|
|
48
|
+
# Append +<available_skills>+ to the system prompt and
|
|
49
|
+
# register the +skill+ tool. No-op when the catalog is empty.
|
|
50
|
+
#
|
|
51
|
+
# The catalog's +format_for_prompt+ historically returns a
|
|
52
|
+
# snippet with leading blank lines (designed for the old
|
|
53
|
+
# +system_prompt + format_for_prompt+ concatenation). The
|
|
54
|
+
# Configurator handles the separator between base and snippet
|
|
55
|
+
# itself ({Configurator#append_system_prompt} drains with a
|
|
56
|
+
# +\n\n+ join), so we +lstrip+ to avoid doubling up the blank
|
|
57
|
+
# lines.
|
|
58
|
+
#
|
|
59
|
+
# @param c [Pikuri::Agent::Configurator]
|
|
60
|
+
# @return [void]
|
|
61
|
+
def configure(c)
|
|
62
|
+
return if @catalog.empty?
|
|
63
|
+
|
|
64
|
+
if c.tools.any?(Pikuri::Skill::SkillTool)
|
|
65
|
+
raise 'Pikuri::Skill::SkillTool cannot be pre-registered (in tools: or via c.add_tool) ' \
|
|
66
|
+
'when adding Pikuri::Skill::Extension — the extension auto-registers it from the catalog.'
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
c.append_system_prompt(@catalog.format_for_prompt.lstrip)
|
|
70
|
+
c.add_tool(Pikuri::Skill::SkillTool.new(catalog: @catalog))
|
|
71
|
+
nil
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pikuri
|
|
4
|
+
module Skill
|
|
5
|
+
# The +skill+ tool: instantiating
|
|
6
|
+
# +Pikuri::Skill::SkillTool.new(catalog:)+ produces a tool whose
|
|
7
|
+
# +execute+ closure looks the requested name up in the bound
|
|
8
|
+
# {Catalog} and returns the skill's body wrapped in a +<skill>+
|
|
9
|
+
# block with the absolute base directory, so the LLM can resolve
|
|
10
|
+
# relative sidecar paths against it via the regular +read+ tool.
|
|
11
|
+
#
|
|
12
|
+
# The catalog of available skills is *not* duplicated into this
|
|
13
|
+
# tool's description. It lives in the system prompt — see
|
|
14
|
+
# {Catalog#format_for_prompt} — because skills describe what the
|
|
15
|
+
# agent is, not just what one of its tools returns. Putting the
|
|
16
|
+
# catalog in the system prompt keeps it next to the agent's persona
|
|
17
|
+
# and matches what PI and Claude Code do (opencode is the outlier
|
|
18
|
+
# here). The tool description below is therefore *static* — it
|
|
19
|
+
# explains the load mechanism, not the inventory.
|
|
20
|
+
#
|
|
21
|
+
# This tool is not registered manually. {Extension#configure}
|
|
22
|
+
# auto-registers it whenever the catalog is non-empty, and skips
|
|
23
|
+
# it otherwise; sub-agents inherit it through the parent's tool
|
|
24
|
+
# snapshot. The bound catalog rides along in the +execute+
|
|
25
|
+
# closure, so any sub-agent invoking +skill+ resolves names
|
|
26
|
+
# against the same catalog the parent saw.
|
|
27
|
+
class SkillTool < Pikuri::Tool
|
|
28
|
+
# Description shown to the LLM. Follows the opencode-shape (summary
|
|
29
|
+
# + +Usage:+ bullets) prescribed by the project's tool-description
|
|
30
|
+
# convention. The list of which skills exist is not here — see
|
|
31
|
+
# the class header.
|
|
32
|
+
#
|
|
33
|
+
# @return [String]
|
|
34
|
+
DESCRIPTION = <<~DESC
|
|
35
|
+
Load a specialized skill that provides domain-specific instructions and resources for a particular task.
|
|
36
|
+
|
|
37
|
+
Usage:
|
|
38
|
+
- The catalog of available skills is listed in your system prompt under `<available_skills>`.
|
|
39
|
+
- Invoke this tool with a skill's `name` to inject its full instructions into the conversation.
|
|
40
|
+
- The loaded skill may reference helper scripts and files in its base directory — use the `read` tool to load those when the skill's instructions tell you to.
|
|
41
|
+
- On `Error: skill '...' not found`, do NOT retry with a guessed name. Pick a name that actually appears in `<available_skills>`, or report to the user that no matching skill is installed.
|
|
42
|
+
DESC
|
|
43
|
+
|
|
44
|
+
# @param catalog [Catalog] the catalog to resolve names against.
|
|
45
|
+
# Captured by closure so the tool retains access to the same
|
|
46
|
+
# instance even when copied into a sub-agent's tool snapshot.
|
|
47
|
+
# @return [SkillTool]
|
|
48
|
+
def initialize(catalog:)
|
|
49
|
+
super(
|
|
50
|
+
name: 'skill',
|
|
51
|
+
description: DESCRIPTION,
|
|
52
|
+
parameters: Pikuri::Tool::Parameters.build { |p|
|
|
53
|
+
p.required_string :name,
|
|
54
|
+
'Name of the skill to load, e.g. "pdf-extraction". ' \
|
|
55
|
+
'Must match a name listed under `<available_skills>` ' \
|
|
56
|
+
'in the system prompt.'
|
|
57
|
+
},
|
|
58
|
+
execute: lambda { |name:|
|
|
59
|
+
loaded = catalog.get(name)
|
|
60
|
+
if loaded.nil?
|
|
61
|
+
available = catalog.list.map(&:name).sort
|
|
62
|
+
list = available.empty? ? 'none' : available.join(', ')
|
|
63
|
+
next "Error: skill '#{name}' not found. Available skills: #{list}."
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
base_dir = File.dirname(loaded.location)
|
|
67
|
+
<<~OUT
|
|
68
|
+
<skill name="#{loaded.name}" location="#{loaded.location}">
|
|
69
|
+
Sidecar paths in this skill (e.g. scripts/, references/) are relative to #{base_dir}.
|
|
70
|
+
|
|
71
|
+
#{loaded.body}
|
|
72
|
+
</skill>
|
|
73
|
+
OUT
|
|
74
|
+
}
|
|
75
|
+
)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'pikuri-core'
|
|
4
|
+
|
|
5
|
+
# Entry file for the pikuri-skills gem. Sets up a dedicated Zeitwerk
|
|
6
|
+
# loader rooted at this gem's +lib/+, contributing to the shared
|
|
7
|
+
# +Pikuri::+ namespace alongside pikuri-core. After +require
|
|
8
|
+
# 'pikuri-skills'+, +Pikuri::Skill::Catalog+, +Pikuri::Skill::SkillTool+
|
|
9
|
+
# and +Pikuri::Skill::Extension+ are all defined.
|
|
10
|
+
#
|
|
11
|
+
# The loader is per-gem (not shared with pikuri-core's loader) so each
|
|
12
|
+
# gem owns its own +lib/+ tree and the cooperation between gems is via
|
|
13
|
+
# the Pikuri namespace alone. See pikuri-core/lib/pikuri-core.rb for
|
|
14
|
+
# the core loader.
|
|
15
|
+
module Pikuri
|
|
16
|
+
module Skill
|
|
17
|
+
LOADER = Zeitwerk::Loader.new
|
|
18
|
+
LOADER.tag = 'pikuri-skills'
|
|
19
|
+
LOADER.push_dir(File.expand_path('.', __dir__))
|
|
20
|
+
LOADER.ignore(__FILE__)
|
|
21
|
+
LOADER.setup
|
|
22
|
+
LOADER.eager_load
|
|
23
|
+
end
|
|
24
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: pikuri-skills
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.0.3
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Martin Vysny
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-05-22 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: pikuri-core
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - '='
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: 0.0.3
|
|
20
|
+
type: :runtime
|
|
21
|
+
prerelease: false
|
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
23
|
+
requirements:
|
|
24
|
+
- - '='
|
|
25
|
+
- !ruby/object:Gem::Version
|
|
26
|
+
version: 0.0.3
|
|
27
|
+
description: |
|
|
28
|
+
pikuri-skills implements the Agent Skills standard
|
|
29
|
+
(agentskills.io) for pikuri-core agents: a +Pikuri::Skill::Catalog+
|
|
30
|
+
that discovers skill folders under .pikuri/skills, .claude/skills,
|
|
31
|
+
and .agents/skills; a +Pikuri::Skill::SkillTool+ that loads a
|
|
32
|
+
skill's body on demand; and a +Pikuri::Skill::Extension+ that wires
|
|
33
|
+
both into a +Pikuri::Agent+ via +c.add_extension(...)+ in the
|
|
34
|
+
+Agent.new+ block.
|
|
35
|
+
email:
|
|
36
|
+
- martin@vysny.me
|
|
37
|
+
executables: []
|
|
38
|
+
extensions: []
|
|
39
|
+
extra_rdoc_files: []
|
|
40
|
+
files:
|
|
41
|
+
- README.md
|
|
42
|
+
- lib/pikuri-skills.rb
|
|
43
|
+
- lib/pikuri/skill/catalog.rb
|
|
44
|
+
- lib/pikuri/skill/extension.rb
|
|
45
|
+
- lib/pikuri/skill/skill_tool.rb
|
|
46
|
+
homepage: https://codeberg.org/mvysny/pikuri
|
|
47
|
+
licenses:
|
|
48
|
+
- MIT
|
|
49
|
+
metadata:
|
|
50
|
+
source_code_uri: https://codeberg.org/mvysny/pikuri/src/branch/master
|
|
51
|
+
changelog_uri: https://codeberg.org/mvysny/pikuri/src/branch/master/CHANGELOG.md
|
|
52
|
+
bug_tracker_uri: https://codeberg.org/mvysny/pikuri/issues
|
|
53
|
+
rubygems_mfa_required: 'true'
|
|
54
|
+
post_install_message:
|
|
55
|
+
rdoc_options: []
|
|
56
|
+
require_paths:
|
|
57
|
+
- lib
|
|
58
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
59
|
+
requirements:
|
|
60
|
+
- - ">="
|
|
61
|
+
- !ruby/object:Gem::Version
|
|
62
|
+
version: '3.3'
|
|
63
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
64
|
+
requirements:
|
|
65
|
+
- - ">="
|
|
66
|
+
- !ruby/object:Gem::Version
|
|
67
|
+
version: '0'
|
|
68
|
+
requirements: []
|
|
69
|
+
rubygems_version: 3.5.22
|
|
70
|
+
signing_key:
|
|
71
|
+
specification_version: 4
|
|
72
|
+
summary: Agent Skills standard support for pikuri.
|
|
73
|
+
test_files: []
|