Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
158 changes: 158 additions & 0 deletions .claude/skills/derivation-to-flake/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
---
name: derivation-to-flake
description: >-
Extract an in-tree Nix package/derivation into its own flake-parts sub-flake (under
flakes/<name>/) that the root flake consumes via a relative-path input — the stepping
stone to later splitting it into a separate repo with a one-line URL swap. Use this skill
whenever the user wants to modularize or split a Nix flake or reorganize packages:
"turn this derivation into its own flake", "extract <pkg> into a sub-flake", "make <pkg>
a standalone / in-repo flake", "split pkgs/<x> out", "prep <pkg> to move to its own repo",
"modularize the flake", or is moving things from pkgs/ into flakes/. Covers the whole
procedure end to end — scaffold, wire the root (relative-path input + follows), test
(standalone build + parity + flake check), adversarial quality review, and documentation.
Bundled bun/TypeScript scripts (inventory.ts, scaffold.ts, verify.ts) do the rote steps.
compatibility: Requires nix (flakes enabled), bun, and git. Assumes a flake-parts repo.
---

# derivation-to-flake

Extracts a package that currently lives in-tree (`pkgs/<name>.nix` or `pkgs/<name>/`) into a
**self-contained sub-flake** at `flakes/<name>/`, and rewires the root flake to consume it
through a **relative-path input**. One git tree still serves everything, but the package is now
independently buildable and lockable — so later promoting it to its own repository is just
changing `"./flakes/<name>"` → `"github:owner/<name>"`.

This is the repo's documented "Adding a Custom Package as a Sub-flake" pattern (see `AGENTS.md`).
Reach for it when a package warrants its own flake: it carries forked/patched source, you want it
standalone-buildable, or you intend to spin it out later. For a plain, tightly-coupled package
that will always live here, the in-tree `pkgs/<name>.nix` path is simpler — don't over-extract.

The rote, deterministic steps are bun scripts under `scripts/` (run them from anywhere inside the
repo — they walk up to `flake.nix`). The judgment-heavy steps (the root AST edits, the quality
review) stay with you, guided by what the scripts print.

## The procedure

### 1. Inventory — know what you're touching
```
bun .claude/skills/derivation-to-flake/scripts/inventory.ts <name>
```
Reports where the package is defined and **every** reference to it (the consumers you'll repoint:
typically `modules/packages.nix`, an `overlays/<name>.nix`, and any `pkgs.<name>` usage). Read this
before changing anything so nothing is missed.

### 2. Scaffold the sub-flake
```
bun .claude/skills/derivation-to-flake/scripts/scaffold.ts <name> [--from <path>] [--systems a,b,c]
```
Creates `flakes/<name>/` with a flake-parts `flake.nix` (exposing `packages.<system>.<name>` and
`packages.<system>.default`), copies the package files in (`pkgs/<name>.nix` → `package.nix`; a
directory is copied whole so patches/README come along), **`git add`s** them, and runs
`nix flake lock`. It then prints the exact root edits for the next step. It does **not** delete the
original or edit the root — those are deliberate, reviewed edits you make by hand.

