~ruther/agent-environments

3e0d1cbb83a203ed67f3231be7e5dc608ed6d859 — František Boháček 2 months ago
Add design spec
2 files changed, 193 insertions(+), 0 deletions(-)

A .gitignore
A docs/superpowers/specs/2026-09-26-agent-environments-design.md
A  => .gitignore +2 -0
@@ 1,2 @@
result
result-*

A  => docs/superpowers/specs/2026-09-26-agent-environments-design.md +191 -0
@@ 1,191 @@
# agent-environments — design

Status: self-authored during an autonomous run (user asked to work autonomously
and consult Codex). Reviewed by Codex (review-1: blocking points folded in), not
yet by the user.

## Goal

A Nix flake **library** for declaring reproducible configurations ("environments")
for coding agents — Claude Code and Codex for now — and launching them under
separate identities (logins). Other flakes consume it; `examples/` shows how.

## Requirements (from the user)

- Module-based: modules add text to the instruction file (AGENTS.md / CLAUDE.md)
  and enable skills, e.g. `modules.skills.xyz.enable = true`.
- Several launchers such as `claude-work-acc`, `claude-pers-acc`. They may share
  one environment but log in separately. Each launcher gets its own state
  directory `~/.local/agent-environments/<id>`, passed in with
  `CLAUDE_CONFIG_DIR` / `CODEX_HOME`.
- Each environment is its own set of modules.
- Skills are packaged. A skill can need runtime tools and other skills.
- Flakes. `claude-code` and `codex` come in as inputs:
  `github:sadjow/claude-code-nix`, `github:sadjow/codex-cli-nix`.
- Standalone only (no home-manager). The root flake exposes the library plus one
  default environment with no skills.

## Architecture

```
flake.nix                  inputs: nixpkgs, claude-code, codex
lib/default.nix            mkEnvironment, mkSkill, mkSkillModule
modules/core.nix           instructions, skills, launchers, assertions
modules/claude.nix         claude.{enable,package,settings}
modules/codex.nix          codex.{enable,package,settings}
modules/launcher.nix       wrapper script generation
checks/                    behaviour tests (stub agents, temp HOME)
examples/<name>/flake.nix  consumer flakes
```

### Evaluation

`lib.mkEnvironment { pkgs, modules ? [] }` runs `lib.evalModules` over the core
modules plus the user's modules, with `pkgs` passed as a module argument. It
returns the evaluated `config` along with `package` (a `symlinkJoin` of every
launcher) and `launchers` (an attrset of per-launcher packages).

The flake's `lib` is system-independent. `mkEnvironment` receives `pkgs`, and the
flake sets default agent packages for `pkgs.stdenv.hostPlatform.system` from its
own inputs. Consumers can override those with `claude.package` /
`codex.package`.

### Core options

- `instructions.<name> = { text : lines; order : int = 1000; }` — fragments
  sorted by `(order, name)` and joined with a blank line into one document. That
  document becomes `CLAUDE.md` for Claude and `AGENTS.md` for Codex. Modules add
  a fragment by defining an entry.
- `skills.packages : listOf package` — skill derivations made by `mkSkill`. The
  effective set is the transitive closure over `passthru.skillDeps`, deduplicated
  by skill name. Two different derivations with the same name → evaluation error.
- `claude.enable`, `claude.package`, `claude.settings : attrs` (JSON).
- `codex.enable`, `codex.package`, `codex.settings : attrs` (TOML).
- `launchers.<name> = { agent : enum ["claude" "codex"]; id : str = <name>;
  stateDir : str = "~/.local/agent-environments/${id}"; }`. The name is the
  command name. `stateDir` must be absolute or start with `~/` (assertion); it is
  shell-quoted as a literal, and only the leading `~/` becomes `$HOME`.
  A launcher's agent must be enabled (assertion).
  `id` and launcher names must match `^[A-Za-z0-9][A-Za-z0-9._-]*$` (assertion).
- Launchers are always declared explicitly. There are no implicit default
  launchers, because attrset options merge by key and a hidden default could not
  be dropped cleanly.

### Modules namespace

`modules.*` is where optional features live, e.g. `modules.skills.<name>.enable`.
The helper `lib.mkSkillModule skill` produces a module declaring
`options.modules.skills.<skill.skillName>.enable` and, when it is enabled, adding
the skill to `skills.packages`. Options cannot be generated from `config` (that
would recurse infinitely), so consumers import one module per skill they want to
make available. Instruction modules are plain modules that define
`instructions.<name>`.

### Skill packaging

`lib.mkSkill { pkgs, name, src, runtimeInputs ? [], skills ? [] }`:

- `name` must match `^[A-Za-z0-9][A-Za-z0-9._-]*$` (evaluation-time check).
- Output: `$out/` is the skill directory (it must contain `SKILL.md`; checked at
  build time). The same directory works for both agents, since Claude Code and
  Codex share the `SKILL.md` format.
