NAME
Langertha::Raider::Config - Internal resolver and writer for the project config file
VERSION
version 0.503
SYNOPSIS
# Internal to Langertha-Raider -- no API promise.
my $config = Langertha::Raider::Config->new( root => $dir );
my $engine = $config->engine; # engine: from the file, or undef
my $options = $config->engine_options('openai'); # for the engine constructor
my @specs = $config->skill_specs('openai');
my $report = $config->explain('openai'); # which value came from where
my @added = $config->add_skills('claude'); # the one writer
$config->set_model('gpt-4o');
DESCRIPTION
Internal module. Its interface may change without notice; use Langertha::Raider::CLI instead.
The one place that reads and writes the project config of "root". The file in use ("file") is .raider/config.yml (ADR 0011) when it exists, else the legacy .raider.yml; both take the same keys. When both exist, only .raider/config.yml is loaded: the legacy file is not read and is reported by "ignored_files".
Under the project file lies the home file ~/.raider/config.yml (ADR 0011; "home_file"), with the same keys: default < home < project < command line. Each file is read in three layers, later ones winning:
top-- every top-level key whose value is not a hash (plusskills,detectandproject_tools, which may be one)default-- thedefault:section- the engine section -- the section named after the active engine (
openai:,anthropic:, ...)
Every other top-level hash is the section of an engine that is not active. skills is merged across the layers instead of replaced. engine is read from top and default only, as it picks the engine section.
Then the project file's values are laid over the home file's, per key:
skills-- both files' lists, home first, duplicates droppedno_detect-- both files' pack names, home first, duplicates droppeddetect-- when both are maps, per pack: the project's entry for a pack replaces the home's (ADR 0012); otherwise the project's value replaces the home's- everything else,
packsandapi_keyincluded -- the project's value replaces the home's
project_tools is not layered at all: only the top level of the home file grants tools to projects ("project_tools").
The project writers never touch the home file ("update_home" is the only one, with no caller yet). When the home file is the file in use -- raider runs in the home directory itself -- it is read once, as the project file, and there is no home layer.
A file, project or home, that does not parse, whose top level is not a mapping, or that holds a mapping under one of raider's own keys other than skills, detect and project_tools (see "is_app_key") -- at top level or in any section, active or not -- is an error: readers croak and the writer refuses to overwrite it.
root
The project directory. Required.
file
Path::Tiny of the file in use: "native_file" when it exists, else "legacy_file". Decided once, when first asked. The writers write here, so without any file they create the legacy .raider.yml, never .raider/config.yml; only raider config migrate (Langertha::Raider::Config::Migrate, with "migration_content") creates that.
home_file
Path::Tiny of config.yml in the user's ~/.raider ("home_base" in Langertha::Raider::Home), present or not; undef when there is no home at all.
uses_home
True when "home_file" is read as the home layer: it exists and is not the file in use ("file", compared by real path). Decided once, when first asked.
home_label
home: how reports name the home file as a source.
native_file
Path::Tiny of .raider/config.yml in "root", present or not.
legacy_file
Path::Tiny of .raider.yml in "root", present or not.
is_native
True when "file" is .raider/config.yml.
label
The file in use relative to "root", .raider/config.yml or .raider.yml: how reports name it as a source.
ignored_files
for my $ign ($config->ignored_files) {
warn 'ignoring '.$ign->{file}.': '.$ign->{reason}."\n";
}
The config files that are present but not loaded, each as file (absolute path) and reason: the legacy .raider.yml while .raider/config.yml is in use. Empty otherwise.
data
The parsed file as a hash; empty when the file is missing or empty. Croaks when the file does not parse, its top level is not a mapping, or a raider key other than skills, detect and project_tools holds a mapping, at top level or in a section.
home_data
"home_file" parsed like "data", croaking like it; empty unless "uses_home".
file_exists
True when "file" exists.
layer_label
$config->layer_label('default'); # '.raider/config.yml default:'
$config->layer_label('home openai'); # 'home openai:'
How reports name a layer of "explain": the label of its file ("label", or "home_label" for a home layer), then the section and a colon unless it is the top level.
value_label
my $where = $config->value_label($engine_name, 'packs'); # 'home'
The label of the file the effective value of a key comes from: "home_label" when only the home file sets it, else "label".
detect_rule_label
my $from = $config->detect_rule_label($engine_name, 'perl'); # 'home detect:'
Where the effective detect: entry of a pack comes from: the label of its file, as "value_label", and detect:.
engine
The engine name from engine: (top level or default:), or undef.
options
my $opts = $config->options($engine_name);
Every effective key except skills, layered for $engine_name.
engine_options
my $opts = $config->engine_options($engine_name);
"options" without raider's own keys (detect, engine, no_detect, packs, perl, preferred_lib_target, project_tools, skills): what goes to the engine constructor.
is_app_key
$config->is_app_key('perl'); # 1
True for the keys that configure raider itself (detect, engine, no_detect, packs, perl, preferred_lib_target, project_tools, skills) and never reach the engine constructor.
detect_settings
my $d = $config->detect_settings($engine_name);
# { enabled => 1,
# rules => { perl => { must => [ { file => 'cpanfile' } ] } },
# off => { rust => 'no_detect', go => 'detect: go: false' } }
Pack detection (ADR 0012) as configured in the file: "normalize_detect" of the effective detect and no_detect values for $engine_name. Croaks on an invalid value or rule.
normalize_detect
my $d = $config->normalize_detect($detect, $no_detect);
Checks and normalizes a detect value -- a map of pack name to rule (Langertha::Raider::Detect) or false, a pack name mapped to false switching detection off for that pack, the whole key false switching it off entirely -- and a no_detect list of pack names (or a comma-separated string). Returns enabled, rules and off (pack name to the reason) as in "detect_settings"; croaks naming the offending key.
project_tools
for my $entry (@{ $config->project_tools }) {
# { selector => '~/dev/*', kind => 'path', tools => ['telegram'] }
}
project_tools of the home file (ADR 0011): which home tools and services a project gets, as a map of workspace selector to a list of tool names (or one comma-separated string). Only the top level of ~/.raider/config.yml counts -- the home layer, or the file in use when raider runs in the home directory itself. A project file cannot grant: its project_tools, like one inside a default: or engine section of either file, is not read and is listed as ignored by "explain".
The entries, sorted by selector, each with its kind:
all-- the selector*, every projectpath-- a path glob: absolute, or starting with~/(~is the user's home). It is matched against the real path of "root", the whole path:**matches any characters,*and?any characters or one character except/; everything else,[and{included, is literal. A trailing/is dropped, so~/dev/*matches ~/dev/foo but not ~/dev/foo/bar, and~/dev/**matches every directory below ~/dev, not ~/dev itself. The leading directories without a wildcard are resolved to their real path when they exist, so a symlink on the way does not keep a glob from matching.workspace-- any other selector: a workspace name (ADR 0004). Accepted, but never matched yet: raider has no workspace registry.
Empty when the home file has no project_tools. Croaks with Invalid project_tools setting ... naming the offending key when the value is not a map, a selector holds no list of names, or a selector is ~user, a relative path, or a name with a wildcard. Information only: nothing is mounted from it yet ("explain").
normalize_project_tools
my $entries = $config->normalize_project_tools($value);
Checks a project_tools value and returns its entries as in "project_tools"; croaks like it.
project_tools_matches
for my $m (@{ $config->project_tools_matches }) {
# { selector => '~/dev/*', kind => 'path', tools => ['telegram'],
# matched => 1, reason => 'matches /home/me/dev/app' }
}
The entries of "project_tools", each with whether it applies to "root" (matched) and why (reason). A workspace name never matches: workspace names are not supported yet.
normalize_skill_spec
my @specs = $config->normalize_skill_spec($item);
Turns one skills: item into skill-source hashes: claude becomes CLAUDE.md plus .claude/skills, openai / codex / agents become AGENTS.md, any other string a markdown directory, a hash passes through.
skill_specs
my @specs = $config->skill_specs($engine_name, @cli_specs);
The skill sources of every layer, then @cli_specs, normalized and deduplicated in that order.
profiles
my @profiles = $config->profiles($engine_name);
The agent profiles (claude, openai) named in skills:, in order.
explain
my $report = $config->explain($engine_name);
Where each effective value came from:
{
file => '/path/.raider/config.yml',
exists => 1,
label => '.raider/config.yml',
ignored_files => [ { file => '/path/.raider.yml', reason => '...' } ],
engine => 'openai',
values => [
{ key => 'temperature', value => 0.7, source => 'openai',
shadowed => ['default'], applies_to => 'engine' },
{ key => 'skills', value => ['claude'], source => 'top',
merged => 1, applies_to => 'raider' },
],
ignored => [ { key => 'anthropic', reason => 'section of an inactive engine' } ],
}
source is a layer (top, default or the engine name), for the home file prefixed with home (home top, home default, ...; "layer_label" names them); applies_to says whether the value reaches the engine constructor or configures raider itself. skills gets one entry per layer, as they merge. A no_detect or detect merged from both files names the home layer it took values from in merged_with. file is the file in use, label its "label" and ignored_files the "ignored_files"; home_file is "home_file", present only when "uses_home".
project_tools is present when the home file has one: file and label of the file it was read from, and selectors, the "project_tools_matches" -- information only, it mounts nothing. A project_tools that is not read (in a project file, or inside a section) is listed in ignored.
update_home
$config->update_home(sub {
my ( $data ) = @_;
$data->{default}{model} = 'gpt-4o';
return 1; # true: write it
});
Internal, no caller yet besides tests. The one writer of the home file "home_file", shaped like the project writers: the callback gets the parsed data of that file (empty when the file is absent), changes it in place and returns true to have it written; false leaves the disk alone. Returns true when it wrote.
Nothing but this method writes the home file. It creates ~/.raider (mode 0700) and the file (mode 0600, the home file may hold an api_key) when absent. The new content is dumped first, written to a temporary file next to the target and renamed over it, so a failure never leaves a partial file; the previous file is kept as config.yml.bak, one generation, replaced by every write. A home file that does not parse (or whose top level is no mapping) croaks like "data" does and stays untouched.
When raider runs in the home directory itself, "home_file" is the project file: the same file is written, and the caches are dropped. Croaks when there is no home at all.
add_skills
my @added = $config->add_skills('claude', 'my-skills');
Appends the items not yet listed to the top-level skills: list and writes the file. Items already present at top level or in default: are skipped. Returns the items that were added.
set_model
$config->set_model('gpt-4o');
Writes default: { model: ... }, the shape the REPL's /model saves.
remove_api_keys
my @where = $config->remove_api_keys($data); # ('top', 'openai')
Deletes api_key from the top level and from every section of the parsed $data, in place. Returns the layers it was in, named as in "explain" (top, default, the engine sections sorted).
migration_content
my $m = $config->migration_content;
# { text => "...", api_keys => ['top', 'openai'],
# removed_lines => [ 3, 9 ], rewritten => 0 }
The legacy .raider.yml ("legacy_file") as the content of a .raider/config.yml, for raider config migrate: the file as it is, but without any api_key ("remove_api_keys"), because a project must not choose a secret. The api_key lines are dropped from the text, so comments and layout stay, when what is left parses to exactly the data without the keys; removed_lines are their line numbers. Otherwise the data without the keys is written anew (rewritten), losing comments and layout. Croaks like "data" when the file does not parse; an absent file gives an empty text.
SEE ALSO
SUPPORT
Issues
Please report bugs and feature requests on GitHub 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.