@@ 0,0 1,177 @@
+{
+ 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
+ );
+ };
+}