# Home-manager module: link centralized agent skills into Claude Code, Codex, # Pi and opencode. Usage: add this flake as an input, then import this module # in your home config. # # inputs.agent-skills.url = "git+https://git.naps.pt/yolo/agent-skills.git"; # # in home.nix imports: inputs.agent-skills.homeModules.default # # and pick a machine profile: # programs.agentSkills.machine = "yolo"; # # recursive=true links each FILE individually, so machine-local skills can still # live alongside the managed ones in the same dir (a whole-dir symlink would not). { agent-skills }: { config, lib, pkgs, ... }: let cfg = config.programs.agentSkills; # Pi and opencode have no `@file` imports in context files, so the shared # fragments are concatenated into one AGENTS.md per tool. concatMd = name: files: pkgs.writeText name (lib.concatMapStringsSep "\n" builtins.readFile files); # A user unit gets almost no PATH by default; the units below shell out to # aoe, git and tmux, which live in the profile dirs. toolPath = lib.concatStringsSep ":" [ "%h/.local/bin" "%h/.nix-profile/bin" "/etc/profiles/per-user/${config.home.username}/bin" "/run/current-system/sw/bin" ]; # These run from the working checkout, not the store: the scripts and the # daemon are edited far more often than the flake input is bumped, and a # restart is meant to be enough to pick a change up. repo = cfg.repoPath; mkEnable = what: lib.mkEnableOption "the ${what} user unit"; in { options.programs.agentSkills = { machine = lib.mkOption { type = lib.types.str; default = "default"; example = "yolo"; description = '' Which claude-md/machines/.md to link as ~/.claude/machine.md. The shared entry file @imports it, so it always has to resolve; the "default" profile is the conservative one (no passwordless root). ''; }; repoPath = lib.mkOption { type = lib.types.str; default = "%h/tea/agent-skills"; description = '' Checkout the units run from, as a systemd unit specifier path. Not a store path: the units are pointed at working copies so an edit takes effect on restart instead of requiring a flake bump and a rebuild. ''; }; # Off by default, and that matters: every one of these starts an agent # session, so enabling them on a second machine would run the same job twice. prDaemon.enable = mkEnable "PR daemon"; hourlog.enable = mkEnable "Friday hour log timer"; weekReview.enable = mkEnable "weekly review timer"; }; config.home.file = { ".claude/skills" = { source = "${agent-skills}/skills"; recursive = true; }; ".agents/skills" = { source = "${agent-skills}/skills"; recursive = true; }; ".claude/commands" = { source = "${agent-skills}/commands"; recursive = true; }; ".claude/hooks" = { source = "${agent-skills}/hooks"; recursive = true; }; # Referenced by hook commands in settings.json, which this module does not # own — so these have to exist under the same path on every machine. ".claude/scripts" = { source = "${agent-skills}/scripts"; recursive = true; }; # CLAUDE.md fragments land in ~/.claude root, pulled in by `@name.md` imports. # Listed one by one: recursive on ~/.claude would fight every other tool # writing there (settings.json, projects/, file-history/). ".claude/writing.md".source = "${agent-skills}/claude-md/writing.md"; ".claude/operating.md".source = "${agent-skills}/claude-md/operating.md"; # Per-machine section: what this box permits (sudo, network exposure). ".claude/machine.md".source = "${agent-skills}/claude-md/machines/${cfg.machine}.md"; # Path-scoped: loads only when Claude reads a matching source file. ".claude/rules/code-comments.md".source = "${agent-skills}/claude-md/code-comments.md"; ".claude/RTK.md".source = "${agent-skills}/claude-md/RTK.md"; # Entry files: machine-local sections + @imports of the fragments above. ".claude/CLAUDE.md".source = "${agent-skills}/entry/CLAUDE.md"; ".codex/AGENTS.md".source = "${agent-skills}/entry/codex-AGENTS.md"; # Pi: skills need no wiring — pi reads ~/.agents/skills, linked above. # Settings stay unmanaged: pi writes ~/.pi/agent/settings.json itself. ".pi/agent/AGENTS.md".source = concatMd "pi-AGENTS.md" [ "${agent-skills}/claude-md/machines/${cfg.machine}.md" "${agent-skills}/claude-md/operating.md" "${agent-skills}/claude-md/writing.md" "${agent-skills}/claude-md/code-comments.md" "${agent-skills}/claude-md/RTK.md" ]; # opencode auto-loads skills from ~/.claude/skills and ~/.agents/skills, # so only the rules file and commands need linking here. ".config/opencode/AGENTS.md".source = concatMd "opencode-AGENTS.md" [ "${agent-skills}/claude-md/opencode-header.md" "${agent-skills}/claude-md/machines/${cfg.machine}.md" "${agent-skills}/claude-md/operating.md" "${agent-skills}/claude-md/writing.md" "${agent-skills}/claude-md/code-comments.md" "${agent-skills}/claude-md/RTK.md" ]; ".config/opencode/commands" = { source = "${agent-skills}/commands"; recursive = true; }; }; # Unit definitions live here, next to the scripts they run; a machine opts in # with `programs.agentSkills..enable`. Nothing is enabled by default -- # each of these starts an agent session, and two machines running the same # timer means the same job twice. config.systemd.user.services = lib.mkMerge [ (lib.mkIf cfg.prDaemon.enable { pr-daemon = { Unit = { Description = "pr-daemon — watches GitHub/Gitea PRs and routes them to aoe sessions"; Documentation = [ "https://git.naps.pt/yolo/agent-skills" ]; After = [ "network.target" ]; # Neither is in the store: the config names the repos, the env file # holds the read-only forge tokens. A missing config would crash-loop # against Restart=always. ConditionPathExists = [ "%h/.config/reviewer/config.json" "%h/.config/reviewer/env" ]; # MUST stay 0: at RestartSec=5 a fast-crashing daemon burns the # default 5-starts-per-10s budget and systemd parks the unit in # `failed` until a manual `systemctl --user reset-failed`. StartLimitIntervalSec = 0; }; Service = { Type = "simple"; WorkingDirectory = "%h"; ExecStart = "${pkgs.bun}/bin/bun ${repo}/bin/reviewer-poll.ts"; EnvironmentFile = "%h/.config/reviewer/env"; Environment = [ "PATH=${toolPath}" # Without this the daemon reaches a different tmux server than the # shell and TUI do, so sessions it starts are invisible where you # look for them. "TMUX_TMPDIR=%t" ]; Restart = "always"; RestartSec = 5; # The agent tmux sessions this daemon starts land in its cgroup, so # the default control-group kill takes every running agent down with # a daemon restart. KillMode = "process"; }; Install.WantedBy = [ "default.target" ]; }; }) (lib.mkIf cfg.hourlog.enable { hourlog = { Unit = { Description = "Start the Friday hour log in a tmux session"; Documentation = [ "https://git.naps.pt/yolo/agent-skills" ]; ConditionPathIsDirectory = repo; }; Service = { Type = "oneshot"; ExecStart = "${repo}/bin/hourlog-session.sh"; Environment = [ "PATH=${toolPath}" ]; # This unit may be what starts the tmux server; the default cgroup # kill would take it back down as soon as ExecStart returns. KillMode = "process"; }; }; }) (lib.mkIf cfg.weekReview.enable { week-review = { Unit = { Description = "Start the weekly agent-skills review in a tmux session"; Documentation = [ "https://git.naps.pt/yolo/agent-skills" ]; ConditionPathIsDirectory = repo; }; Service = { Type = "oneshot"; ExecStart = "${repo}/bin/week-review-session.sh"; Environment = [ "PATH=${toolPath}" ]; KillMode = "process"; }; }; }) ]; config.systemd.user.timers = lib.mkMerge [ (lib.mkIf cfg.hourlog.enable { hourlog = { Unit.Description = "Friday hour log, 18:00 Europe/Lisbon"; Timer = { # Zone suffix pinned because the machine clock is UTC; keeps it at # 18:00 wall time across DST. OnCalendar = "Fri 18:00 Europe/Lisbon"; Persistent = true; AccuracySec = "1min"; }; Install.WantedBy = [ "timers.target" ]; }; }) (lib.mkIf cfg.weekReview.enable { week-review = { Unit.Description = "Weekly agent-skills review, Fridays 17:00 Europe/Lisbon"; Timer = { OnCalendar = "Fri 17:00 Europe/Lisbon"; Persistent = true; AccuracySec = "1min"; }; Install.WantedBy = [ "timers.target" ]; }; }) ]; } # Hook wiring lives in ~/.claude/settings.json, which this module does not own. # See hooks/README.md for the snippet.