{"id":"0060f2b7-3bc2-4f1d-a92d-35d7de955443","slug":"agent-skills-nix","kind":"skill","name":"agent-skills-nix","summary":"Declarative management of Agent Skills on Nix","owner":"Kyure-A","repoUrl":"https://github.com/Kyure-A/agent-skills-nix","license":"MIT","licenseTier":"permissive","trust":"community","stars":268,"match":null,"fewStars":false,"format":"skill","createdAt":"2025-12-22T13:15:25.000Z","addedAt":"2026-10-14T05:54:10.264Z","logoUrl":"https://avatars.githubusercontent.com/u/49436968?u=2bd974e60d54693e89708e351842262245025e9b&v=4","linkOnly":false,"collection":null,"skillPath":null,"categories":["writing"],"tags":["nix"],"runtime":"binary","local":true,"cloudCapable":false,"cloudCapability":"unknown","cloudReadyOnPlatform":false,"needsApiKey":false,"reviewed":false,"syncedAt":"2026-10-15T02:39:37.034Z","sample":false,"url":"https://conduits.dev/tools/agent-skills-nix","language":"Nix","transports":["stdio"],"install":{},"requirements":{"env":[],"upstreamMayCharge":false},"tools":null,"readme":"# agent-skills-nix\n\nDeclarative management of Agent Skills (directories containing `SKILL.md`) with flake-pinned sources, discovery, selection, bundling, Home Manager integration, and skills-only Agent Plugin export.\n\n## Concepts\n\n- **sources**: Named inputs or paths pointing at a skills root (`subdir`). They can be written directly as before, or generated from the optional source registry. Optional `idPrefix` namespaces discovered skill IDs to avoid collisions across sources.\n- **discover**: Recursively scans sources for directories that contain `SKILL.md`, producing a catalog. Skills can be nested (e.g. `ecosystem/c-ecosystem/`) and their IDs use `/` as separator.\n- **skills.enable / skills.enableAll / skills.explicit**: Declaratively pick discovered skills, enable-all (global or by source list), and explicitly specified ones. Explicit skills can restrict installation to named targets with `agents`; no accidental auto-install unless you opt in.\n- **Agent Plugin export**: Maps selected catalog entries to portable skill names and produces a self-contained, skills-only plugin directory.\n- **targets**: Agent-specific destinations synced from a store bundle (structure: `link`, `symlink-tree`, `copy-tree`). Targets are opt-in (`enable = false` by default). Runtime destinations support `$HOME`, `~`, and `${VAR:-$HOME/...}` fallback forms without general shell evaluation. See **Default target paths** below.\n\n## Source filters\n\nEach source supports optional filters to control discovery:\n\n- **`idPrefix`** (`null | string`, default: `null`): Prefix prepended to discovered skill IDs. Useful when multiple sources expose the same relative path, e.g. `idPrefix = \"openai\";` turns `pdf` into `openai/pdf`.\n- **`filter.maxDepth`** (`null | int`, default: `null`): Maximum recursion depth for SKILL.md discovery. `null` = unlimited (capped internally at 100 to guard against symlink loops), `0` = source root only, `1` = immediate children, `2` = one level of nesting. Negative depths are rejected.\n- **`filter.nameRegex`** (`null | string`, default: `null`): Regex matched against the skill's relative path (e.g. `cat-a/skill-1`) to restrict discovery.\n\nIf two sources both expose `pdf`, prefix them explicitly to keep IDs unique:\n\n```nix\nsources.openai = {\n  input = \"openai-skills\";\n  subdir = \"skills\";\n  idPrefix = \"openai\";\n};\n\nsources.anthropic = {\n  input = \"anthropic-skills\";\n  subdir = \"skills\";\n  idPrefix = \"anthropic\";\n};\n\nskills.enable = [ \"openai/pdf\" \"anthropic/pdf\" ];\n```\n\nDirect sources, Home Manager sources, and registry manifests use the same\nvalidation for `subdir`, `idPrefix`, and `filter`. Unknown source/filter fields,\ninvalid types, unsafe relative paths, and invalid ID prefixes fail before\nselection. A source must provide `path` or `input`; `path` takes precedence\nwhen both are set.\n\n## Source registry (optional)\n\nThe source registry removes the need to declare every skill repository as a\nflake input. It uses [npins](https://github.com/andir/npins), the pin manager\nrecommended by the [nix.dev remote-source guide](https://nix.dev/guides/recipes/dependency-management.html),\nfor revision resolution and hashing. Existing `sources.<name>.input` and\n`sources.<name>.path` declarations remain supported and can be mixed with this\napproach.\n\nKeep one human-edited Nix manifest per source and commit the generated JSON\nlock:\n\n```text\nregistry/\n├── sources/\n│   ├── anthropic.nix\n│   └── internal.nix\n└── sources.lock.json\n```\n\nFor a GitHub source that follows a branch:\n\n```nix\n# registry/sources/anthropic.nix\n{\n  pin = {\n    type = \"github\";\n    owner = \"anthropics\";\n    repo = \"skills\";\n    branch = \"main\";\n  };\n\n  subdir = \"skills\";\n  idPrefix = \"anthropic\";\n  filter.maxDepth = 2;\n}\n```\n\n`pin.type` can be `github`, `git`, or `tarball`. Git and GitHub pins accept\n`branch`, `at`, `submodules`, `preReleases`, `versionUpperBound`, and\n`releasePrefix`; generic Git pins additionally require `url` and accept\n`forge`. If `branch` is omitted, npins follows releases and writes a\n`GitRelease` pin. Tarball pins require `url` and optionally accept `mutable`.\nThe remaining fields (`subdir`, `idPrefix`, and `filter`) are the same source\ndiscovery settings as the existing DSL.\n\nLoad the lock as an ordinary path-backed `sources` value:\n\n```nix\nsources = agentLib.sourcesFromLock {\n  manifestsDir = ./registry/sources;\n  lockFile = ./registry/sources.lock.json;\n};\n\ncatalog = agentLib.discoverCatalog sources;\n```\n\nExpose the updater in a consumer flake:\n\n```nix\nsourceLockProgram = agentLib.mkSourceLockProgram { inherit pkgs; };\n\napps.${system}.skills-sources-lock = {\n  type = \"app\";\n  program = \"${sourceLockProgram}/bin/skills-sources-lock\";\n};\n```\n\nThen update all sources together:\n\n```console\nnix run .#skills-sources-lock\n```\n\nThe command validates and normalizes every manifest through the same Nix\nloader used by consumers, passes every npins argument without shell evaluation,\nand resolves all pins in a temporary sibling directory. The generated lock has\nan agent-skills schema-v1 wrapper containing the normalized manifests and an\nnpins version-8 lock. `sourcesFromLock` requires the current normalized\nmanifests to match that snapshot exactly, so changing a repository, branch, or\ndiscovery option without updating the lock fails evaluation. The updater sorts\nJSON keys and only then atomically renames the completed lock into place. A\nfailed manifest or fetch leaves the old lock byte-for-byte unchanged; rerunning\nwith unchanged upstream revisions produces the same lock bytes. See the complete\n[`examples/source-registry`](./examples/source-registry) flake.\n\n## Default target paths\n\n| Target | Global path | Local path |\n|--------|-------------|------------|\n| agents | `$HOME/.agents/skills` | `.agents/skills` |\n| codex | `${CODEX_HOME:-$HOME/.codex}/skills` | `.codex/skills` |\n| opencode | `$HOME/.config/opencode/skills` | `.opencode/skills` |\n| claude | `${CLAUDE_CONFIG_DIR:-$HOME/.claude}/skills` | `.claude/skills` |\n| copilot | `$HOME/.copilot/skills` | `.github/skills` |\n| cursor | `$HOME/.cursor/skills` | `.cursor/skills` |\n| windsurf | `$HOME/.codeium/windsurf/skills` | `.windsurf/skills` |\n| antigravity | `$HOME/.gemini/antigravity/skills` | `.agents/skills` |\n| gemini | `$HOME/.gemini/skills` | `.gemini/skills` |\n| pi | `$HOME/.pi/agent/skills` | `.pi/skills` |\n\n## Quick start (child flake + Home Manager)\n\nPut skills config in a small child flake so the only pinned inputs there are skill sources.\n\nUse the quickstart example:\n\n- Overview: [`examples/quickstart/README.md`](./examples/quickstart/README.md)\n- Main (tightly coupled): [`examples/quickstart/main/flake.nix`](./examples/quickstart/main/flake.nix)\n- Child (separated catalog): [`examples/quickstart/child/flake.nix`](./examples/quickstart/child/flake.nix)\n\nNotes:\n\n- In `main`, `agent-skills` and skill sources are listed directly in the top-level inputs.\n- In `child`, top-level only depends on `skills-catalog = path:./skills`; skills inputs live under `./skills/flake.nix`.\n- If you use source `input` references in your module config, pass flake `inputs` to Home Manager via `extraSpecialArgs`.\n- To enable a default target, set `targets.<name>.enable = true;` (e.g. `targets.claude.enable = true;`).\n- `structure = \"link\"` uses `home.file` symlinks; `symlink-tree` and `copy-tree` run in `home.activation`.\n- `symlink-tree` uses `rsync -a --delete` (preserve symlinks); `copy-tree` uses `rsync -aL --delete` (dereference symlinks).\n- Runtime `dest` values support `$HOME`, `~`, and `${VAR:-$HOME/...}` fallback forms (e.g. `${CLAUDE_CONFIG_DIR:-$HOME/.claude}/skills`). Home Manager's `link` structure requires a static path and uses the fallback path.\n- Symlinks inside skills are preserved when their target stays inside the source root; escaping or dangling ones are dropped. See [Symlinks inside skills](#symlinks-inside-skills).\n\n## Flake outputs\n\n- `packages.<system>.agent-skills-bundle`: Store bundle of selected skills (empty by default; configure in consumers).\n- `apps.<system>.skills-install`: Sync selected skills to enabled global targets (see **Default target paths**). Unrestricted bundles support destination overrides with `AGENT_SKILLS_DESTS`.\n- `apps.<system>.skills-install-local`: Sync selected skills to enabled local targets (see **Default target paths**) using `copy-tree`. Override root with `AGENT_SKILLS_ROOT`; unrestricted bundles support destination overrides with `AGENT_SKILLS_LOCAL_DESTS`.\n- `apps.<system>.skills-list`: JSON view of the default catalog.\n- `apps.<system>.skills-sources-lock`: Resolve `registry/sources/*.nix` and atomically update `registry/sources.lock.json`.\n- `checks.<system>.skills`: Sanity check that the bundle builds.\n- `homeManagerModules.default`: Home Manager module implementing the DSL above.\n- `lib.agent-skills`: Helper functions (`discoverCatalog`, `selectSkills`, `mkBundle`, `bundlesForTargets`, `mkAgentPlugin`, `loadSourceManifests`, `sourcesFromLock`, `mkSourceLockProgram`, `mkSyncProgram`, `mkLocalInstallProgram`, compatibility wrappers `mkSyncScript` / `mkLocalInstallScript`, `mkShellHook`, `catalogJson`, `defaultConfig`).\n\n## Development structure\n\nThe root `flake.nix` depends only on nixpkgs and explicitly wires the library,\npackages, apps, checks, and Home Manager module with ordinary Nix imports.\nBuild and app definitions live under `nix/`; runtime and domain logic live in\n`scripts/`, `lib/`, `modules/`, and `test/`.\n\nHome Manager is a test-only dependency declared in `test/flake.nix`. That flake\ntests the root flake through a relative path input and follows its nixpkgs input,\nso consumers do not inherit a Home Manager dependency.\n\nRun both check suites from the repository root:\n\n```console\nnix flake check\nnix flake check ./test\n```\n\nTo update dependencies, update the root lock first, then the test lock:\n\n```console\nnix flake update\nnix flake update --flake ./test\n```\n\nCI runs both suites, and the dependency update workflow updates both lock files.\n\n## Library functions\n\nSee [`examples/library-functions/snippet.nix`](./examples/library-functions/snippet.nix).\n\n`discoverCatalog` recursively discovers `SKILL.md` directories and generates `/`-separated IDs for nested skills (e.g. `cat-a/skill-1`). Set `idPrefix` on a source to namespace discovered IDs (for example, `openai/pdf`). It enforces `SKILL.md` presence and rejects duplicate IDs after prefixing (error messages include absolute paths for both conflicting sources). `selectSkills` errors on unknown allowlist entries or missing files, preventing accidental drift. (Home Manager maps `skills.enable` → `allowlist` and `skills.explicit` → `skills`.)\n\nSelection validates enabled declarations before returning its result, including\nunknown source names in `enableAll` and invalid transforms or packages. Explicit\nskills use `rename` as their final output ID; an omitted or `null` rename uses\nthe declaration's name. Selection keys and entry IDs always match. Collisions\nare checked after renaming, both against discovered selections and between\nexplicit skills. For example, `skills.explicit.original = { from = \"local\";\npath = \"pdf\"; rename = \"documents/pdf\"; };` installs to `documents/pdf/`.\n\n`loadSourceManifests` loads, validates, and normalizes a directory of per-source\nNix manifests. `sourcesFromLock` verifies them against an agent-skills\nschema-v1 JSON lock (containing an npins version-8 pin set) and returns the same\npath-backed source shape accepted by `discoverCatalog`. `mkSourceLockProgram`\nbuilds the updater used by the `skills-sources-lock` app.\n\n`mkSyncProgram` returns a `skills-install` executable after filtering enabled targets for the requested system. `mkLocalInstallProgram` is its project-local wrapper and returns `skills-install-local`. Both serialize a versioned JSON configuration and invoke the shared synchronization runtime; they do not return inline shell source. Existing consumer flakes can continue using `mkSyncScript` and `mkLocalInstallScript`; these compatibility wrappers preserve their original return types while delegating to the shared runtime. `mkShellHook` runs the local program from a development shell.\n\n`mkBundle { inherit pkgs selection; }` builds the full selection and exposes\n`bundle.forTarget \"claude\"` for a bundle filtered by each skill's `agents` list.\nPass the bundle derivation directly to `mkSyncProgram`, `mkLocalInstallProgram`,\nor `mkShellHook` to apply that filtering automatically for each named target.\n`bundlesForTargets { inherit bundle targets system; }` validates the target\nnames and returns the filtered bundles for enabled targets on that system.\nA plain store path does not carry selection metadata. Home Manager exposes\nthe full selection as `programs.agent-skills.bundlePath` and the filtered\nbundles for enabled targets on the current system as\n`programs.agent-skills.targetBundlePaths`.\n\n## Agent Plugin export (experimental)\n\n`mkAgentPlugin` turns an explicit map of selected skills into a skills-only\nplugin for ChatGPT and Codex. It follows OpenAI's current\n[plugin packaging format](https://developers.openai.com/plugins/build/plugins):\nthe manifest is written to `.codex-plugin/plugin.json`, while the exported\nskills live directly under `skills/`.\n\n```nix\nplugin = agentLib.mkAgentPlugin {\n  inherit pkgs;\n\n  manifest = {\n    name = \"document-tools\";\n    version = \"0.1.0\";\n    description = \"Portable document workflows\";\n  };\n\n  skills = {\n    pdf = selection.\"openai/pdf\";\n  };\n};\n```\n\nThe attribute names in `skills` become the exported skill directory names.\nThis explicit mapping flattens catalog IDs such as `openai/pdf` into portable\nnames such as `pdf`; the matching `SKILL.md` frontmatter must use that same\nname. The exporter adds `\"skills\": \"./skills/\"` to the manifest. Per-skill\n`agents` restrictions do not filter plugin exports; the explicit export map\ndetermines their contents.\n\nThe initial exporter deliberately supports skills only. A selected skill with\n`packages` or a `transform` is rejected because those bundle features can\ncreate output that is not portable outside the Nix store. Each accepted skill\nis copied with its payload into the result, internal links are materialized,\nand the completed plugin contains no symlinks. See the runnable\n[`examples/agent-plugin`](./examples/agent-plugin) flake.\n\nThe exporter owns the manifest's `skills` field and rejects `apps`,\n`mcpServers`, and `hooks`. Plugin-level asset fields such as `composerIcon`,\n`logo`, `logoDark`, and `screenshots` are also outside this initial API; keep\nvisual assets inside individual skills instead. If present, `defaultPrompt`\nsupports at most three entries of 128 characters each. Plugin-bundled skills\nmust follow the Agent Skills naming and description rules, and cannot set\n`disable-model-invocation` to `true`.\n\n## Skill customisation\n\n### Install skills only for selected agents\n\nSet `agents` on an explicit skill to choose the named targets that receive it:\n\n```nix\nprograms.agent-skills = {\n  targets.claude.enable = true;\n  targets.codex.enable = true;\n  targets.pi.enable = true;\n\n  skills.explicit = {\n    writing = {\n      from = \"my-source\";\n      agents = [ \"claude\" \"codex\" ];\n    };\n    pi-tools = {\n      from = \"my-source\";\n      agents = [ \"pi\" ];\n    };\n    shared = {\n      from = \"my-source\";\n    };\n  };\n};\n```\n\nHere `writing` goes to Claude and Codex, `pi-tools` goes to Pi, and `shared`\ngoes to all three. Omitted `agents` or `agents = null;` allows every enabled\ntarget on the current system; `agents = [];` installs the skill nowhere while\nkeeping it in the full bundle. Skills selected through `skills.enable` or\n`skills.enableAll` also go to every enabled target.\n\nNames match `targets` attribute keys, including custom targets, regardless of\ntheir `dest` paths. Listing a target never enables it or overrides its `systems`\nfilter. Unknown names fail evaluation when constructing the install programs;\nknown but disabled targets, or targets for another system, are allowed.\n\nThese restrictions apply to Home Manager links and activation, global and\nlocal install programs, and dev-shell hooks. If any selected skill has a\nnon-null `agents` list, `AGENT_SKILLS_DESTS` and `AGENT_SKILLS_LOCAL_DESTS`\noverrides are rejected because those paths do not identify a target. Configure\n`targets.<name>.dest` to change destinations while keeping target names.\n\n### Transform content and bundle dependencies\n\nExplicit skills support `transform` and `packages` options to customise SKILL.md and bundle dependencies:\n\nSee [`examples/skill-customization/explicit-transform.nix`](./examples/skill-customization/explicit-transform.nix).\n\nThis generates:\n\n```\nmy-skill/\n├── SKILL.md\n├── jq -> /nix/store/xxx-jq/bin/jq\n└── curl/ -> /nix/store/xxx-curl/bin/  (for packages with multiple binaries)\n```\n\nWith SKILL.md containing the transformed content.\n\n**Transform function arguments:**\n- `original`: The original SKILL.md content\n- `dependencies`: A markdown table of package dependencies with local paths (e.g., `./jq`)\n\n**Default behaviour (no transform):**\n- If only `packages` is specified, the default is `dependencies + original`\n- If neither is specified, the original SKILL.md is used as-is\n\nPackage binaries are referenced with local paths (`./jq` or `./pkg/` for multi-binary packages) to reduce context consumption when agents load the skill.\n\n## Apps usage\n\n### Global skills (Home Manager)\n\n- List catalog: `nix run .#skills-list`\n- Sync bundle to `$HOME`: `nix run .#skills-install` (for unrestricted bundles, override destinations via `AGENT_SKILLS_DESTS=\"~/tmp/skills1 ~/tmp/skills2\"`)\n\n### Local skills (project-local)\n\n- Sync bundle to current directory: `nix run .#skills-install-local`\n\nLocal skills are installed to enabled local targets in **Default target paths** relative to the current working directory (or `AGENT_SKILLS_ROOT` if set). For unrestricted bundles, override destinations via `AGENT_SKILLS_LOCAL_DESTS`; with `agents` restrictions, configure named targets' `dest` values instead.\nTargets respect `enable`, `systems`, and `structure` (default `copy-tree`). To exclude a target, disable it or provide custom targets to `mkLocalInstallProgram`.\n\nSynchronization builds and validates the complete execution plan before\nchanging any destination. Targets with the same resolved destination,\nstructure, and bundle synchronize once (for example, local `agents` and\n`antigravity` when their selected skills match);\nthe first target's name is used for the ownership marker and progress message.\nDifferent structures or bundles at one destination, or destinations nested\ninside each other, are rejected before synchronization starts.\n\nFor unrestricted bundles, destination overrides are separated by spaces, tabs,\nor newlines. Global\noverrides replace the entire target list. Local overrides replace destinations\nin target-name order, retaining each matched target's structure; remaining\ntargets keep their configured destinations. Additional overrides use\n`overrideStructure` (`copy-tree` for `mkLocalInstallProgram`). Use configured\ntargets for destinations containing whitespace.\n\nThe synchronizer refuses to replace a non-empty, unmarked directory. A successful tree sync records ownership in `.agent-skills-managed.json`; set `AGENT_SKILLS_FORCE=1` only when you intentionally want agent-skills to take over an existing destination.\n\nBoth apps operate on the flake's default (empty) config; point at your own flake/module for real catalogs.\n\n## Local skills in your project\n\nTo install skills locally in your project, use `mkLocalInstallProgram` in your flake:\n\nSee [`examples/local-install/flake.nix`](./examples/local-install/flake.nix).\n\nThen run `nix run .#skills-install-local` from your project root to install skills to enabled local targets in **Default target paths**.\n\n### Auto-install with devShell\n\nUse `mkShellHook` to automatically install skills when entering a dev shell:\n\nSee [`examples/devshell/flake.nix`](./examples/devshell/flake.nix).\n\nNow `nix develop` will automatically install skills to your project directory.\n\nThe hook prints one line per installed target. Under direnv the hook is\nre-evaluated on every shell entry, so pass `quiet = true;` to `mkShellHook` to\ndrop those lines; warnings and failures still go to stderr. `AGENT_SKILLS_QUIET=1`\ndoes the same for any synchronization program at runtime.\n\n## Symlinks inside skills\n\nSymlinks inside skill directories are kept when their textual target stays inside the declared source root (e.g. `../shared` to a sibling at the root). Symlinks whose target escapes the root are dropped, along with any links left dangling by that drop.\n\n- The entire source root is imported into the store, not just each skill subdirectory. Scope the source via `path = ./skills` or `subdir` if the root contains unrelated heavy trees (`.git`, build artefacts).\n- `--safe-links` checks the textual target, not the resolved path. Keep symlinks source-root-relative.\n- Sources resolving to the same physical directory share one store path and one safe-tree derivation.\n\n## Checks / safety\n\n- Disallows skill IDs containing `/..` or leading `/`.\n- Disallows source `idPrefix` values ending with `/`.\n- Verifies `SKILL.md` for discovered and explicit skills.\n- Fails on duplicate IDs across sources.\n- Preserves symlinks that stay inside a declared source root and drops escaping or dangling symlinks when materializing bundles.\n- Rejects `..` traversal in source `subdir` and explicit skill `path` values.\n- Rejects unknown registry fields, unsafe manifest names and subdirectories, unsupported lock versions, and manifest/lock source drift.\n- Caps recursion at 100 levels when maxDepth is null to guard against symlink loops.\n- Passes destinations as JSON data; shell metacharacters and command-substitution syntax are not evaluated as shell code.\n- Tree synchronization validates destination ownership before using `rsync --delete`; managed destinations carry a `.agent-skills-managed.json` marker.\n- Local destinations are resolved beneath `AGENT_SKILLS_ROOT` (or the current directory) and paths that escape that root are rejected.\n\n## Breaking changes\n\n### Managed destination ownership\n\nTree synchronization now requires a valid `.agent-skills-managed.json` marker before replacing a non-empty destination. For an existing destination created by an older release, inspect it first and set `AGENT_SKILLS_FORCE=1` for the one-time takeover.\n\n### filter.maxDepth default changed from 1 to null\n\nSkill discovery now recurses into nested directories by default. If your source layout relies on flat-only discovery (one level of directories under the source root), add `filter.maxDepth = 1;` to your source configuration:\n\n```nix\nsources.my-skills = {\n  path = ./skills;\n  filter.maxDepth = 1;  # restore flat-only behavior\n};\n```\n"}