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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3225ec6dbaa280bbb0ca84df6207a4d3c08030b80c0ad6246ed72c164a1bf80d
4
- data.tar.gz: 4605f1727c2c11223798f6a10dbe6b1e46ccbabcb5d874a4a935fa8897e45ab9
3
+ metadata.gz: 36c3fb0aa368f1f7e0986b8bd045dd8da16af4f777d4a8241b225986ac63281f
4
+ data.tar.gz: 3519593f2c0aa7e0a9e84803e28f3c2bf9818325edd17f55b49074dd1ba9bc99
5
5
  SHA512:
6
- metadata.gz: 7eb4aaf9b73614909760697e9645d290cb9c752205b3cb05a2e6a340cc41281e79f55d4c80f9bbd22beea48f200b8503000801f14f94964b0a974bd2c8cd4f94
7
- data.tar.gz: 4dca2734c9ac979a6b8f4d9e79c1bc2a5390046cd2fabe647e348e22233391647741d3073a7f4260717051f409599d36b418c69ec7925787353780e80429aa31
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` (project
10
- + global). Walks from CWD up to the git root.
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
- catalog = Pikuri::Skill::Catalog::Bundled.discover(cwd: Dir.pwd)
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.
@@ -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 {Tool}.
8
+ # frontmatter that the agent loads on demand via {SkillTool}.
9
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).
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
- # == 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}.
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. 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.
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
- # 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>]
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
- # keys its auto-wiring (system-prompt augmentation + {Tool}
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
- # 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.
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
- # 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.
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("'", '&apos;')
136
100
  end
137
101
 
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.
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. Frozen.
118
+ # Shared singleton for the no-skills case.
154
119
  EMPTY = Empty.new.freeze
155
120
 
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 +~/+.
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
- # +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.
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, 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).
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
- # 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.
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 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:)
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.to_s) }
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). Warnings
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 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).
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.discover
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.3
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-05-22 00:00:00.000000000 Z
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.3
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.3
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+