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
40 changes: 23 additions & 17 deletions agents/skills/nix-dev-init/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@ The workflow has two phases, and the split is load-bearing:
- **Phase 1 — Nix shell** (this file): `github/create_repo.sh --template` (or manual `flake.nix` → `.envrc`) → `direnv allow`. Language-agnostic.
- **Phase 2 — Language init** (see `references/lang-<name>.md`): verify toolchain, run language-specific sync/build **inside the direnv-activated shell**.

The whole reason Phase 1 runs before Phase 2 is so the language's commands see the nix-provided toolchain on PATH, not the host's. Do not collapse or reorder the phases.
The whole reason Phase 1 runs before Phase 2 is so the language's commands see the project
devShell toolchain on PATH, not the Nix-managed global fallback. Do not collapse or reorder the
phases.

## Mandatory Order (Phase 1)

Expand All @@ -37,20 +39,22 @@ The whole reason Phase 1 runs before Phase 2 is so the language's commands see t
### Why this order

- **VCS before flake**: the git repo must exist before `nix flake update` creates `flake.lock`. Use `github/create_repo.sh` for template-backed projects and `gh repo create --clone` for one-off non-template projects instead of `git init` — they set up remote and default branch in one shot. Private repos skip `--license` because an accidental visibility flip with MIT attached grants everyone usage rights.
- **Toolchain before init**: running language init commands (e.g. `pnpm install`) on the host shell picks up the host's toolchain, whose version leaks into lockfiles. On another machine (or in CI) this silently breaks reproducibility.
- **Toolchain before init**: running language init commands (e.g. `pnpm install`) on the host shell
picks up the global fallback toolchain, whose version may differ from the project. On another
machine or in CI this silently breaks reproducibility.
- **Commit `.envrc` before `direnv allow`**: direnv's trust state is keyed by file hash. Allowing first and editing after immediately invalidates the allow, forcing a re-allow.

## Template Repos

All supported languages have a GitHub template repo under `furedea/`. Use `github/create_repo.sh --template` to create new projects — it clones, applies rulesets, and patches the project name in config files automatically.

| Project type | Template repo | create_repo.sh post-processing | Ship `flake.lock`? |
| --- | --- | --- | --- |
| Python (uv) | `furedea/template-python` | `pyproject.toml` name sub + `ruleset_python` | no |
| TypeScript / Node (pnpm) | `furedea/template-typescript` | `package.json` name sub + `ruleset_typescript` | no |
| Rust | `furedea/template-rust` | `Cargo.toml` name sub + `ruleset_rust` | no |
| TeX / LaTeX | `furedea/template-tex` | `ruleset_tex` only (no name sub) | **yes** (in repo) |
| Fallback (unlisted languages) | `furedea/template-minimal` | base ruleset only | no |
| Project type | Template repo | create_repo.sh post-processing | Ship `flake.lock`? |
| ----------------------------- | ----------------------------- | ---------------------------------------------- | ------------------ |
| Python (uv) | `furedea/template-python` | `pyproject.toml` name sub + `ruleset_python` | no |
| TypeScript / Node (pnpm) | `furedea/template-typescript` | `package.json` name sub + `ruleset_typescript` | no |
| Rust | `furedea/template-rust` | `Cargo.toml` name sub + `ruleset_rust` | no |
| TeX / LaTeX | `furedea/template-tex` | `ruleset_tex` only (no name sub) | **yes** (in repo) |
| Fallback (unlisted languages) | `furedea/template-minimal` | base ruleset only | no |

Non-TeX templates intentionally omit `flake.lock`: `nix flake update` runs on first `direnv allow` to resolve a fresh `nixpkgs` commit. The TeX template includes `flake.lock` for reasons explained below.

Expand Down Expand Up @@ -97,13 +101,13 @@ plus language-specific entries. For non-template projects (fallback path), add t

After `direnv allow`, hand off to the language-specific reference. Each ref covers verification, sync/build, and language-specific anti-patterns.

| Project type | Reference | Downstream skill |
| --- | --- | --- |
| Python (uv) | `references/lang_python.md` | `python-style` |
| TypeScript / Node (pnpm) | `references/lang_typescript.md` | — |
| Rust | `references/lang_rust.md` | — |
| TeX / LaTeX | `references/lang_tex.md` | — |
| Unlisted languages | `references/lang_fallback.md` | — |
| Project type | Reference | Downstream skill |
| ------------------------ | ------------------------------- | ---------------- |
| Python (uv) | `references/lang_python.md` | `python-style` |
| TypeScript / Node (pnpm) | `references/lang_typescript.md` | — |
| Rust | `references/lang_rust.md` | — |
| TeX / LaTeX | `references/lang_tex.md` | — |
| Unlisted languages | `references/lang_fallback.md` | — |

Read only the ref that matches the project's primary language — the files are intentionally standalone so Phase 2 loads one language's context, not all of them.

