pikuri-skills 0.0.3 → 0.0.5
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 +21 -3
- data/lib/pikuri/skill/catalog.rb +74 -152
- data/lib/pikuri/skill/extension.rb +3 -1
- metadata +4 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 36c3fb0aa368f1f7e0986b8bd045dd8da16af4f777d4a8241b225986ac63281f
|
|
4
|
+
data.tar.gz: 3519593f2c0aa7e0a9e84803e28f3c2bf9818325edd17f55b49074dd1ba9bc99
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 572385454a685a66fd5426d37e7af385afa14816ad209cbd20ede492f9d8bc1031f97c9d1be4e87e1c0091d225256f9e14effb34b4b4ec79f3dd4d407a831594
|
|
7
|
+
data.tar.gz: 0770d24db6f0d08a7fe43d57fe0718d18efc8fea1898c6c50d587f81b1103dda0514e7c94d5f9d34ea5f25fd86a23327ae7d870c6d3ee8d71a8147d2bdfda2d7
|
data/README.md
CHANGED
|
@@ -6,8 +6,9 @@ AI-assistant toolkit.
|
|
|
6
6
|
|
|
7
7
|
Provides:
|
|
8
8
|
- `Pikuri::Skill::Catalog` — discovery + validation of skill folders
|
|
9
|
-
under `.pikuri/skills`, `.claude/skills`, `.agents/skills`
|
|
10
|
-
|
|
9
|
+
under `.pikuri/skills`, `.claude/skills`, `.agents/skills` beneath
|
|
10
|
+
each *search base* you supply (typically the project root and
|
|
11
|
+
`Dir.home`).
|
|
11
12
|
- `Pikuri::Skill::SkillTool` — the loading tool the LLM uses to pull
|
|
12
13
|
a skill's body into the conversation on demand.
|
|
13
14
|
- `Pikuri::Skill::Extension` — wires both into a `Pikuri::Agent` via
|
|
@@ -26,7 +27,13 @@ gem 'pikuri-skills'
|
|
|
26
27
|
require 'pikuri-core'
|
|
27
28
|
require 'pikuri-skills'
|
|
28
29
|
|
|
29
|
-
|
|
30
|
+
# Pass the directories to search under. The catalog scans
|
|
31
|
+
# .pikuri/skills, .claude/skills and .agents/skills beneath each base;
|
|
32
|
+
# missing subdirs are skipped. Earlier bases beat later ones on a
|
|
33
|
+
# name collision.
|
|
34
|
+
catalog = Pikuri::Skill::Catalog::Bundled.new(
|
|
35
|
+
search_bases: [project_root, Dir.home]
|
|
36
|
+
)
|
|
30
37
|
|
|
31
38
|
agent = Pikuri::Agent.new(transport: ..., system_prompt: ...) do |c|
|
|
32
39
|
c.add_extension(Pikuri::Skill::Extension.new(catalog: catalog))
|
|
@@ -38,3 +45,14 @@ When the catalog is non-empty, the extension's `configure` appends
|
|
|
38
45
|
skill) and registers the `skill` tool. The LLM invokes `skill` with a
|
|
39
46
|
name; the tool returns the skill's body wrapped with its base
|
|
40
47
|
directory so the LLM can resolve sidecar files via `read`.
|
|
48
|
+
|
|
49
|
+
## Further reading
|
|
50
|
+
|
|
51
|
+
- **Narrative walkthrough:**
|
|
52
|
+
[chapter 12 of the pikuri guide](../docs/guide/12-skills.md) —
|
|
53
|
+
how skill folders are discovered, the `SKILL.md` format, and a
|
|
54
|
+
worked drop-in example. (Currently a stub chapter; the API
|
|
55
|
+
surface lives in the YARD docs below.)
|
|
56
|
+
- **API reference:** browse the YARD docs at
|
|
57
|
+
<https://rubydoc.info/gems/pikuri-skills> (once published), or
|
|
58
|
+
run `bundle exec yard` in this directory for a local copy.
|
data/lib/pikuri/skill/catalog.rb
CHANGED
|
@@ -5,37 +5,19 @@ require 'yaml'
|
|
|
5
5
|
module Pikuri
|
|
6
6
|
module Skill
|
|
7
7
|
# Discovery + validation of *skills* — Markdown files with YAML
|
|
8
|
-
# frontmatter that the agent loads on demand via {
|
|
8
|
+
# frontmatter that the agent loads on demand via {SkillTool}.
|
|
9
9
|
#
|
|
10
|
-
# A skill is a directory containing a +SKILL.md+
|
|
11
|
-
# declares
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
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).
|
|
10
|
+
# A skill is a directory containing a +SKILL.md+ whose frontmatter
|
|
11
|
+
# declares +name+ and +description+. Sibling files (scripts,
|
|
12
|
+
# references, templates) are sidecar content the LLM pulls in via
|
|
13
|
+
# the regular +read+ tool against +File.dirname(location)+. Pikuri
|
|
14
|
+
# implements the {https://agentskills.io/specification Agent Skills
|
|
15
|
+
# standard} leniently — only a missing +description+ is fatal.
|
|
19
16
|
#
|
|
20
|
-
#
|
|
21
|
-
#
|
|
22
|
-
#
|
|
23
|
-
#
|
|
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}.
|
|
17
|
+
# Two concrete subclasses ship: {Empty} (the +EMPTY+ singleton)
|
|
18
|
+
# and {Bundled} (the on-disk scanner). Hosts that read skills from
|
|
19
|
+
# something other than the filesystem (virtual FS, hardcoded
|
|
20
|
+
# registry) subclass directly.
|
|
39
21
|
class Catalog
|
|
40
22
|
# Frontmatter +name+ cap per the Agent Skills standard.
|
|
41
23
|
MAX_NAME_LENGTH = 64
|
|
@@ -43,42 +25,20 @@ module Pikuri
|
|
|
43
25
|
# Frontmatter +description+ cap per the Agent Skills standard.
|
|
44
26
|
MAX_DESCRIPTION_LENGTH = 1024
|
|
45
27
|
|
|
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
28
|
LOGGER = Pikuri.logger_for('Skills')
|
|
55
29
|
private_constant :LOGGER
|
|
56
30
|
|
|
57
|
-
# A loaded skill record.
|
|
58
|
-
#
|
|
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.
|
|
31
|
+
# A loaded skill record. Bodies are eager-loaded at scan time;
|
|
32
|
+
# SKILL.md files are small and the tool's hot path stays IO-free.
|
|
69
33
|
Skill = Data.define(:name, :description, :location, :body)
|
|
70
34
|
|
|
71
|
-
#
|
|
72
|
-
#
|
|
73
|
-
#
|
|
74
|
-
# @return [Array<Skill>]
|
|
35
|
+
# @return [Array<Skill>] skills in discovery order (which equals
|
|
36
|
+
# precedence order).
|
|
75
37
|
# @raise [NotImplementedError] in the abstract base
|
|
76
38
|
def list
|
|
77
39
|
raise NotImplementedError, "#{self.class}#list must be implemented"
|
|
78
40
|
end
|
|
79
41
|
|
|
80
|
-
# Look up a skill by name.
|
|
81
|
-
#
|
|
82
42
|
# @param name [String]
|
|
83
43
|
# @return [Skill, nil]
|
|
84
44
|
# @raise [NotImplementedError] in the abstract base
|
|
@@ -86,21 +46,25 @@ module Pikuri
|
|
|
86
46
|
raise NotImplementedError, "#{self.class}#get must be implemented"
|
|
87
47
|
end
|
|
88
48
|
|
|
89
|
-
# @return [Boolean] true when {#list} is empty. {Extension}
|
|
90
|
-
#
|
|
91
|
-
# registration) off this — empty catalog ⇒ no surface change.
|
|
49
|
+
# @return [Boolean] true when {#list} is empty. {Extension} keys
|
|
50
|
+
# its auto-wiring off this — empty catalog ⇒ no surface change.
|
|
92
51
|
def empty?
|
|
93
52
|
list.empty?
|
|
94
53
|
end
|
|
95
54
|
|
|
96
|
-
#
|
|
97
|
-
#
|
|
98
|
-
#
|
|
99
|
-
# renders consistently here.
|
|
55
|
+
# Absolute paths of the skill directories this catalog covers.
|
|
56
|
+
# Hosts wire these into +Pikuri::Workspace::Filesystem+'s
|
|
57
|
+
# +readable:+ list so the LLM can +read+ sidecar files.
|
|
100
58
|
#
|
|
101
|
-
#
|
|
102
|
-
#
|
|
103
|
-
|
|
59
|
+
# @return [Array<String>]
|
|
60
|
+
# @raise [NotImplementedError] in the abstract base
|
|
61
|
+
def roots
|
|
62
|
+
raise NotImplementedError, "#{self.class}#roots must be implemented"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# System-prompt block advertising every loaded skill to the LLM,
|
|
66
|
+
# in the Agent Skills standard's XML shape. Returns +""+ for an
|
|
67
|
+
# empty catalog so callers can unconditionally concatenate.
|
|
104
68
|
#
|
|
105
69
|
# @return [String]
|
|
106
70
|
def format_for_prompt
|
|
@@ -135,109 +99,67 @@ module Pikuri
|
|
|
135
99
|
.gsub("'", ''')
|
|
136
100
|
end
|
|
137
101
|
|
|
138
|
-
# Null-object catalog. The +EMPTY+ constant is the singleton
|
|
139
|
-
#
|
|
140
|
-
# additional instances is harmless but pointless.
|
|
102
|
+
# Null-object catalog. The +EMPTY+ constant is the singleton used
|
|
103
|
+
# as the default when no skills are wired in.
|
|
141
104
|
class Empty < Catalog
|
|
142
|
-
# @return [Array<Skill>] always empty
|
|
143
105
|
def list
|
|
144
106
|
[].freeze
|
|
145
107
|
end
|
|
146
108
|
|
|
147
|
-
# @return [nil]
|
|
148
109
|
def get(_name)
|
|
149
110
|
nil
|
|
150
111
|
end
|
|
112
|
+
|
|
113
|
+
def roots
|
|
114
|
+
[].freeze
|
|
115
|
+
end
|
|
151
116
|
end
|
|
152
117
|
|
|
153
|
-
# Shared singleton for the no-skills case.
|
|
118
|
+
# Shared singleton for the no-skills case.
|
|
154
119
|
EMPTY = Empty.new.freeze
|
|
155
120
|
|
|
156
|
-
# On-disk catalog
|
|
157
|
-
#
|
|
158
|
-
# pikuri
|
|
159
|
-
#
|
|
160
|
-
#
|
|
121
|
+
# On-disk catalog. Constructed with a list of *search bases*
|
|
122
|
+
# (project root, home dir, …); for each base, scans
|
|
123
|
+
# +.pikuri/skills+, +.claude/skills+ and +.agents/skills+ —
|
|
124
|
+
# +.pikuri+ first (the user's own pikuri-specific skills),
|
|
125
|
+
# +.claude+ second (so Claude Code skills travel along for free),
|
|
126
|
+
# +.agents+ last (the cross-harness convention PI also honors).
|
|
127
|
+
# Missing subdirs are silently skipped.
|
|
161
128
|
#
|
|
162
129
|
# == Precedence
|
|
163
130
|
#
|
|
164
|
-
#
|
|
165
|
-
#
|
|
166
|
-
#
|
|
167
|
-
# "project beats global" pass project
|
|
131
|
+
# Bases are processed left to right; within each base the subdir
|
|
132
|
+
# order above applies. The *first* skill seen for a given name
|
|
133
|
+
# wins; later occurrences are dropped with a warning. Callers
|
|
134
|
+
# that want "project beats global" pass the project root first.
|
|
168
135
|
#
|
|
169
136
|
# == Validation
|
|
170
137
|
#
|
|
171
138
|
# The Agent Skills standard is enforced leniently. A missing
|
|
172
139
|
# +description+ is the only hard failure (the skill is skipped);
|
|
173
140
|
# name/dir mismatch, oversized name/description, invalid name
|
|
174
|
-
# characters
|
|
175
|
-
# still loads with whatever could be salvaged.
|
|
176
|
-
#
|
|
177
|
-
#
|
|
141
|
+
# characters and malformed YAML all log warnings and the skill
|
|
142
|
+
# still loads with whatever could be salvaged. Files without
|
|
143
|
+
# YAML frontmatter at all are skipped silently — they're
|
|
144
|
+
# presumably not meant as skills.
|
|
178
145
|
class Bundled < Catalog
|
|
179
|
-
#
|
|
180
|
-
#
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
#
|
|
185
|
-
#
|
|
186
|
-
#
|
|
187
|
-
#
|
|
188
|
-
# @param cwd [String, Pathname] working directory whose
|
|
189
|
-
# ancestor chain is searched for project skill roots.
|
|
146
|
+
# Subdirectory names scanned under each search base, in
|
|
147
|
+
# precedence order.
|
|
148
|
+
SKILL_SUBDIRS = ['.pikuri/skills', '.claude/skills', '.agents/skills'].freeze
|
|
149
|
+
private_constant :SKILL_SUBDIRS
|
|
150
|
+
|
|
151
|
+
# @param search_bases [Array<String, Pathname>] directories
|
|
152
|
+
# under which to look for skill subdirs. Typical values:
|
|
153
|
+
# the project root and +Dir.home+. Processed left to right.
|
|
190
154
|
# @return [Bundled]
|
|
191
|
-
def
|
|
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:)
|
|
155
|
+
def initialize(search_bases:)
|
|
238
156
|
super()
|
|
157
|
+
@roots = search_bases
|
|
158
|
+
.flat_map { |base| SKILL_SUBDIRS.map { |sub| File.join(base.to_s, sub) } }
|
|
159
|
+
.select { |dir| File.directory?(dir) }
|
|
160
|
+
.freeze
|
|
239
161
|
@skills = {}
|
|
240
|
-
roots.each { |root| scan_root(root
|
|
162
|
+
@roots.each { |root| scan_root(root) }
|
|
241
163
|
@skills.freeze
|
|
242
164
|
@list = @skills.values.freeze
|
|
243
165
|
freeze
|
|
@@ -254,11 +176,13 @@ module Pikuri
|
|
|
254
176
|
@skills[name]
|
|
255
177
|
end
|
|
256
178
|
|
|
179
|
+
# @return [Array<String>] absolute paths of the existing skill
|
|
180
|
+
# directories this catalog covered, in scan order.
|
|
181
|
+
attr_reader :roots
|
|
182
|
+
|
|
257
183
|
private
|
|
258
184
|
|
|
259
185
|
def scan_root(root)
|
|
260
|
-
return unless File.directory?(root)
|
|
261
|
-
|
|
262
186
|
Dir.children(root).sort.each do |entry|
|
|
263
187
|
skill_dir = File.join(root, entry)
|
|
264
188
|
next unless File.directory?(skill_dir)
|
|
@@ -282,8 +206,7 @@ module Pikuri
|
|
|
282
206
|
end
|
|
283
207
|
|
|
284
208
|
# Read +path+ and return a {Skill}, or +nil+ if the file is
|
|
285
|
-
# unusable (no frontmatter, no description, unreadable).
|
|
286
|
-
# for soft violations are logged but do not block loading.
|
|
209
|
+
# unusable (no frontmatter, no description, unreadable).
|
|
287
210
|
def parse_skill(path, parent_dir_name:)
|
|
288
211
|
raw = File.read(path)
|
|
289
212
|
frontmatter, body = split_frontmatter(raw)
|
|
@@ -315,11 +238,10 @@ module Pikuri
|
|
|
315
238
|
end
|
|
316
239
|
|
|
317
240
|
# Split a SKILL.md into +[frontmatter_hash, body_string]+.
|
|
318
|
-
# Returns +[nil, raw]+ when
|
|
319
|
-
#
|
|
320
|
-
# +[{}, body]+ so the caller
|
|
321
|
-
#
|
|
322
|
-
# below and discards the skill).
|
|
241
|
+
# Returns +[nil, raw]+ when no detectable frontmatter block is
|
|
242
|
+
# present. On YAML parse failure, logs a warning and returns
|
|
243
|
+
# +[{}, body]+ so the caller's "missing description" path
|
|
244
|
+
# discards the skill.
|
|
323
245
|
def split_frontmatter(content)
|
|
324
246
|
normalized = content.gsub(/\r\n?/, "\n")
|
|
325
247
|
return [nil, normalized] unless normalized.start_with?("---\n")
|
|
@@ -15,7 +15,9 @@ module Pikuri
|
|
|
15
15
|
#
|
|
16
16
|
# Pass the extension via the +Agent.new+ block:
|
|
17
17
|
#
|
|
18
|
-
# catalog = Pikuri::Skill::Catalog::Bundled.
|
|
18
|
+
# catalog = Pikuri::Skill::Catalog::Bundled.new(
|
|
19
|
+
# search_bases: [project_root, Dir.home]
|
|
20
|
+
# )
|
|
19
21
|
# Pikuri::Agent.new(transport: ..., system_prompt: ...) do |c|
|
|
20
22
|
# c.add_extension Pikuri::Skill::Extension.new(catalog: catalog)
|
|
21
23
|
# end
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: pikuri-skills
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.0.
|
|
4
|
+
version: 0.0.5
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Martin Vysny
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-
|
|
11
|
+
date: 2026-06-04 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: pikuri-core
|
|
@@ -16,14 +16,14 @@ dependencies:
|
|
|
16
16
|
requirements:
|
|
17
17
|
- - '='
|
|
18
18
|
- !ruby/object:Gem::Version
|
|
19
|
-
version: 0.0.
|
|
19
|
+
version: 0.0.5
|
|
20
20
|
type: :runtime
|
|
21
21
|
prerelease: false
|
|
22
22
|
version_requirements: !ruby/object:Gem::Requirement
|
|
23
23
|
requirements:
|
|
24
24
|
- - '='
|
|
25
25
|
- !ruby/object:Gem::Version
|
|
26
|
-
version: 0.0.
|
|
26
|
+
version: 0.0.5
|
|
27
27
|
description: |
|
|
28
28
|
pikuri-skills implements the Agent Skills standard
|
|
29
29
|
(agentskills.io) for pikuri-core agents: a +Pikuri::Skill::Catalog+
|