refactor(agents): manage agent config in a home-manager module (#367)

This commit is contained in:
ryan4yin | 二花 authored and GitHub committed 2026-10-03 16:57:38 +08:00
1 parent 0d2665c286
commit afdaa3b869
15 files changed
+116 -241

No files matched your search

+23 -37
View File
@@ -1,9 +1,10 @@
# agents
Reusable, symlink-first agent resources shared across projects.
Portable agent resources shared across projects: the global baseline rules, behavioral scenarios for
those rules, and reference snippets for external tooling.
This directory is the canonical source for baseline agent rules and supporting command references.
The primary workflow is to symlink files from here into each agent runtime/config location.
Home Manager links the rules from here into each agent's config location, so `agents/` itself stays
plain Markdown with no Nix dependency.
It is shared across projects. Repo-scoped task procedures for one repository belong elsewhere: put
them in that repository's `.agents/skills/` (note the leading dot), which OpenCode and compatible
@@ -20,27 +21,23 @@ Keep one canonical home for each fact; link between layers instead of copying pa
- `AGENTS.md`: global baseline rules for coding agents.
- `evals/global-rules.md`: behavioral scenarios for validating changes to the global rules.
- `install-rules.py`: installs the baseline by creating symlinks in supported agent config dirs.
- `test_install_rules.py`: regression tests for file preservation and independent target handling.
- `install-cli.md`: curated CLI install/update command snippets.
- `install-skills.md`: curated `npx skills` command snippets.
- `install-skills.md`: curated `npx skills` command snippets, plus the `npx ctx7` docs-tool setup.
The Nix side of the agents — deploying these rules, the agent CLIs, and their environment — lives in
the Home Manager module [`home/base/tui/agents/`](../home/base/tui/agents/README.md).
## Core workflow
1. Maintain shared rules in `agents/AGENTS.md`.
2. Configure permissions directly in the agent runtime; auto-approval is generally used.
3. Run `install-rules.py` to refresh symlinks in local agent homes.
4. Use `install-cli.md` and `install-skills.md` as reference snippets when needed.
3. Edit the rules; Home Manager links them out-of-store, so the change reaches the next agent
session without a rebuild. Run a Home Manager switch only when the deployed target set changes.
4. Use `install-skills.md` as a reference when installing external skills.
## Install baseline rules (symlink-based)
## Deployment
Run:
```bash
python3 agents/install-rules.py
```
Current targets:
[`home/base/tui/agents/rules.nix`](../home/base/tui/agents/rules.nix) links `AGENTS.md` into every
supported agent config directory as an out-of-store symlink, so edits apply without a rebuild:
- Codex: `AGENTS.md` -> `${CODEX_HOME:-~/.codex}/AGENTS.md`
- OpenCode: `AGENTS.md` -> `${XDG_CONFIG_HOME:-~/.config}/opencode/AGENTS.md`
@@ -48,30 +45,20 @@ Current targets:
- OMP: `AGENTS.md` -> `~/.omp/agent/AGENTS.md`
- Generic cross-tool (read by Kimi Code): `AGENTS.md` -> `~/.agents/AGENTS.md`
Behavior:
The module is imported through `home/base/tui`, so it covers the hosts that import
`home/linux/gui.nix` or the macOS `home/darwin` stack; core-only servers are unchanged.
- Each target is handled independently.
- Target errors are reported while remaining targets are attempted; any error yields a nonzero exit.
- Missing destination directories are skipped.
- Existing regular files are preserved as `.bak` backups (numbered when a backup already exists).
- Destination links are replaced atomically; a failed link creation leaves the destination intact.
The installer links only `AGENTS.md`; it does not install permission configuration, skills, or CLIs.
The repository-root `AGENTS.md` contains guidance for this Nix configuration repository. It is not
the global rules source and is not installed by this script.
Only `AGENTS.md` is deployed to the agents. Permission configuration and skills are not installed by
Nix; the CLIs themselves are, via the same module's `packages.nix`. The repository-root `AGENTS.md`
contains guidance for this Nix configuration repository. It is not the global rules source and is
not deployed.
Auto-approval controls tool prompting. The global rules still define task authorization, safety, and
secret handling.
Run installer regression tests without changing installed rules:
## About `install-skills.md`
```bash
python3 -B -m unittest discover -s agents -p test_install_rules.py
```
## About `install-cli.md` and `install-skills.md`
Use them as snippet libraries:
Use it as a snippet library:
- review the commands
- select what you need
@@ -79,8 +66,7 @@ Use them as snippet libraries:
## TODO
Ideas worth adopting once a concrete need appears; nothing here is implemented yet, and the install
scripts above do not cover it.
Ideas worth adopting once a concrete need appears; nothing here is implemented yet.
- **Shared instructions and skills.** Deploy one rules body plus declared skills to every agent
instead of maintaining a copy per agent. Reference: `mirkolenz/infra`
-25
View File
@@ -1,25 +0,0 @@
# Agent CLI Commands
Reference commands for installing and updating agent CLIs. Run only the commands you need.
## Install CLIs
Installed via Nix:
- codex
- opencode
- kimi-code
- pi
- omp
## Optional tooling
```bash
# context7: up-to-date docs and code examples for LLMs and agents
npx ctx7 setup
```
## Update
The agent CLIs come from Nix (`home/base/gui/dev-tools.nix`) through the `llm-agents` flake input,
so update them with the flake rather than npm.
-70
View File
@@ -1,70 +0,0 @@
#!/usr/bin/env python3
import os
import sys
import tempfile
from pathlib import Path
def install_one(target_dir: Path, source_file: Path, target_name: str) -> None:
if not target_dir.exists():
print(f"skipped {target_dir} (not found)")
return
target_file = target_dir / target_name
if target_file.is_file() and not target_file.is_symlink():
# Preserve the old inode without reading its contents or replacing an earlier backup.
index = 0
while True:
suffix = ".bak" if index == 0 else f".bak.{index}"
backup = target_file.with_name(target_file.name + suffix)
try:
os.link(target_file, backup)
break
except FileExistsError:
index += 1
print(f"backed up {target_file} -> {backup}")
# Create the link on the same filesystem before atomically replacing the destination.
with tempfile.TemporaryDirectory(prefix=".install-rules-", dir=target_dir) as staging:
link = Path(staging) / target_name
link.symlink_to(source_file)
link.replace(target_file)
print(f"linked {target_file} -> {source_file}")
def main() -> int:
script_dir = Path(__file__).resolve().parent
agents_file = script_dir / "AGENTS.md"
if not agents_file.is_file():
print(f"Missing source file: {agents_file}", file=sys.stderr)
return 1
codex_dir = Path(os.environ.get("CODEX_HOME", "~/.codex")).expanduser()
xdg_config_home = Path(os.environ.get("XDG_CONFIG_HOME", "~/.config")).expanduser()
opencode_dir = xdg_config_home / "opencode"
pi_dir = Path("~/.pi/agent").expanduser()
omp_dir = Path("~/.omp/agent").expanduser()
agents_dir = Path("~/.agents").expanduser()
targets = (
(codex_dir, "AGENTS.md"),
(opencode_dir, "AGENTS.md"),
(pi_dir, "AGENTS.md"),
(omp_dir, "AGENTS.md"),
(agents_dir, "AGENTS.md"),
)
failed = False
for target_dir, target_name in targets:
try:
install_one(target_dir, agents_file, target_name)
except OSError as error:
print(f"failed {target_dir / target_name}: {error}", file=sys.stderr)
failed = True
return 1 if failed else 0
if __name__ == "__main__":
raise SystemExit(main())
+7
View File
@@ -81,6 +81,13 @@ npx skills add anthropics/skills --skill 'pdf'
npx skills add trailofbits/skills --skill 'codeql' --skill 'semgrep'
```
## Other agent tooling (npx)
```bash
# context7: up-to-date library docs and code examples for LLMs and agents
npx ctx7 setup
```
References:
- https://github.com/obra/superpowers
-87
View File
@@ -1,87 +0,0 @@
import importlib.util
import io
import os
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
spec = importlib.util.spec_from_file_location(
"install_rules", Path(__file__).with_name("install-rules.py")
)
installer = importlib.util.module_from_spec(spec)
spec.loader.exec_module(installer)
class InstallRulesTests(unittest.TestCase):
def test_main_links_all_supported_agent_targets(self):
with tempfile.TemporaryDirectory() as directory:
home = Path(directory)
target_names = (
(home / ".codex", "AGENTS.md"),
(home / ".config" / "opencode", "AGENTS.md"),
(home / ".pi" / "agent", "AGENTS.md"),
(home / ".omp" / "agent", "AGENTS.md"),
(home / ".agents", "AGENTS.md"),
)
for target_dir, _ in target_names:
target_dir.mkdir(parents=True)
with patch.dict(os.environ, {"HOME": str(home)}, clear=True):
self.assertEqual(installer.main(), 0)
for target_dir, target_name in target_names:
target = target_dir / target_name
self.assertTrue(target.is_symlink(), target)
self.assertEqual(target.resolve(), installer.Path(__file__).with_name("AGENTS.md"))
def test_target_failure_does_not_stop_remaining_targets(self):
with (
patch.object(
installer,
"install_one",
side_effect=[PermissionError("denied"), None, None, None, None],
) as install,
patch("sys.stderr", new_callable=io.StringIO) as stderr,
):
self.assertEqual(installer.main(), 1)
self.assertEqual(install.call_count, 5)
self.assertIn("denied", stderr.getvalue())
def test_successful_targets_return_success(self):
with patch.object(installer, "install_one") as install:
self.assertEqual(installer.main(), 0)
self.assertEqual(install.call_count, 5)
def test_existing_file_is_backed_up_without_overwriting_backup(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
source = root / "source.md"
source.write_text("global rules")
target = root / "AGENTS.md"
target.write_text("personal rules")
backup = root / "AGENTS.md.bak"
backup.write_text("older rules")
installer.install_one(root, source, target.name)
self.assertEqual(target.resolve(), source)
self.assertEqual(backup.read_text(), "older rules")
self.assertEqual((root / "AGENTS.md.bak.1").read_text(), "personal rules")
def test_failed_link_creation_preserves_existing_file(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
source = root / "source.md"
source.write_text("global rules")
target = root / "AGENTS.md"
target.write_text("personal rules")
with patch.object(Path, "symlink_to", side_effect=OSError("link failed")):
with self.assertRaises(OSError):
installer.install_one(root, source, target.name)
self.assertTrue(target.is_file())
self.assertFalse(target.is_symlink())
self.assertEqual(target.read_text(), "personal rules")
if __name__ == "__main__":
unittest.main()