~ruther/agent-environments

34f8df1cf2965fd17c72befd7400034e975d17b2 — František Boháček 2 months ago eb6133b
Add launchers with a state dir per login
1 files changed, 177 insertions(+), 0 deletions(-)

A modules/launcher.nix
A modules/launcher.nix => modules/launcher.nix +177 -0
@@ 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
    );
  };
}