{ 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 = name: launcher: let agent = build.agents.${launcher.agent}; links = managedLinks agent; runtimePath = lib.makeBinPath 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" ${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 = { launchers = lib.mapAttrs mkLauncher 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 ); }; }