Match `--systems` to the package's actual support: check its `meta.platforms` and pass only what it
builds. A darwin-only package (e.g. a prebuilt `*-darwin` binary) should be `--systems aarch64-darwin`
— otherwise the sub-flake advertises Linux outputs that fail to build (and `nix flake check` on a Mac
won't catch it, since it omits incompatible systems). The default covers darwin + the two common Linux.

> Why `git add` matters: Nix only sees git-tracked files in a flake's source tree. An untracked
> `flakes/<name>/flake.nix` evaluates to "does not exist". The scaffold tracks them for you; if you
> hand-create files, track them before building.

### 3. Test the sub-flake on its own
```
nix build ./flakes/<name>#<name> # or .#packages.<system>.<name>
./result/bin/<binary> --version # whatever proves it actually works
```
Confirm it builds and runs in isolation before wiring it into the root — that isolates "is the
sub-flake correct?" from "is the consumption correct?".

### 4. Wire the root flake (manual — these are AST edits)
The scaffold prints these tailored to `<name>`; the shape is:

- **`flake.nix`** — add the relative-path input. `follows` makes the parent build the package
against the *parent's* nixpkgs (no second nixpkgs in the closure):
```nix
<name> = {
url = "./flakes/<name>";
inputs.nixpkgs.follows = "nixpkgs";
inputs.flake-parts.follows = "flake-parts";
};
```
- **`modules/packages.nix`** — re-export from the input instead of `callPackage` (perSystem needs
the `system` arg). For systems outside the root's `systems` list, re-export via a `flake.packages`
block:
```nix
# in perSystem = { pkgs, system, ... }:
<name> = inputs.<name>.packages.${system}.<name>;

# for the extra systems the sub-flake builds but the root doesn't list:
flake.packages = builtins.listToAttrs (map (system: {
name = system;
value.<name> = inputs.<name>.packages.${system}.<name>;
}) [ "aarch64-linux" "x86_64-linux" ]);
```
- **`modules/overlays.nix`** — if a host needs `pkgs.<name>`, define the overlay **inline** here.
Overlays are pure `final: prev:` functions and can't import `inputs`, so close over the module's
`inputs` (and read the system off `prev`):
```nix
{ inputs, ... }:
{
flake.overlays.<name> = _final: prev: {
<name> = inputs.<name>.packages.${prev.stdenv.hostPlatform.system}.<name>;
};
}
```
- **Remove the originals** once consumers are repointed: `git rm -r pkgs/<name>(.nix) overlays/<name>.nix`.
- **`nix flake lock`** to add the `<name>` input to the root `flake.lock`.

### 5. Verify the whole thing
```
bun .claude/skills/derivation-to-flake/scripts/verify.ts <name>
```
Builds the sub-flake standalone, builds the root's re-exported `.#packages.<system>.<name>` (proving
consumption), runs `nix flake check`, and scans for stale references to the old in-tree path. It
exits non-zero on any hard failure. A drv-parity note explains the expected outcome (see references).

### 6. Quality review — verify like an adversary
A green build doesn't mean it's right. Review (ideally with independent subagents / a workflow, each
defaulting to skeptical) across these dimensions, then confirm each finding:
- **Flake correctness** — sub-flake `flake.nix` is valid flake-parts; the root input + `follows` are
correct; both `flake.lock`s are consistent (the `<name>` node is a relative path, nixpkgs/flake-parts
follow the root — no duplicate nixpkgs).
- **Consumption integrity** — the overlay provides `pkgs.<name>` on every relevant system; the
home-manager / host consumers resolve; **no dangling references** to the old `pkgs/<name>` or
`overlays/<name>.nix` remain anywhere.
- **Docs & pattern** — the repo's guide (`AGENTS.md`/`CLAUDE.md`) still matches reality; the sub-flake
README is accurate; the "swap to github later" story actually holds.

### 7. Document
- Give the sub-flake a `README.md` describing its outputs and how a parent consumes it (relative-path
input + `follows`), so it reads correctly once it's a separate repo.
- Keep the repo's contributor guide truthful: if you removed the in-tree path and the overlay file,
make sure `AGENTS.md`/`CLAUDE.md` documents the sub-flake pattern rather than a deleted file.

### 8. Use it / extract later
The relative-path input means the parent **picks up edits to the sub-flake automatically** on the
next evaluation — `nix flake update <name>` is a no-op until you swap the URL. When you're ready to
move it to its own repository: push `flakes/<name>/` somewhere and change the input URL to
`github:owner/<name>` (then `nix flake update <name>` becomes the way to advance the pin). Nothing
else about the consumers changes.

## Gotchas (the ones that bite)

