feat: package skills as per-skill Nix derivations (task 0001)

Add the content tier: a standalone flake that auto-discovers each skill
(a directory containing a SKILL.md, at any depth) and exposes it as an
individually addressable derivation built by lib.mkSkill. Directories
without a SKILL.md are descended through as cosmetic containers; once a
SKILL.md is found, that directory's subfolders are its assets, not
further skills. Skill names must be globally unique across the tree — a
collision is a hard eval-time error, not a warning.

A fixture-driven skill-build check under `nix flake check` exercises the
recursive walk, the builder, the SKILL.md-at-$out-root contract, the
eval-time name passthru, and the collision error. The repo ships no real
skill content yet, so packages.<system> is empty today.
This commit is contained in:
2026-07-21 22:50:37 -04:00
parent 9068aa44f9
commit 5b6c158d2e
15 changed files with 424 additions and 0 deletions

42
lib/discover.nix Normal file
View File

@@ -0,0 +1,42 @@
# Recursive skill discovery: one `{ name; src; }` per skill in a source tree.
lib: root:
let
walk =
dir:
let
subdirs = lib.filterAttrs (_: type: type == "directory") (builtins.readDir dir);
in
lib.concatLists (
lib.mapAttrsToList (
entryName: _:
let
child = dir + "/${entryName}";
in
# A directory with a SKILL.md is a skill, and its subdirectories are
# that skill's assets rather than nested skills, so the walk stops here.
# A directory without one is a cosmetic container to recurse through.
if (builtins.readDir child) ? "SKILL.md" then
[
{
name = entryName;
src = child;
}
]
else
walk child
) subdirs
);
found = walk root;
names = map (s: s.name) found;
duplicates = lib.unique (lib.filter (n: lib.count (x: x == n) names > 1) names);
in
if duplicates != [ ] then
throw ''
Skill name collision: ${lib.concatStringsSep ", " duplicates}.
Two or more skills under ${toString root} resolve to the same name.
A skill's name is its leaf directory name and must be globally unique
across the whole tree, independent of the cosmetic folders above it.
Rename one of the colliding skills.''
else
found

27
lib/mk-skill.nix Normal file
View File

@@ -0,0 +1,27 @@
# Builds a skill source directory into a derivation, `SKILL.md` at the `$out` root.
{
pkgs,
src,
name ? builtins.baseNameOf (toString src),
}:
pkgs.stdenvNoCC.mkDerivation {
inherit name src;
# A skill is just files to copy, with nothing to unpack, configure, or build.
dontUnpack = true;
dontConfigure = true;
dontBuild = true;
installPhase = ''
runHook preInstall
mkdir -p "$out"
cp -R "$src"/. "$out/"
test -f "$out/SKILL.md" \
|| { echo "mkSkill: skill '${name}' has no SKILL.md at its root" >&2; exit 1; }
runHook postInstall
'';
# The name as an eval-time attribute, readable without building `$out`
# (no import-from-derivation).
passthru.skillName = name;
}