NAME

Langertha::Raider::Config - Internal resolver and writer for the project config file

VERSION

version 0.504

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('my-skills');     # the project writers
$config->enable_claude;                           # claude: true
$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 (plus skills, detect, project_tools, providers and permissions, which may be one)
default -- the default: 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 dropped
no_detect -- both files' pack names, home first, duplicates dropped
detect -- 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, packs and api_key included -- 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"). Neither is providers: only the top level of the home file holds provider bindings ("providers"). permissions is read on its own too ("permissions"): the rules of both files are concatenated with their source, never replaced. So is mcp ("mcp_servers", ADR 0029): the MCP servers of both files, the project file's first, each with its source. mcp_approvals ("mcp_approvals") is read from the top level of the home file only.

The project writers never touch the home file ("update_home" is the only one; raider provider add calls it). 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, project_tools, providers, permissions, agent_models, mcp and mcp_approvals (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.

parse_bool

my $bool = $config->parse_bool($value, 'claude');   # 1, 0 or undef

The one boolean parser of raider's own keys, for a -o KEY=VALUE, a config file and an API caller alike: undef stays undef; a boolean object is read by truth; the words true, yes, on, y (any case) are 1; false, no, off, n, 0 and the empty string are 0; other numbers are read by truth. Anything else croaks naming $label, so -o claude=false can never be read as the true string "false".

bool_keys

The raider keys that hold a plain boolean: claude and perl. (detect also takes a map of rules; its scalar form is read with "parse_bool" too.)

normalize_bools

$config->normalize_bools(\%options);

Replaces the "bool_keys" of a hash by "parse_bool", in place, and returns it.

engine_options

my $opts = $config->engine_options($engine_name);

"options" without raider's own keys (agent_models, claude, detect, engine, max_agent_depth, max_concurrent_agents, no_detect, packs, perl, preferred_lib_target, project_tools, providers, skills, web_fetch_model): what goes to the engine constructor.

is_app_key

$config->is_app_key('perl');   # 1

True for the keys that configure raider itself (agent_models, claude, detect, engine, max_agent_depth, max_concurrent_agents, no_detect, packs, permissions, perl, preferred_lib_target, project_tools, providers, skills, web_fetch_model, worktree_base_ref) and never reach the engine constructor. mcp and mcp_approvals are raider keys too ("mcp_servers", "mcp_approvals").

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 project
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.

providers

my $bindings = $config->providers;   # { ALIAS => { origin => ..., ... } }

The providers: map of the home file ("home_file"), as written by raider provider add through "update_home": one flat entry per alias (origin, issuer, auth, credential_env, allow_internal, pinned_at and the fingerprint of what was accepted; see Langertha::Raider::Provider::Binding). raider provider remove deletes one through the same writer. Empty when there is no home file or no such key. Read from the home file only -- a providers: in a project file is ignored and reported ("explain"), so a repository cannot plant a binding. Croaks like "data" does, and when providers: is not a mapping.

permissions

my $p = $config->permissions;

The permissions: of the home file and the project file (ADR 0021), in the shape of "permissions" in Langertha::Raider::Context::Settings:

{
  allow => [ { rule => 'Bash(make test)', source => '.raider/config.yml' }, ... ],
  ask   => [ ... ],
  deny  => [ ... ],
  default_mode           => { value => 'acceptEdits', source => ... } | undef,
  additional_directories => [ { path => '../docs', source => ... }, ... ],
}

permissions:
  allow: [ "Bash(make test)", "Edit(lib/**)" ]
  deny:  [ "Read(./.env)" ]
  default_mode: acceptEdits
  additional_directories: [ ../docs ]

The rules are strings in Claude Code syntax (Langertha::Raider::Permissions::Rule); a single string counts as a list of one. Both files' lists are concatenated, home first, with the source of every entry (~/.raider/config.yml or "label"); default_mode of the project file wins over the home file's. Only the top level is read; a permissions: inside a section is reported by "explain". Rules and modes are not checked here -- the policy reports the invalid ones. Croaks when permissions: is not a mapping, has a key other than these five, or a list holds anything but non-empty strings.

mcp_servers

for my $s (@{ $config->mcp_servers }) {
  # { name => 'github', entry => { type => 'http', url => ... },
  #   scope => 'raider-project', source => '.raider/config.yml' }
}

The mcp: entries of the project file, then of the home file (ADR 0029 §2), each with its scope (raider-project, or raider-home for the home file -- also when raider runs in the home directory and the home file is the file in use) and source ("label", or ~/.raider/config.yml). Sorted by name within a file. The entries are as written, in Claude Code's .mcp.json format; checking their fields, precedence and expansion are Langertha::Raider::MCP::Config's business.

mcp:
  db:
    command: npx
    args: [ -y, "@bytebase/dbhub" ]
    env: { DB_URL: "${DB_URL}" }

Only the top level is read; an mcp: inside a section is reported by "explain". Croaks with Invalid mcp setting in FILE: ... when mcp: is not a mapping or an entry is not a mapping.

mcp_approvals

for my $a (@{ $config->mcp_approvals }) {
  # { path => '~/dev/app', dir => '/home/me/dev/app', enable_all => 1,
  #   enabled => ['github'], disabled => ['db'], source => '~/.raider/config.yml' }
}

The mcp_approvals: of the home file (ADR 0029 §7): which project MCP servers (.mcp.json, the mcp: of a project config) may start in which project. Read from the top level of the home file only -- a project file cannot approve its own servers, its mcp_approvals: is reported by "explain".

mcp_approvals:
  ~/dev/app:
    enable_all: false
    enabled: [ github ]
    disabled: [ db ]

Each key is the absolute or ~/ path of a project directory (no globs); dir is it with ~ expanded and, when it exists, by its real path. enable_all is a boolean ("parse_bool"), undef when not set; enabled and disabled are lists of server names (a single string counts as a list of one). Croaks with Invalid mcp_approvals setting in FILE: ... on any other shape, an unknown key (camelCase gets a hint) or a path that is neither absolute nor ~/.

normalize_skill_spec

my @specs = $config->normalize_skill_spec($item);

Turns one skills: item into the skill directories it names (ADR 0019 §4): any string that is no profile keyword becomes { type => 'dir', path => $item }, a hash with type claude or dir (or none) passes through. The profile keywords name no directory and give nothing: claude is the legacy spelling of claude: true, openai / codex / agents load AGENTS.md as an instructions item (see "profiles"). A hash with type file names one skill file (a flat *.md skill, named by its file name). A hash with another type, without a path, or an item that is neither a string nor a hash croaks: a skill source is never dropped silently.

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);
my @profiles = $config->profiles($engine_name, @more_items);

The agent profiles (claude, openai) named in skills:, then in @more_items (skills: items from elsewhere, such as -o skills=), in order: claude for the item claude, openai for openai, codex and agents.

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. 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('openai', '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.

enable_claude

my $written = $config->enable_claude;

Writes the top-level claude: true (ADR 0019 §1, what --claude persists) and returns true -- unless the file already says claude: true at the top level or in default:, or still lists the legacy claude in skills: there: then nothing is written and it returns false. Only that one key is touched. Without any file it creates the legacy .raider.yml, as every project writer does.

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 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.

https://raudssus.de/