feat: add global-placement home-manager module (task 0002) #2
48
.claude/tasks/0002-global-placement-home-manager.md
Normal file
48
.claude/tasks/0002-global-placement-home-manager.md
Normal 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.
|
||||||
154
checks/home-manager-module.nix
Normal file
154
checks/home-manager-module.nix
Normal 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
21
flake.lock
generated
@@ -18,6 +18,26 @@
|
|||||||
"type": "github"
|
"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": {
|
"nixpkgs": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1784497964,
|
"lastModified": 1784497964,
|
||||||
@@ -37,6 +57,7 @@
|
|||||||
"root": {
|
"root": {
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"flake-utils": "flake-utils",
|
"flake-utils": "flake-utils",
|
||||||
|
"home-manager": "home-manager",
|
||||||
"nixpkgs": "nixpkgs"
|
"nixpkgs": "nixpkgs"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
29
flake.nix
29
flake.nix
@@ -4,8 +4,17 @@
|
|||||||
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||||
inputs.flake-utils.url = "github:numtide/flake-utils";
|
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 =
|
outputs =
|
||||||
{ self, nixpkgs, flake-utils }:
|
{ self, nixpkgs, flake-utils, home-manager }:
|
||||||
let
|
let
|
||||||
mkSkill = import ./lib/mk-skill.nix;
|
mkSkill = import ./lib/mk-skill.nix;
|
||||||
discoverSkills = import ./lib/discover.nix nixpkgs.lib;
|
discoverSkills = import ./lib/discover.nix nixpkgs.lib;
|
||||||
@@ -23,6 +32,15 @@
|
|||||||
in
|
in
|
||||||
{
|
{
|
||||||
lib = { inherit mkSkill; };
|
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`.
|
# An explicit system list rather than `eachDefaultSystem`.
|
||||||
@@ -49,6 +67,15 @@
|
|||||||
skill-build = import ./checks/skill-build.nix {
|
skill-build = import ./checks/skill-build.nix {
|
||||||
inherit pkgs mkSkill discoverSkills;
|
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
63
home-manager-module.nix
Normal 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
|
||||||
|
);
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user