From 8dfe6a8356b6b1022abc2dabc18375283c82d972 Mon Sep 17 00:00:00 2001
From: m3tm3re
Date: Fri, 10 Apr 2026 16:47:55 +0200
Subject: [PATCH 1/3] feat(lib): add agents.nix with loadCanonical and renderer
stubs
---
lib/agents.nix | 82 +++++++++++++++++++++++++++++++++++++++++++++++++
lib/default.nix | 3 ++
2 files changed, 85 insertions(+)
create mode 100644 lib/agents.nix
diff --git a/lib/agents.nix b/lib/agents.nix
new file mode 100644
index 0000000..d059726
--- /dev/null
+++ b/lib/agents.nix
@@ -0,0 +1,82 @@
+# Harness-agnostic agent management utilities
+#
+# This module provides functions to load canonical agent definitions and
+# render them for different AI coding tools (OpenCode, Claude Code, Pi).
+#
+# Usage in your configuration:
+#
+# # In your flake or configuration:
+# let
+# m3taLib = inputs.m3ta-nixpkgs.lib.${system};
+#
+# # Load canonical agents from the AGENTS flake input
+# canonical = m3taLib.agents.loadCanonical { agentsInput = inputs.agents; };
+#
+# # Render for a specific tool
+# agentFiles = m3taLib.agents.renderForTool {
+# inherit pkgs;
+# agentsInput = inputs.agents;
+# tool = "opencode";
+# };
+# in { ... }
+#
+# Renderers are stubs for now (Tasks 9-11 will provide real implementations).
+{lib}: let
+ agentsLib = {
+ # Load canonical agent definitions from the AGENTS flake input.
+ # agentsInput: the AGENTS flake (e.g., inputs.agents in a consuming flake)
+ # Returns the canonical attrset from lib.loadAgents (keyed by slug).
+ loadCanonical = {agentsInput}: agentsInput.lib.loadAgents;
+
+ # Stub: OpenCode renderer — produces .opencode/agents/*.md files.
+ # To be implemented in Task 9.
+ renderForOpencode = {
+ pkgs,
+ canonical,
+ modelOverrides ? {},
+ }:
+ pkgs.runCommand "opencode-agents" {} "echo stub > $out";
+
+ # Stub: Claude Code renderer — produces .claude/agents/*.md files.
+ # To be implemented in Task 10.
+ renderForClaudeCode = {
+ pkgs,
+ canonical,
+ modelOverrides ? {},
+ }:
+ pkgs.runCommand "claude-code-agents" {} "echo stub > $out";
+
+ # Stub: Pi renderer — produces AGENTS.md + SYSTEM.md.
+ # To be implemented in Task 11.
+ renderForPi = {
+ pkgs,
+ canonical,
+ }:
+ pkgs.runCommand "pi-agents" {} "echo stub > $out";
+
+ # Dispatch renderer by tool name.
+ # tool: "opencode" | "claude-code" | "pi"
+ renderForTool = {
+ pkgs,
+ agentsInput,
+ tool,
+ modelOverrides ? {},
+ }: let
+ canonical = agentsInput.lib.loadAgents;
+ in
+ if tool == "opencode"
+ then
+ agentsLib.renderForOpencode {
+ inherit pkgs canonical modelOverrides;
+ }
+ else if tool == "claude-code"
+ then
+ agentsLib.renderForClaudeCode {
+ inherit pkgs canonical modelOverrides;
+ }
+ else if tool == "pi"
+ then agentsLib.renderForPi {inherit pkgs canonical;}
+ else throw "lib.agents.renderForTool: unknown tool '${tool}'. Must be opencode, claude-code, or pi.";
+ };
+in
+ agentsLib
diff --git a/lib/default.nix b/lib/default.nix
index 1b1a2e7..b4dd130 100644
--- a/lib/default.nix
+++ b/lib/default.nix
@@ -9,4 +9,7 @@
# OpenCode rules injection utilities
opencode-rules = import ./opencode-rules.nix {inherit lib;};
+
+ # Agent configuration management utilities
+ agents = import ./agents.nix {inherit lib;};
}
From 6426490fe776508eaf0ad218b802d87b0b514186 Mon Sep 17 00:00:00 2001
From: m3tm3re
Date: Fri, 10 Apr 2026 17:10:46 +0200
Subject: [PATCH 2/3] feat(lib): implement OpenCode renderer in agents.nix
---
lib/agents.nix | 77 +++++++++++++++++++++++++++++++++++++++++++++++---
1 file changed, 73 insertions(+), 4 deletions(-)
diff --git a/lib/agents.nix b/lib/agents.nix
index d059726..fe1af6f 100644
--- a/lib/agents.nix
+++ b/lib/agents.nix
@@ -28,14 +28,83 @@
# Returns the canonical attrset from lib.loadAgents (keyed by slug).
loadCanonical = {agentsInput}: agentsInput.lib.loadAgents;
- # Stub: OpenCode renderer — produces .opencode/agents/*.md files.
- # To be implemented in Task 9.
+ # OpenCode renderer — produces a directory of .opencode/agents/*.md files.
+ # Each file has YAML frontmatter (description, mode, optional model,
+ # optional permission) followed by the agent's systemPrompt content.
renderForOpencode = {
pkgs,
canonical,
modelOverrides ? {},
- }:
- pkgs.runCommand "opencode-agents" {} "echo stub > $out";
+ }: let
+ # Split a rule string on the LAST colon to get { pattern, action }.
+ # e.g. "rm -rf *:ask" → pattern="rm -rf *", action="ask"
+ # e.g. "/run/agenix/**:deny" → pattern="/run/agenix/**", action="deny"
+ parseRule = ruleStr: let
+ parts = lib.strings.splitString ":" ruleStr;
+ # last element is the action, everything before joined with ":" is pattern
+ action = lib.last parts;
+ pattern = lib.concatStringsSep ":" (lib.init parts);
+ in {inherit pattern action;};
+
+ # Render one permission section to YAML lines (list of strings).
+ # intent-only → single line: " : "
+ # intent+rules → nested block starting with "*": , then specific rules
+ renderPermSection = tool: section:
+ if !(section ? rules) || section.rules == []
+ then [" ${tool}: ${section.intent}"]
+ else let
+ parsedRules = map parseRule section.rules;
+ wildcardLine = " \"*\": ${section.intent}";
+ ruleLines = map (r: " \"${r.pattern}\": ${r.action}") parsedRules;
+ in
+ [" ${tool}:"] ++ [wildcardLine] ++ ruleLines;
+
+ # Render the full permission block for an agent.
+ # Returns empty list if no permissions.
+ renderPermBlock = permissions:
+ if permissions == {} || permissions == null
+ then []
+ else
+ ["permission:"]
+ ++ lib.concatLists (
+ lib.mapAttrsToList renderPermSection permissions
+ );
+
+ # Build the YAML frontmatter string for one agent.
+ mkFrontmatter = name: agent: let
+ descLine = "description: \"${agent.description}.\"";
+ modeLine = "mode: ${agent.mode}";
+ modelLine =
+ lib.optionalString
+ (modelOverrides ? ${name})
+ "model: ${modelOverrides.${name}}\n";
+ permBlock = renderPermBlock (agent.permissions or {});
+ permLines =
+ if permBlock == []
+ then ""
+ else lib.concatStringsSep "\n" permBlock + "\n";
+ in "---\n${descLine}\n${modeLine}\n${modelLine}${permLines}---\n";
+
+ # Build the full markdown file content for one agent.
+ mkAgentContent = name: agent:
+ (mkFrontmatter name agent) + agent.systemPrompt;
+
+ # Write each agent as a derivation text file.
+ mkAgentFile = name: agent:
+ pkgs.writeText "${name}.md" (mkAgentContent name agent);
+
+ # Attrset of name → derivation for each agent.
+ agentFiles = lib.mapAttrs mkAgentFile canonical;
+
+ # Shell commands to copy each file into $out.
+ copyCommands = lib.concatStringsSep "\n" (
+ lib.mapAttrsToList (name: file: "cp ${file} $out/${name}.md") agentFiles
+ );
+ in
+ pkgs.runCommand "opencode-agents" {} ''
+ mkdir -p $out
+ ${copyCommands}
+ '';
# Stub: Claude Code renderer — produces .claude/agents/*.md files.
# To be implemented in Task 10.
From 3d8f9e300385ded14bfb806d2d04582daef82394 Mon Sep 17 00:00:00 2001
From: m3tm3re
Date: Mon, 13 Apr 2026 16:52:47 +0200
Subject: [PATCH 3/3] feat: config with agents rework
---
.pi-lens/cache/jscpd.json | 7 +
.pi-lens/cache/jscpd.meta.json | 3 +
.pi-lens/cache/knip.json | 9 +
.pi-lens/cache/knip.meta.json | 3 +
.pi-lens/cache/session-start-guidance.json | 3 +
.../cache/session-start-guidance.meta.json | 3 +
.pi-lens/cache/todo-baseline.json | 3 +
.pi-lens/cache/todo-baseline.meta.json | 3 +
.pi-lens/turn-state.json | 6 +
AGENTS.md | 55 +++
docs/guides/using-modules.md | 63 +++-
flake.nix | 1 +
lib/agents.nix | 348 ++++++++++++++++--
lib/coding-rules.nix | 121 ++++++
lib/default.nix | 7 +-
modules/home-manager/AGENTS.md | 163 +++++++-
.../coding/agents/claude-code.nix | 90 +++++
.../home-manager/coding/agents/default.nix | 10 +
.../home-manager/coding/agents/opencode.nix | 113 ++++++
modules/home-manager/coding/agents/pi.nix | 234 ++++++++++++
modules/home-manager/coding/default.nix | 1 +
modules/home-manager/coding/opencode.nix | 82 -----
22 files changed, 1202 insertions(+), 126 deletions(-)
create mode 100644 .pi-lens/cache/jscpd.json
create mode 100644 .pi-lens/cache/jscpd.meta.json
create mode 100644 .pi-lens/cache/knip.json
create mode 100644 .pi-lens/cache/knip.meta.json
create mode 100644 .pi-lens/cache/session-start-guidance.json
create mode 100644 .pi-lens/cache/session-start-guidance.meta.json
create mode 100644 .pi-lens/cache/todo-baseline.json
create mode 100644 .pi-lens/cache/todo-baseline.meta.json
create mode 100644 .pi-lens/turn-state.json
create mode 100644 lib/coding-rules.nix
create mode 100644 modules/home-manager/coding/agents/claude-code.nix
create mode 100644 modules/home-manager/coding/agents/default.nix
create mode 100644 modules/home-manager/coding/agents/opencode.nix
create mode 100644 modules/home-manager/coding/agents/pi.nix
diff --git a/.pi-lens/cache/jscpd.json b/.pi-lens/cache/jscpd.json
new file mode 100644
index 0000000..ee25c61
--- /dev/null
+++ b/.pi-lens/cache/jscpd.json
@@ -0,0 +1,7 @@
+{
+ "success": true,
+ "clones": [],
+ "duplicatedLines": 0,
+ "totalLines": 0,
+ "percentage": 0
+}
\ No newline at end of file
diff --git a/.pi-lens/cache/jscpd.meta.json b/.pi-lens/cache/jscpd.meta.json
new file mode 100644
index 0000000..555945b
--- /dev/null
+++ b/.pi-lens/cache/jscpd.meta.json
@@ -0,0 +1,3 @@
+{
+ "timestamp": "2026-04-11T04:17:20.531Z"
+}
\ No newline at end of file
diff --git a/.pi-lens/cache/knip.json b/.pi-lens/cache/knip.json
new file mode 100644
index 0000000..a4147c6
--- /dev/null
+++ b/.pi-lens/cache/knip.json
@@ -0,0 +1,9 @@
+{
+ "success": false,
+ "issues": [],
+ "unusedExports": [],
+ "unusedFiles": [],
+ "unusedDeps": [],
+ "unlistedDeps": [],
+ "summary": "Failed to parse output"
+}
\ No newline at end of file
diff --git a/.pi-lens/cache/knip.meta.json b/.pi-lens/cache/knip.meta.json
new file mode 100644
index 0000000..2eda7c6
--- /dev/null
+++ b/.pi-lens/cache/knip.meta.json
@@ -0,0 +1,3 @@
+{
+ "timestamp": "2026-04-11T04:17:21.374Z"
+}
\ No newline at end of file
diff --git a/.pi-lens/cache/session-start-guidance.json b/.pi-lens/cache/session-start-guidance.json
new file mode 100644
index 0000000..f2c1cb4
--- /dev/null
+++ b/.pi-lens/cache/session-start-guidance.json
@@ -0,0 +1,3 @@
+{
+ "content": "📌 pi-lens active — as you work on this project, fix any errors you encounter (including pre-existing). Prefer: lsp_navigation for definitions/references, ast_grep_search for code patterns, grep for text/TODO search."
+}
\ No newline at end of file
diff --git a/.pi-lens/cache/session-start-guidance.meta.json b/.pi-lens/cache/session-start-guidance.meta.json
new file mode 100644
index 0000000..e321daf
--- /dev/null
+++ b/.pi-lens/cache/session-start-guidance.meta.json
@@ -0,0 +1,3 @@
+{
+ "timestamp": "2026-04-11T04:21:36.939Z"
+}
\ No newline at end of file
diff --git a/.pi-lens/cache/todo-baseline.json b/.pi-lens/cache/todo-baseline.json
new file mode 100644
index 0000000..fc69ce2
--- /dev/null
+++ b/.pi-lens/cache/todo-baseline.json
@@ -0,0 +1,3 @@
+{
+ "items": []
+}
\ No newline at end of file
diff --git a/.pi-lens/cache/todo-baseline.meta.json b/.pi-lens/cache/todo-baseline.meta.json
new file mode 100644
index 0000000..449e16d
--- /dev/null
+++ b/.pi-lens/cache/todo-baseline.meta.json
@@ -0,0 +1,3 @@
+{
+ "timestamp": "2026-04-11T04:21:36.940Z"
+}
\ No newline at end of file
diff --git a/.pi-lens/turn-state.json b/.pi-lens/turn-state.json
new file mode 100644
index 0000000..dd32709
--- /dev/null
+++ b/.pi-lens/turn-state.json
@@ -0,0 +1,6 @@
+{
+ "files": {},
+ "turnCycles": 0,
+ "maxCycles": 3,
+ "lastUpdated": "2026-04-11T04:17:22.397Z"
+}
\ No newline at end of file
diff --git a/AGENTS.md b/AGENTS.md
index 5d465b9..1ed3682 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -106,6 +106,37 @@ meta = with lib; {
**Shared lib**: `portsLib = import ../../lib/ports.nix { inherit lib; }; portHelpers = portsLib.mkPortHelpers { ... };`
+## LIBRARY FUNCTIONS
+
+### `lib.ports`
+
+Port management utilities. See [Port Management](#port-management).
+
+### `lib.agents`
+
+Harness-agnostic agent management. Reads canonical `agent.toml` from the AGENTS
+flake input and renders tool-specific configs.
+
+**Functions:**
+
+| Function | Purpose |
+|----------|--------|
+| `loadCanonical { agentsInput }` | Load canonical agents from AGENTS flake |
+| `renderForOpencode { pkgs, canonical, modelOverrides }` | Render to OpenCode file-based agents |
+| `renderForClaudeCode { pkgs, canonical, modelOverrides }` | Render to Claude Code agents + settings.json |
+| `renderForPi { pkgs, canonical }` | Render to Pi AGENTS.md + SYSTEM.md |
+| `renderForTool { pkgs, agentsInput, tool, modelOverrides }` | Dispatch to correct renderer |
+| `shellHookForTool { pkgs, agentsInput, tool, modelOverrides }` | Generate devShell shellHook |
+
+### `lib.coding-rules`
+
+Coding rules injection (renamed from `lib.opencode-rules`). The old name still works.
+
+| Function | Purpose |
+|----------|--------|
+| `mkCodingRules { agents, languages, concerns, frameworks }` | Generate rules config + shellHook |
+| `mkOpencodeRules` | Backward-compat alias for `mkCodingRules` |
+
## PORT MANAGEMENT
Central port management: `config.m3ta.ports.get "service"` with host-specific via `hostOverrides`
@@ -168,3 +199,27 @@ Use `td usage -q` for subsequent reads.
- `td version` - Check version
For full workflow details, see the [td documentation](./docs/packages/td.md).
+
+## MIGRATION: Agent System (OpenCode → Canonical TOML)
+
+The agent system was migrated from embedded `agents.json` to harness-agnostic
+canonical `agent.toml` + `system-prompt.md` in the AGENTS repo. Renderers in
+`lib/agents.nix` generate tool-specific configs.
+
+### What changed in this repo
+
+- **`lib/agents.nix`**: New — 3 renderers (OpenCode, Claude Code, Pi) + dispatcher + shellHook
+- **`lib/coding-rules.nix`**: Renamed from `opencode-rules.nix`, `mkCodingRules` replaces `mkOpencodeRules`
+- **`modules/home-manager/coding/agents/`**: New — per-tool HM sub-modules
+- **`modules/home-manager/coding/opencode.nix`**: Slimmed — no longer handles agents/skills/context
+- **`flake.nix`**: Exports new `agents` HM module
+
+### What the user must do
+
+See `modules/home-manager/AGENTS.md` for the full migration guide. Summary:
+
+1. Move `agentsInput`/`externalSkills` from `coding.opencode` to `coding.agents.opencode`
+2. Add `modelOverrides` with previously hardcoded model strings
+3. Run `home-manager switch`
+4. Remove legacy `agents.json` + `prompts/*.txt` from AGENTS repo
+5. Remove `lib.agentsJson` backward-compat bridge from AGENTS `flake.nix`
diff --git a/docs/guides/using-modules.md b/docs/guides/using-modules.md
index 3c0ba9a..e04208c 100644
--- a/docs/guides/using-modules.md
+++ b/docs/guides/using-modules.md
@@ -32,7 +32,13 @@ modules/home-manager/
│ └── zellij-ps.nix
└── coding/ # Development tools
├── default.nix # Aggregates coding modules
- └── editors.nix
+ ├── editors.nix
+ ├── opencode.nix # OpenCode non-agent config
+ └── agents/ # Per-tool agent deployment
+ ├── default.nix
+ ├── opencode.nix
+ ├── claude-code.nix
+ └── pi.nix
```
## Importing Modules
@@ -197,6 +203,61 @@ m3ta.coding.editors = {
**Documentation**: [Editors Module](../modules/home-manager/coding/editors.md)
+### `coding.opencode`
+
+OpenCode AI coding assistant (non-agent config: theme, formatter, plugins).
+
+```nix
+coding.opencode = {
+ enable = true;
+ ohMyOpencodeSettings = {
+ agents.sisyphus.model = "anthropic/claude-opus-4-5";
+ };
+ extraSettings = {
+ provider.anthropic.name = "Anthropic";
+ };
+};
+```
+
+### `coding.agents.opencode`
+
+OpenCode agent deployment from canonical TOML definitions.
+
+```nix
+coding.agents.opencode = {
+ enable = true;
+ agentsInput = inputs.agents;
+ modelOverrides = {
+ chiron = "anthropic/claude-sonnet-4";
+ };
+ externalSkills = [
+ { src = inputs.skills-anthropic; }
+ ];
+};
+```
+
+### `coding.agents.claude-code`
+
+Claude Code agent deployment from canonical TOML definitions.
+
+```nix
+coding.agents.claude-code = {
+ enable = true;
+ agentsInput = inputs.agents;
+};
+```
+
+### `coding.agents.pi`
+
+Pi agent deployment from canonical TOML definitions.
+
+```nix
+coding.agents.pi = {
+ enable = true;
+ agentsInput = inputs.agents;
+};
+```
+
## Common Patterns
### Module Configuration
diff --git a/flake.nix b/flake.nix
index af33a49..2930806 100644
--- a/flake.nix
+++ b/flake.nix
@@ -79,6 +79,7 @@
default = import ./modules/home-manager;
ports = import ./modules/home-manager/ports.nix;
opencode = import ./modules/home-manager/coding/opencode.nix;
+ agents = import ./modules/home-manager/coding/agents;
zellij-ps = import ./modules/home-manager/zellij-ps.nix;
};
diff --git a/lib/agents.nix b/lib/agents.nix
index fe1af6f..147e9af 100644
--- a/lib/agents.nix
+++ b/lib/agents.nix
@@ -5,50 +5,53 @@
#
# Usage in your configuration:
#
-# # In your flake or configuration:
# let
# m3taLib = inputs.m3ta-nixpkgs.lib.${system};
-#
-# # Load canonical agents from the AGENTS flake input
# canonical = m3taLib.agents.loadCanonical { agentsInput = inputs.agents; };
#
# # Render for a specific tool
-# agentFiles = m3taLib.agents.renderForTool {
-# inherit pkgs;
-# agentsInput = inputs.agents;
-# tool = "opencode";
+# rendered = m3taLib.agents.renderForOpencode {
+# inherit pkgs canonical;
+# modelOverrides = { chiron = "anthropic/claude-sonnet-4"; };
# };
# in { ... }
-#
-# Renderers are stubs for now (Tasks 9-11 will provide real implementations).
{lib}: let
+ # ── Shared helpers ─────────────────────────────────────────────
+ # Split a rule string on the LAST colon to get { pattern, action }.
+ # e.g. "rm -rf *:ask" → pattern="rm -rf *", action="ask"
+ # e.g. "/run/agenix/**:deny" → pattern="/run/agenix/**", action="deny"
+ parseRule = ruleStr: let
+ parts = lib.strings.splitString ":" ruleStr;
+ action = lib.last parts;
+ pattern = lib.concatStringsSep ":" (lib.init parts);
+ in {inherit pattern action;};
+
agentsLib = {
+ # ── loadCanonical ─────────────────────────────────────────────
+ #
# Load canonical agent definitions from the AGENTS flake input.
- # agentsInput: the AGENTS flake (e.g., inputs.agents in a consuming flake)
# Returns the canonical attrset from lib.loadAgents (keyed by slug).
+
loadCanonical = {agentsInput}: agentsInput.lib.loadAgents;
- # OpenCode renderer — produces a directory of .opencode/agents/*.md files.
+ # ── OpenCode renderer ─────────────────────────────────────────
+ #
+ # Produces a directory of agent *.md files suitable for
+ # ~/.config/opencode/agents/ (system-level)
+ # .opencode/agents/ (project-level)
+ #
# Each file has YAML frontmatter (description, mode, optional model,
# optional permission) followed by the agent's systemPrompt content.
+ # The filename (without .md) becomes the agent name in OpenCode.
+
renderForOpencode = {
pkgs,
canonical,
modelOverrides ? {},
}: let
- # Split a rule string on the LAST colon to get { pattern, action }.
- # e.g. "rm -rf *:ask" → pattern="rm -rf *", action="ask"
- # e.g. "/run/agenix/**:deny" → pattern="/run/agenix/**", action="deny"
- parseRule = ruleStr: let
- parts = lib.strings.splitString ":" ruleStr;
- # last element is the action, everything before joined with ":" is pattern
- action = lib.last parts;
- pattern = lib.concatStringsSep ":" (lib.init parts);
- in {inherit pattern action;};
-
- # Render one permission section to YAML lines (list of strings).
+ # Render one permission section to YAML lines.
# intent-only → single line: " : "
- # intent+rules → nested block starting with "*": , then specific rules
+ # intent+rules → nested block
renderPermSection = tool: section:
if !(section ? rules) || section.rules == []
then [" ${tool}: ${section.intent}"]
@@ -59,8 +62,6 @@
in
[" ${tool}:"] ++ [wildcardLine] ++ ruleLines;
- # Render the full permission block for an agent.
- # Returns empty list if no permissions.
renderPermBlock = permissions:
if permissions == {} || permissions == null
then []
@@ -70,7 +71,6 @@
lib.mapAttrsToList renderPermSection permissions
);
- # Build the YAML frontmatter string for one agent.
mkFrontmatter = name: agent: let
descLine = "description: \"${agent.description}.\"";
modeLine = "mode: ${agent.mode}";
@@ -85,18 +85,14 @@
else lib.concatStringsSep "\n" permBlock + "\n";
in "---\n${descLine}\n${modeLine}\n${modelLine}${permLines}---\n";
- # Build the full markdown file content for one agent.
mkAgentContent = name: agent:
(mkFrontmatter name agent) + agent.systemPrompt;
- # Write each agent as a derivation text file.
mkAgentFile = name: agent:
pkgs.writeText "${name}.md" (mkAgentContent name agent);
- # Attrset of name → derivation for each agent.
agentFiles = lib.mapAttrs mkAgentFile canonical;
- # Shell commands to copy each file into $out.
copyCommands = lib.concatStringsSep "\n" (
lib.mapAttrsToList (name: file: "cp ${file} $out/${name}.md") agentFiles
);
@@ -106,25 +102,245 @@
${copyCommands}
'';
- # Stub: Claude Code renderer — produces .claude/agents/*.md files.
- # To be implemented in Task 10.
+ # ── Claude Code renderer ──────────────────────────────────────
+ #
+ # Produces a directory containing:
+ # .claude/agents/.md — one per agent with YAML frontmatter
+ # .claude/settings.json — permission rules in Claude Code DSL
+ #
+ # Claude Code requires:
+ # - name field: [a-z0-9-]+ (kebab-case)
+ # - description field: required
+ # - All agents are subagents (no primary/subagent distinction)
+
renderForClaudeCode = {
pkgs,
canonical,
modelOverrides ? {},
- }:
- pkgs.runCommand "claude-code-agents" {} "echo stub > $out";
+ }: let
+ # Claude Code permission DSL format: "Tool(pattern)" or just "Tool"
+ # Canonical bash rules → "Bash(pattern)" entries
+ # Canonical edit rules → "Edit(pattern)" entries
+ renderPermAllow = permissions: let
+ bashRules =
+ if !(permissions ? bash)
+ then []
+ else if permissions.bash.intent == "allow"
+ then ["Bash"]
+ else
+ map
+ (r: let parsed = parseRule r; in "Bash(${parsed.pattern})")
+ (lib.filter (r: (parseRule r).action == "allow") (permissions.bash.rules or []));
+ editRules =
+ if !(permissions ? edit)
+ then []
+ else if permissions.edit.intent == "allow"
+ then ["Edit"]
+ else
+ map
+ (r: let parsed = parseRule r; in "Edit(${parsed.pattern})")
+ (lib.filter (r: (parseRule r).action == "allow") (permissions.edit.rules or []));
+ webRules =
+ lib.optional (permissions.webfetch.intent or "" == "allow") "WebFetch";
+ in
+ bashRules ++ editRules ++ webRules;
+
+ renderPermDeny = permissions: let
+ bashRules =
+ if !(permissions ? bash)
+ then []
+ else
+ map
+ (r: let parsed = parseRule r; in "Bash(${parsed.pattern})")
+ (lib.filter (r: (parseRule r).action == "deny") (permissions.bash.rules or []));
+ editRules =
+ if !(permissions ? edit)
+ then []
+ else
+ map
+ (r: let parsed = parseRule r; in "Edit(${parsed.pattern})")
+ (lib.filter (r: (parseRule r).action == "deny") (permissions.edit.rules or []));
+ in
+ bashRules ++ editRules;
+
+ # Build YAML frontmatter for one Claude Code agent .md file.
+ mkClaudeFrontmatter = name: agent: let
+ descLine = "description: \"${agent.description}\"";
+ modelLine =
+ lib.optionalString
+ (modelOverrides ? ${name})
+ "model: ${modelOverrides.${name}}\n";
+ skillsLine =
+ if (agent ? skills) && agent.skills != []
+ then "skills:\n" + lib.concatStringsSep "\n" (map (s: " - ${s}") agent.skills) + "\n"
+ else "";
+ in "---\n${descLine}\n${modelLine}${skillsLine}---\n";
+
+ mkClaudeAgentContent = name: agent:
+ (mkClaudeFrontmatter name agent) + agent.systemPrompt;
+
+ mkClaudeAgentFile = name: agent:
+ pkgs.writeText "${name}.md" (mkClaudeAgentContent name agent);
+
+ agentFiles = lib.mapAttrs mkClaudeAgentFile canonical;
+
+ # Build settings.json with permission rules aggregated from all agents.
+ allAllows = lib.flatten (lib.mapAttrsToList (_: agent: renderPermAllow (agent.permissions or {})) canonical);
+ allDenies = lib.flatten (lib.mapAttrsToList (_: agent: renderPermDeny (agent.permissions or {})) canonical);
+
+ settingsJson = builtins.toJSON {
+ permissions = {
+ allow = lib.unique (lib.sort (a: b: a < b) allAllows);
+ deny = lib.unique (lib.sort (a: b: a < b) allDenies);
+ };
+ };
+
+ settingsFile = pkgs.writeText "claude-settings.json" settingsJson;
+
+ copyAgentCommands = lib.concatStringsSep "\n" (
+ lib.mapAttrsToList (name: file: "cp ${file} $out/.claude/agents/${name}.md") agentFiles
+ );
+ in
+ pkgs.runCommand "claude-code-agents" {} ''
+ mkdir -p $out/.claude/agents
+ ${copyAgentCommands}
+ cp ${settingsFile} $out/.claude/settings.json
+ '';
+
+ # ── Pi renderer ───────────────────────────────────────────────
+ #
+ # This renderer produces:
+ # AGENTS.md — concatenated agent descriptions + specialist listing
+ # SYSTEM.md — primary agent's system prompt (replaces Pi default)
+ # agents/{name}.md — one per agent for pi-subagents (YAML frontmatter + prompt)
+ #
+ # The agents/ files use pi-subagents frontmatter format:
+ # name, description, tools, extensions, model, thinking, skill,
+ # output, defaultReads, defaultProgress, interactive, maxSubagentDepth
- # Stub: Pi renderer — produces AGENTS.md + SYSTEM.md.
- # To be implemented in Task 11.
renderForPi = {
pkgs,
canonical,
- }:
- pkgs.runCommand "pi-agents" {} "echo stub > $out";
+ modelOverrides ? {},
+ primaryAgent ? null,
+ }: let
+ # Find the primary agent (there should be exactly one).
+ primaryAgents = lib.filterAttrs (_: a: a.mode == "primary") canonical;
+ primaryNames = lib.attrNames primaryAgents;
+ primaryName =
+ if primaryAgent != null
+ then primaryAgent
+ else if primaryNames == []
+ then throw "lib.agents.renderForPi: no primary agent found"
+ else builtins.head primaryNames;
+ primary = builtins.getAttr primaryName primaryAgents;
- # Dispatch renderer by tool name.
+ # Subagents for the specialist listing.
+ subagents = lib.filterAttrs (_: a: a.mode != "primary") canonical;
+
+ # ── Permission → Pi tool mapping ──────────────────────────────
+ #
+ # Pi built-in tools: read, bash, edit, write, grep, find, ls,
+ # mcp, subagent, web_search, fetch_content, etc.
+ # Canonical tools: bash, edit, webfetch, websearch, question, external_directory
+ #
+ # We map canonical permissions to Pi's tool list.
+ # intent=allow → include tool; intent=deny → exclude; intent=ask → include (Pi has no ask granularity)
+ # When specific allow rules exist, the tool is always included (Pi can't restrict by pattern).
+
+ piToolsForAgent = agent: let
+ perms = agent.permissions or {};
+ tools = [];
+ # Always available: read (no permission concept in Pi)
+ addIf = tool: section:
+ if section.intent == "allow" || section.intent == "ask"
+ then [tool]
+ else [];
+ # bash → bash
+ withBash = tools ++ (addIf "bash" (perms.bash or {intent = "ask";}));
+ # edit → edit
+ withEdit = withBash ++ (addIf "edit" (perms.edit or {intent = "deny";}));
+ # webfetch → fetch_content
+ withFetch = withEdit ++ (addIf "fetch_content" (perms.webfetch or {intent = "deny";}));
+ # websearch → web_search
+ withSearch = withFetch ++ (addIf "web_search" (perms.websearch or {intent = "deny";}));
+ in
+ lib.unique (withSearch ++ ["read" "grep" "find" "ls"]);
+
+ # ── Build YAML frontmatter for pi-subagents .md files ──────────
+ mkPiFrontmatter = name: agent: let
+ tools = piToolsForAgent agent;
+ descLine = "description: \"${agent.description}\"";
+ toolsLine = "tools: ${lib.concatStringsSep ", " tools}";
+ model =
+ if modelOverrides ? ${name}
+ then "model: ${modelOverrides.${name}}"
+ else "";
+ skillsLine =
+ if (agent ? skills) && agent.skills != []
+ then "skill: ${lib.concatStringsSep ", " agent.skills}"
+ else "";
+ in
+ "---\n"
+ + "name: ${name}\n"
+ + "${descLine}\n"
+ + "${toolsLine}\n"
+ + (lib.optionalString (model != "") "${model}\n")
+ + (lib.optionalString (skillsLine != "") "${skillsLine}\n")
+ + "---\n";
+
+ mkPiAgentContent = name: agent:
+ (mkPiFrontmatter name agent) + agent.systemPrompt;
+
+ mkPiAgentFile = name: agent:
+ pkgs.writeText "${name}.md" (mkPiAgentContent name agent);
+
+ piAgentFiles = lib.mapAttrs mkPiAgentFile canonical;
+
+ # ── Build AGENTS.md content ───────────────────────────────────
+ primaryDn = primary.display_name or primaryName;
+ specialistEntries = let
+ mkEntry = name: agent: let
+ dn = agent.display_name or name;
+ in
+ "- **" + dn + "**: " + agent.description;
+ in
+ lib.mapAttrsToList mkEntry subagents;
+ agentsMd =
+ "# Agent Instructions\n"
+ + "\n"
+ + "## "
+ + primaryDn
+ + "\n"
+ + "\n"
+ + primary.description
+ + "\n"
+ + "\n"
+ + (
+ if subagents == {}
+ then ""
+ else "## Available Specialists\n\n" + lib.concatStringsSep "\n" specialistEntries + "\n"
+ );
+
+ agentsMdFile = pkgs.writeText "AGENTS.md" agentsMd;
+ systemMdFile = pkgs.writeText "SYSTEM.md" primary.systemPrompt;
+
+ copyAgentCommands = lib.concatStringsSep "\n" (
+ lib.mapAttrsToList (name: file: "cp ${file} $out/agents/${name}.md") piAgentFiles
+ );
+ in
+ pkgs.runCommand "pi-agents" {} ''
+ mkdir -p $out/agents
+ cp ${agentsMdFile} $out/AGENTS.md
+ cp ${systemMdFile} $out/SYSTEM.md
+ ${copyAgentCommands}
+ '';
+
+ # ── renderForTool dispatcher ──────────────────────────────────
+ #
+ # Dispatches to the correct renderer by tool name.
# tool: "opencode" | "claude-code" | "pi"
+
renderForTool = {
pkgs,
agentsInput,
@@ -144,8 +360,60 @@
inherit pkgs canonical modelOverrides;
}
else if tool == "pi"
- then agentsLib.renderForPi {inherit pkgs canonical;}
+ then
+ agentsLib.renderForPi {
+ inherit pkgs canonical modelOverrides;
+ }
else throw "lib.agents.renderForTool: unknown tool '${tool}'. Must be opencode, claude-code, or pi.";
+
+ # ── shellHookForTool ──────────────────────────────────────────
+ #
+ # Generates a shellHook string for use in devShells that symlinks
+ # rendered agent files into the project directory.
+ #
+ # Usage:
+ # devShells.default = pkgs.mkShell {
+ # shellHook = m3taLib.agents.shellHookForTool {
+ # inherit pkgs;
+ # agentsInput = inputs.agents;
+ # tool = "opencode";
+ # modelOverrides = { chiron = "anthropic/claude-sonnet-4"; };
+ # };
+ # };
+
+ shellHookForTool = {
+ pkgs,
+ agentsInput,
+ tool,
+ modelOverrides ? {},
+ }: let
+ rendered = agentsLib.renderForTool {
+ inherit pkgs agentsInput tool modelOverrides;
+ };
+ in
+ if tool == "opencode"
+ then ''
+ # Agent files for OpenCode
+ mkdir -p .opencode/agents
+ ln -sfn ${rendered}/* .opencode/agents/
+ ''
+ else if tool == "claude-code"
+ then ''
+ # Agent files for Claude Code
+ mkdir -p .claude/agents
+ ln -sfn ${rendered}/.claude/agents/* .claude/agents/
+ ln -sfn ${rendered}/.claude/settings.json .claude/settings.json
+ ''
+ else if tool == "pi"
+ then ''
+ # Agent files for Pi
+ ln -sfn ${rendered}/AGENTS.md AGENTS.md
+ mkdir -p .pi
+ ln -sfn ${rendered}/SYSTEM.md .pi/SYSTEM.md
+ mkdir -p .pi/agents
+ ln -sfn ${rendered}/agents/* .pi/agents/
+ ''
+ else throw "lib.agents.shellHookForTool: unknown tool '${tool}'";
};
in
agentsLib
diff --git a/lib/coding-rules.nix b/lib/coding-rules.nix
new file mode 100644
index 0000000..365b9ee
--- /dev/null
+++ b/lib/coding-rules.nix
@@ -0,0 +1,121 @@
+# Opencode rules management utilities
+#
+# This module provides functions to configure Opencode agent rules across
+# multiple projects. Rules are defined in the AGENTS repository and can be
+# selectively included based on language, framework, and concerns.
+#
+# Usage in your configuration:
+#
+# # In your flake or configuration:
+# let
+# m3taLib = inputs.m3ta-nixpkgs.lib.${system};
+#
+# rules = m3taLib.coding-rules.mkCodingRules {
+# agents = inputs.agents;
+# languages = [ "python" "typescript" ];
+# concerns = [ "coding-style" "naming" "documentation" ];
+# frameworks = [ "react" "fastapi" ];
+# };
+# in {
+# # Use in your devShell:
+# devShells.default = pkgs.mkShell {
+# shellHook = rules.shellHook;
+# inherit (rules) instructions;
+# };
+# }
+#
+# The shellHook creates:
+# - A `.opencode-rules/` symlink pointing to the AGENTS repository rules directory
+# - An `opencode.json` file with a $schema reference and instructions list
+#
+# The instructions list contains paths relative to the project root, all prefixed
+# with `.opencode-rules/`, making them portable across different project locations.
+{lib}: let
+ # Create Opencode rules configuration from AGENTS repository
+ #
+ # Args:
+ # agents: Path to the AGENTS repository (non-flake input)
+ # languages: Optional list of language-specific rules to include
+ # (e.g., [ "python" "typescript" "rust" ])
+ # concerns: Optional list of concern rules to include
+ # Default: [ "coding-style" "naming" "documentation" "testing" "git-workflow" "project-structure" ]
+ # frameworks: Optional list of framework-specific rules to include
+ # (e.g., [ "react" "fastapi" "django" ])
+ # extraInstructions: Optional list of additional instruction paths
+ # (for custom rules outside standard locations)
+ #
+ # Returns:
+ # An attribute set containing:
+ # - shellHook: Bash code to create symlink and opencode.json
+ # - instructions: List of rule file paths (relative to project root)
+ #
+ # Example:
+ # mkCodingRules {
+ # agents = inputs.agents;
+ # languages = [ "python" ];
+ # frameworks = [ "fastapi" ];
+ # }
+ # # Returns:
+ # # {
+ # # shellHook = "...";
+ # # instructions = [
+ # # ".opencode-rules/concerns/coding-style.md"
+ # # ".opencode-rules/concerns/naming.md"
+ # # ".opencode-rules/concerns/documentation.md"
+ # # ".opencode-rules/concerns/testing.md"
+ # # ".opencode-rules/concerns/git-workflow.md"
+ # # ".opencode-rules/concerns/project-structure.md"
+ # # ".opencode-rules/languages/python.md"
+ # # ".opencode-rules/frameworks/fastapi.md"
+ # # ];
+ # # }
+ mkCodingRules = {
+ agents,
+ languages ? [],
+ concerns ? [
+ "coding-style"
+ "naming"
+ "documentation"
+ "testing"
+ "git-workflow"
+ "project-structure"
+ ],
+ frameworks ? [],
+ extraInstructions ? [],
+ }: let
+ rulesDir = ".opencode-rules";
+
+ # Build instructions list by mapping concerns, languages, frameworks to their file paths
+ # All paths are relative to project root via the rulesDir symlink
+ instructions =
+ (map (c: "${rulesDir}/concerns/${c}.md") concerns)
+ ++ (map (l: "${rulesDir}/languages/${l}.md") languages)
+ ++ (map (f: "${rulesDir}/frameworks/${f}.md") frameworks)
+ ++ extraInstructions;
+
+ # Generate JSON configuration for Opencode
+ opencodeConfig = {
+ "$schema" = "https://opencode.ai/config.json";
+ inherit instructions;
+ };
+ in {
+ inherit instructions;
+
+ # Shell hook to set up rules in the project
+ # Creates a symlink to the AGENTS rules directory and generates opencode.json
+ shellHook = ''
+ # Create/update symlink to AGENTS rules directory
+ ln -sfn ${agents}/rules ${rulesDir}
+
+ # Generate opencode.json configuration file
+ cat > opencode.json <<'OPENCODE_EOF'
+ ${builtins.toJSON opencodeConfig}
+ OPENCODE_EOF
+ '';
+ };
+
+ # Backward-compat alias
+ mkOpencodeRules = mkCodingRules;
+in {
+ inherit mkCodingRules mkOpencodeRules;
+}
diff --git a/lib/default.nix b/lib/default.nix
index b4dd130..a6bdfa3 100644
--- a/lib/default.nix
+++ b/lib/default.nix
@@ -7,8 +7,11 @@
# Port management utilities
ports = import ./ports.nix {inherit lib;};
- # OpenCode rules injection utilities
- opencode-rules = import ./opencode-rules.nix {inherit lib;};
+ # Coding rules injection utilities (renamed from opencode-rules)
+ coding-rules = import ./coding-rules.nix {inherit lib;};
+
+ # Backward-compat alias: opencode-rules → coding-rules
+ opencode-rules = import ./coding-rules.nix {inherit lib;};
# Agent configuration management utilities
agents = import ./agents.nix {inherit lib;};
diff --git a/modules/home-manager/AGENTS.md b/modules/home-manager/AGENTS.md
index c08b63c..1629ba4 100644
--- a/modules/home-manager/AGENTS.md
+++ b/modules/home-manager/AGENTS.md
@@ -13,7 +13,13 @@ home-manager/
│ └── zellij-ps.nix
└── coding/ # Development tools
├── default.nix # Category aggregator
- └── editors.nix # Neovim + Zed configs
+ ├── editors.nix # Neovim + Zed configs
+ ├── opencode.nix # OpenCode non-agent config (theme, plugins, formatter)
+ └── agents/ # Per-tool agent deployment (canonical TOML → rendered)
+ ├── default.nix
+ ├── opencode.nix # File-based agents + skills + context
+ ├── claude-code.nix # Claude Code agents + settings.json
+ └── pi.nix # Pi AGENTS.md + SYSTEM.md
```
## Where to Look
@@ -24,11 +30,16 @@ home-manager/
| Add coding module | `coding/.nix`, import in `coding/default.nix` |
| Add new category | Create `/default.nix`, import in root `default.nix` |
| Module with host ports | Import `../../lib/ports.nix`, use `mkPortHelpers` |
+| Add agent renderer | `coding/agents/.nix`, import in `coding/agents/default.nix` |
## Option Namespaces
- `cli.*` - CLI tools (e.g., `cli.zellij-ps.enable`)
- `coding.editors.*` - Editor configs (e.g., `coding.editors.neovim.enable`)
+- `coding.opencode.*` - OpenCode non-agent config (theme, plugins, formatter)
+- `coding.agents.opencode.*` - OpenCode agent deployment (file-based agents)
+- `coding.agents.claude-code.*` - Claude Code agent deployment
+- `coding.agents.pi.*` - Pi agent deployment
- `m3ta.ports.*` - Port management (shared with NixOS)
## Patterns
@@ -72,3 +83,153 @@ config = mkMerge [
| `generateEnvVars` | Available | Not available |
| Output file | `~/.config/m3ta/ports.json` | `/etc/m3ta/ports.json` |
| Package access | `pkgs.*` via overlay | `pkgs.*` via overlay |
+
+## Agent Modules
+
+Agent definitions are stored as canonical `agent.toml` + `system-prompt.md` in the
+[AGENTS repo](https://code.m3ta.dev/m3tam3re/AGENTS). Renderers in `lib/agents.nix`
+transform these into tool-specific configs. Each tool has its own HM sub-module
+under `coding/agents/`.
+
+### OpenCode (`coding.agents.opencode`)
+
+Renders file-based agents to `~/.config/opencode/agents/*.md`:
+
+```nix
+coding.agents.opencode = {
+ enable = true;
+ agentsInput = inputs.agents;
+ modelOverrides = {
+ chiron = "anthropic/claude-sonnet-4";
+ };
+ externalSkills = [
+ { src = inputs.skills-anthropic; }
+ ];
+};
+```
+
+**Options:** `enable`, `agentsInput`, `modelOverrides`, `externalSkills`
+
+### Claude Code (`coding.agents.claude-code`)
+
+Renders agents to `~/.claude/agents/*.md` + `~/.claude/settings.json`:
+
+```nix
+coding.agents.claude-code = {
+ enable = true;
+ agentsInput = inputs.agents;
+ modelOverrides = {};
+};
+```
+
+**Options:** `enable`, `agentsInput`, `modelOverrides`
+
+### Pi (`coding.agents.pi`)
+
+Renders `AGENTS.md` + `SYSTEM.md` to `~/.pi/agent/`:
+
+```nix
+coding.agents.pi = {
+ enable = true;
+ agentsInput = inputs.agents;
+};
+```
+
+**Options:** `enable`, `agentsInput`
+
+### Project-level usage
+
+For per-project agent setup via `flake.nix` + `direnv`:
+
+```nix
+m3taLib.agents.shellHookForTool {
+ inherit pkgs;
+ agentsInput = inputs.agents;
+ tool = "opencode";
+ modelOverrides = { chiron = "anthropic/claude-sonnet-4"; };
+};
+```
+
+## Migration Guide (OpenCode agents)
+
+The agent system was migrated from embedded `agents.json` to file-based canonical
+`agent.toml` definitions. Here is how to migrate your home-manager config.
+
+### What changed
+
+| Before | After |
+|--------|-------|
+| `coding.opencode.agentsInput` | `coding.agents.opencode.agentsInput` |
+| `coding.opencode.externalSkills` | `coding.agents.opencode.externalSkills` |
+| Agents embedded in `config.json` | File-based `~/.config/opencode/agents/*.md` |
+| Model hardcoded in `agents.json` | Per-machine `modelOverrides` |
+| `mkOpencodeRules` | `mkCodingRules` (old name still works) |
+
+### Migration steps
+
+**1. Update home-manager config:**
+
+Move `agentsInput` and `externalSkills` from `coding.opencode` to `coding.agents.opencode`.
+Add `modelOverrides` with the models previously hardcoded in agents.json:
+
+```nix
+# BEFORE (legacy):
+coding.opencode = {
+ enable = true;
+ agentsInput = inputs.agents;
+ externalSkills = [{ src = inputs.skills-anthropic; }];
+ ohMyOpencodeSettings = { ... };
+};
+
+# AFTER (new):
+coding.opencode = {
+ enable = true;
+ ohMyOpencodeSettings = { ... };
+};
+
+coding.agents.opencode = {
+ enable = true;
+ agentsInput = inputs.agents;
+ externalSkills = [{ src = inputs.skills-anthropic; }];
+ modelOverrides = {
+ chiron = "zai-coding-plan/glm-5";
+ "chiron-forge" = "zai-coding-plan/glm-5";
+ };
+};
+```
+
+**2. Run `home-manager switch`:**
+
+```bash
+home-manager switch --flake .
+```
+
+**3. Verify agents are deployed:**
+
+```bash
+ls ~/.config/opencode/agents/
+# Should show: chiron.md chiron-forge.md hermes.md athena.md apollo.md calliope.md
+```
+
+**4. Remove legacy files from AGENTS repo** (after confirming everything works):
+
+```bash
+cd /home/m3tam3re/p/AI/AGENTS
+rm agents/agents.json
+rm prompts/chiron.txt prompts/chiron-forge.txt prompts/hermes.txt \
+ prompts/athena.txt prompts/apollo.txt prompts/calliope.txt
+rmdir prompts/ # if empty
+# Also remove lib.agentsJson from flake.nix
+```
+
+**5. Final cleanup:** After legacy files are removed from AGENTS repo,
+remove `lib.agentsJson` from the AGENTS `flake.nix` (it's only needed for
+backward compatibility during the transition).
+
+### Key advantage of the new system
+
+Prompt changes no longer require `home-manager switch`. Since agents are
+deployed as file-based `~/.config/opencode/agents/*.md` (symlinks to Nix store),
+you only need to edit the `system-prompt.md` in the AGENTS repo, commit, update
+the flake lock, and run `home-manager switch`. Or for local development, edit
+the file directly and restart the tool.
diff --git a/modules/home-manager/coding/agents/claude-code.nix b/modules/home-manager/coding/agents/claude-code.nix
new file mode 100644
index 0000000..2375d88
--- /dev/null
+++ b/modules/home-manager/coding/agents/claude-code.nix
@@ -0,0 +1,90 @@
+{
+ config,
+ lib,
+ pkgs,
+ ...
+}:
+with lib; let
+ cfg = config.coding.agents.claude-code;
+ mcpCfg = config.programs.mcp or null;
+in {
+ options.coding.agents.claude-code = {
+ enable = mkEnableOption "Claude Code agent management via canonical agent.toml definitions";
+
+ agentsInput = mkOption {
+ type = types.nullOr types.anything;
+ default = null;
+ description = ''
+ The `agents` flake input (your personal AGENTS repo).
+ When set, agents are rendered from canonical agent.toml files
+ and symlinked to ~/.claude/agents/.
+ '';
+ };
+
+ modelOverrides = mkOption {
+ type = types.attrsOf types.str;
+ default = {};
+ description = ''
+ Per-agent model overrides. Maps agent slug to model alias or ID.
+ Example: { chiron = "claude-sonnet-4-20250514"; }
+ '';
+ example = literalExpression ''
+ {
+ chiron = "claude-sonnet-4-20250514";
+ "chiron-forge" = "claude-sonnet-4-20250514";
+ }
+ '';
+ };
+
+ mcpServers = mkOption {
+ type = types.attrsOf types.anything;
+ default = if mcpCfg != null then mcpCfg.servers else {};
+ defaultText = literalExpression "config.programs.mcp.servers";
+ description = ''
+ MCP server configurations for Claude Code.
+ Merged into ~/.claude/settings.json alongside permissions.
+ Automatically inherits from config.programs.mcp.servers.
+ '';
+ };
+ };
+
+ config = mkIf cfg.enable (let
+ agentsLib = (import ../../../../lib {inherit lib;}).agents;
+
+ # Rendered agents + permissions (only if agentsInput is set)
+ rendered = mkIf (cfg.agentsInput != null) (
+ agentsLib.renderForClaudeCode {
+ inherit pkgs;
+ canonical = cfg.agentsInput.lib.loadAgents;
+ modelOverrides = cfg.modelOverrides;
+ }
+ );
+
+ # Merge MCP servers into the rendered settings.json.
+ # The renderer produces { permissions: { allow, deny } }.
+ # We add mcpServers on top.
+ settingsJson =
+ if cfg.agentsInput != null
+ then let
+ renderedSettings = builtins.fromJSON (builtins.readFile "${rendered}/.claude/settings.json");
+ withMcp =
+ if cfg.mcpServers != {}
+ then renderedSettings // {mcpServers = cfg.mcpServers;}
+ else renderedSettings;
+ in
+ pkgs.writeText "claude-settings.json" (builtins.toJSON withMcp)
+ else if cfg.mcpServers != {}
+ then pkgs.writeText "claude-settings.json" (builtins.toJSON {mcpServers = cfg.mcpServers;})
+ else null;
+ in {
+ # Rendered agent files symlinked to ~/.claude/agents/
+ home.file.".claude/agents" = mkIf (cfg.agentsInput != null) {
+ source = "${rendered}/.claude/agents";
+ };
+
+ # Rendered settings.json with permissions + MCP servers
+ home.file.".claude/settings.json" = mkIf (settingsJson != null) {
+ source = "${settingsJson}";
+ };
+ });
+}
diff --git a/modules/home-manager/coding/agents/default.nix b/modules/home-manager/coding/agents/default.nix
new file mode 100644
index 0000000..681a47a
--- /dev/null
+++ b/modules/home-manager/coding/agents/default.nix
@@ -0,0 +1,10 @@
+# Per-tool agent sub-modules
+# Each module handles rendering canonical agent.toml definitions
+# for a specific AI coding tool.
+{
+ imports = [
+ ./opencode.nix
+ ./claude-code.nix
+ ./pi.nix
+ ];
+}
diff --git a/modules/home-manager/coding/agents/opencode.nix b/modules/home-manager/coding/agents/opencode.nix
new file mode 100644
index 0000000..babde2e
--- /dev/null
+++ b/modules/home-manager/coding/agents/opencode.nix
@@ -0,0 +1,113 @@
+{
+ config,
+ lib,
+ pkgs,
+ ...
+}:
+with lib; let
+ cfg = config.coding.agents.opencode;
+in {
+ options.coding.agents.opencode = {
+ enable = mkEnableOption "OpenCode agent management via canonical agent.toml definitions";
+
+ agentsInput = mkOption {
+ type = types.nullOr types.anything;
+ default = null;
+ description = ''
+ The `agents` flake input (your personal AGENTS repo).
+ When set, agents are rendered from canonical agent.toml files
+ and symlinked to ~/.config/opencode/agents/.
+ '';
+ };
+
+ modelOverrides = mkOption {
+ type = types.attrsOf types.str;
+ default = {};
+ description = ''
+ Per-agent model overrides. Maps agent slug to model string.
+ Example: { chiron = "anthropic/claude-sonnet-4"; }
+ '';
+ example = literalExpression ''
+ {
+ chiron = "anthropic/claude-sonnet-4";
+ "chiron-forge" = "anthropic/claude-sonnet-4";
+ }
+ '';
+ };
+
+ externalSkills = mkOption {
+ type = types.listOf (types.submodule {
+ options = {
+ src = mkOption {
+ type = types.anything;
+ description = "Flake input pointing to a skills repository root.";
+ };
+ skillsDir = mkOption {
+ type = types.str;
+ default = "skills";
+ description = ''
+ Subdirectory inside src that contains skill folders.
+ '';
+ };
+ selectSkills = mkOption {
+ type = types.nullOr (types.listOf types.str);
+ default = null;
+ description = ''
+ List of skill names to cherry-pick from this source.
+ null means include every skill found in skillsDir.
+ '';
+ };
+ };
+ });
+ default = [];
+ description = ''
+ External skill sources passed to mkOpencodeSkills.
+ Each entry maps directly to an element of the externalSkills
+ list accepted by the AGENTS flake's lib.mkOpencodeSkills.
+ '';
+ example = literalExpression ''
+ [
+ { src = inputs.skills-anthropic; selectSkills = [ "claude-api" ]; }
+ { src = inputs.skills-vercel; }
+ ]
+ '';
+ };
+ };
+
+ config = mkIf cfg.enable {
+ # Rendered agent files symlinked to ~/.config/opencode/agents/
+ xdg.configFile."opencode/agents" = mkIf (cfg.agentsInput != null) {
+ source = (import ../../../../lib {inherit lib;}).agents.renderForOpencode {
+ inherit pkgs;
+ canonical = cfg.agentsInput.lib.loadAgents;
+ modelOverrides = cfg.modelOverrides;
+ };
+ };
+
+ # Skills (merged from personal AGENTS repo + optional external skills)
+ xdg.configFile."opencode/skills" = mkIf (cfg.agentsInput != null) {
+ source = cfg.agentsInput.lib.mkOpencodeSkills {
+ inherit pkgs;
+ customSkills = "${cfg.agentsInput}/skills";
+ externalSkills =
+ map (
+ entry:
+ {inherit (entry) src skillsDir;}
+ // optionalAttrs (entry.selectSkills != null) {inherit (entry) selectSkills;}
+ )
+ cfg.externalSkills;
+ };
+ };
+
+ # Static config dirs from AGENTS repo
+ xdg.configFile."opencode/context" = mkIf (cfg.agentsInput != null) {
+ source = "${cfg.agentsInput}/context";
+ };
+ xdg.configFile."opencode/commands" = mkIf (cfg.agentsInput != null) {
+ source = "${cfg.agentsInput}/commands";
+ };
+ xdg.configFile."opencode/prompts" = mkIf (cfg.agentsInput != null) {
+ source = "${cfg.agentsInput}/prompts";
+ };
+ };
+}
diff --git a/modules/home-manager/coding/agents/pi.nix b/modules/home-manager/coding/agents/pi.nix
new file mode 100644
index 0000000..9548435
--- /dev/null
+++ b/modules/home-manager/coding/agents/pi.nix
@@ -0,0 +1,234 @@
+{
+ config,
+ lib,
+ pkgs,
+ ...
+}:
+with lib; let
+ cfg = config.coding.agents.pi;
+ mcpCfg = config.programs.mcp or null;
+in {
+ options.coding.agents.pi = {
+ enable = mkEnableOption "Pi agent management via canonical agent.toml definitions";
+
+ mcpServers = mkOption {
+ type = types.attrsOf types.anything;
+ default = if mcpCfg != null then mcpCfg.servers else {};
+ defaultText = literalExpression "config.programs.mcp.servers";
+ description = ''
+ MCP server configurations for Pi (pi-mcp-adapter).
+ Written to ~/.pi/agent/mcp.json.
+ Automatically inherits from config.programs.mcp.servers.
+ '';
+ };
+
+ agentsInput = mkOption {
+ type = types.nullOr types.anything;
+ default = null;
+ description = ''
+ The `agents` flake input (your personal AGENTS repo).
+ When set, the primary agent's system prompt is rendered as SYSTEM.md,
+ all agents are listed in AGENTS.md, and subagent .md files are deployed.
+ '';
+ };
+
+ modelOverrides = mkOption {
+ type = types.attrsOf types.str;
+ default = {};
+ description = ''
+ Per-agent model overrides for Pi subagents.
+ Maps agent slug to model string, e.g.:
+ { chiron = "anthropic/claude-sonnet-4"; chiron-forge = "anthropic/claude-sonnet-4"; }
+ '';
+ };
+
+ primaryAgent = mkOption {
+ type = types.nullOr types.str;
+ default = null;
+ description = ''
+ Override which canonical agent is used as primary for SYSTEM.md.
+ When null, the first agent with mode="primary" is used.
+ '';
+ };
+
+ settings = mkOption {
+ type = types.submodule {
+ freeformType = types.attrsOf types.anything;
+ options = {
+ packages = mkOption {
+ type = types.listOf types.str;
+ default = [];
+ description = ''
+ Pi packages to install (npm:, git:, or local paths).
+ These are written to ~/.pi/agent/settings.json.
+ '';
+ };
+
+ defaultProvider = mkOption {
+ type = types.nullOr types.str;
+ default = null;
+ description = "Default LLM provider (e.g. 'anthropic', 'openai', 'zai').";
+ };
+
+ defaultModel = mkOption {
+ type = types.nullOr types.str;
+ default = null;
+ description = "Default model ID.";
+ };
+
+ defaultThinkingLevel = mkOption {
+ type = types.nullOr (types.enum ["off" "minimal" "low" "medium" "high" "xhigh"]);
+ default = null;
+ description = "Default extended thinking level.";
+ };
+
+ theme = mkOption {
+ type = types.nullOr types.str;
+ default = null;
+ description = "Pi theme name.";
+ };
+
+ hideThinkingBlock = mkOption {
+ type = types.nullOr types.bool;
+ default = null;
+ description = "Hide thinking blocks in output.";
+ };
+
+ quietStartup = mkOption {
+ type = types.nullOr types.bool;
+ default = null;
+ description = "Hide startup header.";
+ };
+
+ compaction = mkOption {
+ type = types.nullOr (types.submodule {
+ options = {
+ enabled = mkOption {
+ type = types.nullOr types.bool;
+ default = null;
+ };
+ reserveTokens = mkOption {
+ type = types.nullOr types.int;
+ default = null;
+ };
+ keepRecentTokens = mkOption {
+ type = types.nullOr types.int;
+ default = null;
+ };
+ };
+ });
+ default = null;
+ description = "Auto-compaction settings.";
+ };
+
+ enabledModels = mkOption {
+ type = types.nullOr (types.listOf types.str);
+ default = null;
+ description = "Model patterns for Ctrl+P cycling.";
+ };
+
+ sessionDir = mkOption {
+ type = types.nullOr types.str;
+ default = null;
+ description = "Directory where session files are stored.";
+ };
+
+ extensions = mkOption {
+ type = types.listOf types.str;
+ default = [];
+ description = "Local extension file paths or directories.";
+ };
+
+ skills = mkOption {
+ type = types.listOf types.str;
+ default = [];
+ description = "Local skill file paths or directories.";
+ };
+ };
+ };
+ default = {};
+ description = ''
+ Pi settings written to ~/.pi/agent/settings.json.
+ Only non-null values are included in the generated JSON.
+ See pi docs/settings.md for all options.
+ '';
+ };
+ };
+
+ config = mkIf cfg.enable (let
+ # Build settings.json by filtering out null values recursively
+ filterNulls = attrs:
+ lib.filterAttrs (_: v: v != null) (
+ builtins.mapAttrs (_: v:
+ if builtins.isAttrs v
+ then let
+ filtered = filterNulls v;
+ in
+ if filtered == {} then null else filtered
+ else v) attrs
+ );
+
+ piSettings = filterNulls cfg.settings;
+
+ # Rendered agents (only computed when agentsInput is set)
+ rendered =
+ if cfg.agentsInput != null
+ then
+ (import ../../../../lib {inherit lib;}).agents.renderForPi {
+ inherit pkgs;
+ canonical = cfg.agentsInput.lib.loadAgents;
+ modelOverrides = cfg.modelOverrides;
+ primaryAgent = cfg.primaryAgent;
+ }
+ else null;
+
+ # Dynamic home.file entries for agent .md files
+ agentFiles =
+ if cfg.agentsInput != null
+ then
+ let
+ agentNames = builtins.attrNames cfg.agentsInput.lib.loadAgents;
+ in
+ builtins.listToAttrs (
+ map (name: {
+ name = ".pi/agent/agents/${name}.md";
+ value = {source = "${rendered}/agents/${name}.md";};
+ })
+ agentNames
+ )
+ else {};
+ in {
+ home.file = mkMerge [
+ # ── MCP servers from programs.mcp → ~/.pi/agent/mcp.json ───────
+ (mkIf (cfg.mcpServers != {}) {
+ ".pi/agent/mcp.json".text = builtins.toJSON {mcpServers = cfg.mcpServers;};
+ })
+
+ # ── ~/.pi/agent/settings.json ──────────────────────────────────
+ {
+ ".pi/agent/settings.json".text = builtins.toJSON piSettings;
+ }
+
+ # ── AGENTS.md — agent descriptions and specialist listing ──────
+ (mkIf (cfg.agentsInput != null) {
+ ".pi/agent/AGENTS.md".source = "${rendered}/AGENTS.md";
+ })
+
+ # ── SYSTEM.md — primary agent's system prompt ──────────────────
+ (mkIf (cfg.agentsInput != null) {
+ ".pi/agent/SYSTEM.md".source = "${rendered}/SYSTEM.md";
+ })
+
+ # ── Agents — pi-subagents .md files ────────────────────────────
+ agentFiles
+
+ # ── Skills symlinked from AGENTS repo ──────────────────────────
+ (mkIf (cfg.agentsInput != null) {
+ ".pi/agent/skills".source = cfg.agentsInput.lib.mkOpencodeSkills {
+ inherit pkgs;
+ customSkills = "${cfg.agentsInput}/skills";
+ };
+ })
+ ];
+ });
+}
diff --git a/modules/home-manager/coding/default.nix b/modules/home-manager/coding/default.nix
index a20a685..eb22f5d 100644
--- a/modules/home-manager/coding/default.nix
+++ b/modules/home-manager/coding/default.nix
@@ -3,5 +3,6 @@
imports = [
./editors.nix
./opencode.nix
+ ./agents
];
}
diff --git a/modules/home-manager/coding/opencode.nix b/modules/home-manager/coding/opencode.nix
index a7ef8fa..c9bf28b 100644
--- a/modules/home-manager/coding/opencode.nix
+++ b/modules/home-manager/coding/opencode.nix
@@ -10,54 +10,6 @@ in {
options.coding.opencode = {
enable = mkEnableOption "opencode AI coding assistant";
- agentsInput = mkOption {
- type = types.nullOr types.anything;
- default = null;
- description = ''
- The `agents` flake input (your personal AGENTS repo).
- When set, skills, context, commands, prompts and the agents.json
- are all symlinked from this input.
- '';
- };
-
- externalSkills = mkOption {
- type = types.listOf (types.submodule {
- options = {
- src = mkOption {
- type = types.anything;
- description = "Flake input pointing to a skills repository root.";
- };
- skillsDir = mkOption {
- type = types.str;
- default = "skills";
- description = ''
- Subdirectory inside src that contains skill folders.
- '';
- };
- selectSkills = mkOption {
- type = types.nullOr (types.listOf types.str);
- default = null;
- description = ''
- List of skill names to cherry-pick from this source.
- null means include every skill found in skillsDir.
- '';
- };
- };
- });
- default = [];
- description = ''
- External skill sources passed to mkOpencodeSkills.
- Each entry maps directly to an element of the externalSkills
- list accepted by the AGENTS flake's lib.mkOpencodeSkills.
- '';
- example = literalExpression ''
- [
- { src = inputs.skills-anthropic; selectSkills = [ "claude-api" ]; }
- { src = inputs.skills-vercel; }
- ]
- '';
- };
-
ohMyOpencodeSettings = mkOption {
type = types.attrs;
default = {};
@@ -103,33 +55,6 @@ in {
};
config = mkIf cfg.enable {
- # --- Skills (merged from personal AGENTS repo + optional external skills) ---
- xdg.configFile."opencode/skills" = mkIf (cfg.agentsInput != null) {
- source = cfg.agentsInput.lib.mkOpencodeSkills {
- inherit pkgs;
- customSkills = "${cfg.agentsInput}/skills";
- externalSkills =
- map (
- entry:
- {inherit (entry) src skillsDir;}
- // optionalAttrs (entry.selectSkills != null) {inherit (entry) selectSkills;}
- )
- cfg.externalSkills;
- };
- };
-
- # --- Static config dirs from AGENTS repo ---
- xdg.configFile."opencode/context" = mkIf (cfg.agentsInput != null) {
- source = "${cfg.agentsInput}/context";
- };
- xdg.configFile."opencode/commands" = mkIf (cfg.agentsInput != null) {
- source = "${cfg.agentsInput}/commands";
- };
- xdg.configFile."opencode/prompts" = mkIf (cfg.agentsInput != null) {
- source = "${cfg.agentsInput}/prompts";
- };
-
- # --- Core opencode program settings ---
programs.opencode = {
enable = true;
enableMcpIntegration = true;
@@ -144,17 +69,10 @@ in {
};
};
}
- # Load agents.json from AGENTS repo when available
- (mkIf (cfg.agentsInput != null) {
- agent = builtins.fromJSON (builtins.readFile "${cfg.agentsInput}/agents/agents.json");
- })
- # Machine/org-specific provider config
cfg.extraSettings
];
};
- # --- oh-my-opencode plugin config ---
- # Base defaults (no models — those must be set per machine via ohMyOpencodeSettings)
home.file.".config/opencode/oh-my-opencode.json".text = builtins.toJSON (
recursiveUpdate
{