- **Untracked = invisible.** `git add` the sub-flake before any `nix` command touches it.
- **Overlays can't see `inputs`.** Define the `<name>` overlay inline in `modules/overlays.nix`
(which receives `inputs`); don't try to import a separate `overlays/<name>.nix` that needs them.
- **`follows`, not a second nixpkgs.** Without `inputs.<name>.inputs.nixpkgs.follows = "nixpkgs"`,
you get a duplicate nixpkgs in the closure and possible version skew.
- **Root `systems` may be narrower than the sub-flake's.** The root's `perSystem` only covers its
listed systems; re-export the rest with the `flake.packages` block, or those outputs won't exist.
- **`flake check` won't catch a broken cross-system re-export** on a single-platform repo — it omits
incompatible systems. `verify.ts`/an explicit `nix eval .#packages.<linux>.<name>` will.
- **drv parity is informational.** The standalone sub-flake locks its own nixpkgs; the root build
uses the root's (via `follows`). The two drvs are often equal but need not be — see references.

See [references/patterns.md](references/patterns.md) for the deep relative-path-input semantics, the
drv-parity explanation, troubleshooting, and the worked `flakes/ccglass` example.
20 changes: 20 additions & 0 deletions .claude/skills/derivation-to-flake/evals/evals.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"skill_name": "derivation-to-flake",
"notes": "Run each prompt against a throwaway git worktree of this repo (git worktree add --detach) so the main tree stays clean. Grade with scripts/verify.ts <name> — it is effectively the rubric (standalone build + root build + flake check + no stale refs).",
"evals": [
{
"id": 1,
"name": "extract-iv-clean",
"prompt": "Extract the `iv` package out of pkgs/ into its own in-repo flake under flakes/iv, and rewire the root flake so it consumes that sub-flake instead of the in-tree pkgs/iv.nix. Keep all three systems (aarch64-darwin, aarch64-linux, x86_64-linux) building, and remove the old in-tree copy.",
"expected_output": "flakes/iv/{flake.nix,package.nix,flake.lock} created (flake-parts, 3 systems, packages.<sys>.{iv,default}); root flake.nix has inputs.iv = ./flakes/iv with nixpkgs/flake-parts follows; modules/packages.nix re-exports iv from the input; pkgs/iv.nix removed; nix flake check green; no stale pkgs/iv references.",
"files": []
},
{
"id": 2,
"name": "extract-kitten-for-future-repo",
"prompt": "I eventually want to move my `kitten` derivation to its own github repo. As a first step, turn it into a standalone flake inside this repo (under flakes/) that the root flake still consumes, so later I can just point the input at github. kitten is darwin-only.",
"expected_output": "flakes/kitten/ standalone flake (flake-parts) exposing packages.aarch64-darwin.{kitten,default}; root consumes it via a relative-path input with follows; pkgs/kitten.nix removed; the sub-flake builds standalone and the root still builds kitten; README/notes explain the later github URL swap.",
"files": []
}
]
}
99 changes: 99 additions & 0 deletions .claude/skills/derivation-to-flake/references/patterns.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
# derivation-to-flake — patterns, semantics, troubleshooting

Deep reference for the relative-path sub-flake pattern. Read this when something in the main
procedure is unclear or a verification step behaves unexpectedly.

## Why a relative-path input

`inputs.<name>.url = "./flakes/<name>";` is the key choice. On Nix ≥ 2.26 (verified on Determinate
Nix 2.34) a relative path input is resolved against the parent flake and, when both live in the same
git repo, is served from the **same source tree**. The lock entry is portable — no absolute paths,
no pinned rev:

```json
"<name>": { "locked": { "path": "./flakes/<name>", "type": "path" }, "original": { ... } }
```

Consequences worth internalizing:

- **Auto-pickup.** Because there's no pinned rev/narHash, the parent re-reads the sub-flake's source
on every evaluation. Edits under `flakes/<name>/` take effect on the next `nix build` /
`darwin-rebuild switch` with no lock bump. `nix flake update <name>` is a **no-op** for a path
input (empirically: the lock file is byte-identical before/after).
- **`follows` overrides the sub-flake's own lock.** With
`inputs.<name>.inputs.nixpkgs.follows = "nixpkgs"`, the parent builds `<name>` against the
**parent's** nixpkgs. The sub-flake's own `flake.lock` then only governs *standalone* builds
(`nix build ./flakes/<name>#...`). This is why you commit the sub-flake's lock (reproducible
standalone builds) even though the parent ignores it.
- **Extraction is a URL swap.** Move `flakes/<name>/` to its own repo and change the input to
`github:owner/<name>`. Consumers (`inputs.<name>.packages...`) are unchanged. *After* the swap,
`nix flake update <name>` becomes meaningful — it advances the pinned rev.

