{
config,
lib,
pkgs,
agentEnvLib,
...
}:
let
inherit (lib) mkOption types;
inherit (config) build;
instructionsDrv = pkgs.writeText "agent-instructions.md" build.instructionsText;
# Relative path in the state dir -> store path, for one agent.
managedLinks =
agent:
lib.optionalAttrs (config.instructions != { }) {
${agent.instructionsFile} = "${instructionsDrv}";
}
// lib.listToAttrs (map (s: lib.nameValuePair "skills/${s.skillName}" "${s}") build.skills)
// lib.mapAttrs (_: toString) agent.files;
bin = pkg: name: "${lib.getExe' pkg name}";
mv = bin pkgs.coreutils "mv";
ln = bin pkgs.coreutils "ln";
rm = bin pkgs.coreutils "rm";
mkdir = bin pkgs.coreutils "mkdir";
mktemp = bin pkgs.coreutils "mktemp";
flock = lib.getExe pkgs.flock;
# A literal path; only a leading ~/ is expanded (to $HOME).
stateDirShell =
dir:
if lib.hasPrefix "~/" dir then
"\"$HOME\"/${lib.escapeShellArg (lib.removePrefix "~/" dir)}"
else
lib.escapeShellArg dir;
mkLauncher =
companionBin: name: launcher:
let
agent = build.agents.${launcher.agent};
links = managedLinks agent;
runtimePath = lib.makeBinPath (
lib.optional (companionBin != null) companionBin ++ build.skillRuntimeInputs
);
in
pkgs.writeShellApplication {
inherit name;
text = ''
# shellcheck disable=SC2016 # stateDir is literal on purpose
state=${stateDirShell launcher.stateDir}
if [ -L "$state/skills" ]; then
echo "${name}: '$state/skills' is a symlink; refusing to manage links outside the state dir." >&2
exit 1
fi
${mkdir} -p "$state/skills"
# Append mode: never truncate whatever the lock path might point at.
exec {lock}>>"$state/.agent-environments.lock"
${flock} "$lock"
manifest="$state/.agent-environments-managed"
declare -A wanted=(
${
lib.concatStrings (
lib.mapAttrsToList (p: target: " [${lib.escapeShellArg p}]=${lib.escapeShellArg target}\n") links
)
})
declare -A owned=()
if [ -f "$manifest" ]; then
while IFS= read -r p; do
[ -n "$p" ] && owned["$p"]=1
done <"$manifest"
fi
# Refuse to touch anything we do not own before changing anything.
for p in "''${!wanted[@]}"; do
f="$state/$p"
if [ -e "$f" ] || [ -L "$f" ]; then
if [ -z "''${owned[$p]+x}" ] || [ ! -L "$f" ]; then
echo "${name}: refusing to replace '$f': it is not managed by agent-environments." >&2
echo "${name}: move it away and start again." >&2
exit 1
fi
fi
done
for p in "''${!owned[@]}"; do
if [ -z "''${wanted[$p]+x}" ] && [ -L "$state/$p" ]; then
${rm} -f "$state/$p"
fi
done
for p in "''${!wanted[@]}"; do
# No -f: a unique temp name that already exists is an error, not something to delete.
tmp="$state/$p.agent-env-tmp.$$"
${ln} -sT "''${wanted[$p]}" "$tmp"
${mv} -fT "$tmp" "$state/$p"
done
tmp=$(${mktemp} "$manifest.XXXXXX")
if [ "''${#wanted[@]}" -gt 0 ]; then
printf '%s\n' "''${!wanted[@]}" >"$tmp"
fi
${mv} -fT "$tmp" "$manifest"
exec {lock}>&-
export ${agent.stateEnvVar}="$state"
export AGENT_ENV_LAUNCHER=${lib.escapeShellArg name}
${lib.optionalString (
runtimePath != ""
) ''export PATH=${lib.escapeShellArg runtimePath}"''${PATH:+:$PATH}"''}
exec ${lib.escapeShellArgs ([ agent.exe ] ++ agent.flags)} "$@"
'';
};
in
{
options.launchers = mkOption {
default = { };
description = "Commands that start an agent with this environment. The attribute name is the command name.";
type = types.attrsOf (
types.submodule (
{ name, config, ... }:
{
options = {
agent = mkOption {
type = types.enum [
"claude"
"codex"
];
};
id = mkOption {
type = types.str;
default = name;
description = "Identity of the state dir; launchers with different ids have separate logins.";
};
stateDir = mkOption {
type = types.str;
default = "~/.local/agent-environments/${config.id}";
description = "State dir (CLAUDE_CONFIG_DIR / CODEX_HOME): an absolute path, or one starting with ~/ for $HOME. Taken literally.";
};
};
}
)
);
};
config = {
build = {
# Companions on PATH are launchers without companions of their own: a store
# path cannot contain itself, and companions should not start companions.
launchers = lib.mapAttrs (mkLauncher (
if config.companions.enable then
pkgs.symlinkJoin {
name = "agent-companions";
paths = lib.attrValues (lib.mapAttrs (mkLauncher null) config.launchers);
}
else
null
)) config.launchers;
package = pkgs.symlinkJoin {
name = "agent-environment";
paths = lib.attrValues config.build.launchers;
};
};
assertions = lib.concatLists (
lib.mapAttrsToList (name: l: [
{
assertion = agentEnvLib.safeName name;
message = "launcher name '${name}' is not a safe command name";
}
{
assertion = agentEnvLib.safeName l.id;
message = "launcher '${name}': id '${l.id}' is not a safe path component";
}
{
assertion = lib.hasPrefix "/" l.stateDir || lib.hasPrefix "~/" l.stateDir;
message = "launcher '${name}': stateDir must be absolute or start with ~/";
}
{
assertion = config.build.agents ? ${l.agent};
message = "launcher '${name}' uses ${l.agent}, but ${l.agent}.enable is false";
}
]) config.launchers
);
};
}