diff --git a/.claude/tasks/0002-global-placement-home-manager.md b/.claude/tasks/0002-global-placement-home-manager.md new file mode 100644 index 0000000..6634701 --- /dev/null +++ b/.claude/tasks/0002-global-placement-home-manager.md @@ -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.`, not name strings. + +For each selected skill the module writes an individual `home.file` at `${claude-code.configDir}/skills/` with `source = ` 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//` 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.`), not by name string. +- [x] Each selected skill is written as an individual `home.file` at `${claude-code.configDir}/skills/` 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/` 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//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. diff --git a/checks/home-manager-module.nix b/checks/home-manager-module.nix new file mode 100644 index 0000000..789acca --- /dev/null +++ b/checks/home-manager-module.nix @@ -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" + '' diff --git a/flake.lock b/flake.lock index d24f746..098cb45 100644 --- a/flake.lock +++ b/flake.lock @@ -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" } }, diff --git a/flake.nix b/flake.nix index 8e76895..7fdf076 100644 --- a/flake.nix +++ b/flake.nix @@ -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; + }; }; } ); diff --git a/home-manager-module.nix b/home-manager-module.nix new file mode 100644 index 0000000..816ea3d --- /dev/null +++ b/home-manager-module.nix @@ -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.`, 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//` 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 + ); + }; +}