feat: package skills as per-skill Nix derivations (task 0001) #1
115
.claude/spec/nix-skill-packaging.md
Normal file
@@ -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 `<project>/.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.<system>.<skill-name>`. There is no `packages.<system>.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/<name>` 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.<system>` (e.g. bind `packages.<system>` 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.<other>`), 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/<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, not a style choice**: it forces home-manager to materialize `.../skills/<name>/` 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 `<project>/.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.
|
||||||
53
.claude/tasks/0001-content-tier-skill-packaging.md
Normal file
@@ -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.<system>.<skill-name>`, built by an exposed `lib.mkSkill` builder. There is no `packages.<system>.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/<name>` 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.<system>.<skill-name>`; there is no `packages.<system>.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/<name>` 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.<system>` 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.<system>` 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.
|
||||||
1
checks/fixtures/collision/first/dup/SKILL.md
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# dup (first)
|
||||||
1
checks/fixtures/collision/second/dup/SKILL.md
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# dup (second)
|
||||||
1
checks/fixtures/valid/alpha/SKILL.md
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# alpha — a top-level skill
|
||||||
1
checks/fixtures/valid/delta/SKILL.md
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# delta — another top-level skill
|
||||||
1
checks/fixtures/valid/group/beta/SKILL.md
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# beta — nested under a cosmetic container
|
||||||
1
checks/fixtures/valid/group/beta/tools/SKILL.md
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# not a skill — beta asset that happens to contain SKILL.md
|
||||||
1
checks/fixtures/valid/one/two/three/gamma/SKILL.md
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# gamma — several containers deep
|
||||||
56
checks/skill-build.nix
Normal file
@@ -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,
|
||||||
|
alexion marked this conversation as resolved
Outdated
|
|||||||
|
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"
|
||||||
|
''
|
||||||
61
flake.lock
generated
Normal file
@@ -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
|
||||||
|
}
|
||||||
55
flake.nix
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
{
|
||||||
|
description = "Personal agent skills, packaged through Nix.";
|
||||||
|
alexion marked this conversation as resolved
Outdated
alexion
commented
Remove reference to Claude Code. Remove reference to Claude Code.
alexion
commented
Done. Dropped "Claude Code" from the flake description (now "Personal agent skills, packaged through Nix.") and removed the remaining Claude Code mentions from the content-tier comments too — the tier is tool-agnostic, so naming the tool even to disclaim it was out of place. Done. Dropped "Claude Code" from the flake description (now "Personal agent skills, packaged through Nix.") and removed the remaining Claude Code mentions from the content-tier comments too — the tier is tool-agnostic, so naming the tool even to disclaim it was out of place.
|
|||||||
|
|
||||||
|
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||||
|
inputs.flake-utils.url = "github:numtide/flake-utils";
|
||||||
|
|
||||||
|
alexion marked this conversation as resolved
Outdated
alexion
commented
Bad comment Bad comment
alexion
commented
Removed entirely. The Removed entirely. The `nixos-unstable` URL is self-documenting, and the rest was either obvious (a consumer overriding the input) or described downstream consumers.
|
|||||||
|
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
|
||||||
|
alexion marked this conversation as resolved
Outdated
alexion
commented
Wasn't there a built in flake function for this? Wasn't there a built in flake function for this?
alexion
commented
There's no There's no `forAllSystems` in `nixpkgs.lib` — `genAttrs` (used inside the helper) is the primitive it's built from. The named wrappers are flake-utils (`eachDefaultSystem`) and flake-parts (`perSystem`), both deliberately avoided per the spec to mirror the sibling gitea-axi flake and keep the input set minimal. Happy to pull in flake-utils for the shorter form if you'd rather — just say so and I'll switch both this repo and gitea-axi to stay consistent.
alexion
commented
Switched to Switched to `flake-utils.lib.eachSystem`. To be precise about the earlier answer: there is no official `forAllSystems`/`eachSystem` in `nixpkgs.lib` (verified — `genAttrs` is the only primitive there), so this pulls in `numtide/flake-utils` as an input. It uses `eachSystem` with an explicit three-system list rather than `eachDefaultSystem`, because the default set includes `x86_64-darwin`, which recent nixpkgs dropped and whose `legacyPackages` now throws. `nix flake check` passes and the output shape is unchanged (`packages.<system>.<name>`, `checks.<system>`, system-independent `lib`). Updated the spec and task doc, which had recorded the hand-rolled/no-flake-utils decision. Left the sibling gitea-axi flake alone for now — happy to make the same change there separately if you want the two consistent.
|
|||||||
|
);
|
||||||
|
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:
|
||||||
|
alexion marked this conversation as resolved
Outdated
alexion
commented
These comments should be on the function definition and not here? These comments should be on the function definition and not here?
alexion
commented
Agreed — removed it from the import site. The description already lives on the definition in Agreed — removed it from the import site. The description already lives on the definition in `lib/mk-skill.nix`, so the `mkSkill = import ./lib/mk-skill.nix;` line now stands on its own.
|
|||||||
|
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) // {
|
||||||
|
alexion marked this conversation as resolved
Outdated
alexion
commented
bad variable name? bad variable name?
alexion
commented
Renamed Renamed `ownSkills` → `discoveredSkills`.
|
|||||||
|
skill-build = import ./checks/skill-build.nix {
|
||||||
|
inherit pkgs mkSkill discoverSkills;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
42
lib/discover.nix
Normal 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
@@ -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;
|
||||||
|
}
|
||||||
This is temporary state of the repo and should not be worked around. Keep the fixtures though, but the comments make it sound like this project will never have skills.
Reworded so the fixtures read as deliberate, permanent coverage — they pin every discovery shape and the collision hard-error deterministically, independent of whichever real skills the repo carries — rather than a workaround for today's empty tree. Fixtures kept as-is.