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 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('&', '&amp;')
132
+ .gsub('<', '&lt;')
133
+ .gsub('>', '&gt;')
134
+ .gsub('"', '&quot;')
135
+ .gsub("'", '&apos;')
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: []