- `passthru = { skillName = name; runtimeInputs; skillDeps = skills; }`.
- Runtime inputs: the launcher puts the union of all effective skills'
  `runtimeInputs` on `PATH` (prepended). Skills run inside the agent's shell, so
  this is the smallest mechanism that works for both agents.

### Launcher (runtime)

The wrapper is a `writeShellApplication` per launcher. Everything it manages is
listed in a manifest, `$state/.agent-environments-managed` (one relative path per
line). On every start it:

1. `mkdir -p "$state/skills"`, then takes `flock` on
   `$state/.agent-environments.lock` for the setup phase only; the lock fd is
   closed before `exec`.
2. Computes the wanted set of managed links: the instruction file
   (`CLAUDE.md` for claude, `AGENTS.md` for codex, when there are instructions),
   `skills/<name>` for each effective skill, and the codex profile TOML (when
   there are settings).
3. Removes every path in the old manifest that is not wanted any more, but only
   when it is still a symlink. Anything else is left in place.
4. For each wanted path that exists: unless it is listed in the old manifest and
   is still a symlink, stop with an error naming the path (user data, including
   user symlinks, is never replaced
   and there are no automatic backups). Otherwise replace it atomically
   (`ln -s` to a temp name, then `mv -T`).
5. Writes the new manifest atomically. Codex's own `skills/.system` and any
   user-installed skills are never touched, because they are not in the manifest.
6. Settings, layered so the agents stay free to write their own files:
   - Claude: `--settings <store settings.json>` when `claude.settings != {}`.
     `settings.json`, `.claude.json` and `.credentials.json` in the state dir
     stay owned by Claude.
   - Codex: `$state/agent-environments.config.toml` (a managed link to the store
     TOML) plus `--profile agent-environments`, which layers
     `$CODEX_HOME/<profile>.config.toml` over `config.toml`. `config.toml`
     (which holds trust entries) and `auth.json` stay owned by Codex. Skipped
     when `codex.settings == {}`.
7. `export CLAUDE_CONFIG_DIR="$state"` / `export CODEX_HOME="$state"`, prepend
   the runtime inputs to `PATH`, then `exec <agent> <managed flags> "$@"`.

Logins are therefore per launcher: `claude-work-acc` and `claude-pers-acc` have
different ids, so different state dirs, so different credentials, even when they
come from the same environment. Two launchers that should share a login can set
the same `id`. They then also share managed content: the last launch wins, so they
should come from the same environment. This is documented, not enforced.

### Root flake outputs

- `lib` — `mkEnvironment`, `mkSkill`, `mkSkillModule`.
- `packages.<system>.default` — the default environment: claude + codex enabled,
  launchers `claude` and `codex` with ids `default-claude` / `default-codex`,
  no skills and no instructions.
- `checks.<system>.*` — behaviour tests.
- `formatter` — `nixfmt`.

### Examples

- `examples/basic` — one environment, one instruction fragment, one local skill
  with a runtime dependency and a skill dependency.
- `examples/multi-account` — one module set, launchers `claude-work-acc`,
  `claude-pers-acc`, `codex-work-acc`, plus a second environment with a different
  module set.

Each example is a flake with `inputs.agent-environments.url = "path:../..";`.

## Error handling

- Evaluation-time assertions: a launcher's agent is disabled; an unsafe id,
  launcher or skill name; conflicting skill names. A skill without `SKILL.md`
  fails at build time.
- Runtime: never delete non-symlink files and never replace unmanaged ones; fail
  loudly (`set -euo pipefail`) and say which path conflicts.

## Testing

`nix flake check` runs derivation checks that execute the launchers with stub
`claude`/`codex` packages (scripts that dump argv, env and state-dir contents),
using a temporary `HOME`:

- the state dir is created under `$HOME/.local/agent-environments/<id>` and the
  right env var points at it;
- two launchers from one environment get different state dirs (separate logins);
- the instruction file carries the fragments in order;
- dependency skills are linked transitively; a skill's runtime tool is on `PATH`;
- a skill that is no longer enabled is unlinked on the next launch, while user
  skill dirs, user symlinks and `.system` are kept;
- a pre-existing user `CLAUDE.md` makes the launcher fail and is left untouched;
- the `--settings` / `--profile` flags appear only when settings are non-empty.

One integration check runs the real Codex package offline to confirm that it
discovers `$CODEX_HOME/AGENTS.md`, the skills and the profile, if Codex has a
non-interactive way to show this. If it does not, that is recorded as a coverage
gap and checked by hand.

Examples are built with `nix build ./examples/<x> --override-input
agent-environments path:$PWD`.

## Out of scope (YAGNI)

home-manager integration, MCP server modules, plugins, per-project config,
agents other than Claude Code and Codex.