Expand All @@ -117,7 +121,9 @@ Read only the ref that matches the project's primary language — the files are

- Using `git init` instead of `github/create_repo.sh` or `gh repo create --clone` for new projects → remote URL hand-typing, branch name mismatch (`master` vs `main`), missing license/gitignore.
- Running `uv init` / `pnpm install` / `cargo build` on the host shell before `direnv allow` → host toolchain leaks into the project.
- Adding project-only tooling to `~/ghq/github.com/furedea/dotfiles/nix/home/default.nix` → bloats the global user env; keep project tooling in the project's own flake.
- Adding project-only tooling to `~/ghq/github.com/furedea/dotfiles/nix/home/default.nix` → bloats the
global user env; global fallback runtimes belong there, while project-specific tools stay in the
project's own flake.
- Running `darwin-rebuild switch` after editing a project's `flake.nix` → unnecessary. `darwin-rebuild` only reads the dotfiles flake + the nix-darwin modules.
- Editing files under `.direnv/` by hand → it is a cache; change `flake.nix` instead and let direnv rebuild it on next `cd`.
- Manually scaffolding files that the template repo already provides (e.g. running `pnpm init` when `template-typescript` already has `package.json`).
Expand Down
4 changes: 3 additions & 1 deletion agents/skills/nix-dev-init/references/lang_fallback.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ Do not do this for a single project. The overhead of maintaining a template repo

## What NOT to do

- Do not add the language's toolchain to `~/ghq/github.com/furedea/dotfiles/nix/home/default.nix` "temporarily". Temporary global installs have a way of becoming permanent.
- Do not add project-specific tools to `~/ghq/github.com/furedea/dotfiles/nix/home/default.nix`
temporarily. Deliberate global fallback runtimes are allowed, but a project must still declare
its complete toolchain in its own devShell.
- Do not create a new template repo on the first use of a language. Wait for the pattern.
- Do not skip Phase 1 and go straight to `go mod init` / `mix new` on the host shell "because it's a quick experiment". Experiments that aren't worth a flake aren't worth committing either — use `nix shell nixpkgs#go` as a throwaway instead.
3 changes: 2 additions & 1 deletion agents/skills/nix-dev-init/references/lang_rust.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ Do **not** add `rust-analyzer` to the devShell. The globally-installed `rust-ana
## What NOT to do

- Do not run `cargo init` — the template repo already provides `Cargo.toml` and `src/main.rs`. Running `cargo init` overwrites them.
- Do not add `cargo` / `rustc` to `~/ghq/github.com/furedea/dotfiles/nix/home/default.nix`. Per-project pinning is the whole point.
- Do not rely on the global `cargo` or `rustc` inside a project. The project devShell remains the
source of truth even when Home Manager provides global fallbacks.
- Do not commit `target/`. It is a machine-specific build cache.
- Do not run `rustup` inside the direnv shell. Nix owns the toolchain; rustup would install a second one into `~/.rustup/` and silently shadow it via `cargo`'s PATH lookup.
3 changes: 2 additions & 1 deletion agents/skills/nix-dev-init/references/lang_typescript.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ They are Rust-based, orders of magnitude faster than the JS-native equivalents,
## What NOT to do

- Do not run `pnpm init` — the template repo already provides `package.json`. Running `pnpm init` overwrites it and loses the curated config.
- Do not add `nodejs` or `pnpm` to `~/ghq/github.com/furedea/dotfiles/nix/home/default.nix`. Project-level pinning is the whole point of Phase 1.
- Do not rely on the global `nodejs` or `pnpm` inside a project. The project devShell remains the
source of truth even when Home Manager provides global fallbacks.
- Do not commit `node_modules/`. pnpm's lockfile + nix's `nodejs_22` pin is what makes the build reproducible.
- Do not mix package managers (npm install + pnpm install in the same repo). pnpm's lockfile format is not interchangeable with npm's.
13 changes: 5 additions & 8 deletions agents/skills/nix-dotfiles/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ or rebuild requirements are relevant.
├── home.packages # CLI tools from nixpkgs / unstable / flake inputs
├── programs.* # declarative program configs (git, delta, gh, direnv, atuin, yazi, tmux)
├── home.file.* # dotfile symlinks (mkOutOfStoreSymlink)
└── home.activation # post-activation hooks (rustup, uv, ssh-keygen)
└── home.activation # post-activation hooks for mutable runtime state
```

Platform: `aarch64-darwin` (Apple Silicon). Release channel: `25.11` for nixpkgs, nix-darwin, and home-manager. `nixpkgs-unstable` is available as `unstable` overlay for packages that need bleeding-edge versions.
Expand Down Expand Up @@ -90,6 +90,7 @@ Changes to files linked via `mkOutOfStoreSymlink` (e.g. `.zshrc`, `starship.toml
| CLI tool in nixpkgs stable | `nix/home/default.nix` → `home.packages` | `bat`, `ripgrep` |
| CLI tool only in unstable | `nix/home/default.nix` → `home.packages` | `unstable.atuin`, `unstable.oxfmt` |
| Tool from a flake input | `nix/home/default.nix` → `home.packages` | `nix-claude-code.packages.${system}.default` |
| Global fallback language runtime | `nix/home/default.nix` → `home.packages` | `python314`, `rustc`, `nodejs_24` |
| GUI app (macOS .app) | `nix/darwin/default.nix` → `homebrew.casks` | `"obsidian"`, `"arc"` |
| Mac App Store app | `nix/darwin/default.nix` → `homebrew.masApps` | `LINE = 539883307` |
| Homebrew formula (not in nixpkgs) | `nix/darwin/default.nix` → `homebrew.brews` | `"winebarrel/kasa/kasa"` |
Expand Down Expand Up @@ -182,16 +183,11 @@ Use `.text` when the file content depends on Nix store paths (e.g. plugin source

## Activation Hooks

Run commands after home-manager writes files:
Run commands after home-manager writes files when the target is inherently mutable and cannot be
represented by a package or managed file:

```nix
home.activation = {
rustupInit = lib.hm.dag.entryAfter [ "writeBoundary" ] ''
${pkgs.rustup}/bin/rustup toolchain install stable --no-self-update 2>/dev/null || true
'';
uvPythonInstall = lib.hm.dag.entryAfter [ "writeBoundary" ] ''
${pkgs.uv}/bin/uv python install 2>/dev/null || true
'';
sshKeyGen = lib.hm.dag.entryAfter [ "writeBoundary" ] ''
if [ ! -f ~/.ssh/id_ed25519 ]; then
mkdir -p ~/.ssh
Expand All @@ -201,6 +197,7 @@ home.activation = {
};
```

- Install language runtimes through `home.packages`; do not download them with activation hooks.
- Use `${pkgs.xxx}/bin/xxx` to reference the exact Nix store binary.
- End with `|| true` to prevent rebuild failures from non-fatal errors.
- `entryAfter [ "writeBoundary" ]` ensures dotfiles are written before the hook runs.
Expand Down
12 changes: 12 additions & 0 deletions docs/adr/0024_manage_language_runtimes_with_nix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# ADR-0024: Manage language runtimes with Nix

- Status: Accepted
- Date: 2026-09-12

In the context of needing Python, Node.js, and Rust both for project work and occasional host-shell
commands, facing mutable runtime installations from uv and rustup that cannot be reproduced from a
flake lock, we decided for Nix-managed global fallback runtimes and independently pinned project
devShell toolchains, with uv, pnpm, and Cargo responsible for language dependencies, and against
activation hooks that download runtimes into the home directory, to make each environment
reproducible while keeping convenient host-shell commands, accepting duplicate Nix store closures
when global and project versions differ.
12 changes: 5 additions & 7 deletions nix/home/default.nix
Original file line number Diff line number Diff line change
Expand Up @@ -231,11 +231,15 @@ in
shfmt

# Python tooling
python314
uv

# Rust tooling
cargo
clippy
lspmux
rustup
rustc
rustfmt

# TypeScript tooling
nodePackages."@antfu/ni"
Expand Down Expand Up @@ -578,12 +582,6 @@ in
"${config.xdg.cacheHome}/zsh/.zcompdump" \
9>/dev/null
'';
rustupInit = lib.hm.dag.entryAfter [ "writeBoundary" ] ''
${pkgs.rustup}/bin/rustup toolchain install stable --no-self-update 2>/dev/null || true
'';
uvPythonInstall = lib.hm.dag.entryAfter [ "writeBoundary" ] ''
${pkgs.uv}/bin/uv python install 2>/dev/null || true
'';
sshDirectoryPermissions = lib.hm.dag.entryAfter [ "writeBoundary" ] ''
if [ -d "$HOME/.ssh" ]; then
/bin/chmod 0700 "$HOME/.ssh"
Expand Down
28 changes: 28 additions & 0 deletions tests/nix/language_toolchains.bats
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
#!/usr/bin/env bats
# Executable specifications for globally available language toolchains.

bats_require_minimum_version 1.5.0

setup() {
REPO_ROOT="$(cd "$BATS_TEST_DIRNAME/../.." && pwd)"
}

@test "Home Manager provides global fallback runtimes without mutable installers" {
run --separate-stderr nix eval --no-write-lock-file --json \
"$REPO_ROOT#homeConfigurations.kaito.config" --apply '
config: {
packages = map (package: package.pname or package.name) config.home.packages;
activations = builtins.attrNames config.home.activation;
}'

[ "$status" -eq 0 ]
run jq -e '
(["python3", "uv", "nodejs", "pnpm", "cargo", "rustc", "clippy", "rustfmt"]
- .packages | length == 0)
and (.packages | index("rustup") == null)
and (.activations | index("rustupInit") == null)
and (.activations | index("uvPythonInstall") == null)
' <<<"$output"

[ "$status" -eq 0 ]
}