NAME
Langertha::Raider::Context::Skills - Internal catalogue of the skills raider lists and loads on demand
VERSION
version 0.504
SYNOPSIS
# Internal to Langertha-Raider -- no API promise.
my $skills = Langertha::Raider::Context::Skills->new(
roots => [
{ dir => "$root/.raider/skills", source => '.raider/skills', layout => 'skills' },
{ dir => "$root/my-skills", source => '--skills my-skills', layout => 'flat' },
],
plugins => [ { name => 'superpowers', dirs => [ @skill_dirs ] } ],
);
my $prompt = $skills->prompt_text; # undef when nothing is listed
my $text = $skills->tool_text('pdf', 'report.pdf');
DESCRIPTION
Internal module. Its interface may change without notice.
The skill catalogue of ADR 0019 §4, which holds the custom commands too (ADR 0030 §1: commands are skills). It takes roots in precedence order and the skill directories and command paths of plugins, and knows every entry's name, description and body. Which roots exist in which layout mode is the caller's decision; this module only reads what it is given. A root or skill directory that does not exist is skipped silently.
An entry -- a skill or a command -- is a plain hash ref:
{
name => 'pdf', # the identifier the model calls, /pdf for the user
kind => 'skill', # or 'command' (a commands root or plugin commands)
description => 'Work with PDF files ...',
dir => '/abs/skills/pdf', # base directory; a command's: the directory of its file
file => '/abs/skills/pdf/SKILL.md',
source => '.claude/skills', # root label, or 'plugin superpowers'
frontmatter => { name => 'pdf', description => '...', ... }, # every key kept
model_invocable => 1, # 0 iff disable-model-invocation is true
user_invocable => 1, # 0 iff user-invocable is false
plugin_root => '/abs/plugin', # plugin entries only: ${CLAUDE_PLUGIN_ROOT}
}
Name. In the
skillslayout the skill directory's name, as Claude Code uses it as the identifier. A frontmatternamethat differs stays infrontmatterbut does not rename the skill. In theflatlayout the file name without .md. In thecommandslayout the file's path below the root without .md, directories joined with:(frontend/component.md isfrontend:component). Plugin skills are<plugin>:<dir>, plugin commands<plugin>:<name>(plugin:sub:namebelow a subdirectory).Booleans.
disable-model-invocationanduser-invocabletaketrue/yes/on/1andfalse/no/off/0, in any case. Unset,disable-model-invocationis false anduser-invocabletrue.Description. The frontmatter
description(whitespace collapsed), else the first non-empty line of the body that is not a Markdown heading, trimmed and cut to 250 characters.Frontmatter. A leading
---...---block, read by Langertha::Raider::Context::Frontmatter: YAML, else the lenient line reader for what YAML rejects but Claude Code reads (true/falseare booleans fordisable-model-invocationanduser-invocable). Only when that finds no key either, or the YAML is not a mapping, the frontmatter is empty and "problems" gets an entry; the skill is still loaded.Precedence. Roots in the given order, then plugins (each plugin's skills, then its commands). The first entry of a name wins; later ones are reported by "shadowed". One exception: a skill beats a command of the same name wherever either came from -- the command is shadowed (Claude Code). Plugin names carry a
:, so they never meet a root skill (a root command in a subdirectory can).
roots
Roots, highest precedence first. Required. Each a hash ref:
{ dir => '/abs/dir', source => 'label', layout => 'skills' | 'flat' | 'commands' }
skills: a directory of skill directories, each with a SKILL.md. flat: a directory of *.md files, each one skill (the old --skills DIR behaviour); without top-level *.md files it holds no skills. file: one skill file (the hash has file; dir is its parent): named by its file name without .md; a file that is not there is a problem. commands: a directory searched recursively for *.md (hidden entries skipped), each one command (kind command), named as above.
A root may name its own base (see "base").
plugins
Plugin skill directories and command paths, after all roots. Defaults to none. Each a hash ref
{
name => 'plugin',
dirs => [ '/abs/skill/dir', ... ], # Plugins->skill_dirs
commands => [ '/abs/commands', '/abs/x.md', ... ], # Plugins->command_dirs, optional
root => '/abs/plugin', # optional, becomes plugin_root
}
-- the name, "skill_dirs" in Langertha::Raider::Context::Plugins, "command_dirs" in Langertha::Raider::Context::Plugins and root of a plugin. A command path is a directory, read like a commands root, or one *.md file, named by its file name.
base
The directory the paths: globs of a skill are relative to, unless its root names its own base (the directory with the .claude/ or .raider/ the skill lives in). Optional; without it a skill with paths: can never be activated.
activated
HashRef of the names of skills with paths: that a touched file has activated ("activate_for"). Shared with the caller, who keeps it across catalogues -- a /reload builds a new one.
skills
for my $s ($skills->skills) { say $s->{name}.' ('.$s->{source}.')' }
Every entry (skill or command) that won its name, in precedence order: roots in the given order, sorted by name inside a root, then the plugins in the given order. Model- or user-invocable or not. A skill that took its name from a command stands where the skill was read.
shadowed
for my $s ($skills->shadowed) { say $s->{name}.' in '.$s->{source}.' shadowed by '.$s->{by} }
Entries that lost their name to an earlier one, or commands that lost it to a skill, each { name, source, file, by } -- by is the winner's source.
problems
warn $_->{file}.': '.$_->{reason}."\n" for $skills->problems;
Skill files that could not be read (not loaded) or whose frontmatter is broken (loaded with an empty frontmatter), each { file, reason }.
find
my $skill = $skills->find('/pdf'); # or 'pdf', 'superpowers:brainstorming'
The skill of exactly that name (a leading / is ignored), or undef. Finds skills that are not model-invocable too.
find_user_invocable
my $entry = $skills->find_user_invocable('/frontend:component');
The entry a user runs as /name: "find", but only an entry with user_invocable; undef otherwise. Commands and skills alike, also those that are not model-invocable.
user_invocable
for my $e ($skills->user_invocable) { say '/'.$e->{name} }
The "skills" with user_invocable, in catalogue order.
listed
my @offered = $skills->listed;
The "skills" the model may see and load ("is_listed").
is_listed
if ($skills->is_listed($skill)) { ... }
True for a skill with model_invocable that has no paths: in its frontmatter, or whose paths: a touched file matched ("activate_for").
invocable
The "skills" with model_invocable, listed or not yet: the skills the model can have now or later.
paths_of
my @globs = $skills->paths_of($skill);
The globs of the paths: frontmatter of a skill (Claude Code's conditional skills): a YAML list, or a string with the globs separated by commas (not inside {a,b}). None for a skill without. A glob is matched against the path relative to the skill's base: * within a path segment, ** over any number of segments, ? one character, {a,b} alternatives; a glob without / matches the file name in any directory.
add_roots
my @new = $skills->add_roots({ dir => ..., source => ..., layout => 'skills', base => ... });
Reads more roots into the catalogue, after everything it holds: a name that is known keeps its entry (the new one is "shadowed") -- unless the known one is a command and the new one a skill. Returns the entries that are new. Nested .claude/skills found while working in a subtree come in this way.
activate_for
my @new = $skills->activate_for('/abs/path/to/file');
Marks every skill whose paths: glob matches the path (relative to its base) as "activated", and returns those that were not activated before.
body
my $md = $skills->body('pdf');
The skill file's text without the frontmatter and leading blank lines, or undef for an unknown name.
tool_text
my $text = $skills->tool_text('pdf', 'report.pdf');
What the skill tool returns (the shape of Claude Code's Skill tool), or undef for an unknown name:
Base directory for this skill: /abs/skills/pdf
<body>
A command (kind command) has no Base directory line: its text is the body alone (ADR 0030 §2).
Every $ARGUMENTS in the body is replaced by $args; without $args (undefined or empty) by nothing, as Claude Code does. When the body has no $ARGUMENTS and $args is given, ARGUMENTS: $args is appended after a blank line.
This is the plain text without a renderer. The application's skill tool renders through Langertha::Raider::SlashCommand instead ("skill_tool_text" in Langertha::Raider::Application, ADR 0030 §2): every placeholder and !`cmd`, the same shape.
prompt_text
my $block = $skills->prompt_text;
The system-prompt block that lists the "listed" skills, or undef when none is listed:
Skills: specialised instructions loaded on demand. When a task matches a
skill's description, call the `skill` tool with its name BEFORE acting,
then follow what it returns. Available skills:
- pdf: Work with PDF files ...
- superpowers:brainstorming: ...
(the first paragraph is one line). A skill without description is listed by its name alone.
announce_text
my $text = $skills->announce_text(@new);
The note for skills that became available during the session (a nested .claude/skills or a paths: match), worded as Claude Code does:
The following skills are available for use with the Skill tool:
- name: description
undef for none.
SEE ALSO
SUPPORT
Issues
Please report bugs and feature requests at https://github.com/Getty/langertha-raider/issues.
IRC
Join #langertha on irc.perl.org or message Getty directly.
CONTRIBUTING
Contributions are welcome! Please fork the repository and submit a pull request.
AUTHOR
Torsten Raudssus <torsten@raudssus.de> https://raudssus.de/
COPYRIGHT AND LICENSE
This software is copyright (c) 2026 by Torsten Raudssus.
This is free software; you can redistribute it and/or modify it under the same terms as the Perl 5 programming language system itself.