From 33d008cce3141de597e98109dd3643103fd95310 Mon Sep 17 00:00:00 2001 From: Kris Williams <115474+kriswill@users.noreply.github.com> Date: Thu, 4 Jun 2026 18:22:31 -0700 Subject: [PATCH 1/3] refactor(ccglass): extract into a standalone sub-flake consumed by the root Move the ccglass package out of pkgs/ into a self-contained flake at flakes/ccglass (flake.nix via flake-parts, exposing packages..{ccglass, default} for aarch64-darwin + aarch64-linux + x86_64-linux). The root flake now consumes it through a relative-path input: inputs.ccglass.url = "./flakes/ccglass"; # + nixpkgs/flake-parts follows modules/packages.nix re-exports it, and modules/overlays.nix defines the ccglass overlay inline (closing over inputs) so pkgs.ccglass on the darwin host comes from the sub-flake. overlays/ccglass.nix and pkgs/ccglass/ are removed. A reusable extraction pattern: one git tree serves both, and moving ccglass to its own repo later is just swapping the input URL to github:. Documented in AGENTS.md ("Adding a Custom Package as a Sub-flake"). The patch-ccglass skill is repointed at flakes/ccglass and builds the sub-flake directly. Verified: sub-flake builds standalone (.#ccglass / .#default / all 3 systems), root builds the same drv via the input, nix flake check green, skill prepare+verify pass. --- .claude/skills/patch-ccglass/SKILL.md | 14 +++--- .claude/skills/patch-ccglass/driver.ts | 19 +++---- AGENTS.md | 12 +++++ flake.lock | 20 ++++++++ flake.nix | 7 +++ {pkgs => flakes}/ccglass/README.md | 69 ++++++++++++++++++-------- flakes/ccglass/flake.lock | 61 +++++++++++++++++++++++ flakes/ccglass/flake.nix | 27 ++++++++++ {pkgs => flakes}/ccglass/fork.patch | 0 {pkgs => flakes}/ccglass/package.nix | 0 modules/overlays.nix | 8 ++- modules/packages.nix | 12 ++--- overlays/ccglass.nix | 3 -- 13 files changed, 206 insertions(+), 46 deletions(-) rename {pkgs => flakes}/ccglass/README.md (57%) create mode 100644 flakes/ccglass/flake.lock create mode 100644 flakes/ccglass/flake.nix rename {pkgs => flakes}/ccglass/fork.patch (100%) rename {pkgs => flakes}/ccglass/package.nix (100%) delete mode 100644 overlays/ccglass.nix diff --git a/.claude/skills/patch-ccglass/SKILL.md b/.claude/skills/patch-ccglass/SKILL.md index 7f163888..91a97b6d 100644 --- a/.claude/skills/patch-ccglass/SKILL.md +++ b/.claude/skills/patch-ccglass/SKILL.md @@ -5,10 +5,10 @@ description: Update or fix the ccglass Nix derivation for a new upstream release # patch-ccglass -Maintains [`pkgs/ccglass`](pkgs/ccglass) — a `ccglass` derivation built into a single +Maintains [`flakes/ccglass`](flakes/ccglass) — a `ccglass` derivation built into a single standalone binary with `bun build --compile`. Because a compiled binary can't do the script-relative disk reads upstream relies on, the package carries a **maintained fork** -([`pkgs/ccglass/fork.patch`](pkgs/ccglass/fork.patch)). On every upstream release that patch +([`flakes/ccglass/fork.patch`](flakes/ccglass/fork.patch)). On every upstream release that patch must be re-checked, and the build re-verified. The whole workflow is driven by **[`driver.ts`](.claude/skills/patch-ccglass/driver.ts)** (bun + TypeScript). Paths below are @@ -17,7 +17,7 @@ x86_64-linux** (`bun build --compile` emits a native binary per system); `verify current system. > Background on the three fork edits and the wiring is in -> [`pkgs/ccglass/README.md`](pkgs/ccglass/README.md). This skill is the *update procedure*. +> [`flakes/ccglass/README.md`](flakes/ccglass/README.md). This skill is the *update procedure*. ## Prerequisites @@ -56,7 +56,7 @@ Pass an explicit tag to target a specific release: `… prepare v1.2.0`. bun .claude/skills/patch-ccglass/driver.ts verify ``` -`verify` runs `nix build .#packages..ccglass` (out-link under `$TMPDIR`, not the repo) +`verify` runs `nix build ./flakes/ccglass#packages..ccglass` (out-link under `$TMPDIR`, not the repo) and then asserts all three patched behaviors: - `ccglass --version` → prints the version (Edit A: didn't crash reading `../package.json`). - `ccglass __mcp__` over stdio → `initialize` + `tools/list` return the 4 tools (Edits B/C). @@ -92,10 +92,10 @@ regenerate and re-verify: Then: ```bash -bun .claude/skills/patch-ccglass/driver.ts regen-patch # writes pkgs/ccglass/fork.patch +bun .claude/skills/patch-ccglass/driver.ts regen-patch # writes flakes/ccglass/fork.patch ``` -…and update `version` in [`pkgs/ccglass/package.nix`](pkgs/ccglass/package.nix) (and the +…and update `version` in [`flakes/ccglass/package.nix`](flakes/ccglass/package.nix) (and the hardcoded `VERSION` in the patch) to the new version, then re-run `verify`. ## Troubleshooting (errors actually hit) @@ -103,7 +103,7 @@ hardcoded `VERSION` in the patch) to the new version, then re-run `verify`. - **`build failed … hash mismatch`** — happens after a version/tag bump (the pinned `src.hash` / `npmDepsHash` no longer match). `verify` parses the error and prints the exact lines to paste, e.g. `set src.hash = "sha256-…";` and `set npmDepsHash = "sha256-…";`. Update - [`pkgs/ccglass/package.nix`](pkgs/ccglass/package.nix) and re-run `verify`. (Set them to + [`flakes/ccglass/package.nix`](flakes/ccglass/package.nix) and re-run `verify`. (Set them to `lib.fakeHash` first if starting fresh; src mismatch surfaces before the npm-deps one.) - **`fork.patch does NOT apply`** — upstream moved the lines. Re-author (above). `prepare`'s hazard scan tells you whether the targets still exist or changed shape. diff --git a/.claude/skills/patch-ccglass/driver.ts b/.claude/skills/patch-ccglass/driver.ts index 9cc5143e..deb4c5f5 100755 --- a/.claude/skills/patch-ccglass/driver.ts +++ b/.claude/skills/patch-ccglass/driver.ts @@ -1,11 +1,11 @@ #!/usr/bin/env bun -// patch-ccglass driver — maintenance harness for the pkgs/ccglass derivation. +// patch-ccglass driver — maintenance harness for the flakes/ccglass derivation. // // bun driver.ts latest-tag print the newest upstream git tag // bun driver.ts prepare [TAG] clone TAG (default: latest), scan for bun-compile // hazards, and check whether fork.patch still applies -// bun driver.ts regen-patch write `git -C diff` to pkgs/ccglass/fork.patch -// bun driver.ts verify nix build the flake output + run version/MCP/dashboard checks +// bun driver.ts regen-patch write `git -C diff` to flakes/ccglass/fork.patch +// bun driver.ts verify nix build the sub-flake + run version/MCP/dashboard checks // bun driver.ts all [TAG] prepare, then verify (only if the patch applies) // // Exposed for aarch64-darwin, aarch64-linux, x86_64-linux; `verify` auto-detects the current system. @@ -18,7 +18,8 @@ const OWNER = "jianshuo"; const REPO = "ccglass"; const REPO_URL = `https://github.com/${OWNER}/${REPO}`; const ROOT = resolve(import.meta.dir, "../../.."); // repo root from .claude/skills/patch-ccglass/ -const FORK_PATCH = join(ROOT, "pkgs/ccglass/fork.patch"); +const FLAKE_DIR = join(ROOT, "flakes/ccglass"); // the standalone ccglass sub-flake +const FORK_PATCH = join(FLAKE_DIR, "fork.patch"); const WORK = join(tmpdir(), "patch-ccglass"); const SELF = ".claude/skills/patch-ccglass/driver.ts"; @@ -75,7 +76,7 @@ async function prepare(tagArg?: string): Promise<{ clone: string; tag: string; a await scan("MCP subprocess spawn (must use the __mcp__ sentinel)", String.raw`process\.execPath|mcp\.js`); await scan("other import.meta/__dirname reads (review for new hazards)", String.raw`fileURLToPath\(import\.meta\.url\)|import\.meta\.dir`); - hdr("does pkgs/ccglass/fork.patch still apply?"); + hdr("does flakes/ccglass/fork.patch still apply?"); const applies = await patchApplies(clone); if (applies) { ok(`fork.patch applies cleanly to ${tag}`); @@ -93,7 +94,7 @@ async function prepare(tagArg?: string): Promise<{ clone: string; tag: string; a " 2) re-apply the 3 edits by hand against the new source", ` 3) bun ${SELF} regen-patch ${clone}`, ` 4) set the hardcoded VERSION in fork.patch to "${v}"`, - ` 5) bump 'version' in pkgs/ccglass/package.nix to "${v}"`, + ` 5) bump 'version' in flakes/ccglass/package.nix to "${v}"`, ` 6) bun ${SELF} verify # fill the two hashes when it reports a mismatch`, ].join("\n"), ); @@ -197,10 +198,10 @@ async function checkDashboard(bin: string): Promise { async function verify() { const system = (await $`nix eval --impure --raw --expr builtins.currentSystem`.text()).trim(); - const attr = `.#packages.${system}.ccglass`; + const attr = `${FLAKE_DIR}#packages.${system}.ccglass`; hdr(`nix build ${attr}`); const outLink = join(WORK, "result"); - const build = await $`nix build ${attr} --out-link ${outLink}`.cwd(ROOT).nothrow().quiet(); + const build = await $`nix build ${attr} --out-link ${outLink}`.nothrow().quiet(); if (build.exitCode !== 0) { const log = build.stdout.toString() + build.stderr.toString(); err("build failed"); @@ -208,7 +209,7 @@ async function verify() { ...log.matchAll(/hash mismatch in fixed-output derivation '([^']+)':\s*\n\s*specified: (\S+)\s*\n\s*got:\s*(\S+)/g), ]; if (mism.length) { - hdr("hash mismatch — update pkgs/ccglass/package.nix, then re-run verify:"); + hdr("hash mismatch — update flakes/ccglass/package.nix, then re-run verify:"); for (const [, drv, spec, got] of mism) { const field = drv.includes("npm-deps") ? "npmDepsHash" : "src.hash"; console.log(` set ${field} = "${got}"; (was ${spec})`); diff --git a/AGENTS.md b/AGENTS.md index 84664385..cd1955f5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -80,6 +80,7 @@ Platform: aarch64-darwin (Apple Silicon only). Flake-based, using flake-parts + │ ├── nvim/ # Neovim Lua configuration │ └── tmux/ # Tmux configuration ├── pkgs/ # Custom package definitions (*.nix files or subdirectories) +├── flakes/ # Self-contained sub-flakes consumed by the root via relative-path inputs (e.g. flakes/ccglass) ├── overlays/ # Nixpkgs overlays (makes custom packages available) ├── lib/ # Pure lib helpers (mkProgramOption, kanagawa) — outside modules/ so import-tree skips them └── scripts/ # Helper scripts for package updates @@ -105,6 +106,17 @@ Pure helpers live in `lib/default.nix`: `mkProgramOption` and `kanagawa`. They'r 3. To make it available to hosts, create `overlays/.nix` and register it in `modules/overlays.nix` (`flake.overlays.`) 4. If unfree: add a `nixpkgs.config.allowUnfreePredicate` entry in `modules/darwin/core.nix` +**Adding a Custom Package as a Sub-flake (extraction pattern):** + +For a package that warrants its own flake — forked/patched source, standalone-buildable, or destined to become a separate repo — put it under `flakes//` instead of `pkgs/`: + +1. Create `flakes//{flake.nix,package.nix,…}`. `flake.nix` uses flake-parts and exposes `packages..` (+ `default`). **`git add` it** — sub-flake files must be git-tracked to be seen. +2. Add a relative-path input in `flake.nix`: `inputs..url = "./flakes/";` with `inputs..inputs.{nixpkgs,flake-parts}.follows` to dedupe nixpkgs. +3. Re-export in `modules/packages.nix`: ` = inputs..packages.${system}.;` (plus a `flake.packages` block for systems outside the root `systems` list). +4. If a host needs it on `pkgs`, add an **inline** overlay in `modules/overlays.nix` (which receives `inputs`): ` = _final: prev: { = inputs..packages.${prev.stdenv.hostPlatform.system}.; };`. + +Later extraction to a separate repo is just swapping the input URL `"./flakes/"` → `"github:owner/"`. See `flakes/ccglass/` for a worked example. + **Symlinked Configs:** - Live config files in `config/` directory diff --git a/flake.lock b/flake.lock index 7f60228b..4451b184 100644 --- a/flake.lock +++ b/flake.lock @@ -1,5 +1,24 @@ { "nodes": { + "ccglass": { + "inputs": { + "flake-parts": [ + "flake-parts" + ], + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "path": "./flakes/ccglass", + "type": "path" + }, + "original": { + "path": "./flakes/ccglass", + "type": "path" + }, + "parent": [] + }, "darwin": { "inputs": { "nixpkgs": [ @@ -122,6 +141,7 @@ }, "root": { "inputs": { + "ccglass": "ccglass", "darwin": "darwin", "faster-piper-yazi": "faster-piper-yazi", "flake-parts": "flake-parts", diff --git a/flake.nix b/flake.nix index b9e24ef1..f46a71cb 100644 --- a/flake.nix +++ b/flake.nix @@ -23,5 +23,12 @@ url = "github:yazi-rs/plugins"; flake = false; }; + # ccglass lives in its own flake (./flakes/ccglass) — a relative-path input, so one + # git tree serves both and extracting it to a separate repo later is just a URL swap. + ccglass = { + url = "./flakes/ccglass"; + inputs.nixpkgs.follows = "nixpkgs"; + inputs.flake-parts.follows = "flake-parts"; + }; }; } diff --git a/pkgs/ccglass/README.md b/flakes/ccglass/README.md similarity index 57% rename from pkgs/ccglass/README.md rename to flakes/ccglass/README.md index f5987fcb..0488c0d7 100644 --- a/pkgs/ccglass/README.md +++ b/flakes/ccglass/README.md @@ -11,6 +11,7 @@ executable** with `bun build --compile`, so nothing depends on a global `bun -g | File | Purpose | | --- | --- | +| [`flake.nix`](flake.nix) | Standalone flake (flake-parts) exposing the `ccglass` package per system. | | [`package.nix`](package.nix) | The derivation: `buildNpmPackage` for reproducible deps + `bun build --compile`. | | [`fork.patch`](fork.patch) | Source patch required to survive compilation (see below). | @@ -22,7 +23,7 @@ executable** with `bun build --compile`, so nothing depends on a global `bun -g 2. `fork.patch` is applied (`patches = [ ./fork.patch ]`). 3. A custom `buildPhase` runs `bun build --compile ./bin/ccglass.js --outfile ccglass`, bundling the JS module graph + the three deps (`@modelcontextprotocol/sdk`, `cross-spawn`, `zod`) and the - bun runtime into one ~64 MB Mach-O arm64 binary. + bun runtime into one ~64 MB native binary (Mach-O on darwin, ELF on linux). 4. A custom `installPhase` installs only that binary to `$out/bin/ccglass`. ## Why the fork (`fork.patch`) @@ -40,34 +41,62 @@ reads). The patch makes three behaviors survive compilation: This is a **maintained fork** — the patch is pinned to upstream's source shape and must be re-checked on every version bump. -## How it's wired into the flake +## Flake outputs -- **Package output** — registered in [`modules/packages.nix`](../../modules/packages.nix): - `ccglass = pkgs.callPackage ../pkgs/ccglass/package.nix { };` - → build with `nix build .#packages.aarch64-darwin.ccglass`. -- **Overlay** — [`overlays/ccglass.nix`](../../overlays/ccglass.nix), registered in - [`modules/overlays.nix`](../../modules/overlays.nix), so `pkgs.ccglass` is available to hosts. -- **Consumed by** the `claude-account-selector` home-manager module - ([`default.nix`](../../modules/home-manager/claude-account-selector/default.nix)), which adds - `pkgs.ccglass` to `home.packages`. Its `wrapper.zsh` invokes `command ccglass`, which now resolves - to the Nix store binary instead of `~/.bun/bin/ccglass`. +This directory is a **standalone flake** (`flake.nix`, flake-parts). For `aarch64-darwin`, +`aarch64-linux`, and `x86_64-linux` it exposes: + +- `packages..ccglass` — the compiled binary. +- `packages..default` — alias of `ccglass`. + +Build it on its own: + +```sh +nix build .#ccglass # or .#packages..ccglass +./result/bin/ccglass --version +``` + +## Consuming it from another flake + +The parent dotfiles flake references this sub-flake by **relative path**, so one git tree +serves both — and extracting it to its own repo later is just a URL swap to `github:…`: + +```nix +# parent flake.nix +inputs.ccglass.url = "./flakes/ccglass"; +inputs.ccglass.inputs.nixpkgs.follows = "nixpkgs"; +inputs.ccglass.inputs.flake-parts.follows = "flake-parts"; +``` + +The parent then uses `inputs.ccglass.packages.${system}.ccglass` in an overlay (so +`pkgs.ccglass` is available to home-manager) and re-exports it as a package output. Sub-flake +files must be **git-tracked** for the parent to see them. + +Two consequences of the relative-path + `follows` setup: + +- The parent builds ccglass against the **parent's** nixpkgs (via `follows`); this sub-flake's own + `flake.lock` only governs standalone `nix build` in this directory. +- The input has no pinned rev, so the parent picks up edits here **automatically** on the next + evaluation — `nix flake update ccglass` is a no-op until the input is swapped to a `github:` URL. ## Updating to a new ccglass version +The parent repo's `patch-ccglass` skill automates this end to end. Manually: + 1. Bump `version` in [`package.nix`](package.nix). -2. Set `src.hash` and `npmDepsHash` to `lib.fakeHash`, run - `nix build .#packages.aarch64-darwin.ccglass`, and copy the real `got:` hashes from the two - build errors (src first, then npm-deps). -3. Re-confirm the patch still applies — fetch the new tag and - `git apply --check -p1 pkgs/ccglass/fork.patch`. If it fails, re-apply the three edits above by - hand against the new source and regenerate (`git diff > fork.patch`). Also bump the hardcoded - `VERSION` string in the patch. -4. Re-run the regression checks below, then `darwin-rebuild switch --flake .`. +2. Set `src.hash` and `npmDepsHash` to `lib.fakeHash`, run `nix build .#ccglass`, and copy the + real `got:` hashes from the two build errors (src first, then npm-deps). +3. Re-confirm the patch still applies — fetch the new tag and `git apply --check -p1 fork.patch`. + If it fails, re-apply the three edits above by hand against the new source and regenerate + (`git diff > fork.patch`). Also bump the hardcoded `VERSION` string in the patch. +4. Re-run the regression checks below, then `darwin-rebuild switch --flake .` in the parent repo — + the relative-path input picks these edits up automatically. (Only after a future `github:` swap + does advancing the pin require `nix flake update ccglass` first.) ## Verifying a build ```sh -nix build .#packages.aarch64-darwin.ccglass +nix build .#ccglass # Edit A — must not crash on launch: ./result/bin/ccglass --version # -> 1.0.0 diff --git a/flakes/ccglass/flake.lock b/flakes/ccglass/flake.lock new file mode 100644 index 00000000..fa7ebbc5 --- /dev/null +++ b/flakes/ccglass/flake.lock @@ -0,0 +1,61 @@ +{ + "nodes": { + "flake-parts": { + "inputs": { + "nixpkgs-lib": "nixpkgs-lib" + }, + "locked": { + "lastModified": 1778716662, + "narHash": "sha256-m1Yf0wZ8j1OHjTc2UwHwyQRSnNeSgLJOd7q5Y45hzi4=", + "owner": "hercules-ci", + "repo": "flake-parts", + "rev": "f7c1a2d347e4c52d5fb8d10cb4d94b5884e546fb", + "type": "github" + }, + "original": { + "owner": "hercules-ci", + "repo": "flake-parts", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1780365719, + "narHash": "sha256-QfWfccTN+70ZQ4m2qlU9PiKfz2Yppq94058iJyARNwc=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "ffa10e26ae11d676b2db836259889f1f571cb14f", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixpkgs-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs-lib": { + "locked": { + "lastModified": 1777168982, + "narHash": "sha256-GOkGPcboWE9BmGCRMLX3worL4EMnsnG8MyKmXNeYuhQ=", + "owner": "nix-community", + "repo": "nixpkgs.lib", + "rev": "f5901329dade4a6ea039af1433fb087bd9c1fe14", + "type": "github" + }, + "original": { + "owner": "nix-community", + "repo": "nixpkgs.lib", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-parts": "flake-parts", + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flakes/ccglass/flake.nix b/flakes/ccglass/flake.nix new file mode 100644 index 00000000..e0a8955c --- /dev/null +++ b/flakes/ccglass/flake.nix @@ -0,0 +1,27 @@ +{ + description = "ccglass — local logging reverse-proxy + web dashboard for coding agents, built as a standalone binary"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable"; + flake-parts.url = "github:hercules-ci/flake-parts"; + }; + + outputs = + inputs@{ flake-parts, ... }: + flake-parts.lib.mkFlake { inherit inputs; } { + systems = [ + "aarch64-darwin" + "aarch64-linux" + "x86_64-linux" + ]; + + perSystem = + { pkgs, config, ... }: + { + packages = { + ccglass = pkgs.callPackage ./package.nix { }; + default = config.packages.ccglass; + }; + }; + }; +} diff --git a/pkgs/ccglass/fork.patch b/flakes/ccglass/fork.patch similarity index 100% rename from pkgs/ccglass/fork.patch rename to flakes/ccglass/fork.patch diff --git a/pkgs/ccglass/package.nix b/flakes/ccglass/package.nix similarity index 100% rename from pkgs/ccglass/package.nix rename to flakes/ccglass/package.nix diff --git a/modules/overlays.nix b/modules/overlays.nix index a8065a6c..95974913 100644 --- a/modules/overlays.nix +++ b/modules/overlays.nix @@ -1,9 +1,15 @@ # Nixpkgs overlays, exposed as flake outputs and consumed by the host modules # via `nixpkgs.overlays = builtins.attrValues config.flake.overlays`. +{ inputs, ... }: { flake.overlays = { kitten = import ../overlays/kitten.nix; direnv = import ../overlays/direnv.nix; - ccglass = import ../overlays/ccglass.nix; + # ccglass comes from its own flake (./flakes/ccglass), not an in-tree package. + # Overlays are pure final/prev functions, so we close over `inputs` here rather + # than importing a separate file. The system is read off prev at eval time. + ccglass = _final: prev: { + ccglass = inputs.ccglass.packages.${prev.stdenv.hostPlatform.system}.ccglass; + }; }; } diff --git a/modules/packages.nix b/modules/packages.nix index 30ec0d62..a2bf1c56 100644 --- a/modules/packages.nix +++ b/modules/packages.nix @@ -2,23 +2,23 @@ { inputs, ... }: { perSystem = - { pkgs, ... }: + { pkgs, system, ... }: { packages = { kitten = pkgs.callPackage ../pkgs/kitten.nix { }; iv = pkgs.callPackage ../pkgs/iv.nix { }; - ccglass = pkgs.callPackage ../pkgs/ccglass/package.nix { }; + # ccglass is built by its own flake (./flakes/ccglass); re-export it here. + ccglass = inputs.ccglass.packages.${system}.ccglass; }; }; - # ccglass also builds on Linux (pure-JS deps, `bun build --compile` → ELF). The - # repo's `systems` list is aarch64-darwin only, so expose just the extra ccglass - # outputs directly rather than widening every perSystem output onto Linux. + # Re-export the sub-flake's Linux ccglass outputs (the root `systems` list is + # aarch64-darwin only; the ccglass sub-flake builds all three systems). flake.packages = builtins.listToAttrs ( map (system: { name = system; - value.ccglass = inputs.nixpkgs.legacyPackages.${system}.callPackage ../pkgs/ccglass/package.nix { }; + value.ccglass = inputs.ccglass.packages.${system}.ccglass; }) [ "aarch64-linux" diff --git a/overlays/ccglass.nix b/overlays/ccglass.nix deleted file mode 100644 index 4e58c7d8..00000000 --- a/overlays/ccglass.nix +++ /dev/null @@ -1,3 +0,0 @@ -_final: prev: { - ccglass = prev.callPackage ../pkgs/ccglass/package.nix { }; -} From 0c0a25ec2c3d3a82f7da4354c96c0eea73014bf2 Mon Sep 17 00:00:00 2001 From: Kris Williams <115474+kriswill@users.noreply.github.com> Date: Thu, 4 Jun 2026 19:21:00 -0700 Subject: [PATCH 2/3] feat(skills): add derivation-to-flake skill Captures the in-repo derivation-extraction workflow (the ccglass pattern) as a reusable skill: scaffold a package into flakes//, wire the root via a relative-path input + follows, verify (standalone build + parity + flake check + stale-ref scan), adversarial quality review, and documentation. Bundled bun/TypeScript scripts (inventory.ts, scaffold.ts, verify.ts) do the rote steps. Validated: scripts run green against the repo; a worktree dry-run extracted `iv` into a valid 3-system flake; a with-skill vs no-skill benchmark (iv + kitten in isolated worktrees) confirmed the skill standardizes output (README, doc updates, all-systems re-export); description triggering scored 100% train/test on a 20-query should/shouldn't set. --- .claude/skills/derivation-to-flake/SKILL.md | 158 ++++++++++++++++++ .../derivation-to-flake/evals/evals.json | 20 +++ .../references/patterns.md | 99 +++++++++++ .../derivation-to-flake/scripts/inventory.ts | 72 ++++++++ .../derivation-to-flake/scripts/scaffold.ts | 154 +++++++++++++++++ .../derivation-to-flake/scripts/verify.ts | 105 ++++++++++++ 6 files changed, 608 insertions(+) create mode 100644 .claude/skills/derivation-to-flake/SKILL.md create mode 100644 .claude/skills/derivation-to-flake/evals/evals.json create mode 100644 .claude/skills/derivation-to-flake/references/patterns.md create mode 100755 .claude/skills/derivation-to-flake/scripts/inventory.ts create mode 100755 .claude/skills/derivation-to-flake/scripts/scaffold.ts create mode 100755 .claude/skills/derivation-to-flake/scripts/verify.ts diff --git a/.claude/skills/derivation-to-flake/SKILL.md b/.claude/skills/derivation-to-flake/SKILL.md new file mode 100644 index 00000000..4953b808 --- /dev/null +++ b/.claude/skills/derivation-to-flake/SKILL.md @@ -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//) 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 into a sub-flake", "make + a standalone / in-repo flake", "split pkgs/ out", "prep 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/.nix` or `pkgs//`) into a +**self-contained sub-flake** at `flakes//`, 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/"` → `"github:owner/"`. + +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/.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 +``` +Reports where the package is defined and **every** reference to it (the consumers you'll repoint: +typically `modules/packages.nix`, an `overlays/.nix`, and any `pkgs.` usage). Read this +before changing anything so nothing is missed. + +### 2. Scaffold the sub-flake +``` +bun .claude/skills/derivation-to-flake/scripts/scaffold.ts [--from ] [--systems a,b,c] +``` +Creates `flakes//` with a flake-parts `flake.nix` (exposing `packages..` and +`packages..default`), copies the package files in (`pkgs/.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//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/# # or .#packages.. +./result/bin/ --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 ``; 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 + = { + url = "./flakes/"; + 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, ... }: + = inputs..packages.${system}.; + + # for the extra systems the sub-flake builds but the root doesn't list: + flake.packages = builtins.listToAttrs (map (system: { + name = system; + value. = inputs..packages.${system}.; + }) [ "aarch64-linux" "x86_64-linux" ]); + ``` +- **`modules/overlays.nix`** — if a host needs `pkgs.`, 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. = _final: prev: { + = inputs..packages.${prev.stdenv.hostPlatform.system}.; + }; + } + ``` +- **Remove the originals** once consumers are repointed: `git rm -r pkgs/(.nix) overlays/.nix`. +- **`nix flake lock`** to add the `` input to the root `flake.lock`. + +### 5. Verify the whole thing +``` +bun .claude/skills/derivation-to-flake/scripts/verify.ts +``` +Builds the sub-flake standalone, builds the root's re-exported `.#packages..` (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 `` node is a relative path, nixpkgs/flake-parts + follow the root — no duplicate nixpkgs). +- **Consumption integrity** — the overlay provides `pkgs.` on every relevant system; the + home-manager / host consumers resolve; **no dangling references** to the old `pkgs/` or + `overlays/.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 ` is a no-op until you swap the URL. When you're ready to +move it to its own repository: push `flakes//` somewhere and change the input URL to +`github:owner/` (then `nix flake update ` 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 `` overlay inline in `modules/overlays.nix` + (which receives `inputs`); don't try to import a separate `overlays/.nix` that needs them. +- **`follows`, not a second nixpkgs.** Without `inputs..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..` 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. diff --git a/.claude/skills/derivation-to-flake/evals/evals.json b/.claude/skills/derivation-to-flake/evals/evals.json new file mode 100644 index 00000000..874f8b9c --- /dev/null +++ b/.claude/skills/derivation-to-flake/evals/evals.json @@ -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 — 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..{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": [] + } + ] +} diff --git a/.claude/skills/derivation-to-flake/references/patterns.md b/.claude/skills/derivation-to-flake/references/patterns.md new file mode 100644 index 00000000..abb5a4f6 --- /dev/null +++ b/.claude/skills/derivation-to-flake/references/patterns.md @@ -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..url = "./flakes/";` 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 +"": { "locked": { "path": "./flakes/", "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//` take effect on the next `nix build` / + `darwin-rebuild switch` with no lock bump. `nix flake update ` 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..inputs.nixpkgs.follows = "nixpkgs"`, the parent builds `` against the + **parent's** nixpkgs. The sub-flake's own `flake.lock` then only governs *standalone* builds + (`nix build ./flakes/#...`). 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//` to its own repo and change the input to + `github:owner/`. Consumers (`inputs..packages...`) are unchanged. *After* the swap, + `nix flake update ` becomes meaningful — it advances the pinned rev. + +Alternative forms `path:./flakes/` and `path:flakes/` 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//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. = inputs..packages.${system}.; +}) [ "aarch64-linux" "x86_64-linux" ]); +``` + +This merges cleanly with the perSystem-generated `flake.packages.` (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..drvPath` (or trust `verify.ts`, which evals each). + +## Troubleshooting + +| Symptom | Cause | Fix | +| --- | --- | --- | +| `error: ... flakes/ does not exist` (or input not found) | sub-flake files not git-tracked | `git add flakes/` | +| `error: attribute '' 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..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 ` 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/` / `overlays/` references remain | consumer not repointed, or original not deleted | `git grep `, 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. diff --git a/.claude/skills/derivation-to-flake/scripts/inventory.ts b/.claude/skills/derivation-to-flake/scripts/inventory.ts new file mode 100755 index 00000000..1ad2a9a9 --- /dev/null +++ b/.claude/skills/derivation-to-flake/scripts/inventory.ts @@ -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 +// +// 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 "); + 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`); diff --git a/.claude/skills/derivation-to-flake/scripts/scaffold.ts b/.claude/skills/derivation-to-flake/scripts/scaffold.ts new file mode 100755 index 00000000..6b7eb819 --- /dev/null +++ b/.claude/skills/derivation-to-flake/scripts/scaffold.ts @@ -0,0 +1,154 @@ +#!/usr/bin/env bun +// derivation-to-flake / scaffold — turn an in-tree package into a standalone sub-flake. +// +// bun scaffold.ts [--from ] [--systems a,b,c] [--desc "..."] +// +// Creates flakes// with a flake-parts flake.nix + the copied package files, +// git-adds them (REQUIRED — Nix won't see untracked sub-flake files), and runs +// `nix flake lock`. It deliberately does NOT delete the in-tree copy or edit the +// root flake: those are AST-level edits + consumer repointing that you do by hand, +// guided by the snippets this script prints. Run `verify.ts ` afterward. +import { $ } from "bun"; +import { existsSync, mkdirSync, cpSync, statSync, readdirSync } from "node:fs"; +import { dirname, join, relative } from "node:path"; + +const args = process.argv.slice(2); +const name = args[0]; +if (!name || name.startsWith("-")) { + console.error('usage: bun scaffold.ts [--from ] [--systems a,b,c] [--desc "..."]'); + process.exit(1); +} +const opt = (flag: string) => { + const i = args.indexOf(flag); + return i >= 0 ? args[i + 1] : undefined; +}; +const systems = (opt("--systems") || "aarch64-darwin,aarch64-linux,x86_64-linux") + .split(",").map((s) => s.trim()).filter(Boolean); +const desc = opt("--desc") || `${name} — packaged as a standalone flake`; + +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 above ${start}`); process.exit(1); } + dir = parent; + } +} +const ROOT = findFlakeRoot(); +const c = { grn: "\x1b[32m", ylw: "\x1b[33m", red: "\x1b[31m", b: "\x1b[1m", x: "\x1b[0m" }; +const ok = (m: string) => console.log(`${c.grn}✓${c.x} ${m}`); +const hdr = (m: string) => console.log(`\n${c.b}== ${m} ==${c.x}`); + +const dest = join(ROOT, "flakes", name); +if (existsSync(dest)) { console.error(`${c.red}✗${c.x} ${dest} already exists`); process.exit(1); } + +// Resolve the source package (file or directory). +let from = opt("--from"); +if (from && !from.startsWith("/")) from = join(ROOT, from); +if (!from) { + for (const cand of [`pkgs/${name}.nix`, `pkgs/${name}`]) { + if (existsSync(join(ROOT, cand))) { from = join(ROOT, cand); break; } + } +} +if (!from || !existsSync(from)) { + console.error(`${c.red}✗${c.x} no source found (tried pkgs/${name}.nix, pkgs/${name}/). Pass --from .`); + process.exit(1); +} +const fromRel = relative(ROOT, from); + +mkdirSync(dest, { recursive: true }); + +// Copy the source. A bare .nix becomes package.nix; a directory is copied +// verbatim (default.nix -> package.nix) so adjacent files (patches, README) come along. +if (statSync(from).isDirectory()) { + for (const f of readdirSync(from)) { + cpSync(join(from, f), join(dest, f === "default.nix" ? "package.nix" : f), { recursive: true }); + } + ok(`copied ${fromRel}/ -> flakes/${name}/`); +} else { + cpSync(from, join(dest, "package.nix")); + ok(`copied ${fromRel} -> flakes/${name}/package.nix`); +} + +// flake-parts flake.nix. `default = config.packages.` reuses the same +// derivation (no double-eval). Systems default to darwin + the two common linux. +const sysList = systems.map((s) => ` "${s}"`).join("\n"); +const flakeNix = `{ + description = "${desc}"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable"; + flake-parts.url = "github:hercules-ci/flake-parts"; + }; + + outputs = + inputs@{ flake-parts, ... }: + flake-parts.lib.mkFlake { inherit inputs; } { + systems = [ +${sysList} + ]; + + perSystem = + { pkgs, config, ... }: + { + packages = { + ${name} = pkgs.callPackage ./package.nix { }; + default = config.packages.${name}; + }; + }; + }; +} +`; +await Bun.write(join(dest, "flake.nix"), flakeNix); +ok(`wrote flakes/${name}/flake.nix (systems: ${systems.join(", ")})`); + +// Track + lock. Untracked files are invisible to Nix, so this must happen now. +await $`git -C ${ROOT} add flakes/${name}`.nothrow().quiet(); +ok(`git add flakes/${name} (sub-flake files must be tracked to be seen)`); +const lock = await $`nix flake lock ${dest}`.nothrow().quiet(); +if (lock.exitCode === 0) { + await $`git -C ${ROOT} add ${join("flakes", name, "flake.lock")}`.nothrow().quiet(); + ok("nix flake lock"); +} else { + console.log(`${c.ylw}! nix flake lock failed — run it manually:${c.x}\n${lock.stderr.toString().split("\n").slice(-8).join("\n")}`); +} + +const overlayFile = existsSync(join(ROOT, `overlays/${name}.nix`)) ? `overlays/${name}.nix` : ""; + +hdr("now wire the ROOT flake (manual — these are AST edits)"); +console.log(`1) flake.nix — add the relative-path input (extracting to a separate repo later + is just swapping this url to "github:owner/${name}"): + + ${name} = { + url = "./flakes/${name}"; + inputs.nixpkgs.follows = "nixpkgs"; + inputs.flake-parts.follows = "flake-parts"; + }; + +2) modules/packages.nix — re-export from the input instead of callPackage + (perSystem needs the \`system\` arg): + + ${name} = inputs.${name}.packages.\${system}.${name}; + + For systems outside the root \`systems\` list, re-export via flake.packages, e.g.: + + flake.packages = builtins.listToAttrs (map (system: { + name = system; + value.${name} = inputs.${name}.packages.\${system}.${name}; + }) [ "aarch64-linux" "x86_64-linux" ]); + +3) modules/overlays.nix — if a host needs \`pkgs.${name}\`, define the overlay INLINE + here (the module receives \`inputs\`; overlays are pure final/prev functions and + cannot import inputs from a separate file): + + ${name} = _final: prev: { + ${name} = inputs.${name}.packages.\${prev.stdenv.hostPlatform.system}.${name}; + }; + +4) remove the in-tree copy (and its old overlay) once consumers are repointed: + + git rm -r ${fromRel}${overlayFile ? ` ${overlayFile}` : ""} + +5) nix flake lock # adds the ${name} input to the root flake.lock +6) bun .claude/skills/derivation-to-flake/scripts/verify.ts ${name} # build + parity + flake check + stale-ref scan`); diff --git a/.claude/skills/derivation-to-flake/scripts/verify.ts b/.claude/skills/derivation-to-flake/scripts/verify.ts new file mode 100755 index 00000000..b87a9b4a --- /dev/null +++ b/.claude/skills/derivation-to-flake/scripts/verify.ts @@ -0,0 +1,105 @@ +#!/usr/bin/env bun +// derivation-to-flake / verify — prove the extraction is correct end to end. +// +// bun verify.ts [--no-check] +// +// Runs the rote post-extraction checks: +// 1. the sub-flake builds standalone (the package still works on its own) +// 2. the root re-exports + builds it via the input (consumption is wired) +// 3. drv parity note (follows vs the sub-flake's own lock) +// 4. nix flake check (the whole repo still evaluates/builds) +// 5. no stale references to the old in-tree path remain +// Exits non-zero if any hard check fails. Read-only (no repo mutation). +import { $ } from "bun"; +import { existsSync } from "node:fs"; +import { dirname, join } from "node:path"; + +const args = process.argv.slice(2); +const name = args[0]; +if (!name || name.startsWith("-")) { + console.error("usage: bun verify.ts [--no-check]"); + process.exit(1); +} +const noCheck = args.includes("--no-check"); + +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 above ${start}`); process.exit(1); } + dir = parent; + } +} +const ROOT = findFlakeRoot(); +const SUB = join(ROOT, "flakes", name); +const c = { grn: "\x1b[32m", ylw: "\x1b[33m", red: "\x1b[31m", b: "\x1b[1m", x: "\x1b[0m" }; +const ok = (m: string) => console.log(`${c.grn}✓${c.x} ${m}`); +const bad = (m: string) => console.log(`${c.red}✗${c.x} ${m}`); +const hdr = (m: string) => console.log(`\n${c.b}== ${m} ==${c.x}`); +const base = (p: string) => p.replace(/.*\//, ""); + +if (!existsSync(join(SUB, "flake.nix"))) { + bad(`no flakes/${name}/flake.nix — run scaffold.ts first`); + process.exit(1); +} + +let pass = true; +const system = (await $`nix eval --impure --raw --expr builtins.currentSystem`.text()).trim(); +console.log(`repo: ${ROOT}\nsystem: ${system}`); + +hdr(`sub-flake builds standalone — flakes/${name}#packages.${system}.${name}`); +let subDrv = ""; +const subBuild = await $`nix build ${SUB}#packages.${system}.${name} --no-link`.nothrow().quiet(); +if (subBuild.exitCode === 0) { + subDrv = (await $`nix eval --raw ${SUB}#packages.${system}.${name}.drvPath`.nothrow().quiet()).stdout.toString().trim(); + ok(`standalone build OK (${base(subDrv)})`); +} else { + bad("standalone build failed"); + console.log(subBuild.stderr.toString().split("\n").slice(-12).join("\n")); + pass = false; +} + +hdr(`root consumes it — .#packages.${system}.${name}`); +const rootEval = await $`nix eval --raw .#packages.${system}.${name}.drvPath`.cwd(ROOT).nothrow().quiet(); +if (rootEval.exitCode === 0) { + const rootDrv = rootEval.stdout.toString().trim(); + ok(`root output resolves (${base(rootDrv)})`); + if (subDrv) { + console.log( + rootDrv === subDrv + ? ` drv parity: root == standalone (the sub-flake lock resolves to the same nixpkgs as the root)` + : ` note: root drv != standalone drv — expected when the sub-flake's own lock pins a different\n nixpkgs than the root; \`follows\` makes the ROOT build against the ROOT's nixpkgs.`, + ); + } + const rb = await $`nix build .#packages.${system}.${name} --no-link`.cwd(ROOT).nothrow().quiet(); + if (rb.exitCode === 0) ok("root build OK"); else { bad("root build failed"); pass = false; } +} else { + console.log(` ${c.ylw}root does not expose .#packages.${system}.${name} yet — wire modules/packages.nix (see scaffold output)${c.x}`); +} + +if (!noCheck) { + hdr("nix flake check"); + const fc = await $`nix flake check`.cwd(ROOT).nothrow().quiet(); + if (fc.exitCode === 0) ok("flake check passed"); + else { + bad("flake check failed"); + console.log((fc.stdout.toString() + fc.stderr.toString()).split("\n").slice(-15).join("\n")); + pass = false; + } +} + +hdr("stale references to the old in-tree path"); +const stale = await $`git -C ${ROOT} grep -nIE ${`pkgs/${name}|overlays/${name}`} -- . ":(exclude)flake.lock"`.nothrow().quiet(); +const staleOut = stale.stdout.toString().trim(); +if (staleOut) { + bad("found stale references (repoint or remove):"); + console.log(staleOut); + pass = false; +} else { + ok(`no references to pkgs/${name} or overlays/${name}`); +} + +console.log(""); +if (pass) ok("all checks passed"); +else { bad("some checks FAILED"); process.exit(1); } From 67c110550e2cc55b291bac5a23de6466f3ef27f6 Mon Sep 17 00:00:00 2001 From: Kris Williams <115474+kriswill@users.noreply.github.com> Date: Thu, 4 Jun 2026 21:19:12 -0700 Subject: [PATCH 3/3] fix maintainers attribution --- flakes/ccglass/package.nix | 2 +- pkgs/iv.nix | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/flakes/ccglass/package.nix b/flakes/ccglass/package.nix index 105e9900..c570898c 100644 --- a/flakes/ccglass/package.nix +++ b/flakes/ccglass/package.nix @@ -53,6 +53,6 @@ buildNpmPackage (finalAttrs: { mainProgram = "ccglass"; # Pure-JS deps + `bun build --compile` → builds a native binary on darwin and linux. platforms = lib.platforms.darwin ++ lib.platforms.linux; - maintainers = with lib.maintainers; [ kriswill ]; + maintainers = [ { github = "kriswill"; } ]; }; }) diff --git a/pkgs/iv.nix b/pkgs/iv.nix index 2483adcb..9d7f1c81 100644 --- a/pkgs/iv.nix +++ b/pkgs/iv.nix @@ -49,6 +49,6 @@ buildGoModule rec { description = "A command-line image viewer using terminal graphics"; homepage = "https://github.com/kenshaw/iv"; license = lib.licenses.mit; - maintainers = with lib.maintainers; [ kriswill ]; + maintainers = [ { github = "kriswill"; } ]; }; }