feat: add global-placement home-manager module (task 0002) #2

Merged
alexion merged 1 commits from task-0002-global-placement-home-manager into main 2026-07-22 22:09:28 -04:00
5 changed files with 314 additions and 1 deletions
Showing only changes of commit b0a763e5ce - Show all commits

View File

@@ -0,0 +1,48 @@
---
spec: nix-skill-packaging
blocked-by: 0001-content-tier-skill-packaging
---
## What to build
The global (every-project) placement output: a home-manager module that installs an operator's selected skills into `~/.claude/skills/`, plus the check that proves its composition.
Exposed as `homeModules.default`. The operator-facing option is `programs.agents.skills`, a `listOf package` with default `[]` (an empty list is a no-op). `agents` is deliberately an umbrella namespace with no shared `programs.agents.enable` — each sub-feature self-gates so the namespace stays a clean, mergeable surface another repo could extend. Selection is by derivation: consumers pass skill derivations pulled from `packages.<system>`, not name strings.
For each selected skill the module writes an individual `home.file` at `${claude-code.configDir}/skills/<name>` with `source = <skill derivation>` and `recursive = true`, gated on `programs.claude-code.enable`, reading `configDir` from the claude-code module. `recursive = true` is a hard requirement: it materializes `.../skills/<name>/` as a real directory of per-file symlinks so this module, the operator's own skills declarations, and self-placing tool modules coexist under one `skills/` tree. The module deliberately does not feed the single-valued `programs.claude-code.skills` option, which would collide with an operator already setting it.
Verified by the home-manager-module composition check under `nix flake check`: it instantiates `homeModules.default` under a sample home-manager configuration selecting a couple of skills with `programs.claude-code` enabled, builds the resulting home-files derivation, and asserts the per-skill `home.file`, `recursive = true`, the `claude-code.enable` gate, and `configDir` sourcing all compose as intended (direct analogue of gitea-axi's home-manager-module check).
## Acceptance criteria
- [x] `homeModules.default` is exposed.
- [x] It defines `programs.agents.skills` as `listOf package` with default `[]`, and an empty list installs nothing.
- [x] There is no shared `programs.agents.enable`; the skills feature self-gates.
- [x] Selection is by derivation (skills pulled from `packages.<system>`), not by name string.
- [x] Each selected skill is written as an individual `home.file` at `${claude-code.configDir}/skills/<name>` with `recursive = true`, sourcing `configDir` from the claude-code module.
- [x] Placement is gated on `programs.claude-code.enable`; with it off, no skill files are written.
- [x] The module does not set `programs.claude-code.skills`.
- [x] The module composes with an operator's own skills declarations and self-placing tool modules under one `skills/` tree without collision.
- [x] `nix flake check` includes a home-manager-module composition check that builds the home-files derivation for a sample selection and asserts the above.
## Implementation Notes
Files: `home-manager-module.nix` (the module, at the repo root, mirroring gitea-axi's placement), `checks/home-manager-module.nix` (the composition check), and `flake.nix` wiring (a `homeModules` output plus the check registration and the new `home-manager` input).
- **Placement name comes from `skillName`.**
The module reads each derivation's `passthru.skillName` — the eval-time name attribute task 0001 established for exactly this purpose — to form `.../skills/<name>` without import-from-derivation.
- **`homeModules` exposes an `agents-skills` alias beside `default`.**
The criteria only require `homeModules.default`; the named alias is an additive convenience mirroring gitea-axi's `homeModules` shape, and `default` points at it.
- **`home-manager` flake input follows this flake's nixpkgs.**
It exists solely so `nix flake check` can evaluate the module against real home-manager; a consumer importing the module supplies their own home-manager and pkgs, so the input has no bearing on what they get.
- **Recursive placement is proven by the entry's type, not by file existence.**
A `test -f skills/<name>/SKILL.md` follows symlinks and cannot distinguish a recursive per-file tree from one opaque symlink over the whole skill, so the check asserts the entry is a real directory (`test -d` and `! -L`).
A mutation to `recursive = false` fails the check.
- **A non-default `configDir` scenario was added during review.**
The other scenarios all run at the default `configDir`, so a module hardcoding `.claude/skills/` would have passed them identically.
One configuration now sets a custom `configDir` and asserts placement follows it, closing the "configDir sourcing" leg of the check.
A mutation hardcoding `.claude` fails the check.

View File

@@ -0,0 +1,154 @@
# Builds the home-files tree `homeModules.default` produces under several
# operator configurations and asserts on the skills each one places.
{
pkgs,
home-manager,
module,
mkSkill,
}:
let
inherit (pkgs) lib;
# A sample skill built through the flake's own `mkSkill`, so it carries the
# `skillName` passthru the module reads to place it.
mkSampleSkill =
name:
mkSkill {
inherit pkgs name;
src = pkgs.runCommandLocal "${name}-src" { } ''
mkdir -p "$out"
printf '%s\n' "sample skill ${name}" > "$out/SKILL.md"
'';
};
skillAlpha = mkSampleSkill "sample-alpha";
skillBeta = mkSampleSkill "sample-beta";
# A skill the operator declares as their own attribute-set entry, so the
# attribute-set assertion can look for it beside the module's.
operatorAttrSkill = pkgs.runCommandLocal "operator-attr-skill" { } ''
mkdir -p "$out"
printf '%s\n' "the operator's own attribute-set skill" > "$out/SKILL.md"
'';
# A whole directory of skills, one folder per skill, for the path form of the
# operator's own `programs.claude-code.skills`.
operatorSkillsDir = pkgs.runCommandLocal "operator-skills-dir" { } ''
mkdir -p "$out/operator-dir-skill"
printf '%s\n' "the operator's own whole-directory skill" \
> "$out/operator-dir-skill/SKILL.md"
'';
# Evaluate the real module through home-manager's standalone entry point and
# return the home-files tree it would link into $HOME.
homeFiles =
operatorConfig:
(home-manager.lib.homeManagerConfiguration {
inherit pkgs;
modules = [
module
{
home.username = "operator";
home.homeDirectory = "/home/operator";
home.stateVersion = "24.11";
}
operatorConfig
];
}).config.home-files;
# Two skills selected beside the operator's own attribute-set skill, with
# Claude Code enabled.
attrSetSkills = homeFiles {
programs.claude-code.enable = true;
programs.agents.skills = [
skillAlpha
skillBeta
];
# A store path, not a bare derivation, so the value takes the option's path
# branch rather than its attribute-set branch.
programs.claude-code.skills.operator-attr-skill = "${operatorAttrSkill}";
};
# The operator's own skills as a whole directory (path form).
# Setting this single-valued option to a path also proves the module never
# contributes to it, since an attribute-set contribution would clash with the
# path at eval.
wholeDirSkills = homeFiles {
programs.claude-code.enable = true;
programs.agents.skills = [ skillAlpha ];
programs.claude-code.skills = "${operatorSkillsDir}";
};
# A non-default `configDir`, so the assertions can prove placement follows it
# rather than a hardcoded `.claude`.
customConfigDir = homeFiles {
programs.claude-code.enable = true;
programs.claude-code.configDir = "/home/operator/.config/claude";
programs.agents.skills = [ skillAlpha ];
};
# Claude Code disabled, with a skill still selected.
claudeCodeOff = homeFiles {
programs.claude-code.enable = false;
programs.agents.skills = [ skillAlpha ];
};
# An empty selection with Claude Code enabled.
emptySelection = homeFiles {
programs.claude-code.enable = true;
programs.agents.skills = [ ];
};
in
pkgs.runCommandLocal "skills-home-manager-module-check"
{
# Forcing each derivation as a build input builds its home-files tree, so an
# eval-level composition failure surfaces before any assertion runs.
inherit
attrSetSkills
wholeDirSkills
customConfigDir
claudeCodeOff
emptySelection
;
}
''
fail() { echo "FAIL: $1" >&2; exit 1; }
echo "attribute-set skills: both selected skills and the operator's own land"
test -f "$attrSetSkills/.claude/skills/sample-alpha/SKILL.md" \
|| fail "selected skill sample-alpha did not land at its own name"
test -f "$attrSetSkills/.claude/skills/sample-beta/SKILL.md" \
|| fail "selected skill sample-beta did not land at its own name"
test -f "$attrSetSkills/.claude/skills/operator-attr-skill/SKILL.md" \
|| fail "the operator's own attribute-set skill was clobbered"
echo "recursive placement: a skill is a real directory of per-file symlinks"
# A file-existence test follows symlinks, so it cannot tell a recursive tree
# from one opaque symlink over the whole skill.
# The entry's own type is what the assertion reads instead.
test -d "$attrSetSkills/.claude/skills/sample-alpha" \
&& test ! -L "$attrSetSkills/.claude/skills/sample-alpha" \
|| fail "sample-alpha was placed as an opaque symlink, not a recursive tree"
echo "whole-directory skills: the selected skill lands beside the operator's directory"
test -f "$wholeDirSkills/.claude/skills/sample-alpha/SKILL.md" \
|| fail "selected skill did not coexist with a whole skills directory"
test -f "$wholeDirSkills/.claude/skills/operator-dir-skill/SKILL.md" \
|| fail "the operator's whole skills directory was clobbered"
echo "custom configDir: placement follows configDir rather than a hardcoded .claude"
test -f "$customConfigDir/.config/claude/skills/sample-alpha/SKILL.md" \
|| fail "skill was not placed under the configured configDir"
test ! -e "$customConfigDir/.claude/skills/sample-alpha" \
|| fail "skill was placed under a hardcoded .claude, ignoring configDir"
echo "Claude Code disabled: no skill entry is written despite a selection"
test ! -e "$claudeCodeOff/.claude/skills/sample-alpha" \
|| fail "a skill was placed with claude-code disabled"
echo "empty selection: nothing is installed"
test ! -e "$emptySelection/.claude/skills/sample-alpha" \
|| fail "an unselected skill was placed"
touch "$out"
''

21
flake.lock generated
View File

@@ -18,6 +18,26 @@
"type": "github"
}
},
"home-manager": {
"inputs": {
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1784725727,
"narHash": "sha256-J5+C9wsO0lhDyUalQzfplDbRjyHDYeEH5+9sdyXtwa8=",
"owner": "nix-community",
"repo": "home-manager",
"rev": "041a999e8c1c5b731913855909e68d30ca69b8e0",
"type": "github"
},
"original": {
"owner": "nix-community",
"repo": "home-manager",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1784497964,
@@ -37,6 +57,7 @@
"root": {
"inputs": {
"flake-utils": "flake-utils",
"home-manager": "home-manager",
"nixpkgs": "nixpkgs"
}
},

View File

@@ -4,8 +4,17 @@
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
inputs.flake-utils.url = "github:numtide/flake-utils";
# Present only so `nix flake check` can evaluate `homeModules.default` against
# real home-manager (the home-manager-module check). Its nixpkgs follows this
# flake's, so the module is checked against the same nixpkgs-and-home-manager
# pairing a consumer following this flake would get. It has no bearing on the
# module a consumer imports — that module is a bare function taking the
# importing configuration's own home-manager and pkgs.
inputs.home-manager.url = "github:nix-community/home-manager";
inputs.home-manager.inputs.nixpkgs.follows = "nixpkgs";
outputs =
{ self, nixpkgs, flake-utils }:
{ self, nixpkgs, flake-utils, home-manager }:
let
mkSkill = import ./lib/mk-skill.nix;
discoverSkills = import ./lib/discover.nix nixpkgs.lib;
@@ -23,6 +32,15 @@
in
{
lib = { inherit mkSkill; };
# Global placement: an operator selects skills into `~/.claude/skills/`
# via `programs.agents.skills`. Not per-system — it is a module function,
# and the skills it places come from the importing configuration's own
# selection rather than this flake's nixpkgs.
homeModules = rec {
agents-skills = ./home-manager-module.nix;
default = agents-skills;
};
}
//
# An explicit system list rather than `eachDefaultSystem`.
@@ -49,6 +67,15 @@
skill-build = import ./checks/skill-build.nix {
inherit pkgs mkSkill discoverSkills;
};
# Proves the global-placement module composes: it builds the
# home-files derivation `homeModules.default` produces under several
# operator configurations. Reuses this flake's `mkSkill` so the
# sample skills carry the same name contract real ones do.
home-manager-module = import ./checks/home-manager-module.nix {
inherit pkgs home-manager mkSkill;
module = self.homeModules.default;
};
};
}
);