Alternative forms `path:./flakes/<name>` and `path:flakes/<name>` also work, but they copy the subdir
as a standalone path source rather than sharing the git tree. Prefer the bare relative form for the
clean extraction story.

## Git-tracking is mandatory

A flake only sees git-tracked files in its source tree. An untracked `flakes/<name>/flake.nix`
fails to evaluate (`error: ... does not exist`). `scaffold.ts` `git add`s for you; if you create or
move files by hand, `git add` them before any `nix` command. (A `git worktree` created from `HEAD`
won't contain uncommitted files either — relevant when testing in isolation.)

## drv parity (what `verify.ts` reports)

`verify.ts` builds the sub-flake standalone and the root's re-exported output, then compares
`drvPath`s:

- **Equal** — the sub-flake's own lock happens to resolve to the same nixpkgs closure as the root.
Common when both track `nixpkgs-unstable` and the relevant packages didn't change between revs.
- **Not equal** — expected and fine. The standalone build uses the sub-flake's pinned nixpkgs; the
root build uses the root's (via `follows`). Different nixpkgs → different `stdenv`/toolchain →
different derivation. This is **not** a failure; it's the whole point of `follows`. The hard
signals are: standalone builds, root builds, `nix flake check` passes, no stale refs.

## Re-exporting across systems

flake-parts `perSystem` only emits outputs for the systems in the **root** `systems` list. If the
root is `[ "aarch64-darwin" ]` but the sub-flake builds three systems, re-export the extras directly:

```nix
flake.packages = builtins.listToAttrs (map (system: {
name = system;
value.<name> = inputs.<name>.packages.${system}.<name>;
}) [ "aarch64-linux" "x86_64-linux" ]);
```

This merges cleanly with the perSystem-generated `flake.packages.<darwin>` (different system keys, or
different attr keys under the same system). Note `nix flake check` on a single-platform machine
**omits** incompatible systems, so a broken Linux re-export won't surface there — check it explicitly
with `nix eval .#packages.x86_64-linux.<name>.drvPath` (or trust `verify.ts`, which evals each).

## Troubleshooting

| Symptom | Cause | Fix |
| --- | --- | --- |
| `error: ... flakes/<name> does not exist` (or input not found) | sub-flake files not git-tracked | `git add flakes/<name>` |
| `error: attribute '<system>' missing` evaluating a re-export | the sub-flake doesn't expose that system | add it to the sub-flake's `systems`, or drop it from the re-export list |
| duplicate nixpkgs / version skew in the closure | missing `follows` | add `inputs.<name>.inputs.{nixpkgs,flake-parts}.follows` |
| overlay can't reference `inputs` | overlay imported from a separate file | define the overlay inline in `modules/overlays.nix` (it receives `inputs`) |
| `nix flake update <name>` seems to do nothing | it's a relative path input (no rev to bump) | expected — edits are picked up automatically; `update` only matters after a `github:` swap |
| build fails only in the sub-flake, not the old in-tree build | the sub-flake's fresh lock pulled a newer nixpkgs | `nix flake lock --update-input nixpkgs` in the sub-flake, or pin it; for the parent, `follows` already insulates it |
| stale `pkgs/<name>` / `overlays/<name>` references remain | consumer not repointed, or original not deleted | `git grep <name>`, repoint, `git rm` the originals |

## Worked example: `flakes/ccglass`

The pattern was first applied to `ccglass` (a forked/patched npm package compiled with
`bun build --compile`). End state:

- `flakes/ccglass/` — `flake.nix` (flake-parts, 3 systems) + `package.nix` + `fork.patch` + `README.md`
+ its own `flake.lock`.
- Root `flake.nix` — `inputs.ccglass.url = "./flakes/ccglass"` with nixpkgs/flake-parts `follows`.
- `modules/packages.nix` — `ccglass = inputs.ccglass.packages.${system}.ccglass;` (perSystem) plus the
two-Linux-system `flake.packages` block.
- `modules/overlays.nix` — inline `ccglass = _final: prev: { ccglass = inputs.ccglass.packages.${prev.stdenv.hostPlatform.system}.ccglass; };`.
- `pkgs/ccglass/` and `overlays/ccglass.nix` removed.

`verify.ts ccglass` is green: standalone build, root build (same drv here), `nix flake check`, no
stale refs. Its dedicated maintenance skill (`patch-ccglass`) builds the sub-flake directly. That
extraction is the reference implementation for everything above.
72 changes: 72 additions & 0 deletions .claude/skills/derivation-to-flake/scripts/inventory.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
#!/usr/bin/env bun
// derivation-to-flake / inventory — discover an in-tree package and everything that consumes it.
//
// bun inventory.ts <name>
//
// Read-only. Run from anywhere inside the target flake repo (walks up to flake.nix).
// Prints the package definition location, every reference to it, the likely edit
// targets, and the next command to run. Use this BEFORE scaffolding so you know
// exactly what will need repointing.
import { $ } from "bun";
import { existsSync } from "node:fs";
import { dirname, join } from "node:path";

const name = process.argv[2];
if (!name || name.startsWith("-")) {
console.error("usage: bun inventory.ts <name>");
process.exit(1);
}

function findFlakeRoot(start = process.cwd()): string {
let dir = start;
for (;;) {
if (existsSync(join(dir, "flake.nix"))) return dir;
const parent = dirname(dir);
if (parent === dir) {
console.error(`no flake.nix found walking up from ${start}`);
process.exit(1);
}
dir = parent;
}
}

const ROOT = findFlakeRoot();
const c = { grn: "\x1b[32m", ylw: "\x1b[33m", b: "\x1b[1m", dim: "\x1b[2m", x: "\x1b[0m" };
const hdr = (m: string) => console.log(`\n${c.b}== ${m} ==${c.x}`);

console.log(`repo: ${ROOT}`);

hdr(`package definition for "${name}"`);
const candidates = [
`pkgs/${name}.nix`,
`pkgs/${name}/package.nix`,
`pkgs/${name}/default.nix`,
`flakes/${name}/flake.nix`,
];
let found = "";
for (const rel of candidates) {
if (existsSync(join(ROOT, rel))) {
console.log(` ${c.grn}found${c.x} ${rel}`);
found ||= rel;
}
}
if (!found) console.log(` ${c.ylw}none found (tried pkgs/${name}.nix, pkgs/${name}/) — check the name${c.x}`);
else if (found.startsWith("flakes/")) console.log(` ${c.ylw}already a sub-flake — nothing to extract${c.x}`);

hdr(`references to "${name}" (consumers to repoint)`);
const refs = await $`git -C ${ROOT} grep -nI -w ${name} -- . ":(exclude)flake.lock"`.nothrow().quiet();
const refsOut = refs.stdout.toString().trim();
console.log(refsOut || " (no references found)");

hdr("likely edit targets");
for (const f of ["flake.nix", "modules/packages.nix", "modules/overlays.nix", `overlays/${name}.nix`]) {
if (!existsSync(join(ROOT, f))) continue;
const hit = (await $`git -C ${ROOT} grep -nI -w ${name} -- ${f}`.nothrow().quiet()).stdout.toString().trim();
console.log(` • ${f}${hit ? ` ${c.dim}(mentions ${name})${c.x}` : ""}`);
}

hdr("next");
const here = ".claude/skills/derivation-to-flake/scripts";
console.log(` bun ${here}/scaffold.ts ${name} # create flakes/${name}/ from the in-tree package`);
console.log(` …then repoint the consumers above, delete the in-tree copy, lock, and:`);
console.log(` bun ${here}/verify.ts ${name} # build + parity + flake check + stale-ref scan`);
Loading