From 8622b35f48e5be73355fba96b874a40930416f47 Mon Sep 17 00:00:00 2001 From: alexion Date: Tue, 21 Jul 2026 22:50:37 -0400 Subject: [PATCH] feat: package skills as per-skill Nix derivations (task 0001) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. is empty today. --- .claude/spec/nix-skill-packaging.md | 115 ++++++++++++++++++ .../0001-content-tier-skill-packaging.md | 53 ++++++++ checks/fixtures/collision/first/dup/SKILL.md | 1 + checks/fixtures/collision/second/dup/SKILL.md | 1 + checks/fixtures/valid/alpha/SKILL.md | 1 + checks/fixtures/valid/delta/SKILL.md | 1 + checks/fixtures/valid/group/beta/SKILL.md | 1 + .../fixtures/valid/group/beta/tools/SKILL.md | 1 + .../valid/one/two/three/gamma/SKILL.md | 1 + checks/skill-build.nix | 56 +++++++++ flake.lock | 61 ++++++++++ flake.nix | 55 +++++++++ lib/discover.nix | 42 +++++++ lib/mk-skill.nix | 27 ++++ skills/.gitkeep | 0 15 files changed, 416 insertions(+) create mode 100644 .claude/spec/nix-skill-packaging.md create mode 100644 .claude/tasks/0001-content-tier-skill-packaging.md create mode 100644 checks/fixtures/collision/first/dup/SKILL.md create mode 100644 checks/fixtures/collision/second/dup/SKILL.md create mode 100644 checks/fixtures/valid/alpha/SKILL.md create mode 100644 checks/fixtures/valid/delta/SKILL.md create mode 100644 checks/fixtures/valid/group/beta/SKILL.md create mode 100644 checks/fixtures/valid/group/beta/tools/SKILL.md create mode 100644 checks/fixtures/valid/one/two/three/gamma/SKILL.md create mode 100644 checks/skill-build.nix create mode 100644 flake.lock create mode 100644 flake.nix create mode 100644 lib/discover.nix create mode 100644 lib/mk-skill.nix create mode 100644 skills/.gitkeep diff --git a/.claude/spec/nix-skill-packaging.md b/.claude/spec/nix-skill-packaging.md new file mode 100644 index 0000000..554e851 --- /dev/null +++ b/.claude/spec/nix-skill-packaging.md @@ -0,0 +1,115 @@ +## Problem Statement + +I manage a collection of Claude Code agent skills and need a way to select which ones apply globally (on every project) versus per-project. +Today that per-project selection is driven by three agent-run skills — `setup-skills`, `update-skills`, `remove-skills` — backed by a `.claude/skills-lock.yaml` lockfile and a content-hashing script. +Setup copies a skill out of a shared library into a project's tree; update re-hashes to detect drift and applies safe upstream changes; remove deletes it. +This is a lot of hand-maintained machinery: an LLM chore to run, a lockfile to keep clean, and a hash-based drift classifier to reason about. +I want skills packaged and versioned through Nix instead, so that selection is declarative, updates are a pinned-input bump, and the whole copy-and-hash apparatus disappears. + +## Solution + +A standalone Nix flake (`alexion/skills`) that packages each skill as an individually addressable derivation and exposes optional integration outputs for placing selected skills where Claude Code discovers them. + +- Adding a skill is creating a directory containing a `SKILL.md`; the flake auto-discovers it. +- A home-manager config selects skills it wants **globally** (into `~/.claude/skills/`) via a home-manager module. +- A project's own flake selects skills it wants **for that project** (into `/.claude/skills/`) via a dev-shell helper, pinning this flake as an input. +- "Update" becomes `nix flake update`; "remove" becomes deleting a line from the selecting config; "setup" becomes adding one. +- The three management skills, the lockfile, and the hashing script are retired — Nix subsumes all of their responsibilities. + +Nix is accepted as a hard requirement: every machine and every project is Nix/dev-shell based, so there is no need to keep skills as portable, committed-into-the-tree files for non-Nix consumers. + +## User Stories + +1. As a skill author, I want to add a new skill by creating a directory with a `SKILL.md` and nothing else, so that adding a skill requires no edit to the flake. +2. As a skill author, I want to organize skills into arbitrarily nested subfolders (e.g. group related workflow skills together), so that the source tree stays browsable as the collection grows. +3. As a skill author, I want that grouping to be purely cosmetic, so that where a skill's folder sits never changes how it is selected, placed, or named. +4. As an operator, I want to select a set of skills to be active in every project via my home-manager configuration, so that my always-on skills are declared in one place and installed reproducibly. +5. As a project maintainer, I want to select a set of skills for a single project via that project's flake, so that only the skills relevant to that project are active there. +6. As a project maintainer, I want the selected skills to appear under the project's `.claude/skills/` automatically when I enter its dev shell, so that I never run a manual install step. +7. As a project maintainer, I want the committed record of which skills a project uses to be the project's `flake.nix` selection plus its pinned input, so that the selection diffs cleanly in git and reproduces exactly on any machine. +8. As a project maintainer, I want the generated skill symlinks kept out of git, so that machine-specific store paths never get committed. +9. As a project maintainer, I want to keep my own hand-authored, project-private skills in `.claude/skills/` alongside the Nix-delivered ones, so that a project can have bespoke skills without adding them to the shared repo. +10. As a project maintainer, I want the dev shell to only ever touch the skills it manages, so that my hand-authored skills are never removed or clobbered. +11. As an operator, I want to update all pinned skills with `nix flake update`, so that picking up upstream skill changes is a single, reviewable, atomic operation. +12. As an operator, I want a skill I previously selected to disappear when I remove it from my selection and re-enter the shell (or rebuild), so that removal needs no cleanup command. +13. As an operator, I want to promote a skill from per-project to global (or vice versa) by moving one line between a project flake and my home-manager config, so that changing a skill's reach never moves files. +14. As a skill author, I want two skills that would resolve to the same name to fail the build with a clear error, so that a silent clobber can never happen. +15. As a skill author, I want the packaging to know nothing about `.claude/skills` or Claude Code, so that when a skill later needs to feed a different agent/tool the content is not welded to one placement convention. +16. As an operator, I want my global skill placement to compose with other modules that write into the same skills directory (e.g. a tool that self-installs its own skill), so that independent sources coexist without collisions. +17. As a maintainer, I want `nix flake check` to fail if a skill is malformed, if the home-manager placement regresses, or if the dev-shell helper stops producing a valid hook, so that breakage is caught before it ships. + +## Implementation Decisions + +### Overall structure + +- The flake has two clearly separated tiers: a **content tier** (tool-agnostic skill packaging) and an **integration tier** (optional, Claude-Code-specific placement). A skill derivation in the content tier must never reference `.claude/skills` or anything Claude-Code-specific. +- Flake scaffolding uses `flake-utils.lib.eachSystem` over `x86_64-linux`, `aarch64-linux`, `aarch64-darwin`, with `nixpkgs` tracking unstable. +An explicit system list is passed rather than `eachDefaultSystem`, because that default set includes `x86_64-darwin`, which recent nixpkgs dropped and whose `legacyPackages` now throws. + +### Content tier + +- Skills are **auto-discovered** by recursively walking the `skills/` tree; any directory containing a `SKILL.md` is a skill, at any depth. Once a `SKILL.md` is found, that directory is a skill and its own subfolders are its assets, not further skills. +- Directories that do not contain a `SKILL.md` are organizational containers only; the walk descends through them. This nesting is **cosmetic**: it never reaches the placement location and is not a selectable unit (no "select a whole group" — that is a purely additive future change if ever wanted). +- Each skill becomes an **individually addressable derivation**, exposed at `packages..`. There is no `packages..default` — a catalog has no single default skill. +- A skill is addressed by its **name** (leaf directory / frontmatter `name`), independent of its source path. Placement flattens the source path away — a skill nested at `skills/workflow/to-spec/` is placed as a direct child `.../skills/to-spec/`, because Claude Code only discovers direct children of the skills root. +- **Skill-derivation contract** (the interchange primitive, shared with self-packaging tool repos like `gitea-axi`): + - `$out` contains `SKILL.md` at its root, plus any assets. + - The derivation carries its **name as an eval-time attribute** (e.g. `pname` / a passthru), so placement can form `.../skills/` without reading `$out` (no import-from-derivation). +- **Names must be globally unique** across the whole tree. A collision detected during the discovery walk is a **hard build error** with a clear message, not a warning or a lint. +- A `lib.mkSkill` builder is exposed (turns a skill source directory into a conforming derivation). Exposing it costs nothing and lets a tool repo reuse it, even though tool repos are expected to self-package today. + +### Cross-skill references + +- There is **no dependency/closure machinery.** Skills interact by **name-based invocation** (the Skill tool), not by reading each other's files. A skill that references another skill relies on that skill being loaded (globally or in the same project), not on any relative path resolving on disk. +- Design convention, enforced by convention only (no lint): **skills are self-contained** — a skill never reaches into another skill's files; anything it needs at a path, it carries itself. The retired management skills (which read siblings' `LOCKFILE.md` and executed a sibling's script) are exactly the anti-pattern this convention forbids. + +### Integration tier — selection + +- Selection is **by derivation**, not by name string. Consumers pass skill derivations pulled from `packages.` (e.g. bind `packages.` to a short local name and list the skills off it). +- Rationale: skills are derivations in this ecosystem, so a derivation-based API composes with any skill satisfying the contract, from any repo, and keeps this flake ignorant of skills it does not own (respecting the boundary that tool-specific skills live in and are packaged by their own repos, not registered here). + +### Integration tier — global placement (home-manager) + +- Exposed as `homeModules.default`. +- The operator-facing option is **`programs.agents.skills`**, a `listOf package` with default `[]`. `agents` is deliberately an umbrella namespace (room for future `programs.agents.`), and it has **no shared `programs.agents.enable`** — each sub-feature self-gates, so the namespace stays a clean, mergeable surface a different repo could also extend without an ownership conflict. An empty list is a no-op. +- 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, not a style choice**: it forces home-manager to materialize `.../skills//` as a real directory of per-file symlinks rather than claiming the directory as one opaque symlink. That is what allows this module, the operator's own skills declarations, and self-placing tool modules to coexist under one `skills/` tree. +- The module deliberately does **not** feed `programs.claude-code.skills`. That option is single-valued and would collide with an operator already setting it; per-skill `home.file` composes where the option does not. + +### Integration tier — per-project placement (dev shell) + +- Exposed as `lib.mkSkillsShellHook`, which takes a list of selected skill derivations and returns a shellHook string a project drops into its dev shell. +- On shell entry the hook symlinks each selected skill as a **direct child** of `/.claude/skills/`, pointing into the Nix store. +- **Stateless lifecycle:** each entry first removes only the symlinks under `.claude/skills/` that point into the store (unambiguously "ours"), then recreates the current selection. This yields free stale-removal (deselect a skill → its symlink is gone next entry) and never touches real directories. No manifest or state file. +- **Coexistence with hand-authored skills:** real (non-symlink) skill directories under `.claude/skills/` are left untouched. The hook maintains a generated, self-ignoring `.claude/skills/.gitignore` that lists the names it manages (and ignores itself), so Nix-delivered symlinks stay out of git while hand-authored skills remain tracked. +- The committed record of a project's selection is its **`flake.nix`** (the selection list) plus its pinned `flake.lock`; the generated symlinks are gitignored. +- The hook is a plain shell string relying on ambient POSIX tools; it does not need a home-manager context (projects are not home-manager-managed). + +### Retired + +- `setup-skills`, `update-skills`, `remove-skills`, the `skills-lock.yaml` lockfile concept, and the directory-hashing script are all replaced by Nix and are **not** carried into this repo. Their responsibilities map to: selection in a flake (setup / remove) and `nix flake update` (update). + +## Testing Decisions + +- A good test here exercises the flake's **public outputs** as a consumer would observe them — a built skill, an evaluated-and-built module, an instantiated helper — not the internal shape of the discovery walk or builder. The outputs are the surface; there is no separate production code path needing its own seam. +- The single seam is **`nix flake check`**, with three focused checks beneath it (prior art: the `gitea-axi` flake's package-build and `checks/home-manager-module.nix` checks): + 1. **Skill-build check** — builds every auto-discovered skill derivation. This one check transitively exercises the recursive discovery walk, the `mkSkill` builder, the `SKILL.md`-at-`$out`-root contract, and the name-uniqueness hard error (which fires at eval and so also surfaces here). + 2. **Home-manager-module composition check** — instantiates `homeModules.default` under a sample home-manager configuration that selects a couple of skills with `programs.claude-code` enabled, and builds the resulting home-files derivation. This is the load-bearing check: it proves per-skill `home.file`, `recursive = true`, the `claude-code.enable` gate, and `configDir` sourcing compose as intended. Direct analogue of gitea-axi's home-manager-module check. + 3. **shellHook assertion** — instantiates `lib.mkSkillsShellHook` with sample skills and asserts the produced hook is non-empty and references the expected store paths / skill names. +- Prefer the highest seam: none of these introduce a bespoke test hook into production logic; they evaluate/realize the flake outputs directly. + +## Out of Scope + +- **Dotfiles and any migration of existing skills.** This repo owns only the packaging and integration outputs. Moving current skills out of the dotfiles tree, rewiring the dotfiles home-manager configuration to consume this flake, deleting the retired management skills from wherever they currently live, and reviewing/rewriting individual skills are all handled separately and by hand. The repo starts with flake machinery and no skill content; skills are added incrementally, "as needed." +- **Integration with tool-specific skills.** Skills that ship inside a tool's own repo (e.g. `gitea-axi`) self-package and self-place; they are not listed in or delivered by this flake. This flake stays ignorant of them. The shared skill-derivation contract is the only thing in common. +- **Selectable groups.** Nested folders are cosmetic; selecting a whole group as a unit is not built (additive later if wanted). +- **A formal cross-skill dependency system.** Not built; name-based invocation plus the self-containment convention is the whole mechanism. +- **Non-Nix portability.** Skills are not required to work on a machine without Nix; there is no committed-into-the-tree copy for non-Nix consumers. +- **Multiple harnesses.** Placement targets Claude Code only. The `programs.agents` namespace is chosen to leave room for other harnesses later, but no second harness is implemented now. + +## Further Notes + +- The global-vs-per-project distinction is no longer a directory (`library/` is gone) or a file-copy state; it is purely **which config selects a skill**. The same per-skill derivation is selected by home-manager for the global set and by a project's dev shell for that project's set. +- The two integration outputs place skills through different mechanisms because they run in different contexts: home-manager (`home.file`, `recursive = true`) for the global set, and a dev-shell shellHook (store symlinks) for the per-project set. Both operate on the same by-derivation selection and the same skill-derivation contract. +- `programs.agents` is a generic namespace; if this repo is ever made public it is a mild land-grab worth revisiting, but it is appropriate for a personal ecosystem. +- The design intentionally mirrors `gitea-axi` (flake scaffolding, per-skill `home.file` with `recursive = true`, module shape, check style) so the two repos stay consistent and the placement lessons already encoded in gitea-axi's ADRs carry over. diff --git a/.claude/tasks/0001-content-tier-skill-packaging.md b/.claude/tasks/0001-content-tier-skill-packaging.md new file mode 100644 index 0000000..3d4ba89 --- /dev/null +++ b/.claude/tasks/0001-content-tier-skill-packaging.md @@ -0,0 +1,53 @@ +--- +spec: nix-skill-packaging +--- + +## What to build + +The foundational tracer bullet: a standalone Nix flake that packages each skill as an individually addressable derivation, plus the check that proves it. + +The flake scaffolding uses `flake-utils.lib.eachSystem` over `x86_64-linux`, `aarch64-linux`, `aarch64-darwin`, with `nixpkgs` tracking unstable. +An explicit system list is passed rather than `eachDefaultSystem`, whose default set includes the now-dropped `x86_64-darwin`. + +The content tier recursively walks the `skills/` tree: any directory containing a `SKILL.md` is a skill (at any depth), and once found, its own subfolders are its assets rather than further skills. Directories without a `SKILL.md` are cosmetic organizational containers the walk descends through — the nesting never reaches the placement name and is not a selectable unit. + +Each discovered skill becomes an individually addressable derivation exposed at `packages..`, built by an exposed `lib.mkSkill` builder. There is no `packages..default`. A skill is addressed by its name (leaf directory / frontmatter `name`), independent of its source path. The derivation satisfies the skill-derivation contract: `$out` contains `SKILL.md` at its root plus any assets, and the derivation carries its name as an eval-time attribute (e.g. `pname` / a passthru) so placement can form `.../skills/` without import-from-derivation. Nothing in this tier references `.claude/skills` or anything Claude-Code-specific. + +Names must be globally unique across the whole tree; a collision detected during the discovery walk is a hard eval-time build error with a clear message, not a warning. + +Verified by the skill-build check under `nix flake check`, which builds every auto-discovered skill derivation and thereby transitively exercises the recursive walk, the `mkSkill` builder, the `SKILL.md`-at-`$out`-root contract, and the name-uniqueness hard error. Because the repo ships with no real skill content, this check drives fixture skills (à la gitea-axi's `runCommandLocal` fixtures), including a collision fixture that must fail the build. + +## Acceptance criteria + +- [x] `flake.nix` iterates the three systems via `flake-utils.lib.eachSystem` (explicit list, not `eachDefaultSystem`) with `nixpkgs` unstable. +- [x] The `skills/` tree is auto-discovered recursively: any directory containing a `SKILL.md` is a skill at any depth; its subfolders become its assets, not further skills. +- [x] Directories without a `SKILL.md` are traversed as cosmetic containers only and never affect a skill's placement name. +- [x] Each discovered skill is exposed at `packages..`; there is no `packages..default`. +- [x] `lib.mkSkill` is exposed and turns a skill source directory into a conforming derivation. +- [x] A built skill's `$out` contains `SKILL.md` at its root, and the derivation carries its name as an eval-time attribute (no import-from-derivation needed to read it). +- [x] No content-tier derivation references `.claude/skills` or anything Claude-Code-specific. +- [x] Two skills that resolve to the same name fail the build at eval with a clear collision message. +- [x] `nix flake check` includes a skill-build check that builds every auto-discovered skill; a name-collision fixture makes it fail. + +## Implementation Notes + +Files: `flake.nix` (scaffolding + wiring), `lib/mk-skill.nix` (builder), `lib/discover.nix` (recursive walk + uniqueness), `checks/skill-build.nix` with fixtures under `checks/fixtures/`, and an empty `skills/.gitkeep` root. + +- **Name source — leaf directory name, not frontmatter.** + The spec's "What to build" phrases the name as "leaf directory / frontmatter `name`", but the acceptance criteria only ever require the leaf directory name (criteria 2 and 6). + Discovery and `mkSkill` derive the name purely from the leaf directory (`mkSkill`'s `name` defaults to `builtins.baseNameOf src`). + The two are equal by Claude Code convention; parsing `SKILL.md` YAML frontmatter at eval would add real complexity for no behavioural gain when they agree, so the frontmatter branch is deliberately not implemented. + If divergence between directory name and frontmatter name ever needs enforcing, that is an additive validation for later. + +- **Eval-time name attribute.** + Carried as `passthru.skillName` (in addition to the derivation's own `name`), so the integration tier can form `.../skills/` at eval time without import-from-derivation. + +- **Empty `skills/` root.** + The repo ships no real skill content, so `skills/` holds only a `.gitkeep` and `packages.` is an empty set today. + The `skill-build` check therefore drives fixture skills (a top-level skill, one under a cosmetic container with an asset subfolder, and one nested several containers deep) plus a separate two-skills-one-name collision fixture that `builtins.tryEval` confirms fails discovery at eval. + +- **Real skills also folded into `checks` (beyond the literal criteria).** + Each real discovered skill is added to `checks.` alongside `skill-build`, so once skills land `nix flake check` builds each one and fails on a malformed skill (spec user story 17). + This is empty today and is a natural extension of criterion 9's "builds every auto-discovered skill", not new scope. + +- **Integration tier (home-manager module, dev-shell shellHook) is intentionally absent** — it belongs to tasks 0002 and 0003. No `home-manager` flake input is added yet for that reason. diff --git a/checks/fixtures/collision/first/dup/SKILL.md b/checks/fixtures/collision/first/dup/SKILL.md new file mode 100644 index 0000000..cb7ecdb --- /dev/null +++ b/checks/fixtures/collision/first/dup/SKILL.md @@ -0,0 +1 @@ +# dup (first) diff --git a/checks/fixtures/collision/second/dup/SKILL.md b/checks/fixtures/collision/second/dup/SKILL.md new file mode 100644 index 0000000..f172df3 --- /dev/null +++ b/checks/fixtures/collision/second/dup/SKILL.md @@ -0,0 +1 @@ +# dup (second) diff --git a/checks/fixtures/valid/alpha/SKILL.md b/checks/fixtures/valid/alpha/SKILL.md new file mode 100644 index 0000000..a417230 --- /dev/null +++ b/checks/fixtures/valid/alpha/SKILL.md @@ -0,0 +1 @@ +# alpha — a top-level skill diff --git a/checks/fixtures/valid/delta/SKILL.md b/checks/fixtures/valid/delta/SKILL.md new file mode 100644 index 0000000..0d97d6b --- /dev/null +++ b/checks/fixtures/valid/delta/SKILL.md @@ -0,0 +1 @@ +# delta — another top-level skill diff --git a/checks/fixtures/valid/group/beta/SKILL.md b/checks/fixtures/valid/group/beta/SKILL.md new file mode 100644 index 0000000..789938b --- /dev/null +++ b/checks/fixtures/valid/group/beta/SKILL.md @@ -0,0 +1 @@ +# beta — nested under a cosmetic container diff --git a/checks/fixtures/valid/group/beta/tools/SKILL.md b/checks/fixtures/valid/group/beta/tools/SKILL.md new file mode 100644 index 0000000..390a44e --- /dev/null +++ b/checks/fixtures/valid/group/beta/tools/SKILL.md @@ -0,0 +1 @@ +# not a skill — beta asset that happens to contain SKILL.md diff --git a/checks/fixtures/valid/one/two/three/gamma/SKILL.md b/checks/fixtures/valid/one/two/three/gamma/SKILL.md new file mode 100644 index 0000000..7cf4d05 --- /dev/null +++ b/checks/fixtures/valid/one/two/three/gamma/SKILL.md @@ -0,0 +1 @@ +# gamma — several containers deep diff --git a/checks/skill-build.nix b/checks/skill-build.nix new file mode 100644 index 0000000..d06ed9d --- /dev/null +++ b/checks/skill-build.nix @@ -0,0 +1,56 @@ +# The skill-build check: drives fixture skills through the content tier so +# `nix flake check` covers every discovery shape and the collision hard-error. +{ + pkgs, + mkSkill, + discoverSkills, +}: +let + inherit (pkgs) lib; + + discovered = discoverSkills ./fixtures/valid; + built = map (skill: mkSkill { inherit pkgs; inherit (skill) name src; }) discovered; + + collision = builtins.tryEval (discoverSkills ./fixtures/collision); + + discoveredNames = lib.sort (a: b: a < b) (map (skill: skill.name) discovered); +in +pkgs.runCommandLocal "skill-build-check" + { + # Referencing every built skill forces each one to build before this + # derivation — that is the "build every auto-discovered skill" coverage. + # Each entry is "name=storepath" so the assertions can read each $out root + # without re-deriving anything, and confirm the name passthru round-trips. + pairs = lib.concatStringsSep " " (map (skill: "${skill.skillName}=${skill}") built); + collisionSucceeded = if collision.success then "yes" else "no"; + expectedNames = lib.concatStringsSep " " discoveredNames; + } + '' + fail() { echo "FAIL: $1" >&2; exit 1; } + + echo "content tier: the name-collision fixture must fail discovery at eval" + [ "$collisionSucceeded" = no ] \ + || fail "a two-skills-one-name tree was discovered without error" + + echo "content tier: discovery finds every skill regardless of nesting depth," + echo " and stops at a skill rather than descending into its assets" + [ "$expectedNames" = "alpha beta delta gamma" ] \ + || fail "discovered names were [$expectedNames], expected [alpha beta delta gamma]" + + echo "content tier: each built skill has SKILL.md at its \$out root" + beta_path= + for pair in $pairs; do + name="''${pair%%=*}" + path="''${pair#*=}" + test -f "$path/SKILL.md" || fail "$name has no SKILL.md at its \$out root" + [ "$name" = beta ] && beta_path="$path" + done + + echo "content tier: a skill's asset subfolder rides along in \$out and is" + echo " not split off into its own skill" + [ -n "$beta_path" ] || fail "beta was not discovered" + test -f "$beta_path/tools/SKILL.md" \ + || fail "beta's asset subfolder did not ride along into \$out" + + touch "$out" + '' diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..d24f746 --- /dev/null +++ b/flake.lock @@ -0,0 +1,61 @@ +{ + "nodes": { + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1784497964, + "narHash": "sha256-vlHUuqAcbcH2RKmHbPiuQzbv1pnzzavXnI62RD0bqCU=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "241313f4e8e508cb9b13278c2b0fa25b9ca27163", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..8e76895 --- /dev/null +++ b/flake.nix @@ -0,0 +1,55 @@ +{ + description = "Personal agent skills, packaged through Nix."; + + inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + inputs.flake-utils.url = "github:numtide/flake-utils"; + + outputs = + { self, nixpkgs, flake-utils }: + let + mkSkill = import ./lib/mk-skill.nix; + discoverSkills = import ./lib/discover.nix nixpkgs.lib; + + discoveredSkills = discoverSkills ./skills; + + skillPackages = + pkgs: + nixpkgs.lib.listToAttrs ( + map (skill: { + inherit (skill) name; + value = mkSkill { inherit pkgs; inherit (skill) name src; }; + }) discoveredSkills + ); + in + { + lib = { inherit mkSkill; }; + } + // + # An explicit system list rather than `eachDefaultSystem`. + # That default set includes x86_64-darwin, which current nixpkgs no longer + # evaluates (its `legacyPackages` throws). + flake-utils.lib.eachSystem + [ + "x86_64-linux" + "aarch64-linux" + "aarch64-darwin" + ] + ( + system: + let + pkgs = nixpkgs.legacyPackages.${system}; + in + { + # No `default`: a catalog has no single default skill. + packages = skillPackages pkgs; + + # Each discovered skill is folded in as its own check, so + # `nix flake check` builds every skill and fails on a malformed one. + checks = (skillPackages pkgs) // { + skill-build = import ./checks/skill-build.nix { + inherit pkgs mkSkill discoverSkills; + }; + }; + } + ); +} diff --git a/lib/discover.nix b/lib/discover.nix new file mode 100644 index 0000000..0d78dc3 --- /dev/null +++ b/lib/discover.nix @@ -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 diff --git a/lib/mk-skill.nix b/lib/mk-skill.nix new file mode 100644 index 0000000..5f675ed --- /dev/null +++ b/lib/mk-skill.nix @@ -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; +} diff --git a/skills/.gitkeep b/skills/.gitkeep new file mode 100644 index 0000000..e69de29