63
home-manager-module.nix Normal file
View File

@@ -0,0 +1,63 @@
# Places operator-selected skills into Claude Code's skills directory, one per
# skill, for every project.
{
config,
lib,
...
}:
let
cfg = config.programs.agents;
claudeCode = config.programs.claude-code;
in
{
options.programs.agents.skills = lib.mkOption {
type = lib.types.listOf lib.types.package;
default = [ ];
example = lib.literalExpression "[ inputs.skills.packages.\${system}.some-skill ]";
description = ''
Skills to install globally, into the `skills/` subdirectory of
{option}`programs.claude-code.configDir`, so they are active in every
project.
Selection is by derivation: pass skill derivations from the skills flake's
`packages.<system>`, each of which carries its placement name as an
eval-time attribute. An empty list installs nothing.
Every skill is written as its own recursive {option}`home.file`, so this
option composes with an operator's own {option}`programs.claude-code.skills`
and with other modules placing skills under the same directory rather than
claiming the tree. Placement is gated on
{option}`programs.claude-code.enable`.
'';
};
# `agents` is an umbrella namespace with no shared enable, so the skills
# feature self-gates.
# An empty list writes nothing, and files land only when Claude Code is
# enabled.
# Reading each skill's source realises its derivation, so gating on
# `claude-code.enable` also keeps that realisation off a host that installs
# nothing.
config = lib.mkIf claudeCode.enable {
# `configDir` comes from the Claude Code module, so skills land beside its
# own wherever the operator points it.
# Each name is the derivation's eval-time `skillName`, so placement reads no
# `$out` and needs no import-from-derivation.
# `recursive = true` materialises `skills/<name>/` as a directory of per-file
# symlinks, which is what lets this module, the operator's own skills, and
# self-placing tool modules share one `skills/` tree.
#
# This deliberately does not feed the single-valued
# `programs.claude-code.skills`, which would collide with an operator already
# setting it.
home.file = lib.listToAttrs (
map (skill: {
name = "${claudeCode.configDir}/skills/${skill.skillName}";
value = {
source = skill;
recursive = true;
};
}) cfg.skills
);
};
}