Compare commits
1 Commits
5b6c158d2e
...
8622b35f48
| Author | SHA1 | Date | |
|---|---|---|---|
| 8622b35f48 |
115
.claude/spec/nix-skill-packaging.md
Normal file
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
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
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
1
checks/fixtures/collision/second/dup/SKILL.md
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# dup (second)
|
||||||
1
checks/fixtures/valid/alpha/SKILL.md
Normal file
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
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
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
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
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
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,
|
||||||
|
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
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
55
flake.nix
Normal file
@@ -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;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
42
lib/discover.nix
Normal file
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
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;
|
||||||
|
}
|
||||||
0
skills/.gitkeep
Normal file
0
skills/.gitkeep
Normal file
Reference in New Issue
Block a user