Compare commits

..

195 Commits

Author SHA1 Message Date
55ed1bf5a9 fix(shell): show groups in long listings 2026-08-03 09:55:48 -04:00
ad2e6f5f4a refactor(ssh): centralize fleet access 2026-08-02 22:45:56 -04:00
2738061b5d fix(pikachu): configure gitea ssh remote 2026-08-02 21:45:33 -04:00
ffc9b331ea feat(pikachu): manage ssh keys 2026-08-02 21:34:20 -04:00
729c8fdd5f feat(ssh): add host aliases 2026-08-02 21:06:39 -04:00
e56b710344 feat(pikachu): add initial NixOS host 2026-08-02 19:20:56 -04:00
d782308b42 fix(pi): expire terminal subagents automatically 2026-08-02 14:23:24 -04:00
8e8752e519 fix: eliminate configuration evaluation warnings 2026-08-02 13:20:59 -04:00
58f6b108c3 docs(pi): clarify subagent completion behavior 2026-08-02 08:23:57 -04:00
cb26a044d3 fix(pi): prevent oversized subagent result crashes 2026-08-02 07:24:52 -04:00
af643c452d feat(pi): manage global subagent configuration 2026-08-02 07:04:28 -04:00
1e80216b07 feat(steam): add Steam desktop module 2026-08-02 00:05:45 -04:00
eb67944e68 Revert "feat(pi): add read-only subagent attach view"
This reverts commit 7bc0d0772c.
2026-08-01 23:24:16 -04:00
7bc0d0772c feat(pi): add read-only subagent attach view 2026-08-01 23:06:55 -04:00
ede3c0583f feat(pi): capture subagent activity events 2026-08-01 22:56:50 -04:00
aafc68e911 feat(pi): render subagent monitor views 2026-08-01 22:41:25 -04:00
8c85c00ae9 feat(pi): retain terminal subagent work 2026-08-01 22:33:34 -04:00
c5828e0591 feat(pi): label subagent work items 2026-08-01 22:24:04 -04:00
5fd8de031d feat(pi): patch prompt layout spacer 2026-08-01 20:43:06 -04:00
de99b4a89e feat(pi): add compact status extension 2026-08-01 19:45:52 -04:00
f5d799c64b fix(pi): load subagent extension in child sessions 2026-08-01 19:23:46 -04:00
e143495d6c feat(pi): expire recent subagent statuses 2026-08-01 18:25:43 -04:00
3c4eaec76b feat(pi): add subagent wait tool 2026-08-01 18:25:43 -04:00
007ba81c02 feat(pi): finish subagent runtime surfaces 2026-08-01 18:25:43 -04:00
3977ed6822 feat(pi): add subagent config and agents 2026-08-01 18:25:43 -04:00
289ea1344c feat(pi): harden subagent lifecycle 2026-08-01 18:25:43 -04:00
63da676b5a feat(pi): add subagent tracer bullet 2026-08-01 18:25:43 -04:00
8fc816bcd9 feat(agents): enable subagents skill 2026-08-01 18:22:59 -04:00
585d4919e7 feat(agents): add implementation workflow skills 2026-08-01 13:40:11 -04:00
6544d3d8a0 feat(agents): add slice skill 2026-07-31 23:39:46 -04:00
2fed687a00 feat(agents): enable Herdr tooling 2026-07-31 22:49:05 -04:00
36d7a53029 feat(pi): install Herdr integration declaratively 2026-07-31 22:48:43 -04:00
6422bb96f2 chore(agents): stop installing retired skills 2026-07-31 18:47:43 -04:00
9ed4809837 feat(agents): add wayfinding skills 2026-07-31 14:44:13 -04:00
6945c29a47 feat(agents): add design skill 2026-07-30 19:15:02 -04:00
521e4c7fb6 chore(pi): use gpt-5.6-sol by default 2026-07-30 09:03:48 -04:00
b8bb26da75 feat(firefox): pin signed browser extensions 2026-07-30 08:31:11 -04:00
ef1eebda58 chore(agents): migrate project artifacts to store 2026-07-30 00:39:08 -04:00
06e327ed85 feat(direnv): silence directory change logs 2026-07-29 22:48:46 -04:00
adcb7bfd77 fix(agents): keep project skills visible to Pi 2026-07-29 19:19:30 -04:00
2b957c7f09 feat(agents): make Pi a first-class agent 2026-07-29 12:04:21 -04:00
7a97ee4e31 chore(agents): remove obsolete guest scaffolding 2026-07-29 09:28:12 -04:00
3e3975c724 feat(pi): point Pi at the Codex subscription
Switch the default provider from Anthropic to OpenAI and the default
model to gpt-5.3-codex, so Pi runs on a ChatGPT Plus/Pro (Codex)
subscription. The OAuth login stays unmanaged in ~/.pi/agent/auth.json,
as before.
2026-07-28 22:36:32 -04:00
f218e47814 docs: note the repo pins no formatter and nix files aren't nixfmt-clean
Record a gotcha so a future change does not run nixfmt across a file and
reflow untouched code, injecting churn unrelated to the change.
2026-07-26 00:01:10 -04:00
53a070a59a fix(guests): keep a networked guest's resolver and order it after its bridge (task 0010)
A guest placed on a VLAN runs its own networkd, which default-enables
systemd-resolved. That conflicts with the nested-container default of
inheriting the host's resolv.conf, so its toplevel failed to build; keep the
guest's own resolver with networking.useHostResolvConf = false.

The same guest's container enslaves its veth to the VLAN bridge at start,
but the container backend orders the unit only after the network is up, not
after that specific bridge exists — a race the veth enslavement could lose.
Order container@<name> after the br-vlan<id> device so the bridge is there
first.

Both were surfaced by a VM integration test that was explored and then
dropped as not worth its regression cost; the task file records the call.
2026-07-26 00:01:10 -04:00
969737b6b5 feat(guests): let a guest nest OCI containers (task 0009)
Add a `nesting` placement field to the Host-side guest interface, a bool
off by default. On, it grants the guest's container the prerequisites its
interior needs to run Podman and other OCI containers: the `CAP_NET_ADMIN`
capability an OCI runtime uses to build its bridges and firewall rules,
and the `/dev/net/tun` and `/dev/fuse` device nodes it reaches for to
network those containers and back their overlay storage. Off, both the
capability and device lists are empty, so a non-nesting guest is untouched.

cgroup delegation, the other nested prerequisite, the NixOS container
backend already grants every container unconditionally, so the Skeleton
records it with an absence pointer rather than re-emitting it.

Add a nesting-sample guest whose interior defines an `oci-containers`
workload on Podman, and enable it on neogaia with `nesting` on, so the
path builds end to end through the Host's `nix flake check` — which pulls
in podman and the generated container unit for the nested system.
2026-07-25 22:10:46 -04:00
0b7d409fbc feat(guests): cap guest resources and gate boot-start (task 0008)
Give the Host-side placement a `limits` field — `memory`, `cpu`, and
`tasksMax` — that caps the guest's `container@<name>.service` unit via
`MemoryMax`, `CPUQuota`, and `TasksMax`, uncapped by default so an unset
limit contributes no key and systemd keeps its default.

Add an `autoStart` placement option, on by default, driving the
container's boot-start so a Host can define a guest without starting it
at boot.

Give neogaia's skeleton guest modest demonstrative caps.
2026-07-25 21:26:53 -04:00
d637d3e7f6 feat(guests): give guests only the host-decrypted secrets they name
Add a `secrets` placement option to the guest builder: a list of secret
names. The host declares each as one of its own sops secrets, so the host
is the sole decryptor from the sops files it already holds, and the
decrypted file is bind-mounted into the guest read-only at the same
`/run/secrets/<name>` path it occupies on a host. A service inside the
guest reads its credentials at a predictable location while the guest
carries no age key and decrypts nothing itself.

Ownership needs no new code: the container already runs in the host's uid
and gid space one to one, so the decrypted file's host owner is its owner
inside the guest.

Assert that no in-guest path is claimed by both a mount and a secret, so
the attribute-set merge fails loudly rather than resolving a collision
silently in the secret's favour.
2026-07-25 20:46:08 -04:00
ab9b9e9f8f feat(guests): bind-mount host pool paths into guests
Add a `mounts` placement option to the guest builder: a Host maps guest
interior paths to host paths, each read-write by default or read-only per
mount, realized as the nested container's bind mounts. A guest sees exactly the
data it should, at any granularity from a single folder to a whole pool.

Pin the container to the host's uid and gid space with `privateUsers = "no"`,
so a guest process writing as the shared storage group lands on a bind-mounted
pool as that same group without permission juggling.
2026-07-25 17:53:14 -04:00
78ab95922f feat(agent-skills): add the grill skill
Install the grill skill globally from the skills flake, alongside wiki and
consume. It interviews the operator relentlessly about a plan or design,
resolving every branch to an explicit decision before any implementation.
2026-07-25 17:38:58 -04:00
7edc1ce94b feat(zfs): import ZFS pools and add the shared write group
Add `modules.zfs`, the host-level pool import: a host declares its ZFS host id
and the pools to import with their dataset mountpoints, and the module imports
those pools as durable state rather than recreating them, so a service's data
survives any rebuild or reimage.

Add a shared `storage` group with a fixed gid to the base config both the host
base and the guest-base build on, so a host and every guest carry the same
number and an identity-mapped container write lands on the pool as that group
without per-service permission juggling.

No host enables the module: the only host is a laptop with no pools and a
kernel with no ZFS build, so the enabled build was verified by ad-hoc
enablement against a ZFS-supported kernel while the committed tree stays inert.
2026-07-25 17:26:36 -04:00
e6ea8a0060 feat(guests): place networked guests on tagged VLANs (task 0004)
A guest sets `vlan` to attach to its host's `br-vlan<id>` bridge, `mac`
to reuse an existing address (else a stable one is derived from its
namespace path and read back via `nix eval`), and `address` for a static
IP (else DHCP). The MAC and address are pinned inside the guest by its
own networkd, the only stable MAC pin for a nested container. A guest
naming a VLAN its host has not declared fails the build with an
actionable message.

The `br-vlan<id>` naming moves into a shared `bridgeName` in the lib, so
the bridge a guest attaches to and the bridge the host emits have one
source.
2026-07-25 16:45:26 -04:00
83106239d4 chore(agent-skills): update skills input, drop grill and domain-modeling
Bump the skills flake input and remove the grill and domain-modeling
skills, which are no longer carried in the agent toolset.
2026-07-25 16:36:33 -04:00
e05adef7b7 feat(network): add host VLAN-bridge networking foundation
Introduce `modules.network`, the host-level networking foundation a Host
declares once. A Host states its trunk interface and the tagged VLAN ids to
materialize, and the module emits one systemd-networkd bridge per VLAN, named
by the `br-vlan<id>` convention, plus the host's own management address on a
chosen VLAN's bridge.

The trunk and every bridge set `RequiredForOnline = "no"` so wait-online never
blocks boot on a carrier-less link, and the module owns its own NetworkManager
`unmanaged` guard so enabling it is self-sufficient. Two assertions tie the
management VLAN to the declared VLANs and a management address to a VLAN, so an
address can never be silently dropped.

No host commits an enablement: the only host is a wifi laptop that cannot
present guests as L2 citizens and whose DNS the module's networkd/resolved
would disturb, so the standing enablement waits for the first wired server
host. The build and bridge-name evaluation were verified by enabling the
module ad hoc, and stay reproducible through `extendModules`.
2026-07-25 16:18:10 -04:00
e7d7eb14e1 feat: introduce guests as nested-container definitions
Add a third auto-loaded kind beside the host and the module: the guest, a
reusable definition under guests/ that a host enables like a module and that
realizes its interior as a systemd-nspawn nested container.

Split the shared base config so a guest can stand on it.
base.nix now holds the substrate both bases share — the primary user,
home-manager, and the unstable/stable overlays.
system.nix keeps the host-only machinery, and a new guest.nix is the slim
guest-base: it imports the full modules tree, pins the interior release, and
auto-enables the toolkit bundle and SSH so any guest is workable on sight.

Give modules.ssh a guest flavor.
A host restores its host keys from secrets as before, while a guest sets
hostKeys.restore = false, names no sops files, and self-generates a host key,
so it holds no age key of its own.

The lib grows a guest helper that declares the guests.<path> namespace with an
enable and a backend field.
Only the container backend is built; microvm is a reserved value that trips a
clear build-time assertion rather than silently building nothing.

A sample guest exercises the whole path, and neogaia enables it, so the guest
interior builds through the existing nix flake check seam.
2026-07-25 13:19:07 -04:00
8cd59cb292 feat(toolkit): bundle the baseline interactive toolset
Add modules.toolkit, an aggregator that enables fish, tmux, nvim, git,
and direnv as one unit, and makes fish the login shell, so any Host or
Guest shell feels identical. Each member is set via mkDefault, so a Host
can still override any single piece while the one flag brings up the
whole bundle.

neogaia now enables the bundle as a single line in place of its five
individual enables and its explicit default-shell line, keeping the Host
a flat checklist.

Also lands the domain model, ADR 0006, and the guests spec: the toolkit
is the first piece of the wider guests work, and the guest-base will
auto-enable this bundle.
2026-07-25 12:48:37 -04:00
582800d548 chore: retire consumed specs and tasks, trim CLAUDE.md gotchas
The .claude specs and tasks have been distilled into the notes vault, so
remove the 37 consumed files. Trim the CLAUDE.md gotchas to their
project-specific residue: drop the entries now captured as general notes,
and fix the two references that pointed at the deleted specs.
2026-07-24 21:40:50 -04:00
8885ffae67 feat(agent-skills): install the wiki and consume vault skills
Source the wiki and consume skills from the skills flake, placing them
globally so they are active in every project. wiki reads the personal
Obsidian vault read-only, and consume mines a source into it, pairing
with the Obsidian vault this branch installs.
2026-07-24 20:11:07 -04:00
40ae623094 feat(hyprland): enable native Wayland for Electron apps
Set NIXOS_OZONE_WL in the compositor env so Chromium and Electron
wrappers select native Wayland rather than XWayland, which blurs at this
DPI. Deliver it through Hyprland's env directive because UWSM starts the
session without the shell profile home-manager's sessionVariables rely
on.
2026-07-24 17:48:35 -04:00
7409e4e6a0 feat(obsidian): install Obsidian as the desktop note-taking vault
Add a standalone modules.desktop.obsidian module and enable it on
neogaia. Give it a waybar window-rewrite icon so its windows show a
recognisable glyph on the workspace indicator.
2026-07-24 17:48:29 -04:00
2cb47ef2cf feat(direnv): add direnv with nix-direnv shell caching 2026-07-24 16:29:57 -04:00
48a81bb8a2 style: align comments with the project conventions
Apply a codebase-wide comment audit against the comment conventions:
split banned semicolons and multi-sentence lines into one sentence per
line, cut cross-file and history narration, trim file-top headers to a
single purpose line, and drop verbosity that did not earn its place.
Prose docs (CLAUDE.md, install.md) get the same one-sentence-per-line
and no-semicolon treatment.
2026-07-24 16:17:09 -04:00
d2fbf78927 fix(fish): fit the fastfetch greeting to the terminal width
The greeting always drew the full NixOS logo beside the info block,
which overflows in a narrow terminal. Choose the logo by column count:
the full logo when it fits, the compact one when it does not, and none
below that.
2026-07-24 14:47:49 -04:00
eca87c74a6 fix(firefox): scale the browser to 1.25x on the high-DPI panel
Left at auto, Firefox reads the panel's 1.5x and inflates its whole
chrome while point-sized apps stay put. Pin devPixelsPerPx a shade under
that so the UI and pages match without dropping to a too-small 1:1.
2026-07-24 14:47:49 -04:00
bb9a92b25b feat(desktop): shrink Stylix font sizes for the laptop panel
The graphical layer inherited Stylix's default font sizes, which read
oversized on this display. Step them down a point across applications,
desktop, popups, and the terminal.
2026-07-24 14:47:49 -04:00
68e0aafbbb fix(desktop): apply the configured cursor theme to the Hyprland session
home.sessionVariables (XCURSOR_THEME/XCURSOR_SIZE from home.pointerCursor)
land in hm-session-vars.sh, which only a login shell sources. UWSM starts
the graphical session without it, so Hyprland never saw the theme and drew
its built-in cursor instead of the configured Bibata-Modern-Ice.

Declare the cursor through Hyprland's own env directive, read at compositor
startup regardless of the shell profile, sourced from config.stylix.cursor
so the theme and size stay defined in one place.
2026-07-24 14:29:38 -04:00
f967bc47bc fix(hyprland): cap output volume at 100 percent 2026-07-24 14:11:04 -04:00
75373ebdc1 feat(desktop): show volume and brightness changes with a SwayOSD popup
Route the volume and brightness keys through swayosd-client, which raises
a transient on-screen popup that auto-hides a moment after the last press,
so the level is visible while adjusting without a permanent widget. A new
osd module runs the server for the graphical session and makes the
backlight node group-writable so it dims without root.

Supersedes the plain brightnessctl binds, whose control swayosd bundles.
2026-07-24 14:07:00 -04:00
ac095ba0e4 feat(desktop): add screen brightness controls
Bind XF86MonBrightnessUp/Down to brightnessctl in the repeat-while-held
block, mirroring the volume keys. A new brightness module installs
brightnessctl's udev rule and adds the user to the video group so the
backlight can be dimmed without root.
2026-07-24 13:57:45 -04:00
7f7fc327fd fix(firefox): darken the unreadable address-bar result highlight (task 0032)
Stylix's Nord Firefox Color mapping paints the selected address-bar result in
base04, a near-white grey, against base05 text — light on light, so the text
of the highlighted result is illegible. Override that one highlight to the Nord
selection grey (base03), derived from the same Stylix scheme, restoring
contrast without touching the rest of the theme.
2026-07-24 08:51:24 -04:00
bd32795e23 fix(firefox): auto-enable the sideloaded Firefox Color add-on (task 0032)
Firefox disables profile-sideloaded extensions by default, so the Firefox
Color add-on that carries the Nord chrome theme installed but stayed disabled
and the chrome kept its default look. Set extensions.autoDisableScopes = 0 so
it enables on detection; the policy-installed functional extensions are
force-enabled by policy and unaffected.
2026-07-24 08:41:34 -04:00
6484f9466c docs(firefox): record the profiles.ini and chrome-theming fixes in the task (task 0032) 2026-07-24 08:33:55 -04:00
e42101e08b feat(firefox): Nord-theme the chrome via Stylix colorTheme (task 0032)
The base Stylix Firefox target themes only fonts and the reader view, leaving
the toolbar and tabs at Firefox's default look. Enable colorTheme so Stylix
recolours the chrome Nord from the shared scheme through its managed Firefox
Color add-on, with no hand-written chrome CSS and no flake input of ours.

Scope ADR 0005's no-pinned-add-on rule to the three functional extensions and
record the signed, Stylix-managed theming add-on as a deliberate exception.
2026-07-24 08:33:19 -04:00
09eb9a983d docs: correct the home.file key gotcha — keyed by the writer's path form, not always absolute 2026-07-24 08:25:04 -04:00
e24f808b63 fix(firefox): take ownership of profiles.ini so the profile deploys (task 0032)
Firefox writes ~/.config/mozilla/firefox/profiles.ini itself on first launch,
pointing at an auto-created random-prefix profile. Home-manager then refuses to
clobber that file and fails the whole activation, so the declarative default
profile — search.json.mozlz4 (DuckDuckGo) and the Stylix user.js — never lands
and Firefox keeps running the stale profile with Google as default and no theme.

Set force = true on the generated profiles.ini home.file entry so home-manager
owns it, deploys the default profile, and Firefox switches to it.
2026-07-24 08:25:04 -04:00
3e64bd0b7e docs: record that firefox search force does not prune builtins by omission (task 0032) 2026-07-23 23:05:38 -04:00
ee672d2479 feat(firefox): add the desktop Firefox browser module (task 0032)
Add Firefox as a single-purpose desktop Module configured entirely through
home-manager's programs.firefox, and fan it out from the desktop aggregator so
any Host with the desktop enabled brings the browser up.

Ship stock mainline Firefox, hardened and de-monetized through locked
enterprise policies (telemetry, studies, Pocket, offer-to-save-logins, the
default-browser check, Firefox accounts and sync, and the sponsored new-tab
surfaces all off). Force-install three extensions by policy, keyed by their
verified add-on ids: uBlock Origin, Proton Pass, and SponsorBlock.

Default search to DuckDuckGo over a lean list: the general-purpose commercial
engines are hidden with metaData.hidden so Firefox does not reconcile them back
in, leaving DuckDuckGo and Wikipedia. Theme the browser Nord from the shared
Stylix scheme against the one profile, and register it as the default handler
for the web-link schemes and HTML.

Record the stock-Firefox-plus-policy-extensions decision as ADR 0005.
2026-07-23 23:04:19 -04:00
039802b9e2 docs: record the git-add and dirty-tree gotchas behind the task 0031 miss 2026-07-23 21:47:22 -04:00
6f6f0178b1 fix: apply the namespace edits dropped from the task 0031 merge
The task 0031 commit moved the module files but a mis-staged `git add`
left the option-path rewrites out, so main declared the old namespaces
(`modules.claude-code`, `modules.gitea-axi`, `modules.pi`,
`modules.desktop.hyprlock`/`hypridle`) while CONTEXT.md and ADR 0004
already documented the new ones. Rewrite the paths in the moved and
in-place modules, the neogaia host, and the two live CLAUDE.md gotchas
so the code matches the recorded convention.
2026-07-23 21:44:21 -04:00
e684ac481e refactor: mirror module namespaces to their directories (task 0031)
Adopt the convention that a Module's option path mirrors its directory
under modules/, with an index file naming the directory's own segment.

- Group agent Modules under modules.agents.*: claude-code (whole
  directory), pi (flattened to a file), skills (renamed from
  agent-skills), and gitea-axi under an agents/tools/ subgroup. The
  agents/ and tools/ folders are pure namespace prefixes with no
  aggregator enable.
- Nest hypridle and hyprlock under modules.desktop.hyprland.*, with
  hyprland.nix as the index, and update the desktop aggregator.
- Remove the obsolete example Module.
- Record the convention in CONTEXT.md and ADR 0004, and update the
  neogaia host, the two live CLAUDE.md gotchas, and the skills Module's
  intentional Enable-convention exception comment.
2026-07-23 19:21:12 -04:00
02bb345fd7 feat(fish): exclude .git from the lt tree alias
The lt alias walks the tree with -a, pulling the noisy .git directory
into every listing. Bake -I '.git' into it so the git internals are
skipped by default; extra ignores can still be passed at call time.
2026-07-23 08:42:51 -04:00
a3e3e80c83 feat(agent-skills): wire skills flake for global agent skills
Add the skills flake as an input and a module that imports its
home-manager module into every host via home-manager.sharedModules,
exposing programs.agents.skills. The global skill selection lives in
modules/agent-skills.nix and is empty for now; per-skill granularity
comes from the flake's own listOf-package option, and the placement
self-gates on the agent harness being enabled.
2026-07-23 08:35:32 -04:00
23b1a30c2a fix(fish): bind eza --icons so ls accepts a path argument
The ls/la/ll/lt aliases ended with a bare --icons, whose WHEN value is
optional, so a trailing path was parsed as that value: `ls .claude`
failed with "invalid value '.claude' for --icons". Bind it as
--icons=always, matching --color=always.
2026-07-22 23:33:11 -04:00
ac96639c20 feat(desktop): add XDG portals module for in-app screen sharing (task 0030)
Add modules.desktop.portals, enabled by the desktop aggregator, pinning the
XDG desktop portal routing explicitly: the three interfaces the Hyprland
portal implements (screencast, screenshot, global shortcuts) go to Hyprland,
and GTK is the default for file dialogs and appearance.

The backend packages already arrive with the Hyprland compositor integration,
so this module owns only the routing, which was previously empty and rode on
the config file the Hyprland package happens to ship. Making it a first-class
module so in-app screen sharing does not depend on that incidental default.
2026-07-22 23:27:55 -04:00
ab89ba8391 feat(desktop): resolve media output paths through XDG user-dirs
Add a modules.desktop.userdirs module that declares the XDG user directories,
and route the recorder and screenshot tools through xdg-user-dir so their
output folders (Videos/Recordings, Pictures/Screenshots) follow one relocatable
source instead of a hardcoded $HOME path.
2026-07-22 23:11:16 -04:00
0e92de7eea feat(desktop): add screen recording (task 0029)
Add a wf-recorder module, enabled by the desktop aggregator. Super+Shift+R
picks a region with slurp and toggles a video-only capture (no audio), stopped
with SIGINT so the file finalises, saved to ~/Videos/Recordings. Start and
saved notifications fire via notify-send.

A Waybar custom/recording widget samples the wf-recorder process once a second
and shows a video glyph while a capture runs.
2026-07-22 22:56:56 -04:00
111b985d7d feat(fish): restore ctrl-r history pager in vi insert mode 2026-07-22 22:37:01 -04:00
37ddf4342a fix(desktop): use windowrule v3 syntax for the satty float rule (task 0028) 2026-07-22 22:22:20 -04:00
708a3ee963 feat(desktop): float the satty annotator window (task 0028) 2026-07-22 22:09:56 -04:00
9963a0dbe4 feat(desktop): add screenshot capture (task 0028)
Add a screenshot module wiring grimblast (grim + slurp) through the satty
annotation editor, enabled by the desktop aggregator. Region, active-window,
and full-screen captures each open in satty and, on confirm, land in both the
clipboard and a dated file under ~/Pictures/Screenshots.

Bound to Print / Shift+Print / Ctrl+Print rather than the spec's Super+L
family, whose keys task 0021 already holds for hjkl focus and window movement.
2026-07-22 22:04:38 -04:00
210a260735 feat(desktop): add clipboard history (task 0027)
Add a clipboard module to the desktop group, enabled through the
aggregator. Home-manager's services.cliphist runs the text and image
watchers as systemd user services bound to the graphical-session
target, and a rofi-dmenu picker recalls history on Super+Shift+V.
wl-clipboard is on PATH so the shell can pipe to and from the clipboard.
2026-07-22 21:42:53 -04:00
005928ef88 feat(desktop): add hyprlock and hypridle (task 0026)
Add a session-lock screen and idle management to the Hyprland-native
subdirectory, enabled through the desktop aggregator.

hyprlock draws through the compositor session-lock protocol, themed by
Stylix, with a centered field and clock. Super+X locks directly through a
guarded launch so the key works without the idle daemon.

hypridle locks on idle and powers the displays off, with tunable timeouts,
and locks before every suspend. Lid-close routes through logind to suspend,
which the pre-sleep lock covers, so the lid lands at a locked screen.
2026-07-22 18:37:32 -04:00
900cb6b8e8 feat(desktop): add mako notifications (task 0025)
Add a mako notification daemon module to the desktop group, enabled by
the aggregator. Toasts auto-dismiss into history after five seconds, a
do-not-disturb mode ([mode=dnd] invisible=true) suppresses display while
still recording to history, and Super+N recalls the last notification
via makoctl restore. Colors and the popup font come from Stylix's mako
target. The bar-side do-not-disturb toggle and media controls already
live in waybar.nix, so this task adds only the daemon.
2026-07-22 17:15:32 -04:00
7d9a0dae36 feat(desktop): add the rofi launcher (task 0024)
Add a rofi module to the desktop group, enabled by the aggregator.
It runs the drun application launcher, bound on Super+R. A session menu
built on the same themed rofi is bound on Super+Shift+X, and every
rofi -dmenu call inherits the Stylix theme, so later utility menus
reuse it for free.

Also give layer surfaces their own fade-in (layersIn and fadeLayersIn)
a touch quicker than the window animation, so the launcher fades in
briskly rather than snapping into place.
2026-07-22 17:03:54 -04:00
aca09d87a1 docs: move the comment conventions to the global agent instructions
The comment 'why not what', self-contained, sentence-per-line rules are
project-agnostic, so they belong in the global agent instructions rather
than this repo's conventions. Genericise the four Nix- and domain-specific
examples to their underlying principles on the way across.
2026-07-22 15:33:07 -04:00
10e68957b1 docs: require a Waybar icon mapping for new GUI apps 2026-07-22 15:27:21 -04:00
5a7593a793 feat(desktop): bind the hardware volume and media keys 2026-07-22 15:25:50 -04:00
ba881998bc fix(desktop): highlight the current day in the Waybar calendar 2026-07-22 15:20:32 -04:00
b21b4ff77e feat(desktop): add the Waybar status bar (task 0023)
Add a Waybar module to the desktop group, enabled through the aggregator,
showing workspaces with per-application icons plus a clock, MPRIS media
controls, audio, network, battery, and a do-not-disturb toggle.

The do-not-disturb widget drives mako's dnd mode and degrades gracefully
until the notifications module lands. Module glyphs are Nerd Font codepoints
decoded through JSON, backed by a system symbols font.

Add a PipeWire audio server so the bar's volume widget has a running sink to
read and drive.
2026-07-22 12:03:30 -04:00
6d1afa41ed feat(desktop): let Stylix manage tmux and fish theming (task 0022)
Narrow the terminal tools excluded from Stylix to nvim alone. nvim keeps
its dedicated gbprod/nord.nvim colorscheme, which is richer than the
base16 mapping Stylix would apply.

tmux and fish are now Stylix-managed. Drop the hand-written Nord colours
from the tmux status bar so Stylix themes the status and pane styles,
keeping the minimal layout (session name plus window list) reapplied
after Stylix so it still wins.
2026-07-22 11:38:34 -04:00
27a28b1ae7 feat(desktop): theme the graphical layer Nord with Stylix (task 0022)
Add Stylix as a flake input and a desktop theming module that resolves a
single Nord base16 scheme across the graphical surface: GTK, Qt, cursor,
and the system monospace font, plus a static Nord wallpaper drawn as a
Polar Night gradient.

Scope the theming to the graphical layer alone. The Stylix targets for
fish (at both system and home-manager level), tmux, and nixvim stay off
so the terminal tools keep their hand-written themes.
2026-07-22 10:28:03 -04:00
5ca717ca4a feat(desktop): toggle the tiling layout on Super+Shift+T
Flip between the dwindle and master layouts with one bind. Neither a
dispatcher nor a keyword toggles the layout on its own, so a small pinned
script reads general:layout and sets the other through hyprctl keyword.
2026-07-22 10:08:26 -04:00
c0ec330024 fix(desktop): switch the terminal from Ghostty to Alacritty
Ghostty's GTK4 window construction made every launch sluggish on neogaia's
integrated graphics (~440 ms to map a window), which a head-to-head
comparison against Alacritty confirmed. Alacritty's lightweight OpenGL
renderer opens fast on the iGPU, so it becomes the terminal on Super+Return.

The choice stays reversible per host, so a capable host such as zeus could
adopt Ghostty later.
2026-07-22 09:46:27 -04:00
b9749dd6a2 fix(desktop): correct dwindle keybinds and speed up the terminal
Two issues surfaced on the live neogaia session. Hyprland rejected two names
at load: the split toggle is a dwindle layout message reached through
layoutmsg, not a top-level dispatcher, and dwindle:pseudotile no longer exists
in this version. Bind Super+T via layoutmsg and drop the pseudotile option.

Ghostty also cold-started GTK on every launch. Configure it through
home-manager with gtk-single-instance so windows after the first open in the
existing process. Record the dispatcher/option version-drift trap in CLAUDE.md.
2026-07-22 09:02:51 -04:00
4b6fbfe732 feat(desktop): add the desktop group and Hyprland session (task 0021)
Introduce the modules/desktop/ group behind a single modules.desktop.enable
aggregator that hand-lists and turns on each piece at default priority, so a
host enables the whole desktop with one flag yet can override any single piece.

The Hyprland compositor lives in a subdirectory of the group, sourced from
nixpkgs with the program integration owning the session, portals, and polkit
and home-manager writing the config against that one package. greetd with the
tuigreet greeter provides a mouse-free text login that launches the session
through uwsm, and Ghostty is the terminal on Super+Return.

Port the operator's KDE/i3 bindings expressed entirely in hjkl and letters,
tune input (US layout, Caps->Escape, snappy repeat, touchpad taps and natural
scroll, flat mouse accel), and set a subtle feel with blur left as a host knob.
Enable the desktop on neogaia.
2026-07-22 08:03:01 -04:00
f552269cb3 docs: plan the hyprland desktop epic (spec, ADR, tasks)
Add the hyprland-desktop spec, ADR 0003 recording the compositor choice
over Sway/KDE/niri, and the task breakdown (0021-0030) that decomposes
the desktop into granular grouped modules. Record the corrected host GPU
facts (zeus is AMD, raichu the sole Nvidia machine) in CLAUDE.md.
2026-07-22 08:03:01 -04:00
fc3380f8ce feat(pi): install the coding agent for a side-by-side trial (task 0020)
Add a `pi` module that turns on the home-manager `programs.pi-coding-agent`
integration for the primary user and freezes a single settings.json: the
default provider set to Anthropic, the default model to Opus (claude-opus-4-8,
Pi's own catalogue id), and analytics disabled. Everything else in
~/.pi/agent — the credential and all self-modification state — is left
unmanaged, so login survives rebuilds and backing Pi out is a one-line enable
flip. Enabled on neogaia alone.
2026-07-21 19:48:24 -04:00
6f34c95c51 fix(claude-code): remove the erroring attention-bell hook
The hook rang the terminal bell by walking the process ancestry for a
process named `claude`, but nix wraps the binary so its `comm` is
`.claude-wrapped`.
The match never fired: the bell never rang, and the loop fell through to
a non-zero exit that surfaced as a Stop hook error on every turn.

Remove the hook, its script, and the Stop and Notification wiring.
The tmux monitor-bell config is left in place as a general nicety.
2026-07-21 15:23:03 -04:00
908d7719fb feat(fish): restore ctrl-f autosuggestion accept in vi insert mode 2026-07-20 21:52:00 -04:00
310ff13c77 feat(gitea-axi): install the CLI and wire its agent context
Add gitea-axi as a flake input and a new modules.gitea-axi module that
installs the CLI through its home-manager module. That module also declares
the Claude Code context — the Agent Skill and the SessionStart dashboard hook
— when claude-code is enabled on the host, so neogaia gets both while a host
without the harness would get the CLI alone.

Drop the hand-written SessionStart hook from the claude-code module, which
named a gitea-axi binary nothing installed; the gitea-axi module owns it now.
Remove the vendored skill fork, which had drifted from upstream and would
collide with the copy the module writes.
2026-07-20 21:37:33 -04:00
e49fb929d8 feat(fish): rebuild the flake for the running host
`nixos-rebuild switch` alone leaves the flake implicit, which is not how
this system is built. Name it explicitly, taking the host from the
configuration that installs the shell so the two cannot disagree.

The reference stays relative, so `update` rebuilds the working tree the
shell is standing in rather than a fixed checkout.
2026-07-20 14:04:51 -04:00
78081143cf refactor: flatten the single-file lib and system directories
Each held one default.nix, so the directory added a level to descend without
grouping anything. They are now lib.nix and system.nix beside flake.nix, which
reads as the flake's own scaffolding.

The shared base config's reference to the shared secrets file was relative to
the directory it sat in, so it moves with the file.

Verified to build the same system toplevel as before the move.
2026-07-20 13:05:07 -04:00
6b5729b98a refactor(ssh): state the admitted keys directly instead of deriving them
The access policy was a separate fleet declaration mapping each machine to a
role and a client key, which the module looked up by hostname to derive what to
authorize. Authorizing a key needs the key text and nothing else, so the
per-machine names, the hostname lookup and the role existed only to reconstruct
a grouping that two lists already are.

The module now exposes those two lists and the set a machine admits, and a host
declares what it draws from in its own file. The fleet declaration is gone, and
with it a single-file directory at the repository root.

`authorizedKeys` defaults to the workstation keys. An option of a list type is
not mandatory the way a scalar one is: undeclared, it yields the empty list
rather than failing, and a machine admitting no key is unreachable. The default
makes the safe case the silent one.

Verified to produce the same system as the design it replaces, and against the
running machine in both directions.
2026-07-20 13:05:07 -04:00
f18b40091c feat(ssh): adopt the client key as a secret and derive access from roles (task 0019)
The operator's SSH client key existed only as a file created by hand on one
laptop, so a reimage would destroy it and lock the operator out of the remote.
It now lives in neogaia's own secrets file, encrypted to the admin identity and
neogaia alone, and the client is pointed at the decrypted path rather than a
copy in the user's home.

Access becomes a policy over roles instead of a per-host list of keys. A new
fleet declaration names each machine's role and client public key, and every
machine derives what it authorizes from that: a workstation admits workstations
alone, a server admits both, so a compromised server reaches no machine of the
operator's own. Registering a machine is an entry in that one file.

Only neogaia exists, so the server half of the policy is built rather than
exercised. Two assertions reject a machine missing from the fleet and any entry
whose role no policy defines.
2026-07-20 13:05:07 -04:00
4ecb86052b docs(git): record the verified deployment (task 0015)
Also note that `git config --global` lists only ~/.gitconfig and is not a
view of what git resolves, which misreads as the module's file being
ignored.
2026-07-20 12:22:36 -04:00
62eb6286b4 feat(git): enable the module per host rather than by default
Defaulting the module on hid it from a host's config, leaving no single
place that lists what a host carries. Each host enables it explicitly
instead, at the cost of a step when adding one.
2026-07-20 12:20:13 -04:00
75c5745cbd feat(git): carry the commit identity on every host
Default the module on rather than restating it per host, so a new host
commits without remembering the line. A host that should not carry a
personal identity sets enable to false.
2026-07-20 11:15:15 -04:00
c9fc17ecf5 feat(git): declare the operator's commit identity (task 0015)
Git identity lived only in one checkout's local configuration on one
machine, so it was invisible to every other checkout and lost on a
reimage. Declare it as a module instead, enabled on neogaia.

It is a module rather than base plumbing so a host that should not carry
a personal commit identity can decline it.
2026-07-20 10:53:47 -04:00
0d685ce277 docs: close out task 0014 after confirming the timers 2026-07-20 10:27:40 -04:00
c63c3079be feat(nix): bound the store, generations and boot menu (task 0014)
Garbage collection and store optimisation each run weekly, on separate
days so they never contend, and retained boot configurations are capped
at 15.
2026-07-20 10:21:24 -04:00
dcc03155a2 docs(install): revise for sops-provisioned identities (task 0012)
The procedure was built around a login password set by hand through
nixos-enter, a step that no longer exists now that the password arrives
as a decrypted secret. Rather than patch around it, revise the document:
the LUKS passphrase is named as the only hand-entered secret, and identity
provisioning moves ahead of first boot, since there is no fallback
credential to get in with if decryption fails.

Cover the three procedures the install alone did not: provisioning a
running host live, the editing workflow and what it needs the admin
identity for, and recovery from the live ISO.
2026-07-20 10:04:12 -04:00
11d7cb053c docs(tasks): add task 0019, user SSH keys and an access policy
Per-machine client identities, so a compromised machine surrenders only
its own key, plus a role-based rule for which machines may reach which.
Only neogaia exists, so the server half is recorded rather than exercised.
2026-07-20 09:51:34 -04:00
ec40892560 refactor(ssh): flatten the module to a single file
Every module folder here carries companion files alongside its .nix. This
one has none, so the folder added a level for nothing.
2026-07-20 09:29:13 -04:00
a6ada9dac3 chore(neogaia): comment the host public keys as the machine, not root
The trailing field is free text stamped in by ssh-keygen from the account
that ran it. Naming the machine says what the key identifies.
2026-07-20 07:57:40 -04:00
42ff195556 refactor(ssh): extract the host key wiring into a module
The sops secret declarations, the empty `hostKeys`, and the `HostKey`
lines were boilerplate sitting in the host, and every future host
restoring its identity would have repeated them.

`modules.ssh` takes the encrypted file and the key types, deriving the
secret names and the `HostKey` lines from one list, so the daemon and the
secrets cannot disagree about where a key lives. The resulting system
derivation is unchanged.

Also record two gotchas: reading PR review comments needs the Gitea API
rather than tea, and host keys are not user authentication keys.
2026-07-20 07:40:47 -04:00
60738f65c2 docs: correct the forge CLI gotcha, a tea login does exist 2026-07-19 23:54:08 -04:00
61ce9cc1be feat(neogaia): restore the SSH host keys from sops (task 0011)
The host's SSH identity was generated by the daemon, so reimaging the
laptop would invalidate it and break `known_hosts` for every client that
had ever connected.

Add `secrets/neogaia.yaml`, the first per-host secrets file, encrypted to
the admin identity and `neogaia` alone so a compromised machine cannot
decrypt another's material. It holds both host private keys; the public
halves are committed in plaintext.

Clear `services.openssh.hostKeys` to stop generation and point `HostKey`
at the decrypted paths. These are ordinary secrets rather than the
pre-user ones the password uses, exercising the second decryption path.
2026-07-19 23:53:01 -04:00
ce103a7353 feat(secrets): decrypt the login password from sops (task 0010)
Wire sops-nix into the shared base config as unconditional plumbing, with
a two-tier age identity model: an admin identity held outside the repo, and
a per-host identity generated on the machine and kept on its encrypted root.
Both `sshKeyPaths` defaults are cleared so the SSH host keys stay out of the
decryption path and remain free to become secrets in their own right.

The primary user's password hash moves into a shared secrets file encrypted
to admin plus neogaia, consumed through `hashedPasswordFile` and decrypted
before accounts are created.

This needs `users.mutableUsers = false`: NixOS applies a declared hash to an
already-existing account only when that flag is false, so at the default the
hand-set password would have been kept and the change would have been inert.
Root consequently has no password and is locked; sudo from wheel is the way
in, and generation rollback remains the recovery path.
2026-07-19 23:43:28 -04:00
7711b841dd docs(neogaia): record the boot verification for task 0017 2026-07-19 23:12:14 -04:00
66582b498c fix(neogaia): replace guessed hardware detection with a real scan (task 0017)
The detection file was written before the machine ran NixOS, as an educated
guess at what a Dell XPS 13 9380 needs, and said so. Its contents now come
from nixos-generate-config on the running machine.

The guess was wider than the measurement rather than narrower: it named
thunderbolt, usb_storage and sd_mod, none of which the scan reports, and
omitted rtsx_pci_sdmmc. Nothing needed to reach the root device was missing.

Filesystem, swap, LUKS and microcode declarations from the scan are dropped.
The disk layout derives the first three and the hardware profile supplies the
last, so carrying them here would duplicate definitions that are produced on
every evaluation.
2026-07-19 22:28:02 -04:00
54c191f801 feat(claude-code): share sudo's credential cache across sessions (task 0018)
Key sudo's credential cache per user rather than per terminal, holding it
for 60 minutes. An authentication made in the operator's own terminal then
covers commands issued by processes holding no terminal of their own, which
previously failed with a bare non-zero exit and no output.

No command is made passwordless. The password remains required; only the
cache holding it is shared, and any process running as the primary user can
spend that credential until it lapses.

A PreToolUse hook refuses a privileged command while the cache is cold,
naming the command that warms it, so the condition announces itself rather
than presenting as a stall. Both states were exercised against the running
system.
2026-07-19 22:18:58 -04:00
d0bfc7b21b docs: correct the gitea-axi gotcha and record home.file's absolute keys 2026-07-19 19:08:54 -04:00
e5d8f69f16 feat(claude-code): ask questions in prose, never via AskUserQuestion 2026-07-19 19:04:48 -04:00
42c602b814 docs: close out task 0016 after reimaging neogaia
The reimage is done and verified on hardware: the boot partition is 2 GiB
and reports it, the hardware profile's kernel parameter and module blacklist
are live, and the encrypted root unlocks to a console login.

The install procedure ran without divergence, which is the reproducibility
evidence the task was after.

The reimage also wiped the hand-written git identity, so the gotcha
describing it is updated to record the failure it now provokes.
2026-07-19 18:53:17 -04:00
431f75aad7 feat(neogaia): declare a 2 GiB EFI system partition (task 0016)
Each generation stores a kernel and an initrd on the EFI system partition,
and this host runs a large third-party kernel, so 512 MiB holds only about
seven. An exhausted partition fails at bootloader installation, after the
build has already succeeded.

The partition cannot grow in place: it sits first on the disk with the
encrypted container immediately behind it, and an encrypted volume's start
offset cannot move without rewriting its whole payload. Only an install
applies this layout, so the change takes effect at the next reimage and
does nothing to the running machine until then.

Record that a flake only sees git-tracked files, which cost an evaluation
earlier, and correct the git identity gotcha that a hand-written gitconfig
had made false.
2026-07-19 17:14:18 -04:00
488bb15683 docs: correct two stale claims in the install procedure
The parenthetical enumerating the public flake inputs named three of the
eight now present, so it is stated as a rule rather than a list that drifts
each time an input is added.

The bootstrap-ordering note also said the install consumes the flake from
Gitea, contradicting the later explanation that disko-install reads a local
clone and fetches nothing of ours. Both describe the same requirement, so
the first now states it as the clone it is.
2026-07-19 17:07:37 -04:00
13e5a9bb56 docs: describe the current secrets design in the install procedure
The closing section still described key derivation from each machine's SSH
host key, a mechanism that was superseded because it forces new host keys
on every reimage and makes storing those keys as secrets circular.

Record the two-tier identity model that replaced it, and warn that the
hand-set bootstrap password stops working once the password becomes a
secret: hashedPasswordFile outranks every other password option, so a
machine installed after that must have its identity provisioned before
first boot rather than a password set afterwards.
2026-07-19 16:56:36 -04:00
5b3ebdccf3 docs: correct the repository name in the install procedure
The clone step named a repository that does not exist, so following the
procedure verbatim failed before reaching the install. The remote is
alexion/dotfiles.
2026-07-19 16:55:10 -04:00
8c85c02a7d docs: close out task 0013 after confirming it on hardware
The rebooted laptop selects deep sleep, runs thermal and power management,
and no longer loads the PS/2 mouse driver. Booted, running and freshly
built toplevels are the same store path, so the readings come from this
configuration rather than a surviving generation.
2026-07-19 16:51:03 -04:00
f94aaba6c1 docs: correct the gotcha claiming the Gitea CLI is installed
No gitea-axi, tea or gh exists on the NixOS build, and there is no tea
login or GITEA_* environment to authenticate with. The flake names
gitea-axi only in the claude-code module's permissions and never packages
it, so pull requests cannot be opened from this machine.

The previous wording described the laptop while it still ran CachyOS with
these tools installed by hand.
2026-07-19 16:41:39 -04:00
25049c8aef feat(neogaia): adopt the upstream hardware profile (task 0013)
The laptop's hardware facts were guessed before it ran NixOS. Hand them to
the upstream nixos-hardware profile for this exact model, which fixes four
things that are wrong on the running machine: the laptop suspends into
s2idle rather than deep S3, the PS/2 mouse driver loads over an i2c
touchpad, no thermal management runs, and firmware updates are impossible.

The profile is taken wholesale, including the Intel graphics support it
carries. Those packages are inert without a display server, and trimming
them would mean diverging from upstream for no present benefit.

Drop the host's own Intel microcode setting, which the profile now defaults
from the redistributable firmware already enabled here.

The input follows the base nixpkgs: only its NixOS modules are consumed, so
its own pin would be evaluated by nothing while drifting silently.

The two acceptance criteria needing a reboot are left open; the sleep mode
and module blacklist only take effect on a fresh boot.
2026-07-19 16:41:34 -04:00
4c0d36324c docs: unblock the reimage from the non-boot-affecting tasks
Only the hardware profile needs to precede the reimage, because it is the
one change that touches boot: a kernel parameter and a module blacklist.
Validating it while a known-good generation still exists means the reimage
installs a configuration already proven on this hardware, where a freshly
imaged machine has no rollback target.

The housekeeping and commit-identity changes carry no boot risk and apply
equally well after the wipe, so they no longer gate it. The case for
reimaging soon rests on the machine holding almost nothing, and that
weakens with every day of use.
2026-07-19 16:20:21 -04:00
f80ea948ea docs: plan the post-boot hygiene tasks
neogaia was installed from a configuration written before the hardware
existed, so several things are now knowably wrong rather than guessed.

Adopt the upstream hardware profile for this laptop, which fixes deep
sleep, thermal management and the touchpad driver. Bound the store, the
retained generations and the boot menu, none of which have a limit today.
Declare the commit identity, which currently lives only in one checkout.

Grow the EFI system partition, which holds around seven generations at
its present size and cannot be enlarged in place, since the encrypted
container immediately behind it would have to be relocated. That makes a
reimage the only practical route, and the cheapest moment for one is now.
It doubles as the reproducibility test of the install documentation.
2026-07-19 16:11:52 -04:00
5e254857b9 docs: plan sops secrets with two-tier age identities
Supersede ADR 0001's SSH-derived key mechanism with an admin identity held
outside the repo plus a per-host identity on each encrypted root. Decoupling
the two is what lets the SSH host keys become secrets themselves rather than
the root of trust they were.

Add the spec, the three implementing tasks, and the glossary terms the
breakdown speaks in.
2026-07-19 14:57:41 -04:00
7cd9370366 docs: refresh the gotchas for a self-hosting dev machine
The dev host no longer runs CachyOS, so the per-command experimental-features
export and the from-source CachyOS kernel build no longer apply. Records the
missing git identity in their place.
2026-07-19 11:44:08 -04:00
066bf467ba feat: remap caps lock to escape on every host
Sets the xkb layout and `caps:escape_shifted_capslock` in the shared base
config, and builds the console keymap from it so the remap applies on a
bare TTY rather than only under a graphical session. Shift+Caps Lock
still toggles Caps Lock.

neogaia's `console.keyMap` is dropped: `console.useXkbConfig` defines
that option itself, so the two definitions would conflict.
2026-07-19 11:43:41 -04:00
7809e079e3 docs(claude-code): teach the skill-management skills the Nix layout
~/.claude/skills is generated by home-manager: the directories are real
but every leaf file is a read-only symlink into the store. The skills
that author and install skills assumed it was an ordinary writable tree.

- craft-skill: personal skills are authored in modules/claude-code/skills
  and applied by a rebuild, never edited under ~/.claude/skills; writing
  there succeeds silently and strands the skill outside the repo.
- setup-skills, update-skills: copy out of the library with cp -rL and
  chmod -R u+w. A plain cp -r copies the symlinks, committing store paths
  into the project, and dereferenced files keep the store's read-only mode.
- craft-skill also staged through `dot add`, a fish function this repo no
  longer carries; plain git add replaces it.
2026-07-19 08:34:22 -04:00
bdb6f01934 docs: standardise on gitea-axi as the Gitea CLI
The Gotchas entry named `tea` as the CLI for issue and pull-request work.
`gitea-axi` supersedes it; `tea` stays installed only to hold the `axi`
login that `gitea-axi` authenticates from.
2026-07-19 08:28:34 -04:00
064971f601 docs: adopt Conventional Commits for commit messages
Vendor the v1.0.0 specification as docs/conventional-commits.md and record
the convention in CLAUDE.md: type and optional scope, where the scope names
the module or host, and no Gitea-specific references, since this repository
is mirrored to GitHub where issue and pull-request numbers resolve to
unrelated things.
2026-07-19 08:24:54 -04:00
41709bb977 chore!: remove the pre-NixOS dotfiles tree
The flake and its modules now own every config these files provided;
they remain in history for reference while the migration finishes.

BREAKING CHANGE: `.config/`, `.gitconfig`, and `.github/` no longer exist
at the repository root. A checkout that deploys this tree into `$HOME`
will delete the live configs it previously installed.
2026-07-19 08:01:49 -04:00
77e853ab49 docs: rewrite the in-file comment convention
Encode the sharpened ruleset: why-over-what, self-containment, the
absence-only cross-file exception, no domain-glossary terms, no
against-alternative or future-state framing, one-line file headers,
placeholder wording, and how option description strings differ.
2026-07-19 07:57:44 -04:00
6a10f760cf refactor(lib): remove the unused scriptFromFile helper
It had no callers; the built system's derivation is unchanged by its
removal.
2026-07-19 07:57:44 -04:00
91d0a7d8e4 style: trim verbose comments to concise, self-contained notes
Cut restated "what", domain-glossary framing, cross-file consumption
narration, and against-alternative justification from in-file comments;
keep only non-obvious "why" and load-bearing pointers. Drop the
`generateCompletions` line (a no-op restatement of the upstream default)
and its comment.
2026-07-19 07:57:44 -04:00
7810425849 docs(neogaia): document the one-shot install flow
Add docs/install.md, the operator runbook that turns the completed
neogaia Host into a running encrypted laptop from the NixOS live ISO:
push the flake to its remote, join wifi, clone the repo locally,
disko-install against neogaia with the chaotic substituter handed to the
install-time daemon, set the bootstrap login password by hand via
nixos-enter, and reboot.
2026-07-19 07:57:43 -04:00
b7363ed7e1 feat(claude-code): manage the user's global config in the Module
Bring the declarative half of ~/.claude into modules/claude-code and apply
it when the Module is enabled: the global agent instructions (context =
./CLAUDE.md), the skills tree (skills = ./skills), the attention-bell hook,
and settings.json (model = opus plus the Stop/Notification/SessionStart
hook wiring).

Runtime state (projects, plugins, cache, history, sessions) and the
.credentials.json secret are left out, so login survives rebuilds and no
secret enters the repo. Verified against the built home-files that
~/.claude/{CLAUDE.md,settings.json,skills,hooks/attention-bell.sh} are
generated, the hook executable.
2026-07-19 07:57:43 -04:00
505002bb2b feat(claude-code): add a Module and enable it on neogaia
Install Claude Code through home-manager's native programs.claude-code
module, matching how the tmux and fish Modules use their home-manager
options rather than a raw home.packages entry. The feature Module at
modules/claude-code/claude-code.nix stays thin — just the enable option
and the delegation — and writes no settings, so login and first-run
configuration remain interactive and no auth material enters the repo.

Signing in without a browser (needed over the console or SSH) is
documented in modules/claude-code/authentication.md: the paste-code OAuth
flow, where the printed URL is opened on another device and the code
pasted back, and the ANTHROPIC_API_KEY path for non-interactive use.

The neogaia toplevel builds with claude-code-2.1.209 included.
2026-07-19 07:57:43 -04:00
6f9309d329 feat(nvim): configure Neovim via nixvim with Nix-managed plugins
Add an nvim Module that configures Neovim declaratively through nixvim,
wired as a flake input and consumed as its home-manager module. Options,
globals, keymaps, and plugin settings are typed Nix; the colorscheme
call and two autocmds live in modules/nvim/config.lua via extraConfigLua.
Plugins come from nixpkgs (no plugin manager, no runtime cloning); git,
ripgrep, and fd are provided from Nix; treesitter grammars are built by
Nix so no runtime compiler is needed.

Functionally matches the previous config (plugins, keymaps, options, the
nord colorscheme, markdown conceal, the Neogit blame toggle), verified
headless against the generated init.
2026-07-19 07:57:43 -04:00
98fecc314f chore: untrack the reference/ snapshot and gitignore it
reference/ is a read-only snapshot of the pre-migration machine, kept
locally for porting configs but not part of the flake. Stop tracking it
and ignore it so migrations don't churn the repo.
2026-07-19 07:57:43 -04:00
80d1587189 feat(tmux): add a native Module and enable it on neogaia
Configure tmux through home-manager's programs.tmux: the settings it exposes
as options (prefix, keyMode, mouse, baseIndex, clock24, escapeTime,
historyLimit, terminal) are set as options, and every setting it has no option
for is read verbatim from modules/tmux/extra.conf. No tmux plugin manager is
used. The generated config is behaviourally identical to the reference, verified
by parsing it with a live tmux binary.
2026-07-19 07:57:21 -04:00
7e53ecd946 style(fish): split the plugin comment and drop a redundant one 2026-07-19 07:57:21 -04:00
2d6eb929d7 refactor(fish): merge the config fragments back into one config.fish
The interactive init is small; four fragment files was over-splitting it.
Keep the mechanic (a real fish file inlined by Nix into config.fish) but
merge the fragments into a single modules/fish/config.fish read with
builtins.readFile. Rendered config is unchanged.
2026-07-19 07:57:21 -04:00
f54d0460ad refactor(fish): split config.fish into fragments concatenated by Nix
Break the interactive init into concern-scoped fish files under
modules/fish/config/ (bindings, env, done, path) and assemble them with
lib.concatMapStringsSep + readFile into a single interactiveShellInit, so
home-manager still writes one ~/.config/fish/config.fish. The pieces stay
editable as separate fish files; the rendered file is unchanged.
2026-07-19 07:57:21 -04:00
95869fb11f refactor(fish): assemble the interactive init in Nix, not at runtime
Fold the done plugin tuning back into config.fish and drop the conf.d
fragment: config.fish is read whole into interactiveShellInit, so Nix
assembles the interactive init at build time instead of fish autoloading
a separate conf.d file. functions/copy.fish stays a function file, the
idiomatic home for a lazily-autoloaded function.
2026-07-19 07:57:21 -04:00
5a89d4addb refactor(fish): lay the Module out like ~/.config/fish
Move the Module to modules/fish/fish.nix and mirror a real fish config
directory beside it: config.fish (interactive init), conf.d/done.fish
(the done plugin tuning, now a real conf.d file), and functions/copy.fish.
completions/ and themes/ are omitted as they have no content and git
cannot track empty directories.
2026-07-19 07:57:21 -04:00
b91e434c87 docs(fish): record the file-extraction in the project notes 2026-07-19 07:57:21 -04:00
16f29bd64a refactor(fish): read the non-trivial snippets from files
Move the interactiveShellInit block and the copy function body into
modules/fish/*.fish and pull them in with builtins.readFile, so the
non-trivial fish lives in real fish files (highlighting, fish_indent)
instead of inline Nix strings. The Auto-loader only collects .nix, so
the new .fish files are ignored by it. Rendered config is unchanged.
2026-07-19 07:57:21 -04:00
9eb0fe797a feat(fish): gate the default shell and prefer abbreviations
Move the login-shell switch behind a new modules.fish.defaultShell
option (default false; neogaia opts in). Convert every non-eza alias to
an abbreviation, set preferAbbrs and pin generateCompletions, and switch
the command line to vi-style editing. Cap navigation at four dots and
drop psmem/psmem10/dir/vdir/please. Comment the installed packages.
2026-07-19 07:57:21 -04:00
8346d63e64 feat(fish): add a native Module and set it as neogaia's login shell
Translate the CachyOS fish snapshot into a home-manager Module: the
fastfetch greeting, the bat-backed manpager, the done and bang-bang
plugins (from nixpkgs, not a plugin manager), the history/backup/copy
helper functions, and the eza and navigation aliases. Pacman-specific
aliases are dropped or replaced with NixOS equivalents (update ->
nixos-rebuild switch, cleanup -> nix-collect-garbage). Enabling the
Module also makes fish the user's default login shell.
2026-07-19 07:57:21 -04:00
20f5b33e00 feat(neogaia): enable networking and set the base locale
Turn the booted laptop into a usable, remotely reachable console: enable
NetworkManager so wifi can be joined from the console and an SSH daemon so
the rest of the setup can be driven over the network. Set the base locale to
timezone America/New_York, locale en_GB.UTF-8, and console keymap us.

These live inline in the neogaia Host rather than behind enable Modules,
matching the single-Host MVI precedent; a shared locale/networking Module can
be extracted once a second Host needs the same settings. SSH keeps default
password auth on, as first-boot access relies on the hand-set bootstrap
password until key/sops-based auth lands post-install.
2026-07-19 07:57:21 -04:00
194d64dacd docs: require an agent sign-off on Gitea comments
Add a convention: comments posted to Gitea go out under the operator's
account, so sign each with a — Claude sign-off to attribute them to the agent.
2026-07-19 07:57:21 -04:00
8f83c3ca8c refactor(neogaia): drop the zram Module and enable zram inline
modules/zram.nix only wrapped the native zramSwap.enable toggle without
adding anything. Rewrite the touched comments to describe only the current
file content, and record the in-file-comment convention in CLAUDE.md.
2026-07-19 07:57:21 -04:00
053c4de529 feat(neogaia): add the CachyOS kernel and hardware enablement
Select the CachyOS kernel per-Host via boot.kernelPackages, enable Intel
microcode and redistributable firmware (ath10k for the QCA6174 wifi), and
move zram behind a toggle Module. Declare the chaotic binary cache in the
base Nix settings (extra-substituters/keys) so the built system fetches the
kernel from nyx-cache rather than compiling it.
2026-07-19 07:57:21 -04:00
bfc9e6f75b style(neogaia): scope the hardware comments to the current file state 2026-07-19 07:57:21 -04:00
da30375413 feat(neogaia): declare the encrypted disk with disko
Add disko as a flake input (following nixpkgs) and wire its NixOS module
into the host-builder, so any Host can declare a disk layout while the
layout itself stays a per-Host concern.

neogaia's layout (hosts/neogaia/disk.nix): one GPT NVMe disk with a
systemd-boot EFI system partition and a LUKS container holding btrfs with
@root/@home/@nix subvolumes. The initrd prompts for the passphrase on a
normal boot. Swap is RAM-backed zram, so there is no on-disk swap
partition; zramSwap is enabled directly for now. The placeholder
fileSystems are removed since disko now derives them.
2026-07-19 07:57:21 -04:00
cfe8d4ff9f style: scope in-file comments to the current file state
In-file comments should describe only what the file currently is, not task
numbers, external tooling, or past/future states.

- hosts/neogaia: drop the "replaced by disko" / "arrive later" framing;
  describe the filesystems and hardware profile as the placeholder values
  they are.
- lib: drop the "no null-placeholder traversal hack" comparison to a prior
  implementation.
- system: drop "no impure environment lookup" from the user.name description
  and the sops/post-boot roadmap from the user comment.
2026-07-19 07:57:21 -04:00
25e12f79de build(deps): pin the stable overlay and stateVersion to 26.05
26.05 is the latest stable NixOS release as of now (26.11 is still the
in-development branch that nixos-unstable reports); the previous 25.05 pin
was a release behind.

- flake.nix / flake.lock: stable overlay tracks nixos-26.05.
- neogaia system.stateVersion and the base home.stateVersion set to 26.05
  (fresh install, so aligning to the current stable release).
- Refresh the "latest stable release" references in the project docs to
  match.

Other inputs are rolling branches (nixos-unstable, nixpkgs-unstable,
chaotic nyxpkgs-unstable) or master (home-manager, per spec), so they carry
no version to bump.
2026-07-19 07:57:21 -04:00
9b36cfadd6 feat: build the Skeleton and a minimal neogaia Host
Stand up the walking skeleton the rest of the laptop MVI extends and
re-verifies against: the whole neogaia Host evaluates and its system
toplevel builds (nix flake check green).

- flake.nix: hand-rolled flake (no flake-parts). Base nixos-unstable, plus
  nixpkgs-unstable and nixos-25.05 for the per-package unstable/stable
  overlays, home-manager (nixpkgs followed), and chaotic-nyx (deliberately
  not following our nixpkgs, to keep its binary cache usable). checks build
  each Host toplevel.
- lib/: trimmed helper lib — the Auto-loader (recursive .nix discovery, no
  null-placeholder hack), the host-builder, and the script-from-file helper.
  Deps inherited explicitly; no with lib.my, no nixosModules output.
- system/: shared base config — the unstable/stable overlays, the user
  option (defaults to alexion, in wheel, drives system + home-manager user
  in lockstep), flakes, git, and home-manager as a NixOS module.
- modules/example.nix: Auto-loader / Enable-convention reference Module,
  inert until enabled.
- hosts/neogaia/: minimal laptop Host — placeholder filesystems, bootloader,
  and hardware profile.
- CLAUDE.md: project agent instructions with a Gotchas section (nix on the
  CachyOS dev host, the chaotic overlay/cache behaviour, the Gitea CLI).
2026-07-19 07:57:21 -04:00
d5b67947f9 docs: establish domain model, MVI spec, and environment reference
Capture the design work for the NixOS migration before any implementation:

- .claude/CONTEXT.md: domain glossary (Host, Module, Skeleton, Auto-loader,
  Enable convention, unstable/stable overlay)
- .claude/adr/0001-sops-nix-for-secrets.md: secrets tooling decision
- .claude/spec/laptop-mvi.md: frozen minimum-viable-install spec for neogaia

- reference/: read-only snapshot of the current CachyOS configs (secrets and
  state excluded), plus ENVIRONMENT.md profiling the live environment to guide
  replication
2026-07-19 07:57:21 -04:00
42491d3dd0 Drop stale node path from fish_user_paths
Remove the persisted node-v24.18.0 bin entry from the universal
variable, completing the config.fish cleanup that stopped re-adding it.
2026-07-14 19:43:56 -04:00
a9cc1dc309 Guard rustup env sourcing
Only source ~/.cargo/env.fish when it exists, so the shell doesn't
error on machines without a rust toolchain installed.
2026-07-14 19:42:56 -04:00
1020865026 Adjust fish PATH entries
Prepend ~/.local/bin to fish_user_paths and drop the stale hardcoded
Pi node fish_add_path line from config.fish.
2026-07-14 19:42:51 -04:00
b4650c03b8 Remove the gitea skill
Superseded by the gitea-axi skill.
2026-07-14 19:42:44 -04:00
8d3915ad0e Enforce TDD in the implement flow
Add a PreToolUse hook that reminds the main agent not to author test
files directly, and change the implement skill to always use
test-driven-development rather than only where possible.
2026-07-14 19:42:39 -04:00
0046a87130 skill: Add skill to use tea-cli. 2026-07-13 18:54:23 -04:00
1ca5033bc8 claude: forbid PR attribution trailers, default to opus 2026-07-11 20:05:49 -04:00
aced50f67f skills: implement branches off main and opens a PR; TDD tests come from a sub-agent
implement: sync main and cut task-<NNNN>-<slug> before work, with
stop-and-ask guards for a dirty tree, a diverged main, and an unmerged
blocker. Replaces the no-commit rule with one commit, a push, and a PR
carrying the review's Risk block plus any unaddressed findings.

test-driven-development: every test is written by a general-purpose
sub-agent that never reads the implementation; the main agent writes the
code and never edits a test.
2026-07-11 20:05:44 -04:00
08ee0a2f59 chore: added node and cargo runtime 2026-07-11 08:22:12 -04:00
eee7c8190d claude: Add attention-bell hook so tmux flags Claude sessions needing input
Wire Stop and Notification hooks in ~/.claude/settings.json to a new
~/.claude/hooks/attention-bell.sh, which rings the terminal bell in the
session pane so tmux monitor-bell flags the background window.

The hook runs detached (no controlling tty), so it walks its process
ancestry to the claude process and writes the BEL to that pane tty.

Also replaces the dead settings.json symlink (pointed at a now-missing
~/wrk/claude path) with a real tracked file.
2026-07-10 22:48:28 -04:00
8c35889884 dotcli: Wrapper for VPN connection. 2026-07-10 10:13:15 -04:00
62109e993a dev: Configured Bun and Android Studio. 2026-07-09 08:56:50 -04:00
14f8618c0a skills: add instructions to prevent unsolicited implementations. 2026-07-09 08:53:05 -04:00
b8e91ee042 docs: Remove tree-sitter-cooklang mention. 2026-07-07 09:35:17 -04:00
d0364d5d9e kde: Keybind to kill windows. 2026-07-07 08:26:09 -04:00
00edf973fd docs: added specs about implementing cooklang.
Specifically proposing developing a tree-sitter grammer for cooklang,
not based on existing work, except for the official validation tests.
2026-07-07 08:18:09 -04:00
4af1e967ed kde: Add terminal shortcut 2026-07-06 23:30:50 -04:00
172 changed files with 8253 additions and 4086 deletions

View File

@@ -1,34 +0,0 @@
# Alexion's Agent Instructions
These are common instructions for Alexion's agents across all scenarios.
## General Guidelines
- When writing commit messages, NEVER auto-add your agent name as co-author.
Omit the `Co-Authored-By:` trailer entirely, with no exceptions.
This overrides any default instruction to append one.
- Never manually modify CHANGELOG.md files or any files that are marked as auto-generated.
Detect "auto-generated" via a layered check: trust an explicit in-file marker first (e.g. `AUTO-GENERATED, DO NOT EDIT`).
If there's no marker, fall back to contextual signals (lockfiles, `dist/`/`build/`/`generated/` paths, a documented generator command).
If it's still ambiguous, ask before editing rather than guessing.
- When writing or substantially editing long Markdown files, put each full sentence in its own line.
Preserve normal Markdown structure, but avoid wrapping multiple sentences onto one physical line.
Apply this to any prose you author, regardless of file length; "long" is not a real threshold.
Only format what you're actually writing or changing.
Never reflow an entire pre-existing paragraph or file just because you touched something nearby.
- When making technical decisions, do not give much weight to development cost.
Instead, prefer quality, simplicity, robustness, scalability and long term maintainability.
This is specifically about implementation time.
Human cost/benefit heuristics ("not worth N extra days of engineering") don't transfer to an AI agent that codes far faster than a human.
This is not a license to override standard anti-overengineering guardrails (avoid premature abstraction, no speculative config, etc.); those still apply to unnecessary complexity.
It means: don't discount a more robust or maintainable approach just because it would take a human a long time to build.
- File names should always be lower case, unless there's a valid reason.
Established ecosystem or tool conventions count as a valid reason automatically (e.g. `README.md`, `LICENSE`, `CHANGELOG.md`, `Makefile`, `Dockerfile`, `.github/` files), without needing to ask each time.
- When you discover that a belief you held about an objective fact or convention of the current project was wrong, write it down so it isn't relearned next time.
This applies whether the user corrected you or you caught the mistake yourself, and only to things that are true regardless of who is operating the project (a wrong build command, a wrong file path, a convention you guessed at instead of checking) — not personal working-style preferences or one-off task details.
Record it in that project's own CLAUDE.md, not this global file, under a dedicated `## Gotchas` section (create the section if the file doesn't have one yet).
If the project has nested CLAUDE.md files, use the one nearest to where the mistake occurred, falling back to the project's top-level CLAUDE.md.
Append to an existing CLAUDE.md immediately, without asking; if no CLAUDE.md exists yet for the project, ask before creating one.
Briefly mention the edit in your response rather than making it silently.
If an existing entry is later found to be wrong or stale, correct or remove it the same way.

View File

@@ -1 +0,0 @@
/home/alexion/wrk/claude/settings.json

1
.claude/skills Symbolic link
View File

@@ -0,0 +1 @@
../.agents/skills

View File

@@ -1,47 +0,0 @@
# ADR Format
ADRs live in `.claude/adr/` and use sequential numbering: `0001-slug.md`, `0002-slug.md`, etc.
Create the `.claude/adr/` directory lazily — only when the first ADR is needed.
## Template
```md
# {Short title of the decision}
{1-3 sentences: what's the context, what did we decide, and why.}
```
That's it. An ADR can be a single paragraph. The value is in recording *that* a decision was made and *why* — not in filling out sections.
## Optional sections
Only include these when they add genuine value. Most ADRs won't need them.
- **Status** frontmatter (`proposed | accepted | deprecated | superseded by ADR-NNNN`) — useful when decisions are revisited
- **Considered Options** — only when the rejected alternatives are worth remembering
- **Consequences** — only when non-obvious downstream effects need to be called out
## Numbering
Scan `.claude/adr/` for the highest existing number and increment by one.
## When to offer an ADR
All three of these must be true:
1. **Hard to reverse** — the cost of changing your mind later is meaningful
2. **Surprising without context** — a future reader will look at the code and wonder "why on earth did they do it this way?"
3. **The result of a real trade-off** — there were genuine alternatives and you picked one for specific reasons
If a decision is easy to reverse, skip it — you'll just reverse it. If it's not surprising, nobody will wonder why. If there was no real alternative, there's nothing to record beyond "we did the obvious thing."
### What qualifies
- **Architectural shape.** "We're using a monorepo." "The write model is event-sourced, the read model is projected into Postgres."
- **Integration patterns between contexts.** "Ordering and Billing communicate via domain events, not synchronous HTTP."
- **Technology choices that carry lock-in.** Database, message bus, auth provider, deployment target. Not every library — just the ones that would take a quarter to swap out.
- **Boundary and scope decisions.** "Customer data is owned by the Customer context; other contexts reference it by ID only." The explicit no-s are as valuable as the yes-s.
- **Deliberate deviations from the obvious path.** "We're using manual SQL instead of an ORM because X." Anything where a reasonable reader would assume the opposite. These stop the next engineer from "fixing" something that was deliberate.
- **Constraints not visible in the code.** "We can't use AWS because of compliance requirements." "Response times must be under 200ms because of the partner API contract."
- **Rejected alternatives when the rejection is non-obvious.** If you considered GraphQL and picked REST for subtle reasons, record it — otherwise someone will suggest GraphQL again in six months.

View File

@@ -1,30 +0,0 @@
# CONTEXT.md Format
## Structure
```md
# {Context Name}
{One or two sentence description of what this context is and why it exists.}
## Language
**Order**:
{A one or two sentence description of the term}
_Avoid_: Purchase, transaction
**Invoice**:
A request for payment sent to a customer after delivery.
_Avoid_: Bill, payment request
**Customer**:
A person or organization that places orders.
_Avoid_: Client, buyer, account
```
## Rules
- **Be opinionated.** When multiple words exist for the same concept, pick the best one and list the others under `_Avoid_`.
- **Keep definitions tight.** One or two sentences max. Define what it IS, not what it does.
- **Only include terms specific to this project's context.** General programming concepts (timeouts, error types, utility patterns) don't belong even if the project uses them extensively. Before adding a term, ask: is this a concept unique to this context, or a general programming concept? Only the former belongs.
- **Group terms under subheadings** when natural clusters emerge. If all terms belong to a single cohesive area, a flat list is fine.

View File

@@ -1,56 +0,0 @@
---
name: domain-modeling
description: Build and sharpen a project's domain model. Use when the user wants to pin down domain terminology or a ubiquitous language, record an architectural decision, or when another skill needs to maintain the domain model.
---
# Domain Modeling
Actively build and sharpen the project's domain model as you design. This is the *active* discipline — challenging terms, inventing edge-case scenarios, and writing the glossary and decisions down the moment they crystallise. (Merely *reading* `.claude/CONTEXT.md` for vocabulary is not this skill — that's a one-line habit any skill can do. This skill is for when you're changing the model, not just consuming it.)
## File structure
```
/
├── .claude/
│ ├── CONTEXT.md
│ └── adr/
│ ├── 0001-event-sourced-orders.md
│ └── 0002-postgres-for-write-model.md
└── src/
```
Create files lazily — only when you have something to write. If no `.claude/CONTEXT.md` exists, create it when the first term is resolved. If no `.claude/adr/` exists, create it when the first ADR is needed.
## During the session
### Challenge against the glossary
When the user uses a term that conflicts with the existing language in `.claude/CONTEXT.md`, call it out immediately. "Your glossary defines 'cancellation' as X, but you seem to mean Y — which is it?"
### Sharpen fuzzy language
When the user uses vague or overloaded terms, propose a precise canonical term. "You're saying 'account' — do you mean the Customer or the User? Those are different things."
### Discuss concrete scenarios
When domain relationships are being discussed, stress-test them with specific scenarios. Invent scenarios that probe edge cases and force the user to be precise about the boundaries between concepts.
### Cross-reference with code
When the user states how something works, check whether the code agrees. If you find a contradiction, surface it: "Your code cancels entire Orders, but you just said partial cancellation is possible — which is right?"
### Update .claude/CONTEXT.md inline
When a term is resolved, update `.claude/CONTEXT.md` right there. Don't batch these up — capture them as they happen. Use the format in [CONTEXT-FORMAT.md](./CONTEXT-FORMAT.md).
`.claude/CONTEXT.md` should be totally devoid of implementation details. Do not treat `.claude/CONTEXT.md` as a spec, a scratch pad, or a repository for implementation decisions. It is a glossary and nothing else.
### Offer ADRs sparingly
Only offer to create an ADR when all three are true:
1. **Hard to reverse** — the cost of changing your mind later is meaningful
2. **Surprising without context** — a future reader will wonder "why did they do it this way?"
3. **The result of a real trade-off** — there were genuine alternatives and you picked one for specific reasons
If any of the three is missing, skip the ADR. Use the format in [ADR-FORMAT.md](./ADR-FORMAT.md).

View File

@@ -1,14 +0,0 @@
---
name: grill
description: Interview the user relentlessly about a plan or design, capturing the resolved terms and decisions into the project's domain model as you go if one exists. Use when the user wants to stress-test a plan before building, or uses any 'grill' trigger phrase.
---
Interview me relentlessly about every aspect of this plan or design. Walk down each branch of the design tree, resolving dependencies between decisions one by one, and give your recommended answer for each question. Keep going until every branch carries an explicit decision and no dependency between decisions is left open — not merely until it feels like "we understand each other."
Ask the questions one at a time, waiting for feedback on each before continuing. Asking several at once is bewildering.
If a question can be answered by exploring the codebase, explore the codebase instead of asking it.
## Tracking the domain model as you go
If a `.claude/CONTEXT.md` file exists in the project, also run [`domain-modeling`](../domain-modeling/SKILL.md) alongside this interview: resolve each term into `.claude/CONTEXT.md` the moment it crystallizes, and offer an ADR using that skill's own criteria — hard to reverse, surprising without context, and the result of a real trade-off. If no `.claude/CONTEXT.md` exists, run the interview alone with no doc side effects.

View File

@@ -1,37 +0,0 @@
---
name: implement
description: Implement a task file produced by /to-tasks, review it, and close it out.
disable-model-invocation: true
---
Implement a task file end-to-end: build it, review it, and close it out.
## Process
### 1. Read the task file and check blockers
The user passes the path to a task file (`.claude/tasks/<NNNN>-slug.md`, as produced by `/to-tasks`) explicitly — don't infer one from context.
If the task's frontmatter has a `blocked-by` field, read each referenced task file and check for any unresolved `- [ ]` acceptance criterion. If any blocker isn't fully resolved, warn the user which one and why, and confirm before proceeding — don't refuse outright.
### 2. Implement
Build the work described in the task's "What to build" section, satisfying its acceptance criteria. Use `/test-driven-development` where possible, at the seams already agreed when the spec or task was written.
Run typechecking regularly, single test files regularly, and the full test suite once at the end.
### 3. Stage the changes
Stage (`git add`) each file you create or modify, specifically — not `git add -A` — so nothing untracked and unrelated gets swept in.
### 4. Review
Run `/review-uncommitted`, passing the task file itself as the spec source — it already links back to its parent spec via its `spec` frontmatter field, if any. Address anything it raises before moving on.
### 5. Close out the task file
Mark every acceptance criterion `[x]` if satisfied or `[-]` if deliberately dropped, so none are left `[ ]`. Append a `## Implementation Notes` section explaining any deviations from the plan — dropped criteria (referencing which, and why), scope changes, decisions made mid-implementation, follow-ups worth flagging. Skip the section only if nothing deviated. Leave the `spec` and `blocked-by` frontmatter fields untouched — they're a permanent record, not a checklist to clear (see `to-tasks`'s `TASK-FORMAT.md`).
Stage the updated task file with the rest.
Do not commit — leave the commit itself for the user to make.

View File

@@ -1,88 +0,0 @@
[general]
working_directory = "None"
live_config_reload = true
[env]
TERM = "xterm-256color"
WINIT_X11_SCALE_FACTOR = "1.0"
[window]
dimensions = { columns = 100, lines = 30 }
dynamic_padding = true
decorations = "Full"
opacity = 0.8
title = "Alacritty@CachyOS"
class = { instance = "Alacritty", general = "Alacritty" }
decorations_theme_variant = "Dark"
[scrolling]
history = 10000
multiplier = 3
[font]
normal = { family = "MesloLGS Nerd Font Mono", style = "Regular" }
bold = { family = "MesloLGS Nerd Font Mono", style = "Bold" }
italic = { family = "MesloLGS Nerd Font Mono", style = "Italic" }
bold_italic = { family = "MesloLGS Nerd Font Mono", style = "Bold Italic" }
size = 12.0
[colors]
draw_bold_text_with_bright_colors = true
[colors.primary]
background = "0x2E3440"
foreground = "0xD8DEE9"
[colors.normal]
black = "0x3B4252"
red = "0xBF616A"
green = "0xA3BE8C"
yellow = "0xEBCB8B"
blue = "0x81A1C1"
magenta = "0xB48EAD"
cyan = "0x88C0D0"
white = "0xE5E9F0"
[colors.bright]
black = "0x4C566A"
red = "0xBF616A"
green = "0xA3BE8C"
yellow = "0xEBCB8B"
blue = "0x81A1C1"
magenta = "0xB48EAD"
cyan = "0x8FBCBB"
white = "0xECEFF4"
[selection]
semantic_escape_chars = ",│`|:\"' ()[]{}<>\t"
save_to_clipboard = true
[cursor]
style = { shape = "Underline", blinking = "Off" }
unfocused_hollow = true
thickness = 0.15
[mouse]
hide_when_typing = true
bindings = [
{ mouse = "Middle", mods = "None", action = "PasteSelection" },
]
[keyboard]
bindings = [
{ key = "Paste", mods = "None", action = "Paste" },
{ key = "Copy", mods = "None", action = "Copy" },
{ key = "L", mods = "Control", action = "ClearLogNotice" },
{ key = "L", mods = "Control", mode = "~Vi", chars = "\f" },
{ key = "PageUp", mods = "Shift", mode = "~Alt", action = "ScrollPageUp" },
{ key = "PageDown", mods = "Shift", mode = "~Alt", action = "ScrollPageDown" },
{ key = "Home", mods = "Shift", mode = "~Alt", action = "ScrollToTop" },
{ key = "End", mods = "Shift", mode = "~Alt", action = "ScrollToBottom" },
{ key = "V", mods = "Control|Shift", action = "Paste" },
{ key = "C", mods = "Control|Shift", action = "Copy" },
{ key = "F", mods = "Control|Shift", action = "SearchForward" },
{ key = "B", mods = "Control|Shift", action = "SearchBackward" },
{ key = "C", mods = "Control|Shift", mode = "Vi", action = "ClearSelection" },
{ key = "Key0", mods = "Control", action = "ResetFontSize" },
]

View File

@@ -1 +0,0 @@
{"sessionId":"d1732179-27b7-49fb-82af-6669b783a444","pid":45537,"procStart":"7266197","acquiredAt":1783288905666}

View File

@@ -1,76 +0,0 @@
## Problem Statement
Several KDE settings on this machine have already been changed by hand away from their KDE/CachyOS defaults — the caps-lock/Escape swap is live right now, and screenshot-related keybind changes (Spectacle bindings, moving Lock Session off `Meta+L`) are planned next — but none of this is tracked anywhere in the dotfiles repo. If the machine were rebuilt today, these settings would silently revert to defaults with no record of what needs to be reapplied. There's also no way to notice *unexpected* drift (a setting that changed without the owner deliberately choosing to change it), and no tooling to bring a manually-tweaked setting under tracking without hand-writing one-off `kwriteconfig6`/D-Bus calls — exactly the accumulation of ad hoc scripts the dotfiles project has otherwise avoided.
## Solution
Add a `dot kde` subcommand family with three verbs:
- **`dot kde apply`** — pushes every setting declared in a tracked manifest onto the live KDE session (repo → system).
- **`dot kde diff`** — a broad, read-only scan reporting every live KDE setting that differs from its default, tagging each mismatch as either already-declared (in the manifest) or undeclared (system → discovery, no write).
- **`dot kde save`** — the write path into the manifest (system → repo). Run with no arguments, it refreshes every already-declared entry's stored value from the live system. Run with explicit coordinates, it begins tracking one new setting, seeded from its current live value.
The manifest is a single flat, mechanism-agnostic file: opaque `identifier=value` lines. `dot kde` internally figures out *how* to read/write a given identifier (three different underlying mechanisms exist across KDE's config surface), so the manifest itself never needs to know or care how KDE happens to store that particular setting.
## User Stories
1. As the machine owner, I want to declare that a KDE setting should have a specific value, so that a freshly-built machine ends up with the same intentional deviations from KDE's defaults without me re-discovering and re-typing the underlying `kwriteconfig6`/D-Bus incantations.
2. As the machine owner, I want `dot kde apply` to push all declared settings onto a live session in one idempotent command, so that re-running it after a KDE update or on a new machine is safe and has no unintended side effects.
3. As the machine owner, I want `dot kde diff` to show me every KDE setting currently different from default, so that I can catch drift I didn't intend, not just check the handful of settings I already know about.
4. As the machine owner, I want `dot kde diff`'s output to distinguish "this is already declared and intentional" from "this is undeclared and I've never seen it before," so that the noise of broad scanning doesn't bury genuinely unexpected changes.
5. As the machine owner, I want to run `dot kde save` with no arguments and have every already-tracked setting's manifest value refreshed from whatever is currently live, so that if I tweak a tracked setting by hand (e.g. change a keybind in System Settings) the manifest catches up without me re-typing its identifier.
6. As the machine owner, I want to run `dot kde save` with an explicit identifier to begin tracking one specific setting I just noticed via `diff`, so that I control exactly what enters the manifest instead of everything non-default being swept in at once.
7. As the machine owner, I want global keyboard shortcuts to be read and written through KDE's own shortcut-management service rather than by hand-editing `kglobalshortcutsrc`, so that changes take effect immediately in the running session and I never have to reconstruct KDE's internal triplet bookkeeping (current/default/friendly-name) myself.
8. As the machine owner, I want KConfigXT-schema-backed settings to have their "default" value discovered automatically wherever KDE's schema declares it, so that broad drift-scanning covers as much of the KDE config surface as possible without me manually cataloguing every setting I might ever care about.
9. As a future contributor to this dotfiles repo, I want `dot kde`'s subcommand files to live alongside its Python helper in one place, discoverable the same way every other `dot` subcommand is, so that adding this feature doesn't require bespoke wiring outside the established convention.
## Implementation Decisions
- **Subcommand family**: `dot kde apply` / `dot kde diff` / `dot kde save`, following the project's existing nested-subcommand dispatch convention (each level checks for `help` as its first positional argument before `argparse`, calling its own usage function).
- **`dot kde save` has two modes**:
- No arguments: iterate every identifier already in the manifest, read its current live value via the appropriate mechanism, and rewrite the manifest with the refreshed value.
- Explicit coordinates given: read the current live value for that one setting and add it to the manifest as a new declared entry. This is the only way new entries enter the manifest — there is no bulk/"track everything currently non-default" mode, by design, so that curation stays deliberate.
- **`dot kde diff`**: enumerates every setting it knows how to check (see mechanisms below), compares live vs. default, and reports every mismatch. Each reported mismatch is tagged as declared (present in the manifest, i.e. an intentional, already-tracked deviation) or undeclared (never explicitly declared). Diff never writes anything.
- **Manifest**:
- Location: a flat file directly under `~/.config/dot/` (not nested in a subdirectory — no near-term plan for multiple KDE-like targets that would justify one), named to convey "the set of KDE settings intentionally different from default."
- Format: plain text, one entry per line, `identifier=value`, split on the *first* `=` only (so values may themselves contain `=`).
- Identifier scheme: `file.group.key`, split on the first two `.`s only (so the key portion may contain further dots, spaces, or other characters freely — relevant for `kglobalshortcutsrc` action names, which can contain spaces).
- The manifest carries no mechanism/type discriminator field. It is a pure `identifier → value` map; `dot kde` decides internally how to resolve a given identifier.
- **Three underlying mechanisms**, dispatched purely by inspecting the identifier (no stored metadata):
1. **Shortcuts** (`kglobalshortcutsrc.<componentUnique>.<actionUnique>`) — resolved not by editing the rc file directly, but through KDE's `kglobalaccel` D-Bus service:
- Read current value: `shortcut(actionId)`.
- Read default value: `defaultShortcut(actionId)`.
- Write: `setShortcut(actionId, keys, flags)` with `flags = NoAutoloading` (so the declared value always wins over any previously-saved shortcut; using the `Autoloading` flag would make `apply` a no-op after the first run).
- `actionId` is a 4-element list: `[componentUniqueName, actionUniqueName, componentFriendlyName, actionFriendlyName]` (confirmed against KDE's own `actionIdFields` enum and verified live via `gdbus`). Only `componentUnique`/`actionUnique` are stored in the manifest; the two friendly-name fields (needed to actually place the D-Bus call) are resolved dynamically at call time by looking up the component's shortcut list, not stored.
- No read-modify-write is needed for this mechanism — `setShortcut` only ever touches the live/current value, never the default, so there's no risk of clobbering KDE's own bookkeeping.
2. **KConfigXT schema-backed settings** (most `kwinrc`, `kdeglobals`, etc. entries) — read/write via `kreadconfig6`/`kwriteconfig6`; the "default" value comes from the setting's `.kcfg` schema.
- The `(rcfile → [kcfg files])` mapping table is auto-derived at runtime by scanning the system's `.kcfg` schema directory for files that statically declare their target rc file (`<kcfgfile name="...">`), plus a small hand-maintained list for the exceptions that declare `<kcfgfile arg="true">` (i.e. the target file is only known at runtime by the owning app, not in the schema — `kwin.kcfg` is a known example).
- This mechanism is what enables `diff`'s broad-scan coverage: every entry reachable through the mapping table can be checked automatically, not just entries someone has already thought to add to the manifest.
3. **Freeform/schema-less settings** (e.g. `kxkbrc`'s `Options=` line) — read/write via `kreadconfig6`/`kwriteconfig6`; there is no schema, so "default" is defined as "the key is absent." Because there's no schema to enumerate from, this mechanism cannot participate in broad undeclared-drift discovery the way schema-backed settings can — it can only be checked for settings that are already declared in the manifest.
- Mechanism selection for a given identifier: if the rc file is `kglobalshortcutsrc`, use the shortcuts mechanism; otherwise, if the mapping table resolves the `(rcfile, group, key)` to a schema, use the schema-backed mechanism; otherwise, treat it as freeform.
- **File/module layout**: the fish dispatcher and its Python helper live together in one subdirectory under the project's existing commands location, rather than the Python helper sitting as a same-directory sibling of a same-named fish file at the top level.
- **Cross-cutting change to `dot` itself**: the subcommand-discovery mechanism (used both for help-listing and for dispatch) is extended to glob one additional directory level deep, not just the flat top level — required to support the subcommand-plus-helper layout above. This must be updated in both places the discovery logic currently exists (they are intentionally duplicated today rather than shared, for fish-autoload reasons), and applies to any future subcommand that wants a companion file, not just this one.
## Testing Decisions
- **Guiding principle**: tests should exercise this feature's own logic (manifest parsing, identifier dispatch, mapping-table auto-derivation, mechanism selection), not re-verify that external dependencies (`kreadconfig6`, `kwriteconfig6`, the KDE session itself) work correctly.
- **Primary seam**: full CLI invocation of `dot kde apply` / `dot kde diff` / `dot kde save`, run against a scratch `$HOME`, mirroring the existing project convention for testing `dot` subcommands (override `$HOME` per test case, no mocking of the real `kreadconfig6`/`kwriteconfig6` binaries — they run for real against fixture rc files under the scratch home). This covers the schema-backed and freeform mechanisms end-to-end: manifest read/write, identifier parsing, mechanism dispatch, and mapping-table-driven default lookup.
- **New seam introduced for this feature**: the KConfigXT schema directory is normally a fixed system path outside `$HOME`. To make the auto-derivation logic testable without depending on (or mutating) the real system's schema files, the schema directory location must be overridable (e.g. via an environment variable), defaulting to the real system path in normal use and pointing at a small fixture directory of synthetic `.kcfg` files in tests.
- **Deliberately not covered by automated tests**: the shortcuts mechanism (`kglobalaccel` D-Bus calls). It depends on a live, already-running session service that isn't practically substitutable without building dedicated mock infrastructure, which is disproportionate to what it would protect (three D-Bus calls). This path is verified manually against the real session instead.
- **Prior art**: the existing test suite for `dot`'s other subcommands already establishes the scratch-`$HOME`-plus-`fishtape` pattern this feature reuses.
## Out of Scope
- A `dot setup`-style subcommand for machine bootstrap tasks (extra groups, etc.) — considered during planning and set aside as not currently relevant.
- Folder naming / XDG user-dirs conventions — a real, separate piece of planned work, but standalone from `dot kde` and not part of this spec.
- Tracking Plasma's panel layout (`plasma-org.kde.plasma.desktop-appletsrc`) — previously decided this doesn't need tracking, since the current panel is CachyOS's own shipped default and reproduces automatically on a fresh install.
- An "empirical fallback" mechanism (spinning up a scratch config environment to let an app generate its own default config for diffing) — not needed given the three mechanisms above cover everything currently in scope; noted only as a possible future extension if some setting fits none of them.
- A bulk/`--all` mode for `dot kde save` — deliberately excluded so that every new manifest entry is a deliberate choice.
- Interactive picker UX for `diff`/`save` (e.g. selecting an undeclared entry from a list rather than typing its identifier) — not part of this spec.
- `dot voice` (hands-free dictation) — an unrelated, separately shelved piece of work, not touched by this feature.
## Further Notes
- The caps-lock/Escape swap (`kxkbrc`'s `Options=caps:escape_shifted_capslock`) is already live on this machine by hand, unrecorded anywhere — it's a ready-made first real candidate for the explicit-coordinates form of `dot kde save` once built, and a natural first end-to-end smoke test beyond the automated suite.
- The screenshot-related keybind work (Spectacle bindings, moving Lock Session off `Meta+L` to `Meta+X`, renaming Spectacle's save folder) was the original motivating case for this feature but is applied *through* `dot kde apply`/`save` rather than being separate work — once `dot kde` exists, those keybind changes are just manifest entries.
- Per the project's own cross-cutting convention, once any keybind changes are actually applied via this feature, the corresponding rows in the project's keybindings reference document need to be added/updated in the same change.

View File

@@ -1,59 +0,0 @@
## Problem Statement
The old `~/wrk/dotfiles` repo's `setup_folders` (part of its bash `bin/dot init`) renamed the standard XDG user folders to short names (`Documents→doc`, `Downloads→dwn`, etc.) for better fish shell-completion ergonomics — shorter shared prefixes are easier to disambiguate by typing fewer characters. That behavior has no equivalent in the new bare-repo `dot` CLI. Right now this machine's `user-dirs.dirs` is untracked and has drifted from even the old convention: it uses the full XDG default names, plus an ad hoc `XDG_PROJECTS_DIR=$HOME/Projects` line that never existed in the old repo at all. If this machine were rebuilt today, none of the short-name convention would be restored, and the current drifted state isn't recorded anywhere.
## Solution
Add a `folders` task to a new `dot setup` subcommand family (the general home for idempotent, re-runnable machine-setup tasks, as opposed to `dot init`'s one-shot bootstrap). `dot setup folders` brings the 8 standard XDG user directories under the project's short-name convention, tracks the resulting `user-dirs.dirs` directly as a plain dotfile, and safely migrates any content sitting in the old, full-named folders into their short-named replacements.
`~/wrk` (already in active use, e.g. `~/wrk/dotfiles`) replaces the old `Projects`-style folder as the general working-files location, but is treated as a plain convention-only directory, not a tracked XDG category.
## User Stories
1. As the machine owner, I want the standard XDG user folders renamed to short names (`doc`, `dwn`, `mus`, `pic`, `vid`, `.desktop`), so that fish-completion on my home directory has shorter, easier-to-disambiguate shared prefixes than the full XDG default names.
2. As the machine owner, I want `Templates` and `Public` (both unused) collapsed into a single hidden `.ignoreme` folder, so that apps respecting `XDG_TEMPLATES_DIR`/`XDG_PUBLICSHARE_DIR` don't scatter files directly into `$HOME`, without needing two separate unused folders.
3. As the machine owner, I want the nested `Pictures/Screenshots` folder lowercased to `pic/screenshots` in the same pass as the `Pictures→pic` rename, so that the screenshot folder matches the rest of the short-folder naming convention without a separate migration step.
4. As the machine owner, I want `~/wrk` to have no XDG variable pointing at it, so that a non-standard, barely-recognized XDG extension (`XDG_PROJECTS_DIR`) doesn't get tracked for a directory that already works fine as a plain convention.
5. As the machine owner, I want `user-dirs.dirs` tracked directly in the bare dotfiles repo like any other plain dotfile, so that the desired short names are recorded and restorable on a fresh machine without needing a code-generation step.
6. As the machine owner, I want `dot setup folders` to migrate content out of any legacy full-named folder into its short-named replacement automatically when the legacy folder is empty, so that re-running setup on a fresh install requires no manual folder shuffling.
7. As the machine owner, I want `dot setup folders` to stop and ask for explicit confirmation before moving anything out of a legacy folder that actually has content in it, so that I never silently lose files to an automated migration I forgot was going to run.
8. As the machine owner, I want confirmation to be satisfiable via a `--yes` flag rather than an interactive prompt, so that the same command works identically whether I'm running it by hand or from an automated/tested context.
9. As the machine owner, I want a filename collision between a legacy folder and an already-populated short-named target to never be silently overwritten, so that re-running the migration after a partial/interrupted prior run can't destroy a file just because both sides happen to have a same-named entry.
10. As the machine owner, I want to be told which files were skipped due to a collision and have the legacy folder left in place when that happens, so that I have a clear, actionable signal that something needs manual attention instead of silent partial data loss.
11. As the machine owner, I want `dot setup folders` to notify running apps of the directory changes via `xdg-user-dirs-update` after migrating, so that session-long apps pick up the new paths without requiring a full logout/login.
12. As the machine owner, I want to run `dot setup` with no arguments to perform every machine-setup task (folders plus future ones like extra groups) in one command, so that setting up a fresh machine doesn't require remembering and running each task individually.
13. As the machine owner, I want to also be able to run `dot setup folders` on its own, so that I can re-run just this one task in isolation (e.g. after a confirmation was declined) without re-running unrelated setup tasks.
## Implementation Decisions
- **Subcommand family**: `dot setup`, following the project's existing nested-subcommand dispatch convention (`help`-then-`argparse`, `_dot_<name>_usage`). Bare `dot setup` (no arguments) runs every machine-setup task unconditionally (folders, plus future tasks such as extra groups, mirroring the old bash `bin/dot init`'s dual-mode: no-args ran everything, an explicit keyword ran just one task). `dot setup folders` runs just the folders task.
- **Folder mapping** (identical to the old repo's `setup_folders`, no changes): `Desktop→.desktop`, `Documents→doc`, `Downloads→dwn`, `Music→mus`, `Pictures→pic`, `Videos→vid`, `Templates→.ignoreme`, `Public→.ignoreme`. `Templates` and `Public` both point at the *same* `.ignoreme` folder, as before.
- **Nested screenshots rename**: as part of the same `Pictures→pic` migration pass, the nested `Screenshots` folder (currently created empty by KDE/Spectacle defaults) is renamed to lowercase `screenshots`, so the result is `pic/screenshots`. This is folded into the folders task rather than deferred to the separate Spectacle-keybind work, since it's the same naming-convention concern and falls out for free once `Pictures/*` is moved into `pic/`.
- **`wrk` is out of the XDG mapping**: no `XDG_PROJECTS_DIR` (or any other XDG variable) is written for it. It's a plain, convention-only directory. The currently-existing ad hoc `~/Projects` folder (created by this machine's diverged, untracked `user-dirs.dirs`) is left alone — out of scope for the folders task, since it was never one of the 8 standard XDG categories the task manages, and it's empty and harmless.
- **`user-dirs.dirs` is tracked directly** as a plain dotfile in the bare repo (not generated/overwritten by `dot setup folders` from a hardcoded table each run) — unlike KDE's rc files (tracked via a separate declarative-manifest mechanism, see the `dot-kde` spec), `user-dirs.dirs` has no volatile/machine-specific fields, so it fits the same direct-tracking treatment as any other plain dotfile (`.bashrc`, etc.). The tracked file is the single source of truth for the desired short names.
- **`dot setup folders` still needs a small hardcoded table** mapping each of the 8 standard XDG categories to its legacy default folder name (`Documents`, `Downloads`, etc.) — this is used purely to locate content left behind by a fresh XDG-defaults install and merge it into the already-tracked short-named target; it is not the source of truth for the target names themselves (that's the tracked `user-dirs.dirs`).
- **Migration safety, per legacy folder**:
- Empty (strict check: any file at all, including dotfiles/metadata like a stray KDE `.directory` file, counts as non-empty) → merge silently, no prompt.
- Non-empty → print what would be moved and require an explicit `--yes` flag before proceeding. No interactive prompt.
- Collisions (a same-named entry exists in both the legacy folder and its short-named target) → use no-clobber semantics (e.g. `mv -n`) so a colliding file is never silently overwritten; report which files were skipped; leave the legacy folder in place (don't remove it) if any collision occurred, rather than deleting a folder that still holds something that couldn't be merged.
- **Post-migration step**: run `xdg-user-dirs-update` (no arguments) once folder moves are complete, to notify running apps/portals via its D-Bus signal. This is safe against the hand-tracked file — `user-dirs.dirs`'s own header documents that local edits are preserved across runs of the tool.
## Testing Decisions
- **Guiding principle**: test the folders task's own logic (mapping, empty-vs-non-empty gating, `--yes` behavior, collision handling, idempotency) through the real CLI entry point, not the internals of `mv`/`mkdir` themselves.
- **Primary seam**: full CLI invocation of `dot setup folders` (and bare `dot setup`), run against a scratch `$HOME` per test case — the existing project convention (see `dot install`'s tests). No new seam is introduced.
- **External command handling**: `xdg-user-dirs-update` is faked out via a `PATH`-prepended fake binary that logs its invocation (and exit code), exactly mirroring how `sudo`/`pacman` are faked for `dot install`'s tests. Real `mkdir`/`mv`/`rmdir` run for real against the scratch `$HOME` — no need to fake filesystem operations themselves.
- **Cases to cover**: fresh migration of empty legacy folders (no `--yes` needed); a legacy folder with real content refuses without `--yes` and proceeds with it; the nested `Pictures/Screenshots→pic/screenshots` rename; a stray dotfile (e.g. a fake `.directory`) in an otherwise-"empty" legacy folder still triggers the confirmation gate; a filename collision between legacy and target is skipped (not overwritten), reported, and leaves the legacy folder in place; re-running `dot setup folders` after a clean migration is a no-op (idempotency); bare `dot setup` runs the folders task as part of running everything; `dot setup folders help` prints usage and touches nothing.
- **Prior art**: `tests/dot.fish`'s existing scratch-`$HOME`-plus-`fishtape` pattern, and specifically the fake-`sudo`/fake-`pacman`-via-`PATH` technique used for `dot install`.
## Out of Scope
- The **extra groups** task (`dot setup groups` or similar, porting the old `.extra_groups`/`setup_users` behavior) — it will share the same `dot setup` dispatcher and dual-mode (bare-runs-everything vs. named-task) shape decided here, but its own design (group list format, idempotency, etc.) was not addressed in this spec.
- Any KDE-side settings (caps-lock/Escape swap, screenshot keybinds, Lock Session rebind) — covered separately by the `dot-kde` spec/design.
- Removing the currently-existing, now-orphaned `~/Projects` folder — explicitly left alone, not cleaned up by this feature.
- Any `~/.github/README.md` command-table row or `~/.github/keybindings.md` update — not applicable here (no keybind changes), but the README row is still required by the project's standard "adding a subcommand" checklist at implementation time.
## Further Notes
- The old bash `setup_folders`'s naive `mv $from/* $to` has a latent bug this design deliberately avoids: an unquoted glob against an empty directory can misbehave, and it has no collision protection at all. The no-clobber-plus-report behavior specified here is a deliberate improvement over the old script's behavior, not a straight port.
- This spec covers only the `folders` task; `dot setup` itself (the dispatcher, `_dot_setup_usage`, wiring into `commands/`, the completions/help-glob duplication point noted in the project's `CLAUDE.md`) needs to exist as scaffolding for this task to attach to, even though its only other planned task (extra groups) is out of scope here.

View File

@@ -1,46 +0,0 @@
## Problem Statement
On a freshly cloned dotfiles checkout (or any machine where `~/.local/share/nvim/lazy/` is empty or stale), `lazy.nvim` only discovers that plugins are missing when `nvim` is actually launched. The first interactive launch then silently spends a long time cloning `nord.nvim`, `nvim-treesitter`, and `render-markdown.nvim` and compiling every `nvim-treesitter` parser listed in `ensure_installed`, with no obvious progress indication in a normal terminal session — it reads as "nvim isn't starting" rather than "nvim is installing plugins." Nothing in `dot` proactively drives this sync, even though the exact plugin versions are already pinned and tracked in `~/.config/nvim/lazy-lock.json`.
Separately, `nvim-treesitter`'s parser build step has a known race: concurrent parser installs can collide on a relative `tree-sitter-<lang>-tmp` directory, causing one parser (e.g. `bash`) to fail to compile. Because the compiled `.so` never lands in `~/.local/share/nvim/lazy/nvim-treesitter/parser/`, that parser gets retried (and can fail again) on every subsequent `nvim` launch until it eventually succeeds — a silent, recurring cost with no clear signal to the user that anything is wrong.
## Solution
Add an `nvim` task to the `dot setup` family (introduced by the `dot-setup-folders` spec as the general home for idempotent, re-runnable machine-setup tasks). `dot setup nvim` drives a headless `nvim` session that syncs installed plugins to exactly what `lazy-lock.json` already pins, and verifies afterward that every pinned plugin actually landed on disk — turning a silent, ambiguous first-launch stall into an explicit, scriptable, pass/fail setup step. Bare `dot setup` (no task name) runs this alongside `folders` (and any future tasks).
## User Stories
1. As the machine owner, I want `dot setup nvim` to install/sync every plugin pinned in `lazy-lock.json` before I ever open `nvim` interactively, so that my first real editing session isn't interrupted by an unexplained multi-second-to-multi-minute stall that looks like a hang.
2. As the machine owner, I want `dot setup nvim` to use the already-tracked `lazy-lock.json` as the source of truth (not re-resolve latest versions), so that a fresh machine ends up with the exact plugin commits I've already vetted, not whatever is newest upstream that day.
3. As the machine owner, I want `dot setup nvim` to exit non-zero and say clearly which plugin(s) failed to install, so that a partial/broken sync is an obvious, actionable failure rather than something I only notice later inside nvim.
4. As the machine owner, I want re-running `dot setup nvim` when everything is already in sync to be a fast no-op that still exits 0, so that it's safe to include unconditionally in `dot setup`'s bare "run everything" mode without slowing down every re-run.
5. As the machine owner, I want to be able to run `dot setup nvim` in isolation (not just as part of bare `dot setup`), so that I can re-sync plugins on their own after e.g. manually editing `lazy-lock.json` or clearing the plugin directory.
6. As the machine owner, I want `dot setup nvim help` to print usage without touching any plugin state, so that it's consistent with every other `dot` subcommand's `help` behavior.
## Implementation Decisions
- **Subcommand family**: lives under the `dot setup` dispatcher established by the `dot-setup-folders` spec — same nested-subcommand convention (`help`-then-`argparse`, `_dot_setup_nvim_usage`), same dual-mode shape (bare `dot setup` runs every task; `dot setup nvim` runs just this one). This spec does not re-describe the shared dispatcher scaffolding itself; see `dot-setup-folders.md` for that.
- **Core action**: run `nvim --headless "+Lazy! restore" +qa`. `Lazy! restore` checks out every plugin in the spec to the exact commit recorded in `lazy-lock.json` (installing it first via clone if missing), so it both fixes "missing plugin" and "plugin present but on the wrong commit" in one call. No separate `TSUpdate`/`TSInstall` step is needed: because none of the current plugins (`nord.nvim`, `nvim-treesitter`, `render-markdown.nvim`) declare a lazy-loading trigger (`event`/`cmd`/`ft`), they load eagerly as part of this same headless session, which drives `nvim-treesitter`'s own `ensure_installed` parser-compilation step as a natural side effect — matching what was observed when reproducing the issue.
- **Failure detection**: `nvim`'s process exit code from `--headless ... +qa` does not reliably reflect whether `Lazy! restore` itself succeeded (Lazy reports failures via its own UI/messages, not necessarily the process exit status). `dot setup nvim` must independently verify success after the headless run completes, by checking that every plugin name declared in `lazy-lock.json` has a corresponding directory under `~/.local/share/nvim/lazy/`. Any pinned plugin missing a directory is treated as a failure: print which plugin(s) didn't install and exit non-zero.
- **Parser-compile failures are out of scope for pass/fail**: the `tree-sitter-<lang>-tmp` collision race affects `nvim-treesitter`'s internal parser build, not the plugin-directory check above (nvim-treesitter's own directory will exist regardless of whether an individual parser compiled). `dot setup nvim`'s success criterion is "all pinned plugins are present," not "all treesitter parsers compiled" — a parser-level compile flake is expected to self-heal on a later `nvim` launch or `:TSUpdate`, per the `Further Notes` in this spec's investigation. Detecting and retrying individual parser build failures is not attempted here.
- **No package-list file**: unlike `dot install`, there's nothing to record — `lazy-lock.json` is already the tracked source of truth, so `dot setup nvim` never writes to it.
## Testing Decisions
- **Guiding principle**: test `dot setup nvim`'s own logic (that it invokes `nvim` correctly, that it correctly detects success vs. a missing plugin) through the real CLI entry point, faking only the external `nvim` binary — not real plugin installs, real git clones, or real compilation, which would be slow and network-dependent in tests.
- **Primary seam**: full CLI invocation of `dot setup nvim` (and bare `dot setup`), run against a scratch `$HOME` per test case — the existing project convention (see `dot install`'s and the planned `dot setup folders`' tests). No new seam is introduced.
- **Faking `nvim`**: a `PATH`-prepended fake `nvim` binary, mirroring the fake-`pacman`/fake-`sudo`/fake-`xdg-user-dirs-update` technique already used/planned in `tests/dot.fish`. The fake logs its invocation args (so a test can assert `dot setup nvim` called it with `--headless "+Lazy! restore" +qa`) and, driven by an env var or scratch-`$HOME` fixture, can simulate "all plugins present" vs. "one plugin missing" by controlling whether it creates the expected directories under the scratch `~/.local/share/nvim/lazy/`.
- **Cases to cover**: a successful sync (fake `nvim` creates all pinned plugin directories) exits 0; a plugin missing after the fake run exits non-zero and names the missing plugin; re-running against an already-fully-synced scratch `$HOME` is still a pass (idempotency) without requiring the fake to do anything different; bare `dot setup` runs the `nvim` task alongside `folders`; `dot setup nvim help` prints usage and never invokes the fake `nvim` at all.
- **Prior art**: `tests/dot.fish`'s scratch-`$HOME`-plus-`fishtape` pattern, and specifically the fake-binary-via-`PATH` technique used for `dot install` (and planned for `dot setup folders`'s `xdg-user-dirs-update` fake).
## Out of Scope
- The `dot setup` dispatcher scaffolding itself (bare-runs-everything, per-task dispatch, `_dot_setup_usage`) — already specified in `dot-setup-folders.md`; this spec only adds the `nvim` task onto it.
- The `folders` and any future (e.g. `groups`) `dot setup` tasks — unaffected by this spec beyond now running alongside `nvim` in bare `dot setup`.
- Fixing the underlying `nvim-treesitter` `tree-sitter-<lang>-tmp` race itself (an upstream plugin behavior) — `dot setup nvim` tolerates it rather than working around it.
- Any change to `~/.config/nvim`'s plugin specs, `lazy-lock.json` contents, or which plugins/parsers are installed — this spec only adds a way to proactively sync to what's already pinned.
- A `~/.github/README.md` command-table row — not written here, but required by the project's standard "adding a subcommand" checklist at implementation time.
## Further Notes
- This spec grew out of debugging a real "nvim isn't starting" report: the actual cause was an empty `lazy.nvim` plugin directory triggering a full, slow reinstall on first launch, compounded by a `tree-sitter-bash-tmp` mkdir collision that made the `bash` parser fail and re-attempt on every subsequent launch until it happened to succeed. `dot setup nvim` addresses the first (silent first-launch stall) directly; the second (parser race) is a pre-existing upstream flake this spec does not attempt to fix.

View File

@@ -1,56 +0,0 @@
## Problem Statement
Today, `to-spec`, `to-tasks`, and `implement` track specs and tasks as local files (`.claude/spec/<slug>.md`, `.claude/tasks/<NNNN>-<slug>.md`) scoped to a single git working tree.
That means task state and context don't survive across the machine boundary — a spec or task can't be picked up from a different clone, referenced from a PR, or handed to a differently-scoped agent session without manually carrying the files over.
There's also no natural place for `review-uncommitted`'s findings to live once produced, other than the terminal output, which the operator has to capture manually if they want it preserved as a record.
## Solution
Once `gitea-axi` (see the companion `gitea-axi` spec) exists, replace the local-file storage in this project's skill-based task-management pipeline with Gitea issues and pull requests: specs and tasks become labeled issues, "readiness" becomes a label state, and implemented work becomes a pull request that `review-uncommitted` comments on directly.
The workflow-specific semantics (label names, state transitions, PR-to-issue linking) live entirely in the skills' own prose, calling `gitea-axi`'s generic primitives — `gitea-axi` itself stays unaware of this project's conventions.
## User Stories
1. As the operator, I want `to-spec` to open a Gitea issue containing the spec instead of writing a local file, so that the spec is visible and referenceable outside my local working tree.
2. As the operator, I want the spec issue labeled to mark it ready for task breakdown, so that a later session can find it without me telling it the issue number.
3. As the operator, I want a new session to be able to locate and read a spec issue by its readiness label, so that I can hand off spec-to-task work across sessions without manually passing context.
4. As the operator, I want `to-tasks` to open one Gitea issue per task instead of writing local task files, so that each task is independently discoverable and referenceable the same way the spec is.
5. As the operator, I want each task issue to retain a reference back to its parent spec issue, so that the `spec` traceability that today's local task-file frontmatter provides isn't lost in the move to issues.
6. As the operator, I want `to-tasks` to remove the spec issue's readiness label once tasks are created from it, so that the state machine reflects "spec has already been broken down" and isn't reprocessed.
7. As the operator, I want to ask a new session to implement "the next task" and have it find the right task issue by its readiness label, so that I don't have to look up and paste an issue number myself.
8. As the operator, I want `implement` to read a task issue's full details before starting work, so that it has the same context a local task file would have given it.
9. As the operator, I want `implement` to open a pull request (carrying the implementation commit) once work is done, instead of leaving only an uncommitted or committed local diff, so that the work is reviewable and mergeable through Gitea like any other PR.
10. As the operator, I want `review-uncommitted` to fetch its diff and spec context from the pull request and its linked issue when run in this workflow, so that I don't need a local spec file for it to work against.
11. As the operator, I want `review-uncommitted`'s three-axis findings posted as a comment on the pull request, so that they're visible as a permanent record on the PR itself, not just in my terminal.
12. As the operator, I want the label taxonomy and state machine (spec/task readiness, PR-to-issue linking conventions) to be easy to change later, so that I can iterate on the workflow without touching `gitea-axi`'s code.
13. As the operator, I want PR granularity (one commit vs. several, one task vs. several per PR) decided case-by-case between me and the agent at `implement` time, rather than fixed by a rule baked into the skill.
## Implementation Decisions
- Depends on `gitea-axi` existing first (see the companion spec) — this spec only covers how this project's skills consume it, not the tool itself.
- Affected skills: `to-spec`, `to-tasks`, `implement`, `review-uncommitted`. Each swaps its local-file I/O (`Read`/`Write`/`Edit` against `.claude/spec/` and `.claude/tasks/`) for calls to `gitea-axi`'s generic issue/PR primitives.
- `to-spec` opens an issue (instead of writing `.claude/spec/<feature-slug>.md`) carrying the same spec content and format, labeled to mark it as newly created and ready for breakdown.
- `to-tasks` reads the spec issue, opens one issue per task slice (instead of `.claude/tasks/<NNNN>-<slice-slug>.md`), each carrying a reference back to the parent spec issue (replacing the current `spec` frontmatter field), labels each task issue as ready for implementation, and removes the readiness label from the spec issue once done.
- `implement` locates its target task issue (by number if given, or by readiness label/query if asked for "the next task"), reads it in place of a local task file, does the work, and opens a pull request carrying the implementation commit — in place of just staging locally and leaving the commit to the operator.
- `review-uncommitted` gains a Gitea-aware path: when working against a PR, it fetches PR diff/metadata and the linked spec/task issue instead of `git diff HEAD` and a local spec file, and posts its aggregated Risk/Standards/Spec report as a single PR comment once done (per the companion spec's decision to keep this a single comment, not per-finding inline comments).
- Label taxonomy and exact naming (today referred to provisionally as "spec"/"ready-for-agent") are explicitly left open — to be finalized when these skill updates are actually implemented, not fixed by this spec.
- PR granularity (commits per PR, tasks per PR) is explicitly left as a case-by-case decision made between the operator and the agent at `implement` time — not a fixed rule this spec encodes.
## Testing Decisions
- Skills are prose (`SKILL.md` files), not unit-testable code — there is no automated test seam for the skill updates themselves. Verification is behavioral: running each updated skill against a real (or disposable) Gitea instance end-to-end and confirming the resulting issues, PRs, labels, and comments match what the prose describes.
- The one seam that is testable in the traditional sense is `gitea-axi` itself, already covered by the companion spec — these skill updates are downstream consumers of that seam, not a new one.
- No prior art in this repo for testing prompt-based skills; `~/.config/dot/tests/dot.fish` (fishtape, end-to-end against fixtures) is the closest pattern, but it tests code, not prose, so it doesn't transfer directly.
## Out of Scope
- Building `gitea-axi` itself (fully covered by the companion `gitea-axi` spec).
- Deciding the actual label taxonomy and state machine names — deferred to implementation time.
- Deciding PR granularity rules — deferred to case-by-case decisions at `implement` time.
- Inline per-finding PR review comments for `review-uncommitted` (deferred enhancement, noted in the companion spec).
- Any change to `codebase-design`, `domain-modeling`, `test-driven-development`, or other skills not in the four listed above.
## Further Notes
- This spec assumes `gitea-axi`'s generic primitives (issue create/read/find-by-label/update-labels, PR create/get/comment) are sufficient for the four listed skills. If implementation reveals a missing primitive, it should be added to `gitea-axi` itself (kept generic) rather than special-cased here.
- This is an opinionated, single-adopter view of `gitea-axi` — it intentionally isn't part of the `gitea-axi` spec itself, since that tool is meant to stay usable by others regardless of this project's specific workflow conventions.

View File

@@ -1,70 +0,0 @@
## Problem Statement
Coding agents that need to drive a Gitea-hosted workflow (issues, pull requests, labels) today have two poor options.
The official `tea` CLI is human-oriented: it has no token-efficiency, no contextual guidance, and no agent-facing error conventions.
Gitea's MCP servers expose the full API surface (dozens of tools) rather than being tuned for token or turn efficiency.
There is no Gitea-focused tool built to the same "agent ergonomics" standard that `gh-axi` established for GitHub.
## Solution
Build `gitea-axi`: a thin, generic CLI wrapper around the official `tea` binary that reshapes its output according to the 10 AXI (Agent eXperience Interface) principles — token-efficient output, minimal default schemas, structured errors, contextual next-steps, and so on.
It gives coding agents an ergonomic, low-token way to drive issues and pull requests on any Gitea instance.
It ships both as an installable npm CLI and as an installable Agent Skill, so any agent session can adopt it with one install step.
## User Stories
1. As a coding agent, I want to create a Gitea issue with a title, body, and labels, so that I can record work items for later retrieval.
2. As a coding agent, I want to find issues by label (and other basic filters), so that I can locate relevant work without already knowing its issue number.
3. As a coding agent, I want to read an issue's full body, labels, and comments, so that I can load its context into a session.
4. As a coding agent, I want to add and remove labels on an existing issue, so that I can reflect state transitions as work progresses.
5. As a coding agent, I want to create a pull request from the current branch, so that completed work becomes reviewable.
6. As a coding agent, I want to fetch a pull request's metadata and diff, so that review tooling can operate on it without re-deriving it from git.
7. As a coding agent, I want to post a comment on a pull request, so that findings or notes are visible as a permanent reference on the PR itself.
8. As a coding agent, I want command output in a token-minimized format (TOON, minimal default fields, truncated large fields with an escape hatch), so that repeated calls across a long-running session don't consume excessive context.
9. As a coding agent, I want pre-computed aggregates in list/read output, so that I don't need follow-up calls just to derive obvious derived fields.
10. As a coding agent, I want explicit empty-state output when a query returns nothing, so that "no results" is never ambiguous with an error or a hang.
11. As a coding agent, I want structured errors with actionable suggestions and meaningful exit codes instead of prose failures, so that I can self-correct without the operator's help.
12. As a coding agent, I want mutations to be idempotent and to never prompt interactively, so that unattended, scripted use never stalls or double-applies.
13. As a coding agent, I want contextual next-step suggestions appended after output, so that I know what to call next without being taught the tool from scratch every session.
14. As a coding agent, I want a consistent per-subcommand `--help`, so that I can discover the interface on demand rather than needing it pre-loaded in context.
15. As an operator, I want gitea-axi run with no arguments to show live, actionable repository state instead of a help screen, so that I get immediate value without memorizing flags.
16. As an operator, I want gitea-axi to reuse my existing `tea` login configuration (including multi-instance profiles), so that I don't manage a second set of credentials.
17. As an operator, I want gitea-axi's command surface to stay generic, with no workflow-specific behavior baked in, so that it's useful across different projects and label/workflow conventions without code changes.
18. As an operator, I want gitea-axi published to npm and as an installable Agent Skill, so that I (and others) can adopt it with a single install step.
## Implementation Decisions
- New standalone repository — not bundled into any other tool or CLI framework.
- Developed against the operator's personal Gitea instance; push-mirrored to GitHub for npm publishing and public discoverability/contribution.
- Language/runtime: TypeScript on Node, matching the `gh-axi` reference implementation this design is modeled on.
- Implementation strategy: wrap the `tea` binary as a subprocess, invoking it with `--output json` (or the most structured format it supports) and reshaping that output — not a from-scratch Gitea API client. This reuses `tea`'s auth, multi-instance login, and full command coverage for free.
- **Flagged risk**: subprocess-wrapping-a-CLI can become fragile or slow at higher call volumes or in edge cases (partial output, non-JSON error text, version drift in `tea`'s own output shape). If this proves to be a real problem in practice, the fallback is a direct Gitea HTTP API client (as Gitea's own MCP server already does) — noted here so it isn't re-litigated from scratch if revisited.
- Auth: no independent credential handling. Every command shells out through `tea`, so it relies entirely on `tea login add` already being configured, including `tea`'s own `--login`/multi-instance profile resolution.
- Command surface: generic Gitea primitives only — issue create/read/find-by-label/update-labels, PR create/get/comment (see User Stories above for the full list). No project-specific or workflow-specific commands (e.g. nothing that hardcodes a particular label taxonomy or state machine).
- Output ergonomics follow the 10 AXI principles (https://axi.md/, https://github.com/kunchenguid/axi), grouped as:
- Efficiency: TOON-formatted stdout (~40% fewer tokens than JSON), minimal default schemas (3-4 fields per list item), truncated large fields with size hints and an escape hatch to fetch full content.
- Robustness: pre-computed aggregates to avoid round trips, explicit empty-state messages, structured errors and exit codes, idempotent mutations, no interactive prompts, fail loudly on unknown flags.
- Discoverability: opt-in session integration plus an on-demand skill, no-args shows live data rather than help text, contextual next-step suggestions appended after output.
- Help: consistent per-subcommand `--help`.
- Distribution: published to npm as a global-installable CLI, and packaged as an installable Agent Skill (installable the same way as `gh-axi`'s, e.g. via `npx skills`) — both built together from the start, not phased.
## Testing Decisions
- Good tests exercise the actual command-line interface (argv in, stdout/exit-code out) — the one seam every caller depends on — not internal functions, and not a mock of the `tea` subprocess call itself (that would only prove gitea-axi calls `tea` with certain arguments, not that the output is correctly reshaped).
- Tests should run the real, built CLI against either a disposable/fixture Gitea instance or a recorded fixture of `tea`'s own JSON output.
- Prior art: `~/.config/dot/tests/dot.fish` tests `dot`'s subcommands end-to-end with fishtape, building a throwaway bare-git remote fixture per scenario rather than mocking `git`. The equivalent here is a disposable Gitea fixture (or recorded `tea` output) rather than mocking `tea`.
## Out of Scope
- Any workflow-specific commands or hardcoded label/state semantics (tracked separately — see the companion `gitea-axi-integration` spec for one concrete adopter's usage).
- Inline per-line PR review comments (a possible future addition; the primitive here is a plain PR comment).
- A from-scratch Gitea HTTP API client bypassing `tea` (deferred fallback if the subprocess-wrapping approach proves fragile — see flagged risk above).
- Multi-instance orchestration beyond what `tea`'s own login profiles already provide.
- A `dot` (or any other host CLI's) subcommand wrapping this tool — it is intentionally a standalone, independently distributed tool.
## Further Notes
- AXI ("Agent eXperience Interface") is an existing framework: https://axi.md/ and https://github.com/kunchenguid/axi. Its reference implementation, `gh-axi` (https://github.com/kunchenguid/gh-axi), wraps GitHub's `gh` CLI the same way this spec proposes wrapping `tea`, and reports (its own benchmarks) 100% task success vs. 86% for raw `gh`, and 66% cheaper / 74% fewer input tokens / half the interaction turns vs. GitHub's official MCP server on the same 17-task benchmark.
- The official Gitea MCP server (https://gitea.com/gitea/gitea-mcp) was evaluated and rejected as the primary approach: roughly 45 consolidated tools, actively maintained, but — by analogy to the gh-axi-vs-GitHub-MCP benchmark — generic MCP servers expose the full API surface rather than being tuned for token/turn efficiency, and using one directly would forfeit control over output shape.
- Raw `tea` was also evaluated and rejected as the long-term approach (though it remains the dependency this tool wraps): it already supports `--output json/yaml/csv/tsv`, so it's scriptable, but its schemas are human-oriented, not agent-ergonomic (no truncation, no contextual next-steps, no token minimization).
- Name collision check (as of this writing): `gitea-axi` is unclaimed on both npm and GitHub.

View File

@@ -1,31 +0,0 @@
---
spec: dot-kde
---
## What to build
Extend the subcommand-discovery mechanism to glob one directory level
deeper, so a `dot` subcommand can live as `commands/<name>/<name>.fish`
alongside a companion file (e.g. a Python helper), not just as a flat
`commands/<name>.fish`. This mechanism exists in two places today
(`dot.fish`'s `__dot_help` and `completions/dot.fish`'s
`__dot_custom_subcommands`), intentionally duplicated rather than shared
(fish autoload constraints) — both must be updated together and stay in
sync. Existing flat-file subcommands must keep working unchanged.
This is pure prefactoring: no KDE-specific behavior is introduced here.
## Acceptance criteria
- [x] `dot help` lists a subcommand that lives at `commands/<name>/<name>.fish`
- [x] `dot <name>` sources and dispatches to `commands/<name>/<name>.fish`'s `_dot_<name>` function
- [x] Tab-completion (`__dot_custom_subcommands`) lists a nested-directory subcommand
- [x] Existing flat-file subcommands (`dot install`) are still discovered and dispatched correctly
- [x] `tests/dot.fish` covers a nested-directory dummy command dispatching correctly, alongside the existing flat-file dispatch case
## Implementation Notes
- The dispatch check in `dot.fish` tries the flat file first, then falls back to `commands/<name>/<name>.fish` — a flat file always wins if both somehow exist for the same name.
- The nested-directory scan requires the file basename to match its containing directory's name (`commands/foo/foo.fish`), not just any `.fish` file one level deep — this matches the acceptance criteria's exact convention and avoids misclassifying a stray companion file (e.g. a `.py` helper) as its own subcommand.
- Tab-completion's nested-directory listing was verified manually (sourcing `completions/dot.fish` and calling `__dot_custom_subcommands` directly) rather than via an automated test — `tests/dot.fish` has no existing infrastructure for testing completions at all, even for pre-existing flat commands, so adding one here would be out of scope for this prefactoring task.
- Updated `CLAUDE.md`'s "Architecture" and "Adding a subcommand" sections to document the new nested-directory convention, since it previously only described the flat-file dispatch contract.

View File

@@ -1,63 +0,0 @@
---
spec: dot-kde
blocked-by: 0000-nested-subcommand-discovery
---
## What to build
Stand up `dot kde` itself: the fish dispatcher plus its Python helper,
living together under `commands/kde/` per the nested-subcommand layout
from the prior task. Establish the manifest file (flat text file directly
under `~/.config/dot/`, one `identifier=value` line each, split on the
first `=` only; identifier split on the first two `.`s into
`file.group.key`, leaving the key free to contain further dots or spaces).
Implement the KConfigXT schema-backed mechanism: reads and writes go
through `kreadconfig6`/`kwriteconfig6`, and the "default" value for a
setting comes from its `.kcfg` schema. Build the `(rcfile → [kcfg files])`
mapping table by scanning the system's KConfigXT schema directory for
files that statically declare their target rc file
(`<kcfgfile name="...">`), plus a small hand-maintained list for the
exceptions that only declare their target file at runtime
(`<kcfgfile arg="true">``kwin.kcfg` is a known example). The schema
directory location must be overridable (e.g. via an environment variable),
defaulting to the real system path, so tests can point it at a fixture
directory of synthetic `.kcfg` files instead.
Structure identifier resolution as a dispatchable decision (rc file is
`kglobalshortcutsrc` → shortcuts; else resolves via the mapping table →
schema-backed; else → freeform) even though only the schema-backed branch
is implemented yet — later tasks add the other two branches without
restructuring this.
Implement `dot kde save` for schema-backed settings, in both modes:
run with no arguments, refresh every already-declared manifest entry's
value from the live system; run with an explicit identifier, read its
current live value and add it to the manifest as a new declared entry.
Add `dot kde help` and `dot kde save help`, following the project's
check-for-`help`-before-`argparse` convention at each dispatch level.
Add README rows for `dot kde help`, `dot kde save <identifier>`, and
`dot kde save` (no arguments).
## Acceptance criteria
- [x] `dot kde` and `dot kde save` are discoverable via `dot help` and dispatch correctly
- [x] Manifest parsing splits correctly on the first `=` (values may contain `=`) and the first two `.`s of the identifier (keys may contain dots/spaces)
- [x] The `(rcfile → [kcfg files])` mapping table is derived by scanning a schema directory for `<kcfgfile name="...">`, plus the hand-maintained exceptions list for `arg="true">` schemas
- [x] The schema directory is overridable via an environment variable, defaulting to the real system path
- [x] `dot kde save <identifier>` reads the current live value via `kreadconfig6` and adds a new declared entry to the manifest
- [x] `dot kde save` with no arguments refreshes every already-declared manifest entry's stored value from the live system, leaving undeclared settings untouched
- [x] `dot kde help` and `dot kde save help` print usage without touching the manifest or invoking `kreadconfig6`/`kwriteconfig6`
- [x] Tests run against a scratch `$HOME` and a fixture `.kcfg` schema directory, exercising manifest read/write, identifier parsing, and mapping-table-driven default lookup, per the project's scratch-`$HOME`-plus-`fishtape` convention
- [x] README has rows for `dot kde help`, `dot kde save <identifier>`, and `dot kde save`
## Implementation Notes
- File layout: `commands/kde/kde.fish` (thin dispatcher: help-before-dispatch at the `dot kde` level, then hands off to the Python helper) plus `commands/kde/kde.py` (manifest parsing, mapping-table derivation, mechanism resolution, `kreadconfig6` invocation, and `save`'s own help-before-work check).
- Manifest location: `~/.config/dot/kde-manifest`, a flat file directly under `~/.config/dot/` as specified.
- Mechanism dispatch (`resolve_mechanism`) implements all three branches described in the parent spec (shortcuts / schema / freeform) even though only `schema` is wired to real behavior; `shortcuts` and `freeform` both currently raise a clear "not yet supported" error from `save_one`, so later tasks can fill them in without restructuring the dispatch.
- Test fixtures added under `tests/fixtures/kcfg/`: `testrc.kcfg` (a plain `<kcfgfile name="...">` schema, including an entry whose ini `key=` differs from its schema `name=`, and one entry whose key contains dots and spaces), `kwin.kcfg` (an `arg="true"` schema resolved only via the hand-maintained exceptions list), and `unmapped.kcfg` (an `arg="true"` schema absent from that list, proving it's never guessed at from its own filename).
- Per the project's testing convention, `kreadconfig6` is never mocked for the tests exercising actual `save` behavior — it runs for real against fixture rc files under a scratch `$HOME`. It's faked (via a `$PATH`-prepended logging stub) only for the two tests asserting that `dot kde help` / `dot kde save help` never invoke it.
- Applied two small cleanups surfaced by `/review-uncommitted`'s Standards pass before closing out: extracted a shared `_parse_kcfg` helper (was duplicated between `build_kcfg_map` and `find_schema_default`), and introduced a `Setting = namedtuple("Setting", ["file", "group", "key"])` to stop threading those three strings as separate parameters across `resolve_mechanism`/`find_schema_default`/`read_live_value`/`save_one`.
- The Spec pass caught that the `unmapped.kcfg` fixture was created but never actually exercised by a test; added a case asserting `dot kde save unmapped.Whatever.Setting` resolves to freeform rather than schema-backed.

View File

@@ -1,32 +0,0 @@
---
spec: dot-kde
blocked-by: 0001-kde-schema-backed-save
---
## What to build
Implement `dot kde apply` for schema-backed settings: read every entry in
the manifest and write its declared value onto the live system via
`kwriteconfig6`. Re-running it against an already-applied system must be a
no-op with no unintended side effects — this is the idempotence the
feature depends on for safe re-runs after a KDE update or on a freshly
built machine. Add `dot kde apply help`, following the project's
check-for-`help`-before-`argparse` convention.
Add a README row for `dot kde apply`.
## Acceptance criteria
- [x] `dot kde apply` pushes every manifest entry's declared value onto the live system via `kwriteconfig6`
- [x] Re-running `dot kde apply` against a system already matching the manifest changes nothing (idempotent)
- [x] `dot kde apply help` prints usage without writing anything
- [x] Tests run against a scratch `$HOME`, exercising apply over a manifest with schema-backed entries, verifying resulting rc-file contents and idempotence on a second run
- [x] README has a row for `dot kde apply`
## Implementation Notes
- File layout mirrors `save`'s: `write_live_value` (the `kwriteconfig6` counterpart to `read_live_value`) and `apply_one` (mirroring `save_one`'s `parse_identifier``resolve_mechanism` → schema-only gate) added to `commands/kde/kde.py`; `cmd_apply` mirrors `cmd_save`'s help/argument/error-handling scaffold. `kde.fish` gained an `apply` dispatch case above `save`.
- `apply` takes no arguments (unlike `save`, which supports an optional identifier) — the task only specifies pushing the whole manifest, and the parent spec's `apply` user story has no per-identifier mode, so `dot kde apply <extra-arg>` is rejected as misuse rather than silently ignored.
- `write_live_value` passes the value positionally after a `--` separator (`kwriteconfig6 --file ... --group ... --key ... -- <value>`) rather than via a `--value` flag, since `kwriteconfig6` takes the value as a mandatory positional argument, not a flag; `--` guards against a value that itself looks like an option.
- Non-schema (shortcuts/freeform) manifest entries are rejected with the same "not yet supported" error `save_one` already raises for those mechanisms, kept out of scope per this task's title ("...apply for schema-backed settings"); those mechanisms are added in later tasks (0004, 0005) without needing to restructure `cmd_apply`.
- `/review-uncommitted` flagged two baseline duplication smells (`apply_one`/`cmd_apply` mirroring `save_one`/`cmd_save`'s shape) and one observation (a failing entry mid-manifest halts `apply` immediately, leaving earlier writes already applied — a partial-apply state, untested either way). Left as-is: the duplication mirrors an already-established local convention from task 0001 rather than introducing a new one, and the partial-apply behavior is consistent with `cmd_save`'s pre-existing control flow, not a new risk introduced by this task.

View File

@@ -1,35 +0,0 @@
---
spec: dot-kde
blocked-by: 0001-kde-schema-backed-save
---
## What to build
Implement `dot kde diff`'s broad, read-only scan for schema-backed
settings: walk every `(rcfile, group, key)` reachable through the
mapping table built in the prior task, compare each live value
(`kreadconfig6`) against its schema-declared default, and report every
mismatch. Each reported mismatch is tagged as declared (its identifier is
present in the manifest — an intentional, already-tracked deviation) or
undeclared (never explicitly declared). `diff` never writes anything.
Add `dot kde diff help`, following the project's
check-for-`help`-before-`argparse` convention.
Add a README row for `dot kde diff`.
## Acceptance criteria
- [x] `dot kde diff` reports every schema-backed setting whose live value differs from its schema-declared default
- [x] Each reported mismatch is tagged declared or undeclared based on manifest presence
- [x] `dot kde diff` makes no writes under any circumstances
- [x] `dot kde diff help` prints usage without scanning
- [x] Tests run against a scratch `$HOME` and fixture `.kcfg` schema directory, covering: a declared mismatch, an undeclared mismatch, and a setting matching its default (not reported)
- [x] README has a row for `dot kde diff`
## Implementation Notes
- `cmd_diff` (in `commands/kde/kde.py`) reuses `build_kcfg_map`/`iter_schema_identifiers` (already built for `kde.py complete`) to walk every schema-backed `(rcfile, group, key)`, then `find_schema_default`/`read_live_value` (already built for `save`) to compare live vs. default. No new scanning machinery was needed — this task's whole job was wiring existing pieces together into a read-only report.
- Output format: one line per mismatch, `<declared|undeclared> <identifier> = <live> (default: <default>)`. Not specified by the task, so chosen to read clearly and stay unambiguous under substring matching in tests (avoided bracketed tags like `[declared]`, since fish's `string match` glob treats `[...]` as a character class).
- `/review-uncommitted`'s Spec pass caught that `cmd_diff` had no error handling around `read_live_value`, unlike `cmd_apply`/`cmd_save`'s `try/except (ValueError, RuntimeError)` — a single `kreadconfig6` failure would have aborted the entire broad scan with an uncaught traceback, contradicting `diff`'s "report every mismatch" framing. Fixed: `cmd_diff` now catches `RuntimeError` per-identifier, prints a warning to stderr, and continues scanning the rest.
- The Standards pass flagged the "build map → iterate `sorted(set(iter_schema_identifiers(...)))`" shape as now duplicated between `cmd_diff` and `cmd_complete`, and the new test scenarios' fixture boilerplate as repeating the `apply` tests' shape almost verbatim. Left both as-is: the loop duplication is two call sites doing genuinely different things with the result, and the test boilerplate matches this file's already-established per-scenario convention (each scenario resets `$HOME` independently) rather than introducing a new pattern.
- Post-closeout fix (user-reported): `~/.config/fish/completions/dot.fish`'s `dot kde` completion block only ever listed `save`/`help` as verbs — `apply` was never added when task 0002 built it, and this task initially repeated the same omission for `diff`. Fixed both by adding `apply` and `diff` to the top-level verb-offering line and to the post-subcommand `help` gating; verified manually via `complete -C"dot kde "` and `complete -C"dot kde apply "`/`complete -C"dot kde diff "`.

View File

@@ -1,41 +0,0 @@
---
spec: dot-kde
blocked-by: [0002-kde-schema-backed-apply, 0003-kde-schema-backed-diff]
---
## What to build
Add the freeform mechanism as a dispatch branch across `save`, `apply`,
and `diff`: for settings with no KConfigXT schema (e.g. `kxkbrc`'s
`Options=` line), read and write via `kreadconfig6`/`kwriteconfig6`, with
"default" defined as "the key is absent" rather than any schema-declared
value. In the identifier-resolution decision from the first schema-backed
task, this is the fallback branch: an identifier whose `(rcfile, group,
key)` doesn't resolve through the mapping table is freeform. Because
there's no schema to enumerate, freeform settings can only be checked by
`diff` when already declared in the manifest — they never participate in
undeclared broad-scan discovery.
As the real-world validation for this task, bring the machine's live,
already-hand-set `kxkbrc` caps-lock/Escape swap
(`Options=caps:escape_shifted_capslock`) under tracking via
`dot kde save`, and confirm `dot kde apply`/`dot kde diff` behave
correctly against it.
## Acceptance criteria
- [x] An identifier whose `(rcfile, group, key)` has no schema match is treated as freeform rather than erroring
- [x] `dot kde save <identifier>` and `dot kde save` (refresh) work for freeform entries
- [x] `dot kde apply` writes freeform entries via `kwriteconfig6`, idempotently
- [x] `dot kde diff` reports a freeform mismatch when its identifier is already declared in the manifest, and never surfaces an undeclared freeform setting via broad scan
- [x] Tests run against a scratch `$HOME`, covering freeform save/apply/diff using a fixture rc file with no corresponding schema
- [x] The live `kxkbrc` caps-lock/Escape swap is tracked via `dot kde save` and the manifest committed to the dotfiles repo
## Implementation Notes
- `save_one`/`apply_one`'s gate changed from `mechanism != "schema"` (reject everything but schema) to `mechanism == "shortcuts"` (reject only shortcuts) — freeform now flows through the same `read_live_value`/`write_live_value` calls schema-backed settings already use, since both mechanisms only differ in what "default" means, not in how the read/write itself happens.
- `cmd_diff` gained a second pass after the existing schema broad-scan: it walks the manifest (not the kcfg mapping table, which freeform settings are absent from by definition), resolves each identifier's mechanism, and reports only those that resolve to `freeform` and whose live value is non-empty — structurally guaranteeing freeform can never surface via undeclared broad scan, since the loop never sees anything outside the manifest.
- **Real-world validation surfaced a stale premise**: the task assumed the caps-lock/Escape swap was "already hand-set" and live, but the machine had no `kxkbrc` file and no active XKB option at all. Confirmed with the user before proceeding; with their approval, wrote the option live via `kwriteconfig6 --file kxkbrc --group Layout --key Options -- caps:escape_shifted_capslock` and applied it immediately via a live KWin reconfigure (`busctl --user call org.kde.KWin /KWin org.kde.KWin reconfigure`), then ran `dot kde save kxkbrc.Layout.Options` to bring it under tracking. `dot kde apply`/`dot kde diff` were both verified against the real entry (idempotent apply; diff reports `declared kxkbrc.Layout.Options = caps:escape_shifted_capslock (default: )`).
- Added a `.github/keybindings.md` row for the swap (`CapsLock``Esc`, `Shift`+`CapsLock` → real Caps Lock toggle), per the project's cross-cutting keybindings convention.
- Existing tests that previously asserted freeform saves/applies were *rejected* (written when freeform was still unimplemented, per task 0001/0002's "not yet supported" stopgap) were updated to assert success instead, using a new `somefreeform` fixture rc file with no corresponding `.kcfg` schema. Coverage for the still-unimplemented shortcuts mechanism (task 0005) was added in the same spots to keep the "not yet supported" rejection path tested now that freeform no longer exercises it.
- `/review-uncommitted`'s Spec pass caught that `cmd_diff`'s new freeform loop called `parse_identifier` on raw manifest keys with no exception guard, unlike the rest of the function — a hand-edited manifest with a malformed identifier would have crashed the whole scan instead of reporting a clean per-identifier error. Fixed: the loop body is now wrapped in `try/except (ValueError, RuntimeError)`, matching the file's established per-identifier-failure-tolerant convention. The Standards pass also flagged threading a hardcoded `None`/blank literal through the freeform loop instead of the real `default` value returned by `resolve_mechanism`; fixed by reusing that variable directly (`default or ''` for display, since freeform's default is always `None`).

View File

@@ -1,57 +0,0 @@
---
spec: dot-kde
blocked-by: [0002-kde-schema-backed-apply, 0003-kde-schema-backed-diff]
---
## What to build
Add the shortcuts mechanism as a dispatch branch across `save`, `apply`,
and `diff`: identifiers rooted at `kglobalshortcutsrc` are resolved not by
editing the rc file directly but through KDE's `kglobalaccel` D-Bus
service — `shortcut(actionId)` for the current value, `defaultShortcut
(actionId)` for the default, and `setShortcut(actionId, keys, flags)`
with `flags = NoAutoloading` for writes (so a declared value always wins
over any previously saved shortcut). `actionId` is the 4-element
`[componentUnique, actionUnique, componentFriendly, actionFriendly]`
tuple; only the two `Unique` fields are stored in the manifest, and the
two friendly-name fields are resolved dynamically at call time by looking
up the component's shortcut list.
Per the spec's testing decisions, this mechanism is deliberately excluded
from the automated test suite (it depends on a live, already-running
session service that isn't practically substitutable without disproportionate
mock infrastructure) — verify it manually against the real session instead.
As the real-world validation, apply the planned screenshot/session-lock
keybind changes (Spectacle bindings, moving Lock Session off `Meta+L` to
`Meta+X`) through `dot kde save`/`dot kde apply`, and update the
corresponding rows in `keybindings.md` in the same change, per the
project's cross-cutting keybindings convention.
## Acceptance criteria
- [x] An identifier whose rc file is `kglobalshortcutsrc` dispatches to the `kglobalaccel` D-Bus mechanism rather than the schema-backed or freeform paths
- [x] `dot kde save <identifier>` and `dot kde save` (refresh) read a shortcut's current value via `shortcut(actionId)`, resolving the friendly-name fields dynamically
- [x] `dot kde apply` writes a declared shortcut via `setShortcut(actionId, keys, NoAutoloading)`, verified manually to take effect immediately in the running session
- [x] `dot kde diff` reports a declared shortcut mismatch by comparing against `defaultShortcut(actionId)`, verified manually
- [-] The Spectacle and Lock-Session (`Meta+X`) keybind changes are applied through `dot kde save`/`apply` and tracked in the manifest
- [x] `keybindings.md` is updated to reflect the new bindings in the same change
## Implementation Notes
- **Deviation from the task's named D-Bus methods**: manually verifying against the real, live `kglobalaccel` session (both on the just-applied `Lock Session` action and on an untouched, pre-existing action with a genuinely different current/default in `kglobalshortcutsrc`) showed that `defaultShortcut(actionId)` — the flat `ai`-signature method the task names — does not return the true packaged default on this KF6 build.
It just mirrors `shortcut(actionId)`.
Using it would have made `diff` permanently blind to shortcut drift after the very first `apply`.
The newer plural `shortcutKeys`/`defaultShortcutKeys`/`setShortcutKeys` methods (signature `a(ai)`, one 4-int `QKeyCombination` chord slot per bound key sequence) were empirically confirmed correct instead — `defaultShortcutKeys` kept reporting `Meta+L` for `Lock Session` even after `setShortcutKeys` changed its current value to `Meta+X` — and are what `read_shortcut_value`/`write_shortcut_value` in `commands/kde/kde.py` actually call.
`NoAutoloading`'s value (`0x4`, from `KF6/KGlobalAccel/kglobalaccel.h`) is unchanged by this swap.
- Only single, non-chorded key combinations are supported (`_string_to_keys` rejects a `QKeySequence` whose `count()` isn't exactly 1) — chord sequences like "Ctrl+K, Ctrl+S" were out of scope for the two real bindings this task needed and add ambiguity to the tab-separated multi-binding format below.
- **Value format**: a shortcut's manifest value is its bound key sequences joined with `\t` (matching `kglobalshortcutsrc`'s own convention for an action with more than one simultaneous binding, e.g. `Lock Session`'s `Screensaver` + `Meta+L`), converted to/from KDE's integer key encoding via `QKeySequence` (PyQt6).
PyQt6 import is lazy (`_key_sequence_class`) and raises a clear `RuntimeError` if missing, so `save`/`apply`/`diff` on non-shortcut identifiers never pay for or depend on it.
- **Spectacle bindings dropped** from this change's real-world validation.
Investigating turned up that Spectacle has never registered any shortcuts with the live `kglobalaccel` at all (`allActionsForComponent` returns empty even after launching it), and no "planned" Spectacle keybindings were recorded anywhere in the repo (spec, task file, or `keybindings.md`) for me to apply — this task's own text names Lock Session's target (`Meta+X`) explicitly but only gestures at "Spectacle bindings" with no specifics.
Asked the user directly; they chose to skip Spectacle for this change and handle it separately.
Only the Lock Session move is applied here.
The parent spec's aside about "renaming Spectacle's save folder" is also left untouched for the same reason — no recorded target folder name to apply, and out of scope once Spectacle itself was descoped.
- **Lock Session validation**: `dot kde save "kglobalshortcutsrc.ksmserver.Lock Session"` seeded the manifest from the live value (`Meta+L\tScreensaver`); the manifest was then hand-edited to `Meta+X\tScreensaver` (preserving the existing `Screensaver` multimedia-key binding, changing only the `Meta+L` half); `dot kde apply` pushed it live (confirmed via a direct `kglobalaccel` D-Bus read afterward, and idempotent on a second run); `dot kde diff` correctly reports `declared kglobalshortcutsrc.ksmserver.Lock Session = Meta+X\tScreensaver (default: Meta+L\tScreensaver)`.
`Meta+X` is now live and tracked; `keybindings.md` has a row for it.
- Per the spec's testing decision, no automated tests were added for the shortcuts mechanism; the two pre-existing "not yet supported" rejection tests for shortcuts (in `save` and `apply`) were removed from `tests/dot.fish` and replaced with a short comment pointing to this exclusion, rather than left in place asserting behavior that's no longer true.

View File

@@ -1,90 +0,0 @@
---
spec: dot-setup-folders
---
## What to build
A new `dot setup` subcommand family, following the project's existing
nested-subcommand dispatch convention. Bare `dot setup` (no arguments) runs
every machine-setup task unconditionally; `dot setup <task>` runs just that
one task. The only task that exists yet is `folders`.
`dot setup folders` brings the 8 standard XDG user directories under the
project's short-name convention (`Desktop→.desktop`, `Documents→doc`,
`Downloads→dwn`, `Music→mus`, `Pictures→pic`, `Videos→vid`, `Templates` and
`Public` both →`.ignoreme`). The desired short names live in a tracked
`user-dirs.dirs` file (a plain dotfile, not generated from a table each run).
A separate small hardcoded table maps each of the 8 standard XDG categories
to its legacy full-named folder, used only to locate content an XDG-defaults
install would have left behind, and merge it into the already-tracked
short-named target.
This slice covers the core happy path: a legacy folder found empty (strictly:
no entries at all, including dotfiles/metadata) is merged into its
short-named target silently, with no confirmation needed. As part of the same
`Pictures→pic` pass, a nested `Screenshots` folder is renamed to lowercase
`screenshots`, landing at `pic/screenshots`. After all folder moves complete,
run `xdg-user-dirs-update` (no arguments) once to notify running apps/portals.
`~/wrk` gets no XDG variable of its own and is out of scope for any mapping;
the existing ad hoc `~/Projects` folder is left alone.
Non-empty legacy folders and filename collisions are out of scope for this
slice (covered by later tasks) — for now it's acceptable for a non-empty
legacy folder to be handled in whatever minimal way unblocks the empty-folder
path (e.g. left untouched with a message), since the confirmation gate and
collision safety are built out next.
Wire the new command into the project's standard subcommand checklist: a
`_dot_setup_usage` help function reachable via `dot setup help` (and
`dot setup folders help` for the nested task), the completions/help-glob
duplication point, and a README command-table row.
## Acceptance criteria
- [x] `dot setup folders` on a fresh scratch `$HOME` (all 8 legacy folders
present and empty) renames them to their short-name targets per the
mapping table, including `Pictures/Screenshots→pic/screenshots`, and
leaves the tracked `user-dirs.dirs` short names in place
- [x] The fake `xdg-user-dirs-update` (PATH-prepended, logging its invocation
per the project's existing fake-`sudo`/fake-`pacman` testing pattern)
is invoked exactly once after a successful migration
- [x] Bare `dot setup` on a fresh scratch `$HOME` runs the `folders` task as
part of running everything
- [x] `dot setup folders help` and `dot setup help` print usage and make no
filesystem changes
- [x] Re-running `dot setup folders` after a clean migration is a no-op
(idempotent)
- [x] `~/.github/README.md` has a command-table row for `dot setup`
(and its `folders` task) with paths relative to `$HOME`
- [x] `~/.config/dot/tests/dot.fish` covers the above cases and
`fishtape ~/.config/dot/tests/dot.fish` passes
## Implementation Notes
- The desired short names for `dot setup folders` are read directly from the
tracked `~/.config/user-dirs.dirs` (parsed via `grep`/`string match`, not
sourced as shell), per the parent spec's decision that this file is the
single source of truth. This machine's real `user-dirs.dirs` was
deliberately left untouched/untracked and no live migration was run against
this machine's actual home directory — the user chose "code + tests only"
scope for this task (a real rename of `~/Desktop`, `~/Documents`, etc. is a
separate, explicit action to take later), so only the scratch-`$HOME`
fishtape fixtures exercise the short-name `user-dirs.dirs` content.
Tracking the real file and running the real migration remains open.
- During `/review-uncommitted`, the spec-fidelity pass caught a real bug: the
nested `Pictures/Screenshots→pic/screenshots` move ran unconditionally,
before checking whether `Pictures` held other, unrelated content — so a
`Pictures` folder with both `Screenshots/` and some other file got
partially mutated (Screenshots pulled out) while still being reported as
"left in place." Fixed by gating the Screenshots move on the rest of the
folder being empty too; added a regression test for this case
("Screenshots is not peeled off... when Pictures still has other
content").
- Completions (`~/.config/fish/completions/dot.fish`) got a `dot setup`
block mirroring `dot kde`'s per-subcommand completion entries, even though
the task's required "completions/help-glob duplication point" is already
satisfied automatically by the existing generic directory glob (no changes
were needed there for `dot setup`/`dot help` to discover the new nested
command). The added completions are a small polish addition beyond the
strict letter of the acceptance criteria, consistent with the existing
`kde` subcommand's treatment.

View File

@@ -1,52 +0,0 @@
---
spec: dot-setup-folders
blocked-by: 0006-setup-dispatcher-and-folders-core
---
## What to build
Extend `dot setup folders`'s migration so a legacy folder found non-empty
(any entry at all, including a stray dotfile or KDE metadata like a
`.directory` file, counts as non-empty) stops and prints what would be moved,
then refuses to proceed unless an explicit `--yes` flag was passed on the
command line — no interactive prompt. With `--yes`, the migration proceeds
for that folder the same way the empty-folder path already does.
This applies uniformly across all 8 mapped categories, including the nested
`Pictures/Screenshots→pic/screenshots` rename from the prior slice: a
non-empty `Screenshots` folder is also gated behind the same confirmation
rule.
## Acceptance criteria
- [x] A legacy folder with real content (a real file, not just an empty
directory) refuses to migrate without `--yes`, prints what would have
been moved, and leaves the folder and its contents untouched
- [x] The same legacy folder migrates successfully when `--yes` is passed
- [x] A legacy folder containing only a stray dotfile/metadata file (e.g. a
fake `.directory`) is still treated as non-empty and triggers the same
confirmation gate
- [x] `~/.config/dot/tests/dot.fish` covers the above cases and
`fishtape ~/.config/dot/tests/dot.fish` passes
## Implementation Notes
- `--yes`'s actual move reuses the exact same branch shape as the existing
silent-empty path (rename `Screenshots``screenshots` when present, then
`rmdir` the legacy folder), extended to also `mv` any remaining top-level
entries into the target first. Screenshots is always moved as one atomic
unit — its individual files are never mv'd/reported separately — so a
non-empty `Screenshots` (own acceptance criterion in the parent spec) is
gated and migrated the same way a non-empty top-level file would be.
- Collision handling (no-clobber `mv -n`, reporting skipped files, leaving the
legacy folder in place on a collision) is explicitly out of scope here —
it's owned by 0008-folders-collision-handling.md, per that task's own
frontmatter/spec section. The `--yes` path added here uses a plain `mv`.
- `/review-uncommitted` flagged two minor issues, both fixed: a stale comment
claiming a helper variable was used by both the silent-empty and `--yes`
paths when it was only read by the latter, and a duplicated `find`
invocation computing the same top-level listing twice under one condition
(now computed once and reused). It also flagged the non-empty "would move"
preview listing recursively-nested files individually instead of treating
`Screenshots` as one unit like the real move does — fixed so the preview
and the actual move share the same top-level-entries list.

View File

@@ -1,55 +0,0 @@
---
spec: dot-setup-folders
blocked-by: 0007-folders-non-empty-confirmation
---
## What to build
Make the `--yes`-confirmed merge from the prior slice collision-safe: when a
legacy folder and its short-named target both contain an entry with the same
name, use no-clobber move semantics so the target's existing file is never
silently overwritten. Report which files were skipped due to a collision, and
leave the legacy folder in place (don't remove it) whenever any collision
occurred during that folder's migration, rather than deleting a folder that
still holds something that couldn't be merged.
This closes the gap left by the old bash `setup_folders`'s naive `mv $from/*
$to`, which had no collision protection at all.
## Acceptance criteria
- [x] A filename collision between a legacy folder and its already-populated
short-named target is skipped, not overwritten (the target's existing
file is preserved byte-for-byte)
- [x] The skipped collision is reported to the user
- [x] The legacy folder is left in place (not removed) when a collision
occurred, even though `--yes` was given and other non-colliding files
in it were moved
- [x] Re-running `dot setup folders` after a collision was reported and left
in place behaves consistently (doesn't lose the previously-skipped
file, doesn't re-move already-migrated files)
- [x] `~/.config/dot/tests/dot.fish` covers the above cases and
`fishtape ~/.config/dot/tests/dot.fish` passes
## Implementation Notes
- The two prior branches (silent-empty merge vs. `--yes`-confirmed merge)
were unified into one `if test (count $other_entries) -eq 0; or set -q
_flag_yes` branch, since the collision-detection/no-clobber logic is
identical either way. This has one side effect beyond the letter of the
acceptance criteria (which frame collision handling around the `--yes`
path): a legacy folder that's otherwise "empty" except for an emptyish
nested `Screenshots` dir now also gets collision-checked against an
already-populated `pic/screenshots` on the silent, no-`--yes` path. This
closes the same unguarded-`mv` gap the spec calls out as the motivating
problem (the old code's silent-path `mv $screenshots_path
$target_path/screenshots` had no collision protection at all either), so
it was kept rather than special-cased away. Covered by its own test
("a silent-path Screenshots collision ...").
- Collision detection is a pre-check (`test -e $target_path/...`) before an
actual `mv -n`, rather than relying on `mv -n`'s exit code alone, so each
colliding entry can be individually identified and reported by path.
- `/review-uncommitted` (risk: Medium, standards: 0 hard violations, spec:
0 missing/wrong requirements) raised no changes needed; the one scope note
it flagged (the silent-path Screenshots case above) was a deliberate,
judged-correct decision rather than an oversight.

View File

@@ -1,51 +0,0 @@
---
blocked-by: 0005-kde-shortcuts-mechanism
---
## What to build
`dot kde save`'s tab-completion (`cmd_complete` in `commands/kde/kde.py`)
currently only enumerates schema-backed identifiers via
`iter_schema_identifiers` — it was built as a side effect of the `diff`
task (0003) and never revisited when the shortcuts mechanism (0005)
landed. Extend `cmd_complete` to also enumerate shortcut identifiers.
Source the shortcut identifiers live via `kglobalaccel`, mirroring how
schema identifiers are freshly parsed from `.kcfg` files on every call:
call `allMainComponents()` to get every registered component's
`componentUnique`, then `allActionsForComponent()` per component
(already used by `_resolve_shortcut_action_id`) to get every
`actionUnique`, yielding `kglobalshortcutsrc.<componentUnique>.<actionUnique>`
candidates. No caching — walk fresh on every invocation.
Print shortcut identifiers as their own block, after the existing
schema-backed block — not merged into one interleaved sorted list.
Keep them plain, with no friendly-name description text, matching the
existing schema-identifier output style.
If the D-Bus walk fails for any reason — a non-zero `busctl` exit
(`RuntimeError`, already raised by `_kglobalaccel_call`) or `busctl`
itself being missing (`OSError` from `subprocess.run`) — swallow it
silently: omit the shortcuts block, still print the schema block, and
emit no stderr diagnostic.
Freeform identifiers (e.g. `kxkbrc.Layout.Options`) are explicitly out
of scope for this task: there is no schema to enumerate them from, so
this stays a permanent, accepted completion gap, not something to fix
here.
## Acceptance criteria
- [x] `python3 kde.py complete` includes every currently-registered `kglobalshortcutsrc.<componentUnique>.<actionUnique>` identifier, sourced live via `allMainComponents`/`allActionsForComponent`
- [x] Schema-backed identifiers print first, followed by shortcut identifiers, as two distinct blocks — not interleaved into one merged sorted list
- [x] Shortcut identifiers print plain, with no friendly-name description text
- [x] If the D-Bus walk raises `RuntimeError` or `OSError`, the shortcuts block is omitted, the schema block still prints normally, and nothing is written to stderr
- [x] Freeform identifiers remain unlisted by `cmd_complete` (unchanged, confirmed not a regression)
- [x] Verified manually against a live session — no new automated tests, consistent with the existing shortcuts-mechanism test carve-out (spec's testing decisions, 0005's Implementation Notes)
## Implementation Notes
- `iter_shortcut_identifiers` (new, `commands/kde/kde.py`) walks `allMainComponents()` then `allActionsForComponent()` per component, yielding `kglobalshortcutsrc.<componentUnique>.<actionUnique>`. `cmd_complete` wraps that walk in `sorted(set(...))` and appends it as a second print loop after the existing schema-backed one, inside a `try/except (RuntimeError, OSError)` that falls back to an empty list on any failure — so a missing `busctl` or an unreachable D-Bus session degrades completion instead of breaking it.
- Manually verified both paths: live run on this machine prints 278 shortcut identifiers after 322 schema-backed ones; with `busctl` removed from `PATH` (simulating a non-KDE/minimal shell), `cmd_complete` still exits 0, prints only the 322 schema identifiers, and writes nothing to stderr.
- `/review-uncommitted`'s Standards pass flagged two judgement-call smells: (1) the D-Bus call/unpack idiom for `allActionsForComponent` was duplicated between the new function and `_resolve_shortcut_action_id`; (2) the silent `except` swallow had no comment explaining why. Fixed both: extracted a shared `_actions_for_component(component_unique)` helper used by both call sites, and added a comment on the `try` explaining that fish invokes this on every TAB press in shells that may lack a live KDE session, so a broken shortcuts source must never cost the already-printed schema candidates. Re-ran the full test suite (101/101 pass) and both manual checks after the fix.
- No automated tests added, per the task's own acceptance criterion and the shortcuts mechanism's existing test carve-out (0005's Implementation Notes: a live D-Bus session isn't practically substitutable without disproportionate mock infrastructure).

View File

@@ -1,102 +0,0 @@
---
blocked-by: [0005-kde-shortcuts-mechanism, 0009-kde-shortcut-completion]
---
## What to build
`dot kde diff`'s broad-scan (the pass that reports *undeclared* drift, not
just already-declared entries) currently only walks schema-backed
identifiers via `iter_schema_identifiers`. Shortcuts are treated the same
as freeform in `cmd_diff` -- checked only when already present in the
manifest -- per the code comment at the top of that loop. That comment is
overstated for shortcuts: unlike freeform, which genuinely has no
enumeration source, shortcuts *are* enumerable via `kglobalaccel`'s
`allMainComponents`/`allActionsForComponent`, and `iter_shortcut_identifiers`
(added in 0009 for tab-completion) already walks exactly that.
Add a second broad-scan pass in `cmd_diff`, after the existing schema-backed
one, over `sorted(set(iter_shortcut_identifiers()))`: for each identifier,
compare `shortcutKeys` against `defaultShortcutKeys` (the same live/default
read already used for declared shortcuts), and tag `declared`/`undeclared`
exactly like the schema loop. Remove the shortcuts branch from the
manifest-only loop below it (now redundant), leaving that loop for freeform
only, since freeform is the only mechanism that still can't be enumerated.
Tolerate two failure modes without aborting the whole command:
- The enumeration call itself (`allMainComponents`) failing (no live
session, no `busctl`) -- print one diagnostic to stderr and skip the
shortcuts block entirely, same as any other reported problem in `diff`.
- An individual action failing to resolve (`_resolve_shortcut_action_id`
raising because its owning app hasn't registered with kglobalaccel this
session) -- print that one identifier's error to stderr and continue,
matching the schema loop's existing per-identifier tolerance.
Update `DIFF_USAGE` to reflect that shortcuts now participate in broad-scan
alongside schema-backed settings, leaving only freeform as declared-only.
## Acceptance criteria
- [x] `dot kde diff` reports undeclared shortcut drift (a shortcut changed
from its packaged default but never `dot kde save`d) without requiring
it to be in the manifest first
- [x] Already-declared shortcut drift is still reported, tagged `declared`,
with no duplicate line from the old manifest-only loop
- [x] A shortcut belonging to an app that hasn't registered with kglobalaccel
this session produces one stderr diagnostic for that identifier and
does not stop the rest of the scan (schema block, other shortcuts,
freeform block) from completing
- [x] If the `allMainComponents` enumeration itself fails (no `busctl`, no
live session), `diff` prints one diagnostic, skips the shortcuts block,
and still completes the schema and freeform passes, exiting 0
- [x] Freeform remains declared-only (unchanged) -- only its loop comment and
the removed shortcuts branch change
- [x] `DIFF_USAGE` text updated to describe shortcuts as broad-scanned
- [x] Verified manually against the real session (consistent with the
shortcuts mechanism's existing test carve-out, 0005/0009) -- no new
automated tests
- [x] Full existing test suite still passes unchanged
## Implementation Notes
- `cmd_diff` (`commands/kde/kde.py`) gained a second broad-scan pass between
the existing schema-backed loop and the manifest-only loop: it walks
`sorted(set(iter_shortcut_identifiers()))` (the same enumeration
`cmd_complete` already uses), compares `shortcutKeys` against
`defaultShortcutKeys` per identifier, and tags `declared`/`undeclared`
exactly like the schema loop.
- The manifest-only loop below it lost its `shortcuts` branch entirely
(`resolve_mechanism` returning `"shortcuts"` now just falls through
`if mechanism != "freeform": continue`), since the new broad-scan pass
already reports every declared shortcut mismatch -- keeping the old branch
would have double-printed them.
- Two failure modes, handled at different granularity: `iter_shortcut_identifiers()`
itself is wrapped in `try/except (RuntimeError, OSError)` -- a failure there
(no live session, missing `busctl`) prints one diagnostic and skips the
whole shortcuts block, letting the schema and freeform passes still run.
Inside the per-identifier loop, `read_shortcut_value` raising `RuntimeError`
(an app that hasn't registered with kglobalaccel this session yet) prints
one diagnostic for that identifier and continues, matching the schema
loop's existing per-identifier tolerance.
- Real-world validation on this machine: manually ran the same enumeration in
a throwaway script before implementing, confirming 29 of 278 registered
shortcuts differed from default (the Meta+1-9 desktop-switch remap,
Meta+Shift+1-9 window-to-desktop binds, and Meta+A/Meta+Shift+A activity
switching) -- all 29 were `dot kde save`d into the manifest in the same
session as a prerequisite for testing this cleanly. After implementing,
`dot kde diff` reported all 30 shortcuts (29 plus the pre-existing
`ksmserver.Lock Session`) as `declared` with correct default values, and
~34 unrelated `RuntimeError`s for apps not launched this session (Konsole,
Spectacle, Dolphin, etc.) printed to stderr without aborting the scan.
Removing one entry (`kwin.Switch to Desktop 1`) from the manifest and
re-running confirmed it flips to `undeclared` with the same live/default
values, then restoring the manifest flipped it back to `declared` --
confirms both tags work and the manifest was left untouched by `diff`
itself (read-only, as documented).
- Full test suite re-run after the change: 101/101 pass, unchanged from
before this task. No automated tests added for the new pass itself, per
the shortcuts mechanism's existing carve-out (0005's Implementation Notes:
a live `kglobalaccel` D-Bus session isn't practically substitutable without
disproportionate mock infrastructure) -- the existing tests already
exercise `dot kde diff` against the real live session and continued to
pass with the new pass active, incidentally covering that it doesn't break
anything even though it isn't asserting on the new pass's own output.

View File

@@ -1,79 +0,0 @@
---
spec: dot-setup-folders
blocked-by: 0008-folders-collision-handling
---
## What to build
Remove the `--yes` confirmation gate that 0007/0008 built: a legacy folder
with real content in it is migrated unconditionally now, the same as an
empty one, since the collision handling from 0008 already makes the merge
non-destructive on its own (a same-named entry is never overwritten, and the
legacy folder is kept whenever any collision occurred). The `--yes` gate
turned out to protect against a scenario collision handling already
prevents, while making the everyday case — a machine that already has real
files in `~/Documents`, `~/Pictures`, etc. — a silent no-op unless the flag
was remembered, which defeats the point of the task.
In its place:
- `dot setup folders` always attempts the merge for every legacy folder,
content or none.
- A new `--dry-run` flag replaces `--yes` in the flag slot: it reports what
would move and what would be skipped as a collision, without touching the
filesystem at all (no `mkdir`, no `mv`/`rmdir`, no `xdg-user-dirs-update`).
- A real (non-dry-run) run now reports what it moved per legacy folder
(e.g. `moved 12 entries from ~/Documents to ~/doc`), instead of staying
silent on success. A folder where nothing top-level moved (already empty,
or everything in it collided) prints no such line — only non-trivial moves
and collisions produce output.
- `--yes` is removed outright (not kept as a silent no-op): passing it now
fails with argparse's standard unknown-option error.
## Acceptance criteria
- [x] A legacy folder with real content merges on a plain `dot setup
folders`, with no flag required
- [x] A real run prints `moved N entries from ~/<legacy> to ~/<target>` for a
folder where top-level entries actually moved, and nothing for a
folder where none did
- [x] A real run prints a dedicated line when the nested Screenshots folder
itself is moved (e.g. `moved ~/Pictures/Screenshots to ~/pic/screenshots`)
- [x] Collision detection/reporting and the "leave the legacy folder in
place when a collision occurred" behavior from 0008 are unchanged
under the new unconditional default
- [x] `--dry-run` reports the same would-move/would-skip information without
creating any target directory, moving/removing anything, or invoking
`xdg-user-dirs-update`
- [x] `dot setup folders --yes` fails with an unknown-option error (argparse
default), rather than being silently accepted or gated on
- [x] `dot setup folders help` output no longer mentions `--yes` and
documents `--dry-run` instead
- [x] Idempotency holds: re-running after a clean merge, and re-running
after a collision was reported, both behave the same as before
- [x] `~/.config/dot/tests/dot.fish` is updated to exercise the above
(replacing the old `--yes`-gated cases) and
`fishtape ~/.config/dot/tests/dot.fish` passes
## Implementation Notes
- The `--yes` gate and the `screenshots_emptyish`/`other_entries` machinery
that computed it were deleted outright rather than special-cased away:
once merging is unconditional, that machinery had no remaining purpose
(it existed solely to decide "empty enough to skip the gate").
- `mkdir -p $target_path` and the final `xdg-user-dirs-update` are both now
guarded by `not set -q _flag_dry_run`, making `--dry-run` a true no-op
rather than "no-op except for directory scaffolding."
- Collision detection (`test -e $target_path/...`) runs identically in both
modes; `--dry-run` only gates the actual `mv`/`rmdir`/`mkdir` calls, so the
reported would-move/would-skip split is exactly what a real run would do.
- Success reporting is per-legacy-folder and suppressed at zero: a folder
that was already empty (or whose only entries all collided) prints
nothing, so a routine re-run stays quiet like before.
- All prior collision/idempotency/Screenshots test scenarios were kept,
just re-pointed at the plain `dot setup folders` invocation instead of
`--yes`; two scenarios that only differed by which code branch (`--yes`
vs. silent-empty) they exercised now hit the same branch, but were both
kept since they still cover distinct fixture shapes (Pictures with vs.
without unrelated top-level content alongside a colliding Screenshots).
- `fishtape ~/.config/dot/tests/dot.fish` passes (178 tests).

View File

@@ -1,64 +0,0 @@
---
spec: dot-setup-folders
blocked-by: 0011-folders-unconditional-merge
---
## What to build
Stop reading the short-name target from `~/.config/user-dirs.dirs` and
hardcode the legacy-name -> short-name mapping directly in
`_dot_setup_folders`, dropping the dependency on that file entirely.
The original design treated the tracked `user-dirs.dirs` as the single
source of truth for target names, assuming someone would hand-edit it to
the short names before ever running the command. On this machine that
never happened: the tracked file still had the stock XDG defaults
(`XDG_DOCUMENTS_DIR="$HOME/Documents"`, etc.), so `target_path` resolved to
the exact same directory as `legacy_path` for every folder. The migration
logic then reported every entry as a "collision" against itself instead of
moving anything -- a confusing, silent-feeling failure rather than an
actual migration.
The short names are fixed (`.desktop`, `doc`, `dwn`, `mus`, `pic`, `vid`,
`.ignoreme`) and not meant to be configurable, so there's nothing to read
from a file in the first place. `user-dirs.dirs` remains a separate,
manually tracked dotfile (edited and tracked by hand, like any other
dotfile) for apps/`xdg-user-dirs-update` to consult -- `dot setup folders`
itself no longer reads it, requires its presence, or writes to it.
## Acceptance criteria
- [x] `_dot_setup_folders` no longer reads, parses, or requires
`~/.config/user-dirs.dirs`; the legacy->short-name mapping is a fixed
table in the function itself
- [x] Migration works identically whether `user-dirs.dirs` is absent,
empty, or declares stale/full-name values (the exact real-world case)
- [x] `user-dirs.dirs` is left byte-for-byte untouched by `dot setup
folders` when present, and no file is created when absent
- [x] `dot setup folders help` no longer describes reading target names
from `user-dirs.dirs`
- [x] `~/.config/dot/tests/dot.fish` no longer seeds a `user-dirs.dirs`
fixture as a migration precondition, and covers the stale/missing
cases above; `fishtape ~/.config/dot/tests/dot.fish` passes
## Implementation Notes
- Replaced the `xdg_vars`/`grep`/`string match` parsing of `user-dirs.dirs`
with two parallel hardcoded arrays, `legacy_names` and `target_names`,
indexed together -- same shape the code already used for `legacy_names`
alone, just extended to cover the target side too.
- The early `if not test -f $user_dirs; return 1` guard was deleted outright
rather than kept as a soft check: there's nothing left for the function to
read from that file, so requiring its existence would just be a
vestigial, unjustifiable precondition.
- Removed the `short_name_user_dirs` fixture and its seeding step from every
test scenario (it was previously duplicated into ~15 scenarios as a
migration precondition); added two new scenarios instead: one reproducing
the exact real-machine bug (stale full-name `user-dirs.dirs` values) and
one confirming migration works with no `user-dirs.dirs` file at all.
- Verified against this machine's real, still-stale `~/.config/user-dirs.dirs`
via `_dot_setup_folders --dry-run`: previously reported every entry in
Desktop/Documents/Downloads/Pictures/Videos as a collision against
itself; now correctly reports `would move N entries from ~/Documents to
~/doc` etc.
- `fishtape ~/.config/dot/tests/dot.fish` passes (183 tests).

View File

@@ -1,167 +0,0 @@
# Dotfiles
This machine's dotfiles are a bare git repo at `~/.dotfiles`, checked out with
`$HOME` as its work-tree. The `dot` fish function wraps that invocation
(`git --git-dir=~/.dotfiles --work-tree=$HOME $argv`, declared with
`--wraps=git`), so every git subcommand works through it: `dot status`,
`dot add`, `dot commit`, `dot push`, etc.
This directory (`~/.config/dot`) holds the `dot` CLI's custom subcommands,
tests, and package lists, but the repo tracks files across `$HOME` — fish
config, git identity, the `dot` function itself, and more. To see everything
tracked, run `dot ls-tree -r --name-only HEAD` from `$HOME` (paths are shown
relative to cwd, so running it from elsewhere silently truncates the list).
For an agent driving this through separate tool calls: `cd ~` in one call does
not reliably carry over to the next, since each call may reset to the
project's working directory. Always `cd "$HOME"` and run the `ls-tree` (or any
other cwd-sensitive `dot`/`git` command) in that *same* call — e.g.
`cd "$HOME" && dot ls-tree -r --name-only HEAD` — rather than trusting a prior
`cd` to have stuck. Getting this wrong silently narrows the listing to
whatever the leftover cwd happens to be, which reads as "this file isn't
tracked" when it actually is.
## Always add by explicit path
`status.showUntrackedFiles=no` is set locally (see `dot init` below), and
`.gitignore` only excludes `.dotfiles` itself plus OS/editor cruft — it is
**not** a whitelist. That
means virtually everything under `$HOME` reads as untracked, and `git status`
deliberately hides all of it.
**Always run `dot add <specific-path>`.** Never `dot add -A`, `dot add .`, or
any wildcard add — that would try to stage the entire home directory (caches,
secrets, everything).
**Stage automatically after changes.** Once a tracked file is edited, run
`dot add <specific-path>` for it right away rather than waiting to be asked —
one explicit path per changed file, still never a wildcard. This does not
extend to `dot commit` or `dot push`, which still require an explicit
request.
## The dot CLI
### Architecture
`dot` is defined in one file: `~/.config/fish/functions/dot.fish`. It holds
three functions:
- `dot` (`--wraps=git`) — dispatches `init`, `help`, and any file found under
`~/.config/dot/commands/`, otherwise forwards everything to
`git --git-dir=~/.dotfiles --work-tree=$HOME $argv` (full passthrough).
- `__dot_init` — the bootstrap logic, inlined in the same file rather than
autoloaded separately, because it's the one subcommand that must work
before the dotfiles repo has ever been cloned onto a machine.
- `__dot_help` — prints usage: the built-in commands plus whatever is
currently found under `~/.config/dot/commands/`, generated by globbing that
directory rather than a hardcoded list, so it can't drift from reality.
`__dot_help`'s glob over `~/.config/dot/commands/*.fish` is duplicated in
`~/.config/fish/completions/dot.fish`'s `__dot_custom_subcommands` rather than
shared: fish only autoloads a function from a file named after that function,
so a helper defined inside `dot.fish` would be undefined if tab-completion
ran before `dot` had ever been sourced in the session. Keep both copies in
sync when the listing logic changes.
Both copies also glob one directory level deeper, matching `~/.config/dot/commands/<name>/<name>.fish`, so a subcommand's companion file (e.g. a Python helper) can live alongside it in its own directory.
`dot init`:
- refuses to run if `~/.dotfiles` already exists (no re-init support)
- clones the bare repo from `--url` (default: the hardcoded Gitea remote) —
if the clone fails, it errors out; it never falls back to `git init`
- backs up any pre-existing file that checkout would clobber into
`~/.dotfiles-backup/<timestamp>/`, then retries the checkout
- explicitly sets `status.showUntrackedFiles=no` after cloning — this is a
local-only git setting, so a fresh `git clone` never carries it over
### Adding a subcommand
Beyond `init`, `dot` looks for `~/.config/dot/commands/<name>.fish`, sources
it, and calls `_dot_<name>`.
A subcommand needing a companion file can instead live nested one level deeper, as `~/.config/dot/commands/<name>/<name>.fish` — both layouts dispatch identically.
These files are deliberately kept out of
`~/.config/fish/functions/` (fish's autoload path) so they never become
independently invokable top-level commands or clutter tab-completion outside
of `dot` itself.
1. Create `~/.config/dot/commands/<name>.fish` defining a `_dot_<name>`
function.
2. Confirm `dot <name>` dispatches to it. No other wiring is needed —
`~/.config/fish/completions/dot.fish` and `__dot_help` both discover new
command files by globbing that directory, and `--wraps=git` still covers
raw git subcommands.
3. Implement a `help` subcommand: check for `help` as `_dot_<name>`'s first
positional argument before `argparse`, and call a `_dot_<name>_usage`
function that prints usage and every flag. If `_dot_<name>` itself
dispatches to nested subcommands, apply this same check-then-dispatch
pattern at that level too — there's no central `--help` handling in
`dot.fish` to lean on; each level is responsible for its own.
`_dot_<name>_usage` should print its text as a single multi-line
`echo "..."` string (fish preserves literal newlines inside double
quotes) rather than one `echo` per line.
4. Add a row to `~/.github/README.md`'s command table for it — one row per
distinct use case, with paths written relative to `$HOME`
(`~/.config/dot/...`), not relative to the README's own location.
5. Add a case to `~/.config/dot/tests/dot.fish` covering it, including its
`help` output, and run `fishtape ~/.config/dot/tests/dot.fish` until it
passes.
### Testing
Tests live at `~/.config/dot/tests/dot.fish`, run with
`fishtape ~/.config/dot/tests/dot.fish`. Fishtape is installed via Fisher
(`fisher install jorgebucaran/fishtape`) and tracked in
`~/.config/fish/fish_plugins` — a real, restorable dependency for developing
`dot`, but never required just to use it.
- Each scenario overrides `$HOME` (`set -gx HOME (mktemp -d)`) before calling
`dot`, so tests never touch the real `~/.dotfiles`.
- Build a throwaway bare "remote" fixture with `git init --bare` plus a
seeded commit, and explicitly set its `HEAD`
(`git --git-dir=$remote symbolic-ref HEAD refs/heads/main`). Pushing with
`git push origin HEAD:main` does **not** update the bare repo's `HEAD`
symref — skip this and a clone of the fixture can end up "on a branch yet
to be born."
- Don't use `.gitconfig` as a fake pre-existing "conflict" file in a
fixture — git parses `$HOME/.gitconfig` as its own global config on every
invocation, and garbage content there spams "key does not contain a
section" errors that drown out the real assertion. Use a harmless file
like `.bashrc` instead.
- `@test "description" <expr> <op> <expected>` mirrors fish's `test` builtin
(`-eq`, `-ne`, `=`, `-e`, `-f`, `-d`, `-n`, `-z`); `-a`/`-o` combinators
aren't supported.
## Gotchas
- `~/.claude/` (Claude Code's own config: skills, agents, commands, etc.) is
a plain directory, not a separate git repo of its own — plain `git` commands
run from inside it report "not a git repository". It's tracked the same way
as everything else under `$HOME`: through the `dot` bare repo. Use
`dot add`/`dot status` on paths under `~/.claude/`, not a `git` invocation
scoped to that directory, and don't assume an unrelated repo (e.g. a
separate skills-source checkout elsewhere) is the tracked copy just because
it also holds a copy of the same files.
- `~/.claude/` and this project's own `.claude/` (e.g. `~/.config/dot/.claude/`)
are two different directories that both happen to exist. Project-relative
paths referenced in specs, task breakdowns, or other project docs — like
`.claude/spec/<slug>.md` or `.claude/tasks/<NNNN>-<slug>.md` — are relative
to this project directory (`~/.config/dot/.claude/...`), not to
`$HOME/.claude/`. Writing to `$HOME/.claude/tasks/` instead of
`~/.config/dot/.claude/tasks/` silently lands files in Claude Code's own
global config dir instead of the project.
- An agent's `Bash` tool runs commands through **zsh**, not fish, so the
`dot` fish function (defined in `~/.config/fish/functions/dot.fish`) is
not on that shell's autoload path. Typing `dot <subcommand>` there
silently resolves to `/usr/bin/dot` (Graphviz) instead, producing
confusing "can't open <arg>: No such file or directory" / "syntax error
near '--'" errors rather than a clear "command not found". Either invoke
it as `fish -c "dot <subcommand> ..."`, or bypass the wrapper and call
`git --git-dir=$HOME/.dotfiles --work-tree=$HOME <args>` directly.
## Keybindings
Whenever a keybind is added, changed, or removed in *any* config on this
machine (tmux, KDE, neovim, fish, whatever), add or update its row in
[`~/.github/keybindings.md`](../../.github/keybindings.md) in the same
change. That file is the single reference for every keybind across tools —
it drifts the moment a bind changes somewhere without a matching edit there.

View File

@@ -1,59 +0,0 @@
function _dot_install_usage
echo "usage: dot install [--restore] [--no-sync] [package ...]
--restore reinstall every package from the tracked list
--no-sync skip 'pacman -Sy' before installing"
end
function _dot_install
if test "$argv[1]" = help
_dot_install_usage
return 0
end
argparse 'restore' 'no-sync' -- $argv
or return 1
set -l list_dir $HOME/.config/dot/packages
set -l list_file $list_dir/pacman
set -l packages
if set -q _flag_restore
if test (count $argv) -gt 0
echo "dot install: --restore cannot be combined with package names" >&2
return 1
end
if not test -s $list_file
echo "dot install: no package list found at $list_file" >&2
return 1
end
set packages (cat $list_file)
else
if test (count $argv) -eq 0
echo "dot install: no packages given (use --restore to reinstall from the list)" >&2
return 1
end
set packages $argv
end
if not set -q _flag_no_sync
sudo pacman -Sy
or return 1
end
sudo pacman -S --needed $packages
or return 1
if set -q _flag_restore
return 0
end
mkdir -p $list_dir
test -f $list_file
or touch $list_file
printf '%s\n' $packages >>$list_file
sort -u -o $list_file $list_file
end

View File

@@ -1,35 +0,0 @@
function _dot_kde_usage
echo "usage: dot kde <command>
Commands:
apply push manifest entries onto the live system
diff scan for settings whose live value differs from its default
save write live KDE settings into the manifest
help show this message
Run 'dot kde <command> help' for flags on a specific command."
end
function _dot_kde
if test "$argv[1]" = help
_dot_kde_usage
return 0
end
set -l helper_dir (status dirname)
switch "$argv[1]"
case apply
python3 $helper_dir/kde.py apply $argv[2..-1]
return $status
case diff
python3 $helper_dir/kde.py diff $argv[2..-1]
return $status
case save
python3 $helper_dir/kde.py save $argv[2..-1]
return $status
case '*'
_dot_kde_usage
return 1
end
end

View File

@@ -1,535 +0,0 @@
#!/usr/bin/env python3
import json
import os
import subprocess
import sys
import xml.etree.ElementTree as ET
from collections import defaultdict, namedtuple
from pathlib import Path
KCFG_NS = "{http://www.kde.org/standards/kcfg/1.0}"
DEFAULT_SCHEMA_DIR = "/usr/share/config.kcfg"
KGLOBALACCEL_SERVICE = "org.kde.kglobalaccel"
KGLOBALACCEL_PATH = "/kglobalaccel"
KGLOBALACCEL_IFACE = "org.kde.KGlobalAccel"
# KGlobalAccel::GlobalShortcutLoading::NoAutoloading, per KF6/KGlobalAccel/kglobalaccel.h --
# makes a write always win over whatever shortcut was previously saved, rather than being
# ignored in favor of it (the Autoloading=0x0 default).
SHORTCUT_NO_AUTOLOADING = 0x4
# .kcfg files that only declare their target rc file at runtime
# (<kcfgfile arg="true">), so it can't be discovered by scanning.
ARG_TRUE_RCFILES = {
"kwin.kcfg": "kwinrc",
}
SAVE_USAGE = """usage: dot kde save [identifier]
identifier declare a new manifest entry, seeded from its current live value
(no args) refresh every already-declared manifest entry from the live system
help show this message"""
APPLY_USAGE = """usage: dot kde apply
Pushes every manifest entry's declared value onto the live system.
help show this message"""
DIFF_USAGE = """usage: dot kde diff
Scans every schema-backed setting reachable through the kcfg mapping
table, and every shortcut registered with kglobalaccel, reporting each
one whose live value differs from its default, tagged declared
(present in the manifest) or undeclared. Also reports already-declared
freeform settings whose live value differs from their default (no
schema to broad-scan, so it's only checked when already declared).
Read-only -- never writes the manifest or the live system.
help show this message"""
Setting = namedtuple("Setting", ["file", "group", "key"])
def _split_on_known_prefix(rest, candidates):
matches = [c for c in candidates if rest == c or rest.startswith(c + ".")]
if not matches:
return None
prefix = max(matches, key=len)
remainder = rest[len(prefix):].lstrip(".")
if not remainder:
return None
return prefix, remainder
def _known_schema_groups(file, kcfg_map):
groups = set()
for path in kcfg_map.get(file, []):
root = _parse_kcfg(path)
if root is None:
continue
for group_elem in root.iter(f"{KCFG_NS}group"):
name = group_elem.get("name")
if name:
groups.add(name)
return groups
def _split_schema_group_key(file, rest, kcfg_map):
match = _split_on_known_prefix(rest, _known_schema_groups(file, kcfg_map))
if match is not None:
return match
# No schema group matches -- freeform. Its group is never known to contain
# a dot (there's no schema to have told us otherwise), so the boundary is
# just the first remaining dot.
group, _, key = rest.partition(".")
if not key:
raise ValueError(f"invalid identifier {file}.{rest!r} (expected file.group.key)")
return group, key
def _split_shortcut_group_key(rest):
(components,) = _kglobalaccel_call("allMainComponents", None)
match = _split_on_known_prefix(rest, [component[0] for component in components])
if match is None:
raise RuntimeError(
f"no live kglobalaccel component matches {rest!r} "
"(the owning application may need to run once to register its shortcuts with kglobalaccel)"
)
return match
# Only the file segment is unambiguous (rc file names never contain a dot).
# The group/key boundary can't be found by counting dots -- both KConfig group
# names (e.g. "org.kde.kdecoration2") and kglobalaccel componentUnique names
# (e.g. "org.kde.dolphin.desktop") routinely contain their own dots -- so it's
# resolved against known-good data instead: the live kglobalaccel component
# list for shortcuts, the kcfg schema's declared group names for everything
# else (falling back to freeform's first-dot split when no schema matches).
def parse_identifier(identifier, kcfg_map):
file, sep, rest = identifier.partition(".")
if not sep or not rest:
raise ValueError(f"invalid identifier {identifier!r} (expected file.group.key)")
if file == "kglobalshortcutsrc":
group, key = _split_shortcut_group_key(rest)
else:
group, key = _split_schema_group_key(file, rest, kcfg_map)
return Setting(file, group, key)
def load_manifest(path):
entries = {}
if not path.exists():
return entries
for line in path.read_text().splitlines():
if not line.strip():
continue
identifier, _, value = line.partition("=")
entries[identifier] = value
return entries
def write_manifest(path, entries):
lines = [f"{identifier}={value}" for identifier, value in entries.items()]
path.write_text("".join(f"{line}\n" for line in lines))
def _parse_kcfg(path):
try:
return ET.parse(path).getroot()
except ET.ParseError:
return None
def _kcfgfile_name(root):
elem = root.find(f"{KCFG_NS}kcfgfile")
if elem is None:
return None
return elem.get("name")
def build_kcfg_map(schema_dir):
mapping = defaultdict(list)
if not schema_dir.is_dir():
return mapping
for path in sorted(schema_dir.glob("*.kcfg")):
root = _parse_kcfg(path)
if root is None:
continue
rcfile = _kcfgfile_name(root) or ARG_TRUE_RCFILES.get(path.name)
if rcfile:
mapping[rcfile].append(path)
return mapping
def find_schema_default(kcfg_paths, setting):
for path in kcfg_paths:
root = _parse_kcfg(path)
if root is None:
continue
for group_elem in root.iter(f"{KCFG_NS}group"):
if group_elem.get("name") != setting.group:
continue
for entry in group_elem.findall(f"{KCFG_NS}entry"):
if (entry.get("key") or entry.get("name")) != setting.key:
continue
default_elem = entry.find(f"{KCFG_NS}default")
return default_elem.text if default_elem is not None and default_elem.text else ""
return None
def iter_schema_identifiers(kcfg_map):
for rcfile, paths in kcfg_map.items():
for path in paths:
root = _parse_kcfg(path)
if root is None:
continue
for group_elem in root.iter(f"{KCFG_NS}group"):
group = group_elem.get("name")
if not group:
continue
for entry in group_elem.findall(f"{KCFG_NS}entry"):
key = entry.get("key") or entry.get("name")
if key:
yield Setting(rcfile, group, key)
def resolve_mechanism(setting, kcfg_map):
if setting.file == "kglobalshortcutsrc":
return "shortcuts", None
default = find_schema_default(kcfg_map.get(setting.file, []), setting)
if default is not None:
return "schema", default
return "freeform", None
def read_live_value(setting, default):
cmd = ["kreadconfig6", "--file", setting.file, "--group", setting.group, "--key", setting.key]
if default is not None:
cmd += ["--default", default]
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
raise RuntimeError(
f"kreadconfig6 failed for {setting.file}/{setting.group}/{setting.key}: {result.stderr.strip()}"
)
return result.stdout.rstrip("\n")
def write_live_value(setting, value):
cmd = [
"kwriteconfig6",
"--file", setting.file,
"--group", setting.group,
"--key", setting.key,
"--",
value,
]
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
raise RuntimeError(
f"kwriteconfig6 failed for {setting.file}/{setting.group}/{setting.key}: {result.stderr.strip()}"
)
def _key_sequence_class():
try:
from PyQt6.QtGui import QKeySequence
except ImportError as e:
raise RuntimeError(
"the shortcuts mechanism requires PyQt6 (install python-pyqt6) to translate key names"
) from e
return QKeySequence
def _keys_to_string(key_ints):
QKeySequence = _key_sequence_class()
return "\t".join(QKeySequence(key).toString() for key in key_ints)
def _string_to_keys(value):
if not value:
return []
QKeySequence = _key_sequence_class()
keys = []
for part in value.split("\t"):
part = part.strip()
if not part or part.lower() == "none":
continue
sequence = QKeySequence(part)
if sequence.count() != 1:
raise RuntimeError(f"invalid key sequence {part!r} (expected exactly one key combination)")
keys.append(int(sequence[0].toCombined()))
return keys
def _kglobalaccel_call(method, signature, *tokens):
cmd = ["busctl", "--user", "--json=short", "call",
KGLOBALACCEL_SERVICE, KGLOBALACCEL_PATH, KGLOBALACCEL_IFACE, method]
if signature:
cmd += [signature, *(str(token) for token in tokens)]
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
raise RuntimeError(f"kglobalaccel {method} failed: {result.stderr.strip()}")
return json.loads(result.stdout)["data"]
def _actions_for_component(component_unique):
(actions,) = _kglobalaccel_call("allActionsForComponent", "as", 1, component_unique)
return actions
def iter_shortcut_identifiers():
(components,) = _kglobalaccel_call("allMainComponents", None)
for component in components:
for action in _actions_for_component(component[0]):
yield Setting("kglobalshortcutsrc", action[0], action[1])
def _resolve_shortcut_action_id(component_unique, action_unique):
for action in _actions_for_component(component_unique):
if action[0] == component_unique and action[1] == action_unique:
return action
raise RuntimeError(
f"no shortcut action {action_unique!r} in component {component_unique!r} "
"(the owning application may need to run once to register its shortcuts with kglobalaccel)"
)
# The plural *Keys methods (a(ai), one 4-int QKeyCombination chord slot per bound
# key sequence) are used instead of the singular shortcut()/defaultShortcut()/
# setShortcut() methods the flat ai signature suggests: on this KF6 build,
# defaultShortcut() was empirically found to just mirror shortcut() -- returning
# whatever the *current* value is rather than the true packaged default -- while
# defaultShortcutKeys() correctly returns the untouched default even after
# setShortcutKeys() has changed the current value. Only single, non-chorded key
# combinations are supported (see _string_to_keys), so only the first of each
# chord's 4 int slots is ever meaningful here; the rest are always 0.
def _keys_from_chords(chords):
return [chord[0][0] for chord in chords]
def read_shortcut_value(component_unique, action_unique, method="shortcutKeys"):
action_id = _resolve_shortcut_action_id(component_unique, action_unique)
(chords,) = _kglobalaccel_call(method, "as", len(action_id), *action_id)
return _keys_to_string(_keys_from_chords(chords))
def write_shortcut_value(component_unique, action_unique, value):
action_id = _resolve_shortcut_action_id(component_unique, action_unique)
keys = _string_to_keys(value)
tokens = [len(action_id), *action_id, len(keys)]
for key in keys:
tokens += [4, key, 0, 0, 0]
tokens.append(SHORTCUT_NO_AUTOLOADING)
_kglobalaccel_call("setShortcutKeys", "asa(ai)u", *tokens)
def save_one(identifier, kcfg_map):
setting = parse_identifier(identifier, kcfg_map)
mechanism, default = resolve_mechanism(setting, kcfg_map)
if mechanism == "shortcuts":
return read_shortcut_value(setting.group, setting.key)
return read_live_value(setting, default)
def apply_one(identifier, value, kcfg_map):
setting = parse_identifier(identifier, kcfg_map)
mechanism, _default = resolve_mechanism(setting, kcfg_map)
if mechanism == "shortcuts":
write_shortcut_value(setting.group, setting.key, value)
return
write_live_value(setting, value)
def cmd_save(args, manifest_path, schema_dir):
if args and args[0] == "help":
print(SAVE_USAGE)
return 0
if len(args) > 1:
print("dot kde save: too many arguments", file=sys.stderr)
return 1
kcfg_map = build_kcfg_map(schema_dir)
manifest = load_manifest(manifest_path)
try:
if args:
manifest[args[0]] = save_one(args[0], kcfg_map)
else:
for identifier in manifest:
manifest[identifier] = save_one(identifier, kcfg_map)
except (ValueError, RuntimeError) as e:
print(f"dot kde save: {e}", file=sys.stderr)
return 1
write_manifest(manifest_path, manifest)
return 0
def cmd_apply(args, manifest_path, schema_dir):
if args and args[0] == "help":
print(APPLY_USAGE)
return 0
if args:
print("dot kde apply: too many arguments", file=sys.stderr)
return 1
kcfg_map = build_kcfg_map(schema_dir)
manifest = load_manifest(manifest_path)
try:
for identifier, value in manifest.items():
apply_one(identifier, value, kcfg_map)
except (ValueError, RuntimeError) as e:
print(f"dot kde apply: {e}", file=sys.stderr)
return 1
return 0
def cmd_diff(args, manifest_path, schema_dir):
if args and args[0] == "help":
print(DIFF_USAGE)
return 0
if args:
print("dot kde diff: too many arguments", file=sys.stderr)
return 1
kcfg_map = build_kcfg_map(schema_dir)
manifest = load_manifest(manifest_path)
for setting in sorted(set(iter_schema_identifiers(kcfg_map))):
identifier = f"{setting.file}.{setting.group}.{setting.key}"
default = find_schema_default(kcfg_map.get(setting.file, []), setting)
try:
live = read_live_value(setting, default)
except RuntimeError as e:
print(f"dot kde diff: {e}", file=sys.stderr)
continue
if live == default:
continue
tag = "declared" if identifier in manifest else "undeclared"
print(f"{tag} {identifier} = {live} (default: {default})")
# Shortcuts are enumerable via kglobalaccel's allMainComponents/
# allActionsForComponent (the same source iter_shortcut_identifiers already
# walks for tab-completion), so unlike freeform they can participate in
# broad undeclared-drift discovery too.
try:
shortcut_settings = sorted(set(iter_shortcut_identifiers()))
except (RuntimeError, OSError) as e:
print(f"dot kde diff: shortcuts scan unavailable: {e}", file=sys.stderr)
shortcut_settings = []
for setting in shortcut_settings:
identifier = f"{setting.file}.{setting.group}.{setting.key}"
try:
live = read_shortcut_value(setting.group, setting.key)
default = read_shortcut_value(setting.group, setting.key, method="defaultShortcutKeys")
except RuntimeError as e:
print(f"dot kde diff: {e}", file=sys.stderr)
continue
if live == default:
continue
tag = "declared" if identifier in manifest else "undeclared"
print(f"{tag} {identifier} = {live} (default: {default})")
# Freeform settings have no schema to enumerate from, so unlike the
# schema-backed and shortcuts scans above, they can only be checked by
# walking identifiers already in the manifest -- they never surface an
# undeclared setting via broad scan. Shortcuts entries are skipped here
# (rather than re-parsed) since the broad-scan pass above already reports
# every declared shortcut mismatch; parsing one here would also mean an
# extra live kglobalaccel round-trip per entry for no benefit.
for identifier in manifest:
if identifier.split(".", 1)[0] == "kglobalshortcutsrc":
continue
try:
setting = parse_identifier(identifier, kcfg_map)
mechanism, default = resolve_mechanism(setting, kcfg_map)
if mechanism != "freeform":
continue
live = read_live_value(setting, default)
if live == "":
continue
except (ValueError, RuntimeError) as e:
print(f"dot kde diff: {e}", file=sys.stderr)
continue
print(f"declared {identifier} = {live} (default: {default or ''})")
return 0
def cmd_complete(schema_dir):
kcfg_map = build_kcfg_map(schema_dir)
for setting in sorted(set(iter_schema_identifiers(kcfg_map))):
print(f"{setting.file}.{setting.group}.{setting.key}")
try:
# Fish's completion runs this on every TAB press, in shells that may have no
# live KDE session (or no busctl at all) -- a broken shortcuts source must
# never cost the schema-backed candidates already printed above.
shortcut_settings = sorted(set(iter_shortcut_identifiers()))
except (RuntimeError, OSError):
shortcut_settings = []
for setting in shortcut_settings:
print(f"{setting.file}.{setting.group}.{setting.key}")
return 0
def main(argv):
if not argv:
print("dot kde: no command given", file=sys.stderr)
return 1
command, rest = argv[0], argv[1:]
schema_dir = Path(os.environ.get("DOT_KDE_KCFG_DIR", DEFAULT_SCHEMA_DIR))
manifest_path = Path(os.environ["HOME"]) / ".config" / "dot" / "kde-manifest"
if command == "save":
return cmd_save(rest, manifest_path, schema_dir)
if command == "apply":
return cmd_apply(rest, manifest_path, schema_dir)
if command == "diff":
return cmd_diff(rest, manifest_path, schema_dir)
# Internal, not a user-facing `dot kde` subcommand -- called directly by
# completions/dot.fish to source candidates from the live schema, never
# dispatched to via kde.fish.
if command == "complete":
return cmd_complete(schema_dir)
print(f"dot kde: unknown command {command!r}", file=sys.stderr)
return 1
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))

View File

@@ -1,134 +0,0 @@
function _dot_setup_folders_usage
echo "usage: dot setup folders [--dry-run]
Brings the 8 standard XDG user directories under the project's fixed
short-name convention (Desktop -> .desktop, Documents -> doc, Downloads ->
dwn, Music -> mus, Pictures -> pic, Videos -> vid, Templates/Public ->
.ignoreme). This mapping is fixed and does not depend on
~/.config/user-dirs.dirs, which is a separate, manually tracked dotfile
this command never reads or writes.
Content left behind in a legacy full-named folder (e.g. ~/Documents) by a
fresh XDG-defaults install -- empty or not -- is merged into its short-named
replacement. A nested Pictures/Screenshots folder is renamed to
pic/screenshots as part of the same pass.
An entry that collides by name with something already in the short-named
target is never overwritten: it's skipped, reported, and its legacy folder is
left in place (not removed) even when everything else in it migrated.
--dry-run report what would move and what would be skipped as a
collision, without changing anything on disk
Runs xdg-user-dirs-update once afterwards to notify running apps/portals
(skipped under --dry-run)."
end
function _dot_setup_folders
if test "$argv[1]" = help
_dot_setup_folders_usage
return 0
end
argparse 'dry-run' -- $argv
or return 1
# Fixed legacy-name -> short-name mapping. Deliberately hardcoded rather
# than read from ~/.config/user-dirs.dirs: that file is a separate,
# manually tracked dotfile whose XDG_*_DIR values can drift or go stale
# (or never get edited to the short names at all), and this command's
# own migration logic must not depend on it being correct.
set -l legacy_names Desktop Documents Downloads Music Pictures Videos Templates Public
set -l target_names .desktop doc dwn mus pic vid .ignoreme .ignoreme
for i in (seq (count $legacy_names))
set -l legacy_name $legacy_names[$i]
set -l target_rel $target_names[$i]
set -l target_path $HOME/$target_rel
set -l legacy_path $HOME/$legacy_name
if not set -q _flag_dry_run
mkdir -p $target_path
end
if not test -d $legacy_path
continue
end
# Screenshots is always moved as one atomic unit (renamed to
# lowercase screenshots), so its individual files must never appear
# as separate move/report entries.
set -l screenshots_path $legacy_path/Screenshots
set -l top_level_entries (find $legacy_path -mindepth 1 -maxdepth 1 -not -name Screenshots)
# No-clobber: an entry whose name already exists in the target is
# never moved over. It's collected here and reported below; its
# legacy folder is left in place (not removed) if any collision
# occurred, even though everything else in it migrated successfully.
set -l collisions
set -l movable_entries
set -l screenshots_movable 0
if test -d $screenshots_path
if test -e $target_path/screenshots
set -a collisions $screenshots_path
else
set screenshots_movable 1
end
end
for entry in $top_level_entries
if test -e $target_path/(path basename $entry)
set -a collisions $entry
else
set -a movable_entries $entry
end
end
set -l movable_count (count $movable_entries)
set -l entry_word entries
test $movable_count -eq 1
and set entry_word entry
if set -q _flag_dry_run
if test $screenshots_movable -eq 1
echo "dot setup folders: would move $screenshots_path to $target_path/screenshots"
end
if test $movable_count -gt 0
echo "dot setup folders: would move $movable_count $entry_word from ~/$legacy_name to ~/$target_rel"
end
if test (count $collisions) -gt 0
echo "dot setup folders: ~/$legacy_name has entries already present in ~/$target_rel, would skip (not overwritten):"
for c in $collisions
echo " $c"
end
echo "dot setup folders: ~/$legacy_name would remain in place due to the collision(s) above"
end
continue
end
if test $screenshots_movable -eq 1
mv -n $screenshots_path $target_path/screenshots
echo "dot setup folders: moved $screenshots_path to $target_path/screenshots"
end
if test $movable_count -gt 0
mv -n $movable_entries $target_path/
echo "dot setup folders: moved $movable_count $entry_word from ~/$legacy_name to ~/$target_rel"
end
if test (count $collisions) -gt 0
echo "dot setup folders: ~/$legacy_name has entries already present in ~/$target_rel, skipping (not overwritten):"
for c in $collisions
echo " $c"
end
echo "dot setup folders: leaving ~/$legacy_name in place due to the collision(s) above"
else
rmdir $legacy_path
end
end
if not set -q _flag_dry_run
xdg-user-dirs-update
end
end

View File

@@ -1,35 +0,0 @@
function _dot_setup_usage
echo "usage: dot setup [<task>]
Tasks:
folders bring the 8 standard XDG user directories under the short-name convention
help show this message
Run 'dot setup <task> help' for details on a specific task.
With no task given, runs every setup task."
end
function _dot_setup
if test "$argv[1]" = help
_dot_setup_usage
return 0
end
set -l helper_dir (status dirname)
source $helper_dir/folders.fish
if test -z "$argv[1]"
_dot_setup_folders
return $status
end
switch $argv[1]
case folders
_dot_setup_folders $argv[2..-1]
return $status
case '*'
_dot_setup_usage
return 1
end
end

View File

@@ -1,29 +0,0 @@
kxkbrc.Layout.Options=caps:escape_shifted_capslock
kglobalshortcutsrc.ksmserver.Lock Session=Meta+X Screensaver
kglobalshortcutsrc.kwin.Window to Desktop 1=Meta+!
kglobalshortcutsrc.kwin.Window to Desktop 2=Meta+@
kglobalshortcutsrc.kwin.Window to Desktop 3=Meta+#
kglobalshortcutsrc.kwin.Window to Desktop 4=Meta+$
kglobalshortcutsrc.kwin.Window to Desktop 5=Meta+%
kglobalshortcutsrc.kwin.Window to Desktop 6=Meta+^
kglobalshortcutsrc.kwin.Window to Desktop 7=Meta+&
kglobalshortcutsrc.kwin.Window to Desktop 8=Meta+*
kglobalshortcutsrc.kwin.Window to Desktop 9=Meta+(
kglobalshortcutsrc.kwin.Switch to Desktop 1=Meta+1
kglobalshortcutsrc.kwin.Switch to Desktop 2=Meta+2
kglobalshortcutsrc.kwin.Switch to Desktop 3=Meta+3
kglobalshortcutsrc.kwin.Switch to Desktop 4=Meta+4
kglobalshortcutsrc.kwin.Switch to Desktop 5=Meta+5
kglobalshortcutsrc.kwin.Switch to Desktop 6=Meta+6
kglobalshortcutsrc.kwin.Switch to Desktop 7=Meta+7
kglobalshortcutsrc.kwin.Switch to Desktop 8=Meta+8
kglobalshortcutsrc.kwin.Switch to Desktop 9=Meta+9
kglobalshortcutsrc.plasmashell.activate task manager entry 1=
kglobalshortcutsrc.plasmashell.activate task manager entry 2=
kglobalshortcutsrc.plasmashell.activate task manager entry 3=
kglobalshortcutsrc.plasmashell.activate task manager entry 4=
kglobalshortcutsrc.plasmashell.activate task manager entry 5=
kglobalshortcutsrc.plasmashell.activate task manager entry 6=
kglobalshortcutsrc.plasmashell.activate task manager entry 7=
kglobalshortcutsrc.plasmashell.activate task manager entry 8=
kglobalshortcutsrc.plasmashell.activate task manager entry 9=

View File

@@ -1,2 +0,0 @@
neovim
tmux

View File

@@ -1,927 +0,0 @@
set -l commands_dir (path resolve (status dirname)/../commands)
# Fixture: a fake bare "remote" repo with tracked dotfiles, shared read-only
# across every case below. dot init only ever clones from it, never mutates it.
set -l remote (mktemp -d)/dotfiles.git
git init -q --bare $remote
set -l seed (mktemp -d)
pushd $seed
git init -q -b main
git config user.email test@dot.fish
git config user.name dot-tests
mkdir -p .config/fish/functions
echo 'echo tracked-bashrc' >.bashrc
echo 'echo hi' >.config/fish/functions/greet.fish
git add -A
git commit -qm seed >/dev/null
git remote add origin $remote
git push -q origin HEAD:main >/dev/null 2>&1
popd
git --git-dir=$remote symbolic-ref HEAD refs/heads/main
# --- fresh bootstrap, no conflicts ---
set -gx HOME (mktemp -d)
dot init --url $remote >/dev/null 2>&1
set -l fresh_status $status
@test "dot init succeeds against a clean HOME" $fresh_status -eq 0
@test "clones the bare repo to ~/.dotfiles" -e $HOME/.dotfiles
@test "checks out tracked files onto HOME" -e $HOME/.bashrc
@test "checked-out file has the repo's content" (cat $HOME/.bashrc) = "echo tracked-bashrc"
@test "disables status.showUntrackedFiles" (git --git-dir=$HOME/.dotfiles config --local status.showuntrackedfiles) = no
dot init --url $remote >/dev/null 2>&1
set -l repeat_status $status
@test "re-running dot init refuses when already initialized" $repeat_status -eq 1
set -l passthrough_status (dot status >/dev/null 2>&1; echo $status)
@test "git passthrough still works (dot status)" $passthrough_status -eq 0
# --- a pre-existing conflicting file gets backed up, not clobbered ---
set -gx HOME (mktemp -d)
echo 'pre-existing-content' >$HOME/.bashrc
dot init --url $remote >/dev/null 2>&1
set -l conflict_status $status
@test "dot init still succeeds with a conflicting file present" $conflict_status -eq 0
@test "conflicting file ends up with the tracked content" (cat $HOME/.bashrc) = "echo tracked-bashrc"
@test "a backup directory was created" -d $HOME/.dotfiles-backup
@test "the pre-existing content was preserved in the backup" (cat $HOME/.dotfiles-backup/*/.bashrc) = "pre-existing-content"
# --- an unreachable URL never falls back to creating an empty repo ---
set -gx HOME (mktemp -d)
dot init --url /nonexistent/path.git >/dev/null 2>&1
set -l bad_url_status $status
set -l dotfiles_exists (test -e $HOME/.dotfiles; and echo yes; or echo no)
@test "dot init fails on an unreachable URL" $bad_url_status -eq 1
@test "no .dotfiles directory is left behind on failure" $dotfiles_exists = no
# --- dispatches to files under ~/.config/dot/commands/ without polluting
# the fish function namespace: the file only defines _dot_<name>, which
# only becomes known to fish once dot sources it on demand.
set -gx HOME (mktemp -d)
dot init --url $remote >/dev/null 2>&1
mkdir -p $HOME/.config/dot/commands
set -l marker (mktemp)
echo "function _dot_mark
echo marked >$marker
end" >$HOME/.config/dot/commands/mark.fish
dot mark >/dev/null 2>&1
@test "dispatches to a command file under ~/.config/dot/commands/" (cat $marker) = marked
# --- dispatches to a nested commands/<name>/<name>.fish, same as a flat file
set -gx HOME (mktemp -d)
dot init --url $remote >/dev/null 2>&1
mkdir -p $HOME/.config/dot/commands/nested
set -l nested_marker (mktemp)
echo "function _dot_nested
echo nested-marked >$nested_marker
end" >$HOME/.config/dot/commands/nested/nested.fish
dot nested >/dev/null 2>&1
@test "dispatches to a nested commands/<name>/<name>.fish" (cat $nested_marker) = nested-marked
# --- dot help ---
set -gx HOME (mktemp -d)
dot init --url $remote >/dev/null 2>&1
set -l help_output (dot help)
set -l help_status $status
@test "dot help succeeds" $help_status -eq 0
@test "dot help lists init" (string match -q '*init*' -- $help_output; echo $status) -eq 0
@test "dot help mentions git passthrough" (string match -q '*git*' -- $help_output; echo $status) -eq 0
@test "dot help hints at per-command help" (string match -q "*dot <command> help*" -- $help_output; echo $status) -eq 0
mkdir -p $HOME/.config/dot/commands
echo "function _dot_mark
echo marked
end" >$HOME/.config/dot/commands/mark.fish
set -l help_with_custom (dot help)
@test "dot help lists custom commands found under ~/.config/dot/commands/" (string match -q '*mark*' -- $help_with_custom; echo $status) -eq 0
mkdir -p $HOME/.config/dot/commands/nested
echo "function _dot_nested
echo nested
end" >$HOME/.config/dot/commands/nested/nested.fish
set -l help_with_nested (dot help)
@test "dot help lists a nested-directory subcommand" (string match -q '*nested*' -- $help_with_nested; echo $status) -eq 0
# --- dot install ---
# pacman and sudo are faked out via a bin dir prepended to PATH: sudo just
# execs its arguments, and pacman logs each invocation to $PACMAN_LOG (one
# line per call) and fails only when asked to install a package literally
# named "failpkg", so tests can force the failure path without touching the
# real package manager.
set -l fake_bin (mktemp -d)
echo '#!/bin/sh
exec "$@"' >$fake_bin/sudo
chmod +x $fake_bin/sudo
echo '#!/bin/sh
echo "$@" >>"$PACMAN_LOG"
for arg in "$@"; do
if [ "$arg" = failpkg ]; then
exit 1
fi
done
exit 0' >$fake_bin/pacman
chmod +x $fake_bin/pacman
set -gx PATH $fake_bin $PATH
# --- a successful install records the packages, sorted and deduplicated ---
set -gx HOME (mktemp -d)
dot init --url $remote >/dev/null 2>&1
mkdir -p $HOME/.config/dot/commands
cp $commands_dir/install.fish $HOME/.config/dot/commands/install.fish
set -gx PACMAN_LOG (mktemp)
dot install zeta alpha >/dev/null 2>&1
set -l first_install_status $status
set -l list_file $HOME/.config/dot/packages/pacman
set -l synced_by_default (string match -q '*-Sy*' -- (cat $PACMAN_LOG); and echo yes; or echo no)
set -l installed_named (string match -q '*-S --needed zeta alpha*' -- (cat $PACMAN_LOG); and echo yes; or echo no)
@test "dot install succeeds for real packages" $first_install_status -eq 0
@test "dot install syncs the database by default" $synced_by_default = yes
@test "dot install passes packages to pacman -S --needed" $installed_named = yes
@test "installed packages are recorded, sorted" (cat $list_file | string collect) = "alpha
zeta"
dot install beta >/dev/null 2>&1
@test "a later install merges into the existing list, still sorted" (cat $list_file | string collect) = "alpha
beta
zeta"
dot install alpha >/dev/null 2>&1
@test "re-installing an already-recorded package does not duplicate it" (cat $list_file | string collect) = "alpha
beta
zeta"
# --- --no-sync skips the database refresh ---
set -gx HOME (mktemp -d)
dot init --url $remote >/dev/null 2>&1
mkdir -p $HOME/.config/dot/commands
cp $commands_dir/install.fish $HOME/.config/dot/commands/install.fish
set -gx PACMAN_LOG (mktemp)
dot install --no-sync somepkg >/dev/null 2>&1
set -l synced_with_no_sync (string match -q '*-Sy*' -- (cat $PACMAN_LOG); and echo yes; or echo no)
@test "--no-sync skips pacman -Sy" $synced_with_no_sync = no
# --- a failed pacman run records nothing ---
set -gx HOME (mktemp -d)
dot init --url $remote >/dev/null 2>&1
mkdir -p $HOME/.config/dot/commands
cp $commands_dir/install.fish $HOME/.config/dot/commands/install.fish
set -gx PACMAN_LOG (mktemp)
dot install failpkg >/dev/null 2>&1
set -l failed_install_status $status
set -l list_exists_after_failure (test -e $HOME/.config/dot/packages/pacman; and echo yes; or echo no)
@test "dot install fails when pacman fails" $failed_install_status -ne 0
@test "a failed install leaves no package list behind" $list_exists_after_failure = no
# --- no packages and no --restore is a usage error ---
set -gx HOME (mktemp -d)
dot init --url $remote >/dev/null 2>&1
mkdir -p $HOME/.config/dot/commands
cp $commands_dir/install.fish $HOME/.config/dot/commands/install.fish
set -gx PACMAN_LOG (mktemp)
dot install >/dev/null 2>&1
set -l no_args_status $status
set -l pacman_called_no_args (test -s $PACMAN_LOG; and echo yes; or echo no)
@test "dot install with no arguments and no --restore fails" $no_args_status -ne 0
@test "dot install with no arguments never calls pacman" $pacman_called_no_args = no
# --- --restore reinstalls everything from the list without rewriting it ---
set -gx HOME (mktemp -d)
dot init --url $remote >/dev/null 2>&1
mkdir -p $HOME/.config/dot/commands
cp $commands_dir/install.fish $HOME/.config/dot/commands/install.fish
mkdir -p $HOME/.config/dot/packages
printf 'alpha\nbeta\n' >$HOME/.config/dot/packages/pacman
set -gx PACMAN_LOG (mktemp)
dot install --restore >/dev/null 2>&1
set -l restore_status $status
set -l restored_named (string match -q '*-S --needed alpha beta*' -- (cat $PACMAN_LOG); and echo yes; or echo no)
@test "dot install --restore succeeds" $restore_status -eq 0
@test "--restore installs every package from the list" $restored_named = yes
@test "--restore does not rewrite the list" (cat $HOME/.config/dot/packages/pacman | string collect) = "alpha
beta"
# --- --restore with no list yet is an error ---
set -gx HOME (mktemp -d)
dot init --url $remote >/dev/null 2>&1
mkdir -p $HOME/.config/dot/commands
cp $commands_dir/install.fish $HOME/.config/dot/commands/install.fish
set -gx PACMAN_LOG (mktemp)
dot install --restore >/dev/null 2>&1
set -l restore_no_list_status $status
@test "--restore fails when no package list exists yet" $restore_no_list_status -ne 0
# --- --restore and explicit packages are mutually exclusive ---
set -gx HOME (mktemp -d)
dot init --url $remote >/dev/null 2>&1
mkdir -p $HOME/.config/dot/commands
cp $commands_dir/install.fish $HOME/.config/dot/commands/install.fish
mkdir -p $HOME/.config/dot/packages
printf 'alpha\n' >$HOME/.config/dot/packages/pacman
set -gx PACMAN_LOG (mktemp)
dot install --restore extra >/dev/null 2>&1
set -l restore_conflict_status $status
set -l pacman_called_conflict (test -s $PACMAN_LOG; and echo yes; or echo no)
@test "--restore combined with package names fails" $restore_conflict_status -ne 0
@test "--restore combined with package names never calls pacman" $pacman_called_conflict = no
# --- help prints usage instead of touching pacman ---
set -gx HOME (mktemp -d)
dot init --url $remote >/dev/null 2>&1
mkdir -p $HOME/.config/dot/commands
cp $commands_dir/install.fish $HOME/.config/dot/commands/install.fish
set -gx PACMAN_LOG (mktemp)
set -l help_output (dot install help)
set -l help_status $status
set -l pacman_called_help (test -s $PACMAN_LOG; and echo yes; or echo no)
@test "dot install help succeeds" $help_status -eq 0
@test "dot install help mentions --restore" (string match -q '*--restore*' -- $help_output; echo $status) -eq 0
@test "dot install help mentions --no-sync" (string match -q '*--no-sync*' -- $help_output; echo $status) -eq 0
@test "dot install help never calls pacman" $pacman_called_help = no
# --- dot kde ---
# The fixture schema directory stands in for the real /usr/share/config.kcfg:
# testrc.kcfg declares a plain <kcfgfile name="testrc">, kwin.kcfg declares
# <kcfgfile arg="true"> (resolved only via the hand-maintained exceptions
# list, kwin.kcfg -> kwinrc), and unmapped.kcfg is an arg="true" schema with
# no exceptions-list entry, so it never resolves to anything.
set -l kcfg_fixtures (path resolve (status dirname)/fixtures/kcfg)
set -gx DOT_KDE_KCFG_DIR $kcfg_fixtures
# kreadconfig6 itself is never mocked for the tests that exercise real
# save behavior (per the project's convention, it runs for real against
# fixture rc files under the scratch HOME) -- only the help-path tests below
# swap in a logging fake, to prove kreadconfig6 is never invoked for them.
set -l path_before_fake_kreadconfig $PATH
# --- dot kde help / dot kde save help touch neither the manifest nor kreadconfig6 ---
set -gx HOME (mktemp -d)
dot init --url $remote >/dev/null 2>&1
mkdir -p $HOME/.config/dot/commands/kde
cp $commands_dir/kde/kde.fish $HOME/.config/dot/commands/kde/kde.fish
cp $commands_dir/kde/kde.py $HOME/.config/dot/commands/kde/kde.py
set -l fake_bin_kde (mktemp -d)
set -gx KREADCONFIG_LOG (mktemp)
echo '#!/bin/sh
echo "$@" >>"$KREADCONFIG_LOG"
exit 1' >$fake_bin_kde/kreadconfig6
chmod +x $fake_bin_kde/kreadconfig6
set -gx PATH $fake_bin_kde $PATH
set -l kde_help_output (dot kde help)
set -l kde_help_status $status
set -l kreadconfig_called_for_kde_help (test -s $KREADCONFIG_LOG; and echo yes; or echo no)
set -l manifest_exists_after_kde_help (test -e $HOME/.config/dot/kde-manifest; and echo yes; or echo no)
@test "dot kde help succeeds" $kde_help_status -eq 0
@test "dot kde help mentions save" (string match -q '*save*' -- $kde_help_output; echo $status) -eq 0
@test "dot kde help never invokes kreadconfig6" $kreadconfig_called_for_kde_help = no
@test "dot kde help does not create a manifest" $manifest_exists_after_kde_help = no
set -l kde_save_help_output (dot kde save help)
set -l kde_save_help_status $status
set -l kreadconfig_called_for_save_help (test -s $KREADCONFIG_LOG; and echo yes; or echo no)
set -l manifest_exists_after_save_help (test -e $HOME/.config/dot/kde-manifest; and echo yes; or echo no)
@test "dot kde save help succeeds" $kde_save_help_status -eq 0
@test "dot kde save help mentions identifier" (string match -q '*identifier*' -- $kde_save_help_output; echo $status) -eq 0
@test "dot kde save help never invokes kreadconfig6" $kreadconfig_called_for_save_help = no
@test "dot kde save help does not create a manifest" $manifest_exists_after_save_help = no
set -gx PATH $path_before_fake_kreadconfig
# --- dot kde save <identifier>: declares a new manifest entry from the real live value ---
set -gx HOME (mktemp -d)
dot init --url $remote >/dev/null 2>&1
mkdir -p $HOME/.config/dot/commands/kde
cp $commands_dir/kde/kde.fish $HOME/.config/dot/commands/kde/kde.fish
cp $commands_dir/kde/kde.py $HOME/.config/dot/commands/kde/kde.py
mkdir -p $HOME/.config
printf '[General]\nGreeting=Hi=There\n' >$HOME/.config/testrc
set -l manifest $HOME/.config/dot/kde-manifest
dot kde save testrc.General.Greeting >/dev/null 2>&1
set -l save_status $status
@test "dot kde save <identifier> succeeds" $save_status -eq 0
@test "declares the identifier with its live value, preserving an embedded '='" (cat $manifest | string collect) = "testrc.General.Greeting=Hi=There"
# a kcfg entry whose ini key (key=) differs from its schema name still
# resolves correctly, falling back to the schema default when unset live
dot kde save testrc.General.RealKey >/dev/null 2>&1
@test "resolves an aliased kcfg key (name != key) to its schema default" (string match -q '*testrc.General.RealKey=AliasDefault*' -- (cat $manifest); echo $status) -eq 0
# the identifier is split on the first two dots only, so the key portion
# may itself contain further dots and spaces
dot kde save "testrc.General.Some.Key With Spaces" >/dev/null 2>&1
@test "an identifier's key portion may contain further dots and spaces" (string match -q '*testrc.General.Some.Key With Spaces=SpacedDefault*' -- (cat $manifest); echo $status) -eq 0
# an arg="true" schema resolves through the hand-maintained exceptions list
# (kwin.kcfg -> kwinrc), not by scanning for a static <kcfgfile name>
dot kde save kwinrc.Windows.BorderSize >/dev/null 2>&1
@test "resolves an arg=true schema via the hand-maintained exceptions list" (string match -q '*kwinrc.Windows.BorderSize=Normal*' -- (cat $manifest); echo $status) -eq 0
set -l declared_count_before_freeform (cat $manifest | count)
# a setting whose rc file never appears in the mapping table falls to the
# freeform branch: read/write directly via kreadconfig6/kwriteconfig6, with
# "default" meaning "the key is absent" rather than any schema value
printf '[Group]\nKey=FreeformValue\n' >$HOME/.config/somefreeform
dot kde save somefreeform.Group.Key >/dev/null 2>&1
set -l freeform_save_status $status
set -l declared_count_after_freeform (cat $manifest | count)
@test "dot kde save succeeds for a freeform (unmapped rc file) identifier" $freeform_save_status -eq 0
@test "declares the freeform identifier with its real live value" (string match -q '*somefreeform.Group.Key=FreeformValue*' -- (cat $manifest); echo $status) -eq 0
@test "a freeform save adds exactly one manifest entry" $declared_count_after_freeform -eq (math $declared_count_before_freeform + 1)
# an arg="true" schema *absent* from the exceptions list (unmapped.kcfg)
# must not be guessed at (e.g. from its own filename) -- it contributes
# nothing to the mapping table, so its settings fall to freeform too. Proven
# here by reading with the key absent: a schema-backed read would fall back
# to the schema's declared default ("Unreachable"); freeform's "default" is
# instead "the key is absent", so it reads empty.
dot kde save unmapped.Whatever.Setting >/dev/null 2>&1
set -l unlisted_arg_true_status $status
@test "an arg=true schema missing from the exceptions list resolves to freeform, not schema" $unlisted_arg_true_status -eq 0
@test "a freeform read never falls back to another schema's default" (string match -q '*Unreachable*' -- (cat $manifest); echo $status) -eq 1
@test "a freeform read of an absent key stores an empty value" (string match -q '*unmapped.Whatever.Setting=*' -- (cat $manifest); echo $status) -eq 0
# the shortcuts mechanism (kglobalshortcutsrc -> kglobalaccel D-Bus calls) is
# deliberately excluded from this suite -- it depends on a live, already-running
# session service not practically substitutable without disproportionate mock
# infrastructure. Verified manually against the real session instead.
set -l declared_count_before_refresh (cat $manifest | count)
# --- dot kde save with no arguments refreshes every already-declared entry ---
printf '[General]\nGreeting=Changed\n' >$HOME/.config/testrc
printf '[Group]\nKey=RefreshedFreeform\n' >$HOME/.config/somefreeform
dot kde save >/dev/null 2>&1
set -l refresh_status $status
set -l declared_count_after_refresh (cat $manifest | count)
@test "dot kde save with no arguments succeeds" $refresh_status -eq 0
@test "refreshes an already-declared schema-backed entry's value from the live system" (string match -q '*testrc.General.Greeting=Changed*' -- (cat $manifest); echo $status) -eq 0
@test "refreshes an already-declared freeform entry's value from the live system" (string match -q '*somefreeform.Group.Key=RefreshedFreeform*' -- (cat $manifest); echo $status) -eq 0
@test "refresh leaves other already-declared entries untouched" (string match -q '*testrc.General.RealKey=AliasDefault*' -- (cat $manifest); echo $status) -eq 0
@test "refresh adds no new undeclared entries" $declared_count_after_refresh -eq $declared_count_before_refresh
# --- misuse: too many arguments / a malformed identifier ---
dot kde save one two >/dev/null 2>&1
set -l too_many_args_status $status
@test "dot kde save rejects more than one identifier" $too_many_args_status -ne 0
dot kde save nodots >/dev/null 2>&1
set -l bad_identifier_status $status
@test "dot kde save rejects an identifier without file.group.key structure" $bad_identifier_status -ne 0
# --- dot kde apply help touches neither the manifest nor kwriteconfig6 ---
set -gx HOME (mktemp -d)
dot init --url $remote >/dev/null 2>&1
mkdir -p $HOME/.config/dot/commands/kde
cp $commands_dir/kde/kde.fish $HOME/.config/dot/commands/kde/kde.fish
cp $commands_dir/kde/kde.py $HOME/.config/dot/commands/kde/kde.py
set -l fake_bin_kwrite (mktemp -d)
set -gx KWRITECONFIG_LOG (mktemp)
echo '#!/bin/sh
echo "$@" >>"$KWRITECONFIG_LOG"
exit 1' >$fake_bin_kwrite/kwriteconfig6
chmod +x $fake_bin_kwrite/kwriteconfig6
set -gx PATH $fake_bin_kwrite $path_before_fake_kreadconfig
set -l kde_apply_help_output (dot kde apply help)
set -l kde_apply_help_status $status
set -l kwriteconfig_called_for_apply_help (test -s $KWRITECONFIG_LOG; and echo yes; or echo no)
set -l manifest_exists_after_apply_help (test -e $HOME/.config/dot/kde-manifest; and echo yes; or echo no)
@test "dot kde apply help succeeds" $kde_apply_help_status -eq 0
@test "dot kde apply help mentions manifest" (string match -q '*manifest*' -- $kde_apply_help_output; echo $status) -eq 0
@test "dot kde apply help never invokes kwriteconfig6" $kwriteconfig_called_for_apply_help = no
@test "dot kde apply help does not create a manifest" $manifest_exists_after_apply_help = no
set -gx PATH $path_before_fake_kreadconfig
# --- dot kde apply: pushes every declared manifest entry onto the live rc file ---
set -gx HOME (mktemp -d)
dot init --url $remote >/dev/null 2>&1
mkdir -p $HOME/.config/dot/commands/kde
cp $commands_dir/kde/kde.fish $HOME/.config/dot/commands/kde/kde.fish
cp $commands_dir/kde/kde.py $HOME/.config/dot/commands/kde/kde.py
mkdir -p $HOME/.config/dot
printf 'testrc.General.Greeting=Applied Greeting\ntestrc.General.RealKey=Hi=There\n' >$HOME/.config/dot/kde-manifest
dot kde apply >/dev/null 2>&1
set -l apply_status $status
set -l testrc_after_apply (cat $HOME/.config/testrc)
@test "dot kde apply succeeds" $apply_status -eq 0
@test "dot kde apply writes a declared value onto the live rc file" (string match -q '*Greeting=Applied Greeting*' -- $testrc_after_apply; echo $status) -eq 0
@test "dot kde apply preserves an embedded '=' in the applied value" (string match -q '*RealKey=Hi=There*' -- $testrc_after_apply; echo $status) -eq 0
# re-running against a system already matching the manifest changes nothing
dot kde apply >/dev/null 2>&1
set -l reapply_status $status
set -l testrc_after_reapply (cat $HOME/.config/testrc)
@test "re-running dot kde apply succeeds" $reapply_status -eq 0
@test "re-running dot kde apply against an already-applied system is idempotent" "$testrc_after_reapply" = "$testrc_after_apply"
# a manifest entry whose rc file has no schema (freeform) is written
# directly via kwriteconfig6, idempotently, just like a schema-backed entry
printf 'testrc.General.Greeting=Applied Greeting\nsomefreeform.Group.Key=Value\n' >$HOME/.config/dot/kde-manifest
dot kde apply >/dev/null 2>&1
set -l apply_freeform_status $status
set -l freeformrc_after_apply (cat $HOME/.config/somefreeform)
@test "dot kde apply succeeds for a manifest with a freeform entry" $apply_freeform_status -eq 0
@test "dot kde apply writes a freeform entry via kwriteconfig6" (string match -q '*Key=Value*' -- $freeformrc_after_apply; echo $status) -eq 0
dot kde apply >/dev/null 2>&1
set -l freeformrc_after_reapply (cat $HOME/.config/somefreeform)
@test "re-running dot kde apply against an already-applied freeform entry is idempotent" "$freeformrc_after_reapply" = "$freeformrc_after_apply"
# the shortcuts mechanism is deliberately excluded from this suite -- see the
# note by the `dot kde save` shortcuts exclusion above.
# misuse: apply takes no arguments
printf 'testrc.General.Greeting=Applied Greeting\n' >$HOME/.config/dot/kde-manifest
dot kde apply extra-arg >/dev/null 2>&1
set -l apply_extra_arg_status $status
@test "dot kde apply rejects an unexpected argument" $apply_extra_arg_status -ne 0
# --- dot kde diff help touches neither the manifest nor kreadconfig6 ---
set -gx HOME (mktemp -d)
dot init --url $remote >/dev/null 2>&1
mkdir -p $HOME/.config/dot/commands/kde
cp $commands_dir/kde/kde.fish $HOME/.config/dot/commands/kde/kde.fish
cp $commands_dir/kde/kde.py $HOME/.config/dot/commands/kde/kde.py
set -l fake_bin_kde_diff (mktemp -d)
set -gx KREADCONFIG_LOG (mktemp)
echo '#!/bin/sh
echo "$@" >>"$KREADCONFIG_LOG"
exit 1' >$fake_bin_kde_diff/kreadconfig6
chmod +x $fake_bin_kde_diff/kreadconfig6
set -gx PATH $fake_bin_kde_diff $path_before_fake_kreadconfig
set -l kde_diff_help_output (dot kde diff help)
set -l kde_diff_help_status $status
set -l kreadconfig_called_for_diff_help (test -s $KREADCONFIG_LOG; and echo yes; or echo no)
set -l manifest_exists_after_diff_help (test -e $HOME/.config/dot/kde-manifest; and echo yes; or echo no)
@test "dot kde diff help succeeds" $kde_diff_help_status -eq 0
@test "dot kde diff help mentions undeclared" (string match -q '*undeclared*' -- $kde_diff_help_output; echo $status) -eq 0
@test "dot kde diff help never invokes kreadconfig6" $kreadconfig_called_for_diff_help = no
@test "dot kde diff help does not create a manifest" $manifest_exists_after_diff_help = no
set -gx PATH $path_before_fake_kreadconfig
# --- dot kde diff: broad read-only scan over every schema-backed identifier,
# tagging each mismatch declared/undeclared, and skipping settings that
# already match their schema default ---
set -gx HOME (mktemp -d)
dot init --url $remote >/dev/null 2>&1
mkdir -p $HOME/.config/dot/commands/kde
cp $commands_dir/kde/kde.fish $HOME/.config/dot/commands/kde/kde.fish
cp $commands_dir/kde/kde.py $HOME/.config/dot/commands/kde/kde.py
mkdir -p $HOME/.config/dot
# Greeting differs from its default and is already declared in the manifest;
# RealKey differs from its default but has never been declared; Some.Key With
# Spaces is left unset, so it falls back to (and matches) its schema default,
# and kwinrc.Windows.BorderSize likewise matches its default via the
# arg=true/exceptions-list mapping -- neither should be reported. On the
# freeform side: Group.Key is declared and present live (a mismatch against
# freeform's "absent" default); Group.AbsentKey is declared but never applied
# live, so it matches the absent default and isn't reported; Other.Undeclared
# is present live but never declared, and must never surface via broad scan
# since freeform has no schema to enumerate from.
printf '[General]\nGreeting=Bonjour\nRealKey=ChangedAlias\n' >$HOME/.config/testrc
printf '[Group]\nKey=CustomValue\n\n[Other]\nUndeclared=ShouldNeverAppear\n' >$HOME/.config/somefreeform
printf 'testrc.General.Greeting=Bonjour\nsomefreeform.Group.Key=CustomValue\nsomefreeform.Group.AbsentKey=NeverApplied\n' >$HOME/.config/dot/kde-manifest
set -l manifest_before_diff (cat $HOME/.config/dot/kde-manifest | string collect)
set -l diff_output (dot kde diff)
set -l diff_status $status
set -l manifest_after_diff (cat $HOME/.config/dot/kde-manifest | string collect)
@test "dot kde diff succeeds" $diff_status -eq 0
@test "dot kde diff tags an already-declared mismatch as declared" (string match -q '*declared testrc.General.Greeting = Bonjour (default: Hello)*' -- $diff_output; echo $status) -eq 0
@test "dot kde diff tags a never-declared mismatch as undeclared" (string match -q '*undeclared testrc.General.RealKey = ChangedAlias (default: AliasDefault)*' -- $diff_output; echo $status) -eq 0
@test "dot kde diff does not report a setting matching its default (unset key)" (string match -q '*Some.Key With Spaces*' -- $diff_output; echo $status) -eq 1
@test "dot kde diff does not report a setting matching its default (arg=true mapping)" (string match -q '*BorderSize*' -- $diff_output; echo $status) -eq 1
@test "dot kde diff reports an already-declared freeform mismatch (default is absent)" (string match -q '*declared somefreeform.Group.Key = CustomValue (default: )*' -- $diff_output; echo $status) -eq 0
@test "dot kde diff does not report a declared freeform entry matching its absent default" (string match -q '*AbsentKey*' -- $diff_output; echo $status) -eq 1
@test "dot kde diff never surfaces an undeclared freeform setting via broad scan" (string match -q '*Undeclared*' -- $diff_output; echo $status) -eq 1
@test "dot kde diff makes no writes to the manifest" "$manifest_after_diff" = "$manifest_before_diff"
dot kde diff extra-arg >/dev/null 2>&1
set -l diff_extra_arg_status $status
@test "dot kde diff rejects an unexpected argument" $diff_extra_arg_status -ne 0
# --- kde.py complete: tab-completion candidates, sourced from the live
# schema mapping table rather than a hardcoded list. This is the
# underlying data completions/dot.fish shells out to; the fish
# completion wiring itself is verified manually (no existing
# infrastructure tests completions at all, per the nested-subcommand
# prefactoring task) ---
set -l complete_output (python3 $HOME/.config/dot/commands/kde/kde.py complete)
@test "kde.py complete lists a schema-backed identifier" (string match -q '*testrc.General.Greeting*' -- $complete_output; echo $status) -eq 0
@test "kde.py complete resolves an aliased kcfg key to its ini key, not its schema name" (string match -q '*testrc.General.RealKey*' -- $complete_output; echo $status) -eq 0
@test "kde.py complete lists an arg=true schema resolved via the exceptions list" (string match -q '*kwinrc.Windows.BorderSize*' -- $complete_output; echo $status) -eq 0
@test "kde.py complete never lists an aliased entry under its schema name" (string match -q '*testrc.General.AliasedKey*' -- $complete_output; echo $status) -eq 1
@test "kde.py complete never lists an arg=true schema absent from the exceptions list" (string match -q '*Whatever.Setting*' -- $complete_output; echo $status) -eq 1
# --- dot help / dot help discovers dot kde ---
set -l help_with_kde (dot help)
@test "dot help lists the kde subcommand" (string match -q '*kde*' -- $help_with_kde; echo $status) -eq 0
# --- dot setup folders ---
# The legacy->short-name mapping is fixed in the command itself, not read
# from ~/.config/user-dirs.dirs (a separate, manually tracked dotfile this
# command never reads or writes), so no scenario below needs to seed one.
# xdg-user-dirs-update is faked out via a PATH-prepended bin that logs each
# invocation, exactly mirroring dot install's fake sudo/pacman.
set -l fake_bin_xdg (mktemp -d)
echo '#!/bin/sh
echo "$@" >>"$XDG_UPDATE_LOG"
exit 0' >$fake_bin_xdg/xdg-user-dirs-update
chmod +x $fake_bin_xdg/xdg-user-dirs-update
set -gx PATH $fake_bin_xdg $PATH
# --- a fresh migration: all 8 legacy folders present and empty, including a
# nested empty Pictures/Screenshots ---
set -gx HOME (mktemp -d)
dot init --url $remote >/dev/null 2>&1
mkdir -p $HOME/.config/dot/commands/setup
cp $commands_dir/setup/setup.fish $HOME/.config/dot/commands/setup/setup.fish
cp $commands_dir/setup/folders.fish $HOME/.config/dot/commands/setup/folders.fish
mkdir -p $HOME/Desktop $HOME/Documents $HOME/Downloads $HOME/Music $HOME/Pictures/Screenshots $HOME/Videos $HOME/Templates $HOME/Public
set -gx XDG_UPDATE_LOG (mktemp)
dot setup folders >/dev/null 2>&1
set -l fresh_folders_status $status
@test "dot setup folders succeeds on a fresh scratch HOME" $fresh_folders_status -eq 0
@test "Desktop is renamed to .desktop" -d $HOME/.desktop
@test "Documents is renamed to doc" -d $HOME/doc
@test "Downloads is renamed to dwn" -d $HOME/dwn
@test "Music is renamed to mus" -d $HOME/mus
@test "Pictures is renamed to pic" -d $HOME/pic
@test "Videos is renamed to vid" -d $HOME/vid
@test "Templates and Public both merge into .ignoreme" -d $HOME/.ignoreme
@test "the nested Screenshots folder is renamed to pic/screenshots" -d $HOME/pic/screenshots
@test "the legacy Desktop folder no longer exists" (test -e $HOME/Desktop; and echo yes; or echo no) = no
@test "the legacy Documents folder no longer exists" (test -e $HOME/Documents; and echo yes; or echo no) = no
@test "the legacy Pictures folder no longer exists" (test -e $HOME/Pictures; and echo yes; or echo no) = no
@test "xdg-user-dirs-update is invoked exactly once" (cat $XDG_UPDATE_LOG | count) -eq 1
# --- re-running after a clean migration is a no-op ---
dot setup folders >/dev/null 2>&1
set -l rerun_status $status
@test "re-running dot setup folders succeeds" $rerun_status -eq 0
@test "re-running leaves the short-named folders in place" -d $HOME/pic/screenshots
@test "re-running does not recreate any legacy folder" (test -e $HOME/Pictures; and echo yes; or echo no) = no
# --- the short-name mapping is fixed regardless of what (if anything)
# ~/.config/user-dirs.dirs declares -- this is the exact real-world bug
# that motivated dropping the dependency: a stale, never-updated
# user-dirs.dirs (still pointing XDG_DOCUMENTS_DIR at ~/Documents itself)
# must not make the target collide with the legacy folder ---
set -gx HOME (mktemp -d)
dot init --url $remote >/dev/null 2>&1
mkdir -p $HOME/.config/dot/commands/setup
cp $commands_dir/setup/setup.fish $HOME/.config/dot/commands/setup/setup.fish
cp $commands_dir/setup/folders.fish $HOME/.config/dot/commands/setup/folders.fish
mkdir -p $HOME/.config
echo 'XDG_DESKTOP_DIR="$HOME/Desktop"
XDG_DOWNLOAD_DIR="$HOME/Downloads"
XDG_TEMPLATES_DIR="$HOME/"
XDG_PUBLICSHARE_DIR="$HOME/"
XDG_DOCUMENTS_DIR="$HOME/Documents"
XDG_MUSIC_DIR="$HOME/"
XDG_PICTURES_DIR="$HOME/Pictures"
XDG_VIDEOS_DIR="$HOME/Videos"' >$HOME/.config/user-dirs.dirs
mkdir -p $HOME/Documents
echo real-content >$HOME/Documents/report.txt
set -gx XDG_UPDATE_LOG (mktemp)
dot setup folders >/dev/null 2>&1
@test "a stale user-dirs.dirs pointing at the legacy folder itself doesn't confuse the migration" (cat $HOME/doc/report.txt) = real-content
@test "the legacy folder is still removed despite the stale user-dirs.dirs" (test -e $HOME/Documents; and echo yes; or echo no) = no
@test "the stale user-dirs.dirs file itself is left byte-for-byte untouched" (string match -q '*XDG_DOCUMENTS_DIR="$HOME/Documents"*' -- (cat $HOME/.config/user-dirs.dirs); echo $status) -eq 0
# --- dot setup folders works even when user-dirs.dirs doesn't exist at all ---
set -gx HOME (mktemp -d)
dot init --url $remote >/dev/null 2>&1
mkdir -p $HOME/.config/dot/commands/setup
cp $commands_dir/setup/setup.fish $HOME/.config/dot/commands/setup/setup.fish
cp $commands_dir/setup/folders.fish $HOME/.config/dot/commands/setup/folders.fish
mkdir -p $HOME/Documents
echo real-content >$HOME/Documents/report.txt
set -gx XDG_UPDATE_LOG (mktemp)
set -l no_user_dirs_status
dot setup folders >/dev/null 2>&1
set no_user_dirs_status $status
@test "dot setup folders succeeds with no user-dirs.dirs present at all" $no_user_dirs_status -eq 0
@test "migration still happens with no user-dirs.dirs present at all" (cat $HOME/doc/report.txt) = real-content
@test "no user-dirs.dirs is created as a side effect" (test -e $HOME/.config/user-dirs.dirs; and echo yes; or echo no) = no
# --- bare `dot setup` (no task given) runs folders as part of running everything ---
set -gx HOME (mktemp -d)
dot init --url $remote >/dev/null 2>&1
mkdir -p $HOME/.config/dot/commands/setup
cp $commands_dir/setup/setup.fish $HOME/.config/dot/commands/setup/setup.fish
cp $commands_dir/setup/folders.fish $HOME/.config/dot/commands/setup/folders.fish
mkdir -p $HOME/Desktop $HOME/Documents $HOME/Downloads $HOME/Music $HOME/Pictures $HOME/Videos $HOME/Templates $HOME/Public
set -gx XDG_UPDATE_LOG (mktemp)
dot setup >/dev/null 2>&1
set -l bare_setup_status $status
@test "bare dot setup succeeds" $bare_setup_status -eq 0
@test "bare dot setup runs the folders task" -d $HOME/.desktop
@test "bare dot setup also merges Pictures into pic" -d $HOME/pic
# --- a non-empty legacy folder merges unconditionally, no flag needed --
# an unrelated empty legacy folder migrates in the same run ---
set -gx HOME (mktemp -d)
dot init --url $remote >/dev/null 2>&1
mkdir -p $HOME/.config/dot/commands/setup
cp $commands_dir/setup/setup.fish $HOME/.config/dot/commands/setup/setup.fish
cp $commands_dir/setup/folders.fish $HOME/.config/dot/commands/setup/folders.fish
mkdir -p $HOME/Documents $HOME/Desktop
echo real-content >$HOME/Documents/report.txt
set -gx XDG_UPDATE_LOG (mktemp)
set -l nonempty_output (dot setup folders 2>&1)
set -l nonempty_status $status
@test "dot setup folders succeeds when a legacy folder has content" $nonempty_status -eq 0
@test "a non-empty legacy folder's content is migrated by default" (cat $HOME/doc/report.txt) = real-content
@test "the now-empty legacy folder is removed" (test -e $HOME/Documents; and echo yes; or echo no) = no
@test "prints a message about what was moved" (string match -q '*Documents*' -- $nonempty_output; echo $status) -eq 0
@test "an unrelated empty legacy folder still migrates in the same run" (test -e $HOME/Desktop; and echo yes; or echo no) = no
# --- a legacy folder containing only a stray dotfile still migrates by
# default -- there's no separate empty-vs-non-empty gate to trip ---
set -gx HOME (mktemp -d)
dot init --url $remote >/dev/null 2>&1
mkdir -p $HOME/.config/dot/commands/setup
cp $commands_dir/setup/setup.fish $HOME/.config/dot/commands/setup/setup.fish
cp $commands_dir/setup/folders.fish $HOME/.config/dot/commands/setup/folders.fish
mkdir -p $HOME/Downloads
touch $HOME/Downloads/.directory
set -gx XDG_UPDATE_LOG (mktemp)
dot setup folders >/dev/null 2>&1
@test "a legacy folder holding only a stray dotfile is migrated by default" -e $HOME/dwn/.directory
@test "the legacy folder holding only a stray dotfile is removed" (test -e $HOME/Downloads; and echo yes; or echo no) = no
# --- a nested empty Screenshots folder migrates alongside unrelated real
# content in the same Pictures folder, all in the same default run ---
set -gx HOME (mktemp -d)
dot init --url $remote >/dev/null 2>&1
mkdir -p $HOME/.config/dot/commands/setup
cp $commands_dir/setup/setup.fish $HOME/.config/dot/commands/setup/setup.fish
cp $commands_dir/setup/folders.fish $HOME/.config/dot/commands/setup/folders.fish
mkdir -p $HOME/Pictures/Screenshots
echo vacation-photo >$HOME/Pictures/vacation.jpg
set -gx XDG_UPDATE_LOG (mktemp)
dot setup folders >/dev/null 2>&1
set -l pictures_with_content_status $status
@test "dot setup folders succeeds when Pictures has unrelated content" $pictures_with_content_status -eq 0
@test "the unrelated file in Pictures is migrated into pic" (cat $HOME/pic/vacation.jpg) = vacation-photo
@test "the nested Screenshots folder is renamed to pic/screenshots" -d $HOME/pic/screenshots
@test "the now-empty Pictures folder is removed" (test -e $HOME/Pictures; and echo yes; or echo no) = no
# --- a Screenshots folder that itself holds real content migrates by
# default too, renamed to pic/screenshots, even when the rest of
# Pictures is empty ---
set -gx HOME (mktemp -d)
dot init --url $remote >/dev/null 2>&1
mkdir -p $HOME/.config/dot/commands/setup
cp $commands_dir/setup/setup.fish $HOME/.config/dot/commands/setup/setup.fish
cp $commands_dir/setup/folders.fish $HOME/.config/dot/commands/setup/folders.fish
mkdir -p $HOME/Pictures/Screenshots
echo shot-content >$HOME/Pictures/Screenshots/shot.png
set -gx XDG_UPDATE_LOG (mktemp)
dot setup folders >/dev/null 2>&1
@test "a non-empty Screenshots folder migrates by default, renamed to pic/screenshots" (cat $HOME/pic/screenshots/shot.png) = shot-content
@test "the now-empty Pictures folder is removed after migrating Screenshots" (test -e $HOME/Pictures; and echo yes; or echo no) = no
# --- a filename collision between a legacy folder and its already-populated
# short-named target is skipped (not overwritten), reported, and leaves
# the legacy folder in place -- even when another non-colliding file in
# the same folder is merged successfully ---
set -gx HOME (mktemp -d)
dot init --url $remote >/dev/null 2>&1
mkdir -p $HOME/.config/dot/commands/setup
cp $commands_dir/setup/setup.fish $HOME/.config/dot/commands/setup/setup.fish
cp $commands_dir/setup/folders.fish $HOME/.config/dot/commands/setup/folders.fish
mkdir -p $HOME/Documents $HOME/doc
echo legacy-content >$HOME/Documents/report.txt
echo legacy-only >$HOME/Documents/notes.txt
echo target-content >$HOME/doc/report.txt
set -gx XDG_UPDATE_LOG (mktemp)
set -l collision_output (dot setup folders 2>&1)
set -l collision_status $status
@test "dot setup folders still succeeds when a collision occurs" $collision_status -eq 0
@test "the colliding target file is preserved byte-for-byte" (cat $HOME/doc/report.txt) = target-content
@test "the colliding legacy file is left in place, untouched" (cat $HOME/Documents/report.txt) = legacy-content
@test "the collision is reported" (string match -q '*report.txt*' -- $collision_output; echo $status) -eq 0
@test "the legacy Documents folder is left in place due to the collision" -d $HOME/Documents
@test "a non-colliding file in the same folder is still merged" (cat $HOME/doc/notes.txt) = legacy-only
@test "the merged non-colliding file no longer sits in the legacy folder" (test -e $HOME/Documents/notes.txt; and echo yes; or echo no) = no
# --- re-running after a collision was reported: the skipped file isn't
# lost, and the already-migrated file isn't moved again ---
dot setup folders >/dev/null 2>&1
@test "re-running after a collision still preserves the target file" (cat $HOME/doc/report.txt) = target-content
@test "re-running after a collision still leaves the legacy file in place" (cat $HOME/Documents/report.txt) = legacy-content
@test "re-running after a collision does not resurrect the already-migrated file in the legacy folder" (test -e $HOME/Documents/notes.txt; and echo yes; or echo no) = no
# --- a collision on the nested Screenshots unit is skipped, reported, and
# leaves Pictures in place, even though Pictures also holds other
# content unrelated to the collision ---
set -gx HOME (mktemp -d)
dot init --url $remote >/dev/null 2>&1
mkdir -p $HOME/.config/dot/commands/setup
cp $commands_dir/setup/setup.fish $HOME/.config/dot/commands/setup/setup.fish
cp $commands_dir/setup/folders.fish $HOME/.config/dot/commands/setup/folders.fish
mkdir -p $HOME/Pictures/Screenshots $HOME/pic/screenshots
echo legacy-shot >$HOME/Pictures/Screenshots/shot.png
echo target-shot >$HOME/pic/screenshots/shot.png
set -gx XDG_UPDATE_LOG (mktemp)
set -l screenshots_collision_output (dot setup folders 2>&1)
@test "a Screenshots collision preserves the existing target screenshot" (cat $HOME/pic/screenshots/shot.png) = target-shot
@test "a Screenshots collision leaves the legacy Screenshots folder in place" (cat $HOME/Pictures/Screenshots/shot.png) = legacy-shot
@test "the Screenshots collision is reported" (string match -q '*Screenshots*' -- $screenshots_collision_output; echo $status) -eq 0
@test "Pictures itself is left in place due to the Screenshots collision" -d $HOME/Pictures
# --- the same Screenshots-collision handling also holds when Pictures has
# nothing else in it besides the colliding Screenshots folder ---
set -gx HOME (mktemp -d)
dot init --url $remote >/dev/null 2>&1
mkdir -p $HOME/.config/dot/commands/setup
cp $commands_dir/setup/setup.fish $HOME/.config/dot/commands/setup/setup.fish
cp $commands_dir/setup/folders.fish $HOME/.config/dot/commands/setup/folders.fish
mkdir -p $HOME/Pictures/Screenshots $HOME/pic/screenshots
echo target-shot >$HOME/pic/screenshots/shot.png
set -gx XDG_UPDATE_LOG (mktemp)
set -l bare_screenshots_collision_output (dot setup folders 2>&1)
set -l bare_screenshots_collision_status $status
@test "a Screenshots-only collision still succeeds" $bare_screenshots_collision_status -eq 0
@test "a Screenshots-only collision preserves the existing target screenshot" (cat $HOME/pic/screenshots/shot.png) = target-shot
@test "a Screenshots-only collision leaves the empty legacy Screenshots folder in place" -d $HOME/Pictures/Screenshots
@test "a Screenshots-only collision leaves Pictures itself in place" -d $HOME/Pictures
@test "the Screenshots-only collision is reported" (string match -q '*Screenshots*' -- $bare_screenshots_collision_output; echo $status) -eq 0
# --- --dry-run reports what would move/skip without touching the
# filesystem: no mkdir, no mv/rmdir, no xdg-user-dirs-update ---
set -gx HOME (mktemp -d)
dot init --url $remote >/dev/null 2>&1
mkdir -p $HOME/.config/dot/commands/setup
cp $commands_dir/setup/setup.fish $HOME/.config/dot/commands/setup/setup.fish
cp $commands_dir/setup/folders.fish $HOME/.config/dot/commands/setup/folders.fish
mkdir -p $HOME/Documents $HOME/Pictures/Screenshots
echo real-content >$HOME/Documents/report.txt
set -gx XDG_UPDATE_LOG (mktemp)
set -l dryrun_output (dot setup folders --dry-run 2>&1)
set -l dryrun_status $status
@test "dot setup folders --dry-run succeeds" $dryrun_status -eq 0
@test "--dry-run reports the entry it would move" (string match -q '*Documents*' -- $dryrun_output; echo $status) -eq 0
@test "--dry-run reports the Screenshots folder it would move" (string match -q '*Screenshots*' -- $dryrun_output; echo $status) -eq 0
@test "--dry-run leaves the legacy Documents folder's content untouched" (cat $HOME/Documents/report.txt) = real-content
@test "--dry-run does not remove the legacy Documents folder" -d $HOME/Documents
@test "--dry-run does not create the short-named target folder" (test -e $HOME/doc; and echo yes; or echo no) = no
@test "--dry-run does not rename the nested Screenshots folder" -d $HOME/Pictures/Screenshots
@test "--dry-run never invokes xdg-user-dirs-update" (test -s $XDG_UPDATE_LOG; and echo yes; or echo no) = no
# --- --dry-run also reports a would-be collision without touching
# either side, and doesn't move the non-colliding entry either ---
set -gx HOME (mktemp -d)
dot init --url $remote >/dev/null 2>&1
mkdir -p $HOME/.config/dot/commands/setup
cp $commands_dir/setup/setup.fish $HOME/.config/dot/commands/setup/setup.fish
cp $commands_dir/setup/folders.fish $HOME/.config/dot/commands/setup/folders.fish
mkdir -p $HOME/Documents $HOME/doc
echo legacy-content >$HOME/Documents/report.txt
echo legacy-only >$HOME/Documents/notes.txt
echo target-content >$HOME/doc/report.txt
set -gx XDG_UPDATE_LOG (mktemp)
set -l dryrun_collision_output (dot setup folders --dry-run 2>&1)
@test "--dry-run reports the would-be collision" (string match -q '*report.txt*' -- $dryrun_collision_output; echo $status) -eq 0
@test "--dry-run leaves the colliding target file untouched" (cat $HOME/doc/report.txt) = target-content
@test "--dry-run leaves the colliding legacy file untouched" (cat $HOME/Documents/report.txt) = legacy-content
@test "--dry-run does not move the non-colliding file either" -e $HOME/Documents/notes.txt
# --- a legacy folder with nothing to move produces no --dry-run output ---
set -gx HOME (mktemp -d)
dot init --url $remote >/dev/null 2>&1
mkdir -p $HOME/.config/dot/commands/setup
cp $commands_dir/setup/setup.fish $HOME/.config/dot/commands/setup/setup.fish
cp $commands_dir/setup/folders.fish $HOME/.config/dot/commands/setup/folders.fish
mkdir -p $HOME/Desktop
set -gx XDG_UPDATE_LOG (mktemp)
set -l dryrun_empty_output (dot setup folders --dry-run 2>&1)
@test "--dry-run is silent for a legacy folder with nothing to move" -z "$dryrun_empty_output"
@test "--dry-run leaves an empty legacy folder in place" -d $HOME/Desktop
# --- the removed --yes flag now fails fast as an unknown option ---
set -gx HOME (mktemp -d)
dot init --url $remote >/dev/null 2>&1
mkdir -p $HOME/.config/dot/commands/setup
cp $commands_dir/setup/setup.fish $HOME/.config/dot/commands/setup/setup.fish
cp $commands_dir/setup/folders.fish $HOME/.config/dot/commands/setup/folders.fish
set -gx XDG_UPDATE_LOG (mktemp)
dot setup folders --yes >/dev/null 2>&1
set -l old_yes_status $status
@test "dot setup folders --yes now fails as an unknown option" $old_yes_status -ne 0
# --- help prints usage and makes no filesystem changes ---
set -gx HOME (mktemp -d)
dot init --url $remote >/dev/null 2>&1
mkdir -p $HOME/.config/dot/commands/setup
cp $commands_dir/setup/setup.fish $HOME/.config/dot/commands/setup/setup.fish
cp $commands_dir/setup/folders.fish $HOME/.config/dot/commands/setup/folders.fish
mkdir -p $HOME/Documents $HOME/Desktop
set -gx XDG_UPDATE_LOG (mktemp)
set -l setup_help_output (dot setup help)
set -l setup_help_status $status
set -l folders_help_output (dot setup folders help)
set -l folders_help_status $status
set -l xdg_called_for_help (test -s $XDG_UPDATE_LOG; and echo yes; or echo no)
@test "dot setup help succeeds" $setup_help_status -eq 0
@test "dot setup help mentions folders" (string match -q '*folders*' -- $setup_help_output; echo $status) -eq 0
@test "dot setup folders help succeeds" $folders_help_status -eq 0
@test "dot setup folders help mentions the short-name convention" (string match -q '*.desktop*' -- $folders_help_output; echo $status) -eq 0
@test "dot setup folders help documents --dry-run" (string match -q '*--dry-run*' -- $folders_help_output; echo $status) -eq 0
@test "neither help invocation ever calls xdg-user-dirs-update" $xdg_called_for_help = no
@test "dot setup help leaves the legacy Documents folder untouched" -d $HOME/Documents
@test "dot setup folders help leaves the legacy Desktop folder untouched" -d $HOME/Desktop
@test "help does not create any short-named target folder" (test -e $HOME/doc; and echo yes; or echo no) = no
# --- dot help discovers dot setup ---
set -l help_with_setup (dot help)
@test "dot help lists the setup subcommand" (string match -q '*setup*' -- $help_with_setup; echo $status) -eq 0

View File

@@ -1,9 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<kcfg xmlns="http://www.kde.org/standards/kcfg/1.0">
<kcfgfile arg="true"/>
<group name="Windows">
<entry name="BorderSize" type="String">
<default>Normal</default>
</entry>
</group>
</kcfg>

View File

@@ -1,15 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<kcfg xmlns="http://www.kde.org/standards/kcfg/1.0">
<kcfgfile name="testrc"/>
<group name="General">
<entry name="Greeting" type="String">
<default>Hello</default>
</entry>
<entry name="AliasedKey" key="RealKey" type="String">
<default>AliasDefault</default>
</entry>
<entry name="Some.Key With Spaces" type="String">
<default>SpacedDefault</default>
</entry>
</group>
</kcfg>

View File

@@ -1,9 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<kcfg xmlns="http://www.kde.org/standards/kcfg/1.0">
<kcfgfile arg="true"/>
<group name="Whatever">
<entry name="Setting" type="String">
<default>Unreachable</default>
</entry>
</group>
</kcfg>

View File

@@ -1,34 +0,0 @@
function __dot_custom_subcommands
echo init
echo help
path basename $HOME/.config/dot/commands/*.fish 2>/dev/null | path change-extension ''
for d in $HOME/.config/dot/commands/*/
test -d $d; or continue
set -l name (path basename $d)
test -f $d$name.fish; or continue
echo $name
end
end
complete -c dot -n __fish_use_subcommand -a "(__dot_custom_subcommands)"
# --- dot install ---
complete -c dot -n "__fish_seen_subcommand_from install; and not __fish_seen_argument -l restore" -l restore -d "reinstall every package from the saved list"
complete -c dot -n "__fish_seen_subcommand_from install; and not __fish_seen_argument -l no-sync" -l no-sync -d "skip the pacman -Sy database refresh"
complete -c dot -n "__fish_seen_subcommand_from install; and not __fish_seen_argument -l restore" -f -a "(__fish_print_pacman_packages)"
# --- dot setup ---
complete -c dot -n "__fish_seen_subcommand_from setup; and not __fish_seen_subcommand_from folders help" -f -a folders -d "bring the 8 standard XDG user directories under the short-name convention"
complete -c dot -n "__fish_seen_subcommand_from setup; and not __fish_seen_subcommand_from folders help" -f -a help -d "show usage"
complete -c dot -n "__fish_seen_subcommand_from setup; and __fish_seen_subcommand_from folders" -f -a help -d "show usage"
# --- dot kde ---
complete -c dot -n "__fish_seen_subcommand_from kde; and not __fish_seen_subcommand_from apply diff save help" -f -a apply -d "push manifest entries onto the live system"
complete -c dot -n "__fish_seen_subcommand_from kde; and not __fish_seen_subcommand_from apply diff save help" -f -a diff -d "scan for settings whose live value differs from its default"
complete -c dot -n "__fish_seen_subcommand_from kde; and not __fish_seen_subcommand_from apply diff save help" -f -a save -d "write live KDE settings into the manifest"
complete -c dot -n "__fish_seen_subcommand_from kde; and not __fish_seen_subcommand_from apply diff save help" -f -a help -d "show usage"
complete -c dot -n "__fish_seen_subcommand_from kde; and __fish_seen_subcommand_from apply diff save" -f -a help -d "show usage"
# Sourced live from the schema mapping table (real .kcfg files), not a
# hardcoded list -- same helper kde.py's own save/refresh logic builds from.
complete -c dot -n "__fish_seen_subcommand_from kde; and __fish_seen_subcommand_from save" -f -a "(python3 $HOME/.config/dot/commands/kde/kde.py complete 2>/dev/null)"

View File

@@ -1,2 +0,0 @@
complete --command fishtape --short v --long version --description "Print version"
complete --command fishtape --short h --long help --description "Print help"

View File

@@ -1,4 +0,0 @@
alias cp='cp -v'
alias vi=nvim
alias vim=nvim
alias tmx='tmux new-session -A -s'

View File

@@ -1,10 +0,0 @@
source /usr/share/cachyos-fish-config/cachyos-config.fish
set -gx EDITOR nvim
set -gx VISUAL nvim
# overwrite greeting
# potentially disabling fastfetch
#function fish_greeting
# # smth smth
#end

View File

@@ -1 +0,0 @@
jorgebucaran/fishtape

View File

@@ -1,81 +0,0 @@
# This file contains fish universal variable definitions.
# VERSION: 3.0
SETUVAR __done_min_cmd_duration:10000
SETUVAR __done_notification_urgency_level:low
SETUVAR __fish_initialized:4300
SETUVAR _fisher_jorgebucaran_2F_fishtape_files:\x7e/\x2econfig/fish/functions/fishtape\x2efish\x1e\x7e/\x2econfig/fish/completions/fishtape\x2efish
SETUVAR _fisher_plugins:jorgebucaran/fishtape
SETUVAR _fisher_upgraded_to_4_4:\x1d
SETUVAR pure_begin_prompt_with_current_directory:true
SETUVAR pure_check_for_new_release:false
SETUVAR pure_color_at_sign:pure_color_mute
SETUVAR pure_color_aws_profile:pure_color_warning
SETUVAR pure_color_command_duration:pure_color_warning
SETUVAR pure_color_current_directory:pure_color_primary
SETUVAR pure_color_danger:red
SETUVAR pure_color_dark:black
SETUVAR pure_color_exit_status:pure_color_danger
SETUVAR pure_color_git_branch:pure_color_mute
SETUVAR pure_color_git_dirty:pure_color_mute
SETUVAR pure_color_git_stash:pure_color_info
SETUVAR pure_color_git_unpulled_commits:pure_color_info
SETUVAR pure_color_git_unpushed_commits:pure_color_info
SETUVAR pure_color_hostname:pure_color_mute
SETUVAR pure_color_info:cyan
SETUVAR pure_color_jobs:pure_color_normal
SETUVAR pure_color_k8s_context:pure_color_success
SETUVAR pure_color_k8s_namespace:pure_color_primary
SETUVAR pure_color_k8s_prefix:pure_color_info
SETUVAR pure_color_light:white
SETUVAR pure_color_mute:brblack
SETUVAR pure_color_nixdevshell_prefix:pure_color_info
SETUVAR pure_color_nixdevshell_symbol:pure_color_mute
SETUVAR pure_color_normal:normal
SETUVAR pure_color_prefix_root_prompt:pure_color_danger
SETUVAR pure_color_primary:blue
SETUVAR pure_color_prompt_on_error:pure_color_danger
SETUVAR pure_color_prompt_on_success:pure_color_success
SETUVAR pure_color_success:magenta
SETUVAR pure_color_system_time:pure_color_mute
SETUVAR pure_color_username_normal:pure_color_mute
SETUVAR pure_color_username_root:pure_color_light
SETUVAR pure_color_virtualenv:pure_color_mute
SETUVAR pure_color_warning:yellow
SETUVAR pure_convert_exit_status_to_signal:false
SETUVAR pure_enable_aws_profile:true
SETUVAR pure_enable_container_detection:true
SETUVAR pure_enable_git:true
SETUVAR pure_enable_k8s:false
SETUVAR pure_enable_nixdevshell:false
SETUVAR pure_enable_single_line_prompt:false
SETUVAR pure_enable_virtualenv:true
SETUVAR pure_reverse_prompt_symbol_in_vimode:true
SETUVAR pure_separate_prompt_on_error:false
SETUVAR pure_shorten_prompt_current_directory_length:0
SETUVAR pure_shorten_window_title_current_directory_length:0
SETUVAR pure_show_exit_status:false
SETUVAR pure_show_jobs:false
SETUVAR pure_show_numbered_git_indicator:false
SETUVAR pure_show_prefix_root_prompt:false
SETUVAR pure_show_subsecond_command_duration:false
SETUVAR pure_show_system_time:false
SETUVAR pure_symbol_aws_profile_prefix:
SETUVAR pure_symbol_container_prefix:
SETUVAR pure_symbol_exit_status_prefix:\x7c
SETUVAR pure_symbol_exit_status_separator:\x7c
SETUVAR pure_symbol_git_dirty:\x2a
SETUVAR pure_symbol_git_stash:\u2261
SETUVAR pure_symbol_git_unpulled_commits:\u21e3
SETUVAR pure_symbol_git_unpushed_commits:\u21e1
SETUVAR pure_symbol_k8s_prefix:\u2638
SETUVAR pure_symbol_nixdevshell_prefix:\u2744\ufe0f
SETUVAR pure_symbol_prefix_root_prompt:\x23
SETUVAR pure_symbol_prompt:\u276f
SETUVAR pure_symbol_reverse_prompt:\u276e
SETUVAR pure_symbol_ssh_prefix:
SETUVAR pure_symbol_title_bar_separator:\x2d
SETUVAR pure_symbol_virtualenv_prefix:
SETUVAR pure_system_time_format:\x2b\x25T
SETUVAR pure_threshold_command_duration:5
SETUVAR pure_truncate_prompt_current_directory_keeps:\x2d1
SETUVAR pure_truncate_window_title_current_directory_keeps:\x2d1

View File

@@ -1,130 +0,0 @@
function dot --wraps=git --description 'Manage dotfiles via a bare repo checked out over $HOME'
set -l dotfiles_dir $HOME/.dotfiles
if test "$argv[1]" = init
set -e argv[1]
__dot_init $dotfiles_dir $argv
return $status
end
if test "$argv[1]" = help
__dot_help
return $status
end
set -l commands_dir $HOME/.config/dot/commands
set -l command_file $commands_dir/$argv[1].fish
set -l nested_command_file $commands_dir/$argv[1]/$argv[1].fish
if test -n "$argv[1]"
if test -f "$command_file"
source $command_file
_dot_$argv[1] $argv[2..-1]
return $status
else if test -f "$nested_command_file"
source $nested_command_file
_dot_$argv[1] $argv[2..-1]
return $status
end
end
git --git-dir=$dotfiles_dir --work-tree=$HOME $argv
end
# Kept inline (not a separate autoloaded function file) because this is the
# only subcommand that must work before the dotfiles repo has been cloned.
function __dot_init
set -l dotfiles_dir $argv[1]
set -e argv[1]
argparse 'url=' -- $argv
or return 1
set -l url $_flag_url
test -n "$url"; or set url ssh://gitea@git.alexion.dev:2022/alexion/dotfiles.git
if test -e $dotfiles_dir
echo "dot init: $dotfiles_dir already exists, refusing to re-initialize" >&2
return 1
end
git clone --bare $url $dotfiles_dir
or begin
echo "dot init: failed to clone $url" >&2
return 1
end
git --git-dir=$dotfiles_dir config status.showUntrackedFiles no
set -l checkout_output (git --git-dir=$dotfiles_dir --work-tree=$HOME checkout 2>&1)
set -l checkout_status $status
if test $checkout_status -ne 0
set -l conflicts
set -l in_block 0
for line in $checkout_output
if test $in_block -eq 1
if string match -rq '^\s' -- $line
set -a conflicts (string trim -- $line)
continue
else
set in_block 0
end
end
string match -q '*would be overwritten by checkout:*' -- $line
and set in_block 1
end
if test (count $conflicts) -eq 0
echo "dot init: checkout failed and no recoverable conflicts were found:" >&2
printf '%s\n' $checkout_output >&2
return 1
end
set -l backup_dir $HOME/.dotfiles-backup/(date +%Y%m%dT%H%M%S)
for f in $conflicts
mkdir -p (path dirname $backup_dir/$f)
mv $HOME/$f $backup_dir/$f
echo "dot init: backed up ~/$f to $backup_dir/$f"
end
git --git-dir=$dotfiles_dir --work-tree=$HOME checkout
or begin
echo "dot init: checkout still failing after backing up conflicts, aborting" >&2
return 1
end
end
echo "dot init: bootstrapped $dotfiles_dir from $url"
end
# The custom-subcommand glob is duplicated (not shared with
# completions/dot.fish) because fish only autoloads a function from a file
# named after that function; a shared helper would go undefined if `dot help`
# ran in a completion context before `dot` itself had ever been sourced.
function __dot_help
echo "dot: manage dotfiles via a bare repo checked out over \$HOME
Commands:
init bootstrap the dotfiles repo on a new machine
help show this message"
for f in $HOME/.config/dot/commands/*.fish
test -e $f; or continue
echo " "(path basename $f | path change-extension '')
end
for d in $HOME/.config/dot/commands/*/
test -d $d; or continue
set -l name (path basename $d)
test -f $d$name.fish; or continue
echo " $name"
end
echo "
Run 'dot <command> help' for flags on a specific command.
Any other command is passed through to git (dot status, dot add, dot commit, dot push, ...)."
end

View File

@@ -1,116 +0,0 @@
function fishtape --description "Test scripts, functions, and plugins in Fish"
switch "$argv"
case -v --version
echo "fishtape, version 3.0.1"
case "" -h --help
echo "Usage: fishtape <files ...> Run test files"
echo "Options:"
echo " -v or --version Print version"
echo " -h or --help Print this help message"
case \*
set --local files (realpath $argv)
for file in $files
if test ! -f $file
echo "fishtape: Invalid file or file not found: \"$file\"" >&2
return 1
end
end
set --local operators -{n,z,b,c,d,e,f,g,G,k,L,O,p,r,s,S,t,u,w,x}
set --local expectations \
"a non-zero length string" \
"a zero length string" \
"a block device" \
"a character device" \
"a directory" \
"an existing file" \
"a regular file" \
"a file with the set-group-ID bit set" \
"a file with same group ID as the current user" \
"a file with the sticky bit set" \
"a symbolic link" \
"a file owned by the current user" \
"a named pipe" \
"a file marked as readable" \
"a file of size greater than zero" \
"a socket" \
"a terminal tty file descriptor" \
"a file with the set-user-ID bit set" \
"a file marked as writable" \
"a file marked as executable"
set --universal _fishtape_test_number 0
set --universal _fishtape_test_passed 0
set --universal _fishtape_test_failed 0
function @echo
echo "# $argv"
end
function @test --argument-names name --inherit-variable operators --inherit-variable expectations
set --erase argv[1]
set --query argv[2] || set --append argv ""
set _fishtape_test_number (math $_fishtape_test_number + 1)
if test $argv
set _fishtape_test_passed (math $_fishtape_test_passed + 1)
echo "ok $_fishtape_test_number $name"
else
if test $argv[1] = "!"
set operator "! "
set expected "not "
set --erase argv[1]
end
if set --query argv[3]
set operator "$operator"$argv[2]
set expected (string escape -- $argv[3])
set actual (string escape -- $argv[1])
else
set operator "$operator"$argv[1]
set expected "$expected"$expectations[(contains --index -- $argv[1] $operators)]
set actual (string escape -- $argv[2])
end
set _fishtape_test_failed (math $_fishtape_test_failed + 1)
status print-stack-trace |
string replace --filter --regex -- "\s+called on line (\d+) of file (.+)" '$2:$1' |
read --local at
echo "not ok $_fishtape_test_number $name"
echo " ---"
echo " operator: $operator"
echo " expected: $expected"
echo " actual: $actual"
echo " at: $at"
echo " ..."
end
end
echo TAP version 13
for file in $files
fish --init-command=(functions @echo | string collect) --init-command=(functions @test | string collect) $file
end
echo
echo "1..$_fishtape_test_number"
echo "# pass $_fishtape_test_passed"
test $_fishtape_test_failed -eq 0 &&
echo "# ok" ||
echo "# fail $_fishtape_test_failed"
functions --erase @echo @test
set --local failed $_fishtape_test_failed
set --erase _fishtape_test_number
set --erase _fishtape_test_passed
set --erase _fishtape_test_failed
test $failed -eq 0
end
end

View File

@@ -1 +0,0 @@
vim.opt_local.conceallevel = 2

View File

@@ -1,3 +0,0 @@
require("vim_options")
require("keys")
require("plugin")

View File

@@ -1,13 +0,0 @@
{
"diffview.nvim": { "branch": "main", "commit": "4516612fe98ff56ae0415a259ff6361a89419b0a" },
"gitsigns.nvim": { "branch": "main", "commit": "eb60cc7b94c46005237fd34170d76f3a089a90aa" },
"lazy.nvim": { "branch": "main", "commit": "306a05526ada86a7b30af95c5cc81ffba93fef97" },
"neogit": { "branch": "master", "commit": "6fc2fa890bd2031ed999c074daab0fb4feff20a5" },
"nord.nvim": { "branch": "main", "commit": "87394d4fc35c901bbe38326a78d31ab1ead826b6" },
"nvim-treesitter": { "branch": "master", "commit": "cf12346a3414fa1b06af75c79faebe7f76df080a" },
"oil.nvim": { "branch": "master", "commit": "b73018b75affd13fa38e2fc94ef753b465f770d7" },
"plenary.nvim": { "branch": "master", "commit": "74b06c6c75e4eeb3108ec01852001636d85a932b" },
"render-markdown.nvim": { "branch": "main", "commit": "f422cb5c6855f150e2ddcfaf44e7157b98b34f6a" },
"snacks.nvim": { "branch": "main", "commit": "882c996cf28183f4d63640de0b4c02ec886d01f2" },
"which-key.nvim": { "branch": "main", "commit": "3aab2147e74890957785941f0c1ad87d0a44c15a" }
}

View File

@@ -1,8 +0,0 @@
local map = vim.keymap.set
map("n", "<C-h>", "<C-w>h", { desc = "Move focus left" })
map("n", "<C-j>", "<C-w>j", { desc = "Move focus down" })
map("n", "<C-k>", "<C-w>k", { desc = "Move focus up" })
map("n", "<C-l>", "<C-w>l", { desc = "Move focus right" })
map("n", "<Esc>", "<cmd>nohlsearch<CR>", { desc = "Clear search highlight" })

View File

@@ -1,23 +0,0 @@
local lazypath = vim.fn.stdpath("data") .. "/lazy/lazy.nvim"
if not vim.uv.fs_stat(lazypath) then
local lazyrepo = "https://github.com/folke/lazy.nvim.git"
local out = vim.fn.system({ "git", "clone", "--filter=blob:none", "--branch=stable", lazyrepo, lazypath })
if vim.v.shell_error ~= 0 then
vim.api.nvim_echo({
{ "Failed to clone lazy.nvim:\n", "ErrorMsg" },
{ out, "WarningMsg" },
{ "\nPress any key to exit..." },
}, true, {})
vim.fn.getchar()
os.exit(1)
end
end
vim.opt.rtp:prepend(lazypath)
require("lazy").setup({
spec = {
{ import = "plugins" },
},
install = { colorscheme = { "nord" } },
checker = { enabled = false },
})

View File

@@ -1,36 +0,0 @@
return {
{
"NeogitOrg/neogit",
dependencies = {
"nvim-lua/plenary.nvim",
"sindrets/diffview.nvim",
},
keys = {
{
"<leader>g",
function()
require("gitsigns").toggle_current_line_blame(true)
require("neogit").open()
end,
desc = "Open git (Neogit)",
},
},
config = function()
require("neogit").setup()
vim.api.nvim_create_autocmd("BufUnload", {
callback = function(args)
if vim.bo[args.buf].filetype == "NeogitStatus" then
require("gitsigns").toggle_current_line_blame(false)
end
end,
})
end,
},
{
"lewis6991/gitsigns.nvim",
event = "BufWinEnter",
opts = {
current_line_blame = false,
},
},
}

View File

@@ -1,27 +0,0 @@
return {
{
"stevearc/oil.nvim",
lazy = false,
opts = {
view_options = { show_hidden = true },
},
keys = {
{ "<leader>e", "<cmd>Oil<CR>", desc = "Open file browser" },
},
},
{
"folke/snacks.nvim",
priority = 1000,
lazy = false,
opts = {
picker = { enabled = true },
notifier = { enabled = true },
input = { enabled = true },
},
keys = {
{ "<leader>f", function() require("snacks").picker.files() end, desc = "Find files" },
{ "<leader>s", function() require("snacks").picker.grep() end, desc = "Search text" },
{ "<leader>b", function() require("snacks").picker.buffers() end, desc = "Switch buffer" },
},
},
}

View File

@@ -1,55 +0,0 @@
return {
{
"gbprod/nord.nvim",
lazy = false,
priority = 1000,
opts = {
transparent = true,
},
config = function(_, opts)
require("nord").setup(opts)
vim.cmd.colorscheme("nord")
end,
},
{
"MeanderingProgrammer/render-markdown.nvim",
ft = { "markdown" },
dependencies = { "nvim-treesitter/nvim-treesitter" },
opts = {},
},
{
"folke/which-key.nvim",
lazy = false,
config = true,
},
{
"nvim-treesitter/nvim-treesitter",
branch = "master",
build = ":TSUpdate",
opts = {
ensure_installed = {
"markdown",
"markdown_inline",
"lua",
"bash",
"fish",
"rust",
"javascript",
"typescript",
"java",
"kotlin",
"c",
"cpp",
"html",
"css",
"python",
},
auto_install = false,
highlight = { enable = true },
indent = { enable = true },
},
config = function(_, opts)
require("nvim-treesitter.configs").setup(opts)
end,
},
}

View File

@@ -1,29 +0,0 @@
vim.g.mapleader = " "
local opt = vim.opt
-- Clipboard: use neovim's built-in OSC 52 provider, no external binary needed.
vim.g.clipboard = "osc52"
opt.clipboard = "unnamedplus"
opt.number = true
opt.relativenumber = true
opt.shiftwidth = 2
opt.tabstop = 2
opt.expandtab = true
opt.mouse = "a"
opt.undofile = true
opt.ignorecase = true
opt.smartcase = true
opt.splitright = true
opt.splitbelow = true
opt.wrap = false
opt.scrolloff = 8
opt.cursorline = true

View File

@@ -1,74 +0,0 @@
# Prefix: Ctrl-Space. Chosen over Ctrl-b (awkward reach) and Ctrl-a (collides
# with readline's beginning-of-line, which fights editing text in shells and
# in Claude Code's prompt). Verified clear of IME/KDE/Claude Code bindings.
unbind C-b
set -g prefix C-Space
bind C-Space send-prefix
set -g mouse on
# OSC52 lets copy-mode selections land in the system clipboard via the
# terminal itself (Alacritty supports it) -- no wl-copy/xclip needed, and it
# still works over SSH later since the escape sequence travels with the data.
set -g set-clipboard on
set -g mode-keys vi
set -g status-keys vi
bind -T copy-mode-vi v send -X begin-selection
bind -T copy-mode-vi y send -X copy-selection-and-cancel
bind -T copy-mode-vi MouseDragEnd1Pane send -X copy-selection-and-cancel
# tmux's -h/-v split flags name the *arrangement*, not the divider line, which
# is backwards from how the divider looks -- so pick keys by what they draw:
# \ draws a side-by-side split (vertical line), - draws a stacked split
# (horizontal line). Unshifted versions of |/- since splitting is frequent.
unbind %
unbind '"'
bind \\ split-window -h -c "#{pane_current_path}"
bind - split-window -v -c "#{pane_current_path}"
bind c new-window -c "#{pane_current_path}"
bind h select-pane -L
bind j select-pane -D
bind k select-pane -U
bind l select-pane -R
set -g base-index 1
setw -g pane-base-index 1
set -g renumber-windows on
bind r source-file ~/.config/tmux/tmux.conf \; display-message "tmux.conf reloaded"
# True color passthrough. ",*" (rather than naming Alacritty's xterm-256color
# specifically) so this keeps working if the terminal emulator changes later.
set -g default-terminal "tmux-256color"
set -ag terminal-overrides ",*:RGB"
# Default 500ms delay on Esc exists to disambiguate meta-key sequences; it
# reads as noticeable lag exiting insert mode in neovim, so drop it.
set -sg escape-time 10
set -g history-limit 10000
# Flag a background window in the status bar when its Claude Code session
# rings the terminal bell (permission prompt / task done while unfocused).
# bell-action=none stops tmux from ever passing the actual BEL through to
# Alacritty (no beep, no flash) -- monitor-bell's per-window tracking for the
# status-line highlight is independent of that and keeps working.
setw -g monitor-bell on
set -g bell-action none
# Minimal status bar (session + window list only), styled to match the Nord
# theme already used in alacritty.toml.
set -g status-position bottom
set -g status-style "bg=#2E3440,fg=#D8DEE9"
set -g status-left " #S "
set -g status-left-length 20
set -g status-right ""
setw -g window-status-current-style "bg=#88C0D0,fg=#2E3440,bold"
setw -g window-status-current-format " #I:#W "
setw -g window-status-format " #I:#W "
setw -g window-status-style "fg=#4C566A"
setw -g window-status-bell-style "bg=#BF616A,fg=#2E3440,bold"
set -g pane-border-style "fg=#3B4252"
set -g pane-active-border-style "fg=#88C0D0"

View File

@@ -1,16 +0,0 @@
# This file is written by xdg-user-dirs-update
# If you want to change or add directories, just edit the line you're
# interested in. All local changes will be retained on the next run.
# Format is XDG_xxx_DIR="$HOME/yyy", where yyy is a shell-escaped
# homedir-relative path, or XDG_xxx_DIR="/yyy", where /yyy is an
# absolute path. No other format is supported.
#
XDG_DESKTOP_DIR="$HOME/.desktop"
XDG_DOWNLOAD_DIR="$HOME/dwn"
XDG_TEMPLATES_DIR="$HOME/.ignoreme"
XDG_PUBLICSHARE_DIR="$HOME/.ignoreme"
XDG_DOCUMENTS_DIR="$HOME/doc"
XDG_MUSIC_DIR="$HOME/mus"
XDG_PICTURES_DIR="$HOME/pic"
XDG_VIDEOS_DIR="$HOME/vid"
XDG_PROJECTS_DIR="$HOME/wrk"

1
.envrc Normal file
View File

@@ -0,0 +1 @@
use flake

View File

@@ -1,3 +0,0 @@
[user]
name = alexion
email = contact@alexion.dev

38
.github/README.md vendored
View File

@@ -1,38 +0,0 @@
# dotfiles
Dotfiles managed as a bare git repo checked out over `$HOME`, for machines
running CachyOS with KDE Plasma.
## Bootstrapping a new machine
```sh
mkdir -p ~/.config/fish/functions
curl -fsSL https://git.alexion.dev/alexion/dotfiles/raw/branch/main/.config/fish/functions/dot.fish \
-o ~/.config/fish/functions/dot.fish
fish -c 'dot init'
```
## Commands
| Command | Description |
| ----------------------- | ----------------------------------------------------------------------------------------- |
| `dot help` | Lists available commands. |
| `dot init` | Bootstraps the dotfiles repo on a new machine. |
| `dot install <pkgs>` | Installs the given pacman packages and appends them to the tracked list (`~/.config/dot/packages/pacman`). |
| `dot install --restore` | Reinstalls every package from the tracked list. |
| `dot kde apply` | Pushes every manifest entry's declared value onto the live system. |
| `dot kde diff` | Reports every schema-backed setting whose live value differs from its default, tagged declared or undeclared. |
| `dot kde help` | Lists `dot kde`'s subcommands. |
| `dot kde save <identifier>` | Reads a KDE setting's current live value and declares it in the manifest (`~/.config/dot/kde-manifest`). |
| `dot kde save` | Refreshes every already-declared manifest entry's value from the live system. |
| `dot setup` | Runs every machine-setup task (currently just `folders`). |
| `dot setup folders` | Brings the 8 standard XDG user directories (`~/Desktop`, `~/Documents`, ...) under a fixed short-name convention (`~/.desktop`, `~/doc`, ...). |
| `dot <git>` | Everything else is passed to `git`. |
See [CLAUDE.md](../.config/dot/CLAUDE.md) for the `dot` tool's internal
architecture, bootstrap logic, subcommand dispatch, and test suite.
## Keybindings
See [keybindings.md](keybindings.md) for custom and useful default
keybindings across configured tools (currently: tmux).

View File

@@ -1,40 +0,0 @@
# Keybindings
Quick reference for custom and useful default keybindings, so they don't have
to be re-discovered or looked up per tool.
Comma-separated keys are pressed in sequence, not together.
| Key | Context | Action |
| ----------------------------------------------------- | ------- | -------------------------------------------------- |
| `Ctrl` + `Space`, `\` | tmux | Split side-by-side, opens in current directory |
| `Ctrl` + `Space`, `-` | tmux | Split stacked, opens in current directory |
| `Ctrl` + `Space`, `h` / `j` / `k` / `l` | tmux | Move focus left / down / up / right |
| `Ctrl` + `Space`, `z` | tmux | Zoom/unzoom pane to fullscreen |
| `Ctrl` + `Space`, `o` | tmux | Cycle focus to next pane |
| `Ctrl` + `Space`, `x` | tmux | Kill current pane (asks to confirm) |
| `Ctrl` + `Space`, `Ctrl` + `Up`/`Down`/`Left`/`Right` | tmux | Resize pane |
| `Ctrl` + `Space`, `c` | tmux | New window, opens in current directory |
| `Ctrl` + `Space`, `0`-`9` | tmux | Jump to window by number |
| `Ctrl` + `Space`, `n` / `p` | tmux | Next / previous window |
| `Ctrl` + `Space`, `w` | tmux | Interactive window list |
| `Ctrl` + `Space`, `,` | tmux | Rename current window |
| `Ctrl` + `Space`, `&` | tmux | Kill current window (asks to confirm) |
| `Ctrl` + `Space`, `[` | tmux | Enter copy mode |
| `Ctrl` + `Space`, `]` | tmux | Paste most recent copy |
| `h` / `j` / `k` / `l` | tmux | Move cursor |
| `v` | tmux | Begin selection |
| `y` | tmux | Copy selection to system clipboard, exit copy mode |
| `/` / `?` | tmux | Search forward / backward |
| `q` | tmux | Exit copy mode |
| `Ctrl` + `Space`, `d` | tmux | Detach from session |
| `Ctrl` + `Space`, `$` | tmux | Rename session |
| `Ctrl` + `Space`, `s` | tmux | Interactive session list |
| `Ctrl` + `Space`, `(` / `)` | tmux | Switch to previous / next session |
| `Ctrl` + `Space`, `r` | tmux | Reload `tmux.conf` |
| `Ctrl` + `h` / `j` / `k` / `l` | neovim | Move focus between splits left / down / up / right |
| `Esc` | neovim | Clear search highlight |
| `Space`, `e` | neovim | Toggle file explorer (netrw) |
| `CapsLock` | KDE | Acts as `Esc` (`kxkbrc` `Options=caps:escape_shifted_capslock`) |
| `Shift` + `CapsLock` | KDE | Toggles Caps Lock |
| `Meta` + `X` | KDE | Lock Session (moved off `Meta+L`, tracked via `dot kde`) |

15
.gitignore vendored
View File

@@ -1,7 +1,8 @@
.dotfiles
.DS_Store
*.swp
*.swo
*~
Thumbs.db
**/__pycache__
/reference/
/.direnv/
# BEGIN mkSkillsShellHook
# Generated by mkSkillsShellHook. Nix-delivered skill symlinks, kept out of git.
.claude/skills
.agents/skills/gitea-axi
# END mkSkillsShellHook

34
.sops.yaml Normal file
View File

@@ -0,0 +1,34 @@
# Recipients for the encrypted files under secrets/.
keys:
# A recipient of every file.
# One readable only by machines becomes unrecoverable once they are wiped.
# Adding a recipient requires decrypting first.
# No private half here, only in the operator's password manager.
- &admin age1m0pk94ysjlw3lmf6pyuv5l5pepvdjss8w0vxjv90dq6ndp02tdgsdwdvue
# Generated on the machine it names.
- &neogaia age14a04vphzjq74epfrz9a09wjw8lzchtru84awzuq2n45d8f42ychqjs89qe
- &pikachu age1wf5s0n0tgt6ld2ysgu9dc67mj8ylwecgl4utzg7hqwy3kut9zyms7aglmh
creation_rules:
# Material belonging to one machine.
# No machine other than the one named is a recipient, so a host that is
# compromised cannot decrypt another's material.
- path_regex: secrets/neogaia\.yaml$
key_groups:
- age:
- *admin
- *neogaia
- path_regex: secrets/pikachu\.yaml$
key_groups:
- age:
- *admin
- *pikachu
# Material common to every machine, so it is stored once rather than per host.
- path_regex: secrets/shared\.yaml$
key_groups:
- age:
- *admin
- *neogaia
- *pikachu

94
AGENTS.md Normal file
View File

@@ -0,0 +1,94 @@
# dotfiles-nixos
One flake that builds every machine the user owns.
The domain model (Host, Module, Skeleton, Auto-loader, Enable convention, overlays) lives in `~/Documents/ai-artifacts/projects/dotfiles/003-dotfiles-context.md`.
## Conventions
- Comments posted to Gitea (pull requests, issues, reviews) go out under the operator's account, so sign every one to make clear the author is the agent, not the operator.
End the comment with a `— Claude` sign-off.
(A dedicated bot account may replace this later.
Until then, the sign-off is the only marker.)
- Commit messages follow Conventional Commits, specified in `docs/conventional-commits.md`.
Scope is the module or host the change belongs to (`fish`, `nvim`, `neogaia`), omitted for repo-wide changes.
Keep messages free of Gitea-specific references: this repository is mirrored to GitHub, where issue and pull-request numbers resolve to unrelated things.
- When a graphical application is added, give it a `window-rewrite` icon mapping in `modules/desktop/waybar.nix`.
Without one its windows fall back to the generic default glyph on the workspace indicator instead of showing a recognisable per-application icon.
Match on the window class, which `hyprctl clients -j | jq -r '.[].class' | sort -u` lists for the running session.
## Gotchas
- Subagent completion delivery is non-blocking through immediate spawn, milestone notifications, retained terminal entries, and `subagent_list` or `subagent_result` retrieval.
`subagent_wait` intentionally blocks the parent tool call until its condition or timeout, so do not use it merely to keep background work alive during an interactive workflow.
- Nixvim's flake input following the root nixpkgs source does not make its Home Manager module reuse the host's `pkgs` instance.
Keep `programs.nixvim.nixpkgs.useGlobalPackages = true` so Nixvim uses the shared package set without warning that its source default was affected.
- This host has no `python` or `python3` command on its ordinary `PATH`.
For ad hoc Python, use Nix explicitly, such as `nix shell nixpkgs#python3 -c python3 <script>`.
- ADR bodies are immutable records of decisions as they were made, while frontmatter is mutable.
When a decision changes or its premise proves wrong, preserve the original body, update its status, and add a new ADR that supersedes it.
Filename migrations preserve references in immutable bodies through frontmatter aliases rather than rewriting those bodies.
- This repo pins no Nix formatter, and its committed `.nix` files are not clean under current `nixfmt-rfc-style`.
Running `nixfmt` across a file reflows untouched code (for example `lib.nix`'s `deriveMac` list and multi-line assertion messages) and injects churn unrelated to the change.
Format only the lines being written or changed, matching the surrounding style by hand.
- This repo is developed on `neogaia`, which now runs the NixOS it builds.
Flakes and the chaotic substituter come from this flake's own `nix.settings`, so no `NIX_CONFIG` export or per-command `--extra-experimental-features` is needed, and building a toplevel with `boot.kernelPackages = linuxPackages_cachyos` fetches the kernel from `nyx-cache` rather than compiling it.
Both were true only while the machine still ran CachyOS against a distro Nix daemon.
- Git identity is declared in the flake by `modules/git.nix`, which writes `alexion <contact@alexion.dev>` — the identity all history uses — on any host enabling `modules.git`.
Every new host has to enable it, so that a host reads as a full checklist of what it carries.
It is deployed on `neogaia` and verified: a commit in a repository outside this checkout is authored `alexion <contact@alexion.dev>` with no override.
Verify it that way rather than from this checkout, whose `.git/config` carries the same identity and would mask a broken module.
`~/.gitconfig` (a second global file that outranks the flake-managed `~/.config/git/config`) currently holds only a `tea` credential helper and no `user.*`, so it does not shadow the identity, but it is undeclared and will not survive a reimage.
- The primary build/verify seam for any Host is `nix flake check`, which builds `checks.x86_64-linux.<host>` (the system toplevel).
Cheap targeted checks use `nix eval .#nixosConfigurations.<host>.config...`.
- chaotic-nyx must **not** follow our `nixpkgs`, and its packages are built against chaotic's own pinned nixpkgs (its overlay defaults to `onTopOf = "flake-nixpkgs"`, the cache-friendly path).
That is what lets the `nyx-cache.chaotic.cx` binary cache hit instead of compiling the CachyOS kernel from source.
The tradeoff is that chaotic packages do not see our `unstable`/`stable` overlays.
- The remote is self-hosted Gitea (`git.alexion.dev`), and the forge CLI is `gitea-axi` rather than `tea`.
`gitea-axi` resolves the repository from the `origin` remote and discovers credentials from a `tea` login whose host matches the remote, so both are implicit inside a checkout.
It is installed on `neogaia` by `modules.agents.tools.gitea-axi`, and verified: `gitea-axi` run from this checkout renders the `alexion/dotfiles` dashboard authenticated, so the claude-code `SessionStart` hook that runs it now resolves to a real binary rather than a missing one.
The package wraps the binary so `git` and `tea` are reachable without being on `PATH`, while still preferring the operator's own where present.
Credentials: `~/.config/tea/config.yml` holds a token-bearing login named `alexion`, which `gitea-axi` uses and which also opens pull requests directly with `nix run nixpkgs#tea -- pr create --login alexion --repo alexion/dotfiles --base main --head <branch> ...`.
The `--repo` flag is required on that path, since `tea` resolves `origin` only for a login whose SSH host matches.
The same token reads PR discussion, which `tea` itself does poorly: `tea pr <n> --comments` prints only the body, and `-f comments` returns no comments field at all.
Use the API instead, taking the token from `.logins[] | select(.name=="alexion") | .token`.
Review comments are **not** at `/issues/<n>/comments` — that endpoint holds only top-level discussion and is usually empty.
Inline comments need two calls: `/pulls/<n>/reviews` for the review ids, then `/pulls/<n>/reviews/<id>/comments` for the bodies, whose `path` and `diff_hunk` fields say what each one is attached to.
A review row with an empty `body` is the normal shape when the operator left only inline comments.
- `~/.claude/skills` and `~/.pi/agent/skills` are home-manager-generated (`recursive = true`), so editing a skill in place fails and a new file created there silently escapes the repo.
Shared global skills come from the `skills` flake through `modules/agents/skills.nix`, applied by a rebuild.
Claude-specific legacy skills, when kept, live under `modules/agents/claude-code/skills/<name>/`.
- Pi skill discovery honors `.gitignore`, `.ignore`, and `.fdignore` inside scanned skill directories.
A generated `.agents/skills/.gitignore` entry that ignores a symlinked skill also prevents Pi from loading that skill, even when `.agents/skills/<name>/SKILL.md` exists and the symlink target is valid.
- nixpkgs `vimPlugins.nord-nvim` is `shaunsingh/nord.nvim` (no `require("nord").setup()`).
The config wants `gbprod/nord.nvim`, which is packaged as `vimPlugins.gbprod-nord`.
- `nixos-generate-config --show-hardware-config` needs root on this machine even just to print: unprivileged it dies at `Failed to retrieve subvolume info for /`, because the root filesystem is btrfs.
- This repo's claude-code module sets sudo's credential cache to per-user (`timestamp_type=global`, 60-minute window), so an authentication made in one real terminal counts for the agent's commands.
A `PreToolUse` hook refuses privileged commands while the cache is cold, so a cold cache announces itself instead of stalling.
A privileged-command failure *without* that message is the sandbox, not the cache.
- Host GPUs: `neogaia` is Intel, `zeus` (the desktop) is **AMD**, and `raichu` (a headless server) is the only Nvidia machine.
The corrected fact also lives in artifact `006-dotfiles-hyprland-compositor-adr.md`.
- This repo's `programs.firefox` `search` (with `force = true`) writes `search.json.mozlz4`.
Omission alone does not prune a built-in engine, since Firefox reconciles its app-provided engines back in, so remove one by listing it with `<engine>.metaData.hidden = true`.
Engines are referenced by their current id, so the default is `default = "ddg"`, not `"DuckDuckGo"`.
Decode the built file with `mozlz4a -d <search.json.mozlz4>` to check the result.
- Any non-empty Home Manager Firefox `profiles.<name>.extensions.settings.<id>.settings` causes Home Manager to set `extensions.webextensions.ExtensionStorageIDB.enabled = false` globally for that profile.
This repo's Stylix Firefox `colorTheme` settings trigger it, so every extension in the profile uses the legacy extension-storage backend regardless of how it is installed.
- `home.sessionVariables` do **not** reach the Hyprland session, since UWSM does not source `hm-session-vars.sh`.
The cursor is therefore set through Hyprland's own `env = KEY,VALUE` in `modules/desktop/hyprland/hyprland.nix`, sourced from `config.stylix.cursor`.
Bibata ships XCursor format only (no `hyprcursor/` dir), rendered through Hyprland's XCursor fallback, so `XCURSOR_*` and `HYPRCURSOR_*` naming the same theme are both safe.
- `neogaia`, the repo's only host, is a wifi laptop with a btrfs root and no ZFS pools, so it cannot honestly carry `modules.network`, `modules.zfs`, or a networked/pool-mounted guest.
Enabling networkd takes over its DNS, its CachyOS `zfs-kernel` build is marked broken, and it has no bridge or pool to attach to.
Verify these against it ad hoc through `nixosConfigurations.neogaia.extendModules` (forcing a ZFS-capable `boot.kernelPackages` for the zfs case) plus `nix eval` of the derived values, never by committing the enablement.
A committed guest therefore leaves `vlan`, `mounts`, and `secrets` unset, and the standing enablement waits for the first wired server host with real storage.
- Herdr key names for shifted punctuation are not interchangeable with the physical base key plus `shift`.
The tab rename binding must use the produced literal, such as `prefix+<`, rather than `prefix+shift+comma`.
- Flake-managed Pi extension, prompt, and skill directories may still be written directly for throwaway development or local experiments.
The risk is that a later Home Manager activation can overwrite or hide those unmanaged files, so finished work must be promoted into the dotfiles module before it counts as deployed.
- Pi's tool discovery checks `~/.pi/agent/bin` before `PATH`, and downloaded generic Linux binaries there can be unusable on NixOS with the stub-ld error.
This flake patches Pi to validate local tool binaries before selecting them, so it falls back to usable `fd`/`rg` from `PATH` instead.
Stale unpatched launchers are the remaining failure mode for broken `@` autocomplete.
- Nix flake evaluation ignores untracked files in this checkout.
Keep a new auto-loaded module staged or committed until it is removed, otherwise `nix flake check` and `nixos-rebuild --flake` evaluate without it and report its options as missing.
- The current Steam desktop client is an XWayland application.
Its CEF windows do not support Ozone and Steam composites them into an SDL surface with X11 extensions, so SDL Wayland selectors do not make the visible client native Wayland.
Keep fractional scaling sharp with Hyprland's `xwayland.force_zero_scaling` and Steam's own `STEAM_FORCE_DESKTOPUI_SCALING` instead.

5
CLAUDE.md Normal file
View File

@@ -0,0 +1,5 @@
# Claude Code compatibility
You MUST read and follow [`AGENTS.md`](AGENTS.md) before doing any work in this repository.
`AGENTS.md` is the canonical project instruction file.
This file exists only so Claude Code discovers that canonical instruction file.

91
base.nix Normal file
View File

@@ -0,0 +1,91 @@
{
config,
lib,
inputs,
...
}:
# The shared foundation both the host base and the guest-base build on: the
# primary user, home-manager, and the fresher/pinned package overlays.
let
inherit (lib) mkOption types;
user = config.user;
# Args to instantiate an extra nixpkgs source on the base platform.
pinArgs = prev: {
inherit (prev.stdenv.hostPlatform) system;
config.allowUnfree = true;
};
in
{
imports = [ inputs.home-manager.nixosModules.home-manager ];
options.user = {
name = mkOption {
type = types.str;
default = "alexion";
description = ''
The primary interactive user this system is built for. Drives both the
system account and the home-manager user in lockstep.
'';
};
description = mkOption {
type = types.str;
default = "Alexion";
description = "Human-readable description (GECOS field) for the primary user.";
};
};
config = {
# Reach fresher packages with `unstable.<name>` or pin with `stable.<name>`.
nixpkgs.overlays = [
(_final: prev: {
unstable = import inputs.nixpkgs-unstable (pinArgs prev);
stable = import inputs.nixpkgs-stable (pinArgs prev);
})
];
nixpkgs.config.allowUnfree = true;
# Flakes, so `nixos-rebuild switch` works from the console and a direnv
# `use flake` resolves inside a guest.
nix.settings.experimental-features = [
"nix-command"
"flakes"
];
# Primary user.
# The wheel group is the way in, since root is locked.
# No password is set here, since that is host-only.
# A guest therefore has none and is reached by SSH key or `machinectl`.
users.users.${user.name} = {
isNormalUser = true;
description = user.description;
extraGroups = [
"wheel"
"storage"
];
};
# The shared write group.
# Its gid is fixed, so a host and every guest carry the same number.
# An identity-mapped container write then lands on the pool as this group, sparing every service the permission juggling.
# 10000 clears the system-group ids assigned automatically and leaves headroom above the primary user, so nothing else claims it.
users.groups.storage.gid = 10000;
# home-manager as a NixOS module: one build produces the system and user
# environment together, sharing the system's pkgs and installing user
# packages into the system profile.
home-manager = {
useGlobalPkgs = true;
useUserPackages = true;
extraSpecialArgs = {
inherit inputs;
my = inputs.self.lib;
};
users.${user.name} = {
home.username = user.name;
home.homeDirectory = "/home/${user.name}";
home.stateVersion = "26.05";
};
};
};
}

View File

@@ -0,0 +1,57 @@
# Conventional Commits
> Sourced from [conventionalcommits.org/en/v1.0.0](https://www.conventionalcommits.org/en/v1.0.0/)
> by the Conventional Commits authors, licensed under
> [CC BY 3.0](https://creativecommons.org/licenses/by/3.0/).
---
## Summary
The Conventional Commits specification is a lightweight convention on top of commit messages.
It provides an easy set of rules for creating an explicit commit history; which makes it easier to write automated tools on top of.
This convention dovetails with [SemVer](http://semver.org), by describing the features, fixes, and breaking changes made in commit messages.
The commit message should be structured as follows:
```
<type>[optional scope]: <description>
[optional body]
[optional footer(s)]
```
The commit contains the following structural elements, to communicate intent to the consumers of your library:
1. **fix:** a commit of the _type_ `fix` patches a bug in your codebase (this correlates with `PATCH` in Semantic Versioning).
2. **feat:** a commit of the _type_ `feat` introduces a new feature to the codebase (this correlates with `MINOR` in Semantic Versioning).
3. **BREAKING CHANGE:** a commit that has a footer with a token `BREAKING CHANGE:`, or appends a `!` after the type/scope, introduces a breaking API change (correlating with `MAJOR` in Semantic Versioning). A BREAKING CHANGE can be part of commits of any _type_.
4. _types_ other than `fix:` and `feat:` are allowed, for example `build:`, `chore:`, `ci:`, `docs:`, `style:`, `refactor:`, `perf:`, `test:`, and others.
5. _footers_ other than `BREAKING CHANGE: <description>` may be provided and follow a convention similar to [git trailer format](https://git-scm.com/docs/git-interpret-trailers).
Additional types are not mandated by the Conventional Commits specification, and have no implicit effect in Semantic Versioning (unless they include a BREAKING CHANGE).
A scope may be provided to a commit's type, to provide additional contextual information and is contained within parenthesis, e.g., `feat(parser): add ability to parse arrays`.
---
## Specification
The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in [RFC 2119](https://www.ietf.org/rfc/rfc2119.txt).
1. Commits MUST be prefixed with a type, which consists of a noun, `feat`, `fix`, etc., followed by the OPTIONAL scope, OPTIONAL `!`, and REQUIRED terminal colon and space.
2. The type `feat` MUST be used when a commit adds a new feature to your application or library.
3. The type `fix` MUST be used when a commit represents a bug fix for your application.
4. A scope MAY be provided after a type. A scope MUST consist of a noun describing a section of the codebase surrounded by parenthesis, e.g., `fix(parser):`.
5. A description MUST immediately follow the colon and space after the type/scope prefix. The description is a short summary of the code changes, e.g., _fix: array parsing issue when multiple spaces were contained in string_.
6. A longer commit body MAY be provided after the short description, providing additional contextual information about the code changes. The body MUST begin one blank line after the description.
7. A commit body is free-form and MAY consist of any number of newline separated paragraphs.
8. One or more footers MAY be provided one blank line after the body. Each footer MUST consist of a word token, followed by either a `:<space>` or `<space>#` separator, followed by a string value (this is inspired by the [git trailer convention](https://git-scm.com/docs/git-interpret-trailers)).
9. A footer's token MUST use `-` in place of whitespace characters, e.g., `Acked-by` (this helps differentiate the footer section from a multi-paragraph body). An exception is made for `BREAKING CHANGE`, which MAY also be used as a token.
10. A footer's value MAY contain spaces and newlines, and parsing MUST terminate when the next valid footer token/separator pair is observed.
11. Breaking changes MUST be indicated in the type/scope prefix of a commit, or as an entry in the footer section.
12. If included as a footer, a breaking change MUST consist of the uppercase text `BREAKING CHANGE`, followed by a colon, space, and description, e.g., _BREAKING CHANGE: environment variables now take precedence over config files_.
13. If included in the type/scope prefix, breaking changes MUST be indicated by a `!` immediately before the `:`. If `!` is used, `BREAKING CHANGE:` MAY be omitted from the footer section, and the commit description SHALL be used to describe the breaking change.
14. Types other than `feat` and `fix` MAY be used in your commit messages, e.g., _docs: correct spelling of CHANGELOG_.
15. The units of information that make up Conventional Commits MUST NOT be treated as case sensitive by implementors, with the exception of BREAKING CHANGE which MUST be uppercase.
16. BREAKING-CHANGE MUST be synonymous with BREAKING CHANGE, when used as a token in a footer.

386
docs/install.md Normal file
View File

@@ -0,0 +1,386 @@
# Installing and provisioning a host
This document covers the procedures that put a machine into the fleet and keep its secrets readable.
- [Installing a host from the live ISO](#installing-a-host-from-the-live-iso), the destructive one-shot that turns a host in this flake into a running, encrypted machine.
- [Provisioning an already-running host](#provisioning-an-already-running-host), done live on the machine with no reimage.
- [Editing secrets](#editing-secrets), the day-to-day workflow.
- [Recovering a wrongly-provisioned machine](#recovering-a-wrongly-provisioned-machine) from the live ISO.
The install is destructive: it formats the target disk in full.
Read it end to end before starting, because on a single-machine fleet the reimage is irreversible.
## What arrives by hand
Exactly one secret is entered by hand: the **LUKS passphrase** that encrypts the disk, typed when the disk is formatted and again at every boot.
Everything else arrives declared.
The login password is a `sops`-encrypted secret consumed through `hashedPasswordFile`, and the SSH host keys are restored from secrets rather than generated.
No password is set interactively at any point, and `users.mutableUsers = false` means one set by hand would be ignored anyway.
## Identity before first boot
A machine reads its secrets with an **age identity** at `/var/lib/sops-nix/key.txt` on its encrypted root.
Its public half must be registered as a recipient of every secrets file the machine needs, and the re-keyed files must be in the flake's git tree when the system is built, because the ciphertext is baked into the store.
**A host's identity is therefore generated and registered before its first boot, not after it.**
The login password arrives only from a decrypted secret and there is no fallback credential — no interactive password, no unlocked root account, no authorized SSH key.
A first boot without a registered identity cannot decrypt the password hash, so the account it would log in as has no usable password and the machine has no way in short of the [recovery procedure](#recovering-a-wrongly-provisioned-machine).
Identities come in two tiers.
The **admin identity** lives in Proton Pass, is a recipient of every file, and is the credential that authorizes registering a new host.
Each **host identity** is generated on its own machine, never transmitted, and reads only that machine's file plus the shared one.
A host identity is deliberately not derived from the machine's SSH host key, which is what frees those host keys to be secrets in their own right.
## Tooling
Neither `sops` nor `age` is installed by this flake.
Run them from nixpkgs as needed:
```console
$ nix run nixpkgs#sops -- <args>
$ nix shell nixpkgs#age -c age-keygen <args>
```
On the live ISO these need `--extra-experimental-features 'nix-command flakes'`, since the ISO's daemon has neither enabled.
## Installing a host from the live ISO
### 0. Push the repo to Gitea
From your working checkout, make sure `main` is committed and pushed to the Gitea remote:
```console
$ git push origin main
```
The clone in step 2 sees only what has been pushed, so anything left behind in your working checkout will not reach the machine.
Changes made inside that clone afterwards are a separate matter — step 4 makes one there deliberately.
### 1. Boot the live ISO and join wifi
Boot from a NixOS live ISO (the minimal installer is enough).
The installer logs in as the `nixos` user, who has passwordless `sudo`.
On the minimal ISO, bring up wifi with `wpa_supplicant`:
```console
$ sudo systemctl start wpa_supplicant
$ wpa_cli
> add_network
0
> set_network 0 ssid "YOUR_SSID"
> set_network 0 psk "YOUR_WIFI_PASSWORD"
> enable_network 0
> quit
```
On the graphical ISO, which ships NetworkManager, use `nmcli` instead:
```console
$ nmcli device wifi connect "YOUR_SSID" password "YOUR_WIFI_PASSWORD"
```
Confirm you have connectivity (`ping -c1 github.com`) before continuing.
### 2. Clone the repo locally
Clone this repo onto the live ISO and work from that local checkout:
```console
$ git clone ssh://gitea@git.alexion.dev:2022/alexion/dotfiles.git
$ cd dotfiles
```
Cloning over SSH needs your Gitea SSH key present in the live session, since the ISO starts with none.
If getting the key onto the ISO is inconvenient, clone over HTTPS instead and tell git to skip the self-signed certificate:
```console
$ git -c http.sslVerify=false clone https://git.alexion.dev/alexion/dotfiles.git
$ cd dotfiles
```
Do **not** point `disko-install` straight at the Gitea flake URL.
Gitea serves HTTPS with a self-signed certificate and expects authentication, and Nix's flake fetcher has no easy way to skip certificate verification or supply those credentials mid-install.
A plain `git clone` sidesteps that entirely — over SSH there is no TLS, and over HTTPS git takes the `sslVerify=false` above that the flake fetcher won't — and then `disko-install` consumes the flake from a local path, where no fetch of our repo happens during the build.
(Every other flake input is public and still fetched from GitHub over ordinary, valid TLS.
Only our own repo is the problem the local clone solves.)
### 3. Generate the host identity
Generate the identity in the live session and keep it there until step 6 writes it onto the installed root:
```console
$ nix shell nixpkgs#age -c age-keygen -o /tmp/key.txt
Public key: age1...
```
`age-keygen` prints the public recipient on generation.
Recover it later from the identity itself if the line scrolls away:
```console
$ nix shell nixpkgs#age -c age-keygen -y /tmp/key.txt
```
The private half never leaves this session except onto the target disk.
Do not copy it into the repo, and do not carry it to another machine.
### 4. Register the recipient and re-key
Add the public recipient to `.sops.yaml` as a named anchor, then list it under every file the host must read:
```yaml
keys:
- &admin age1m0pk94ysjlw3lmf6pyuv5l5pepvdjss8w0vxjv90dq6ndp02tdgsdwdvue
- &neogaia age14a04vphzjq74epfrz9a09wjw8lzchtru84awzuq2n45d8f42ychqjs89qe
- &newhost age1...
creation_rules:
- path_regex: secrets/shared\.yaml$
key_groups:
- age:
- *admin
- *neogaia
- *newhost
```
If the host gets a secrets file of its own, give it a rule too.
`sops` matches a file against these rules to decide who to encrypt it to, and refuses a file no rule matches with `no matching creation rules found`:
```yaml
- path_regex: secrets/newhost\.yaml$
key_groups:
- age:
- *admin
- *newhost
```
Then re-key each file you changed, which rewrites its data key for the new recipient list without touching any value:
```console
$ SOPS_AGE_KEY_FILE=/path/to/admin-identity \
nix run nixpkgs#sops -- updatekeys secrets/shared.yaml
```
Re-keying requires an identity that can already decrypt the file.
The live ISO holds no host identity of its own, so paste the admin identity out of Proton Pass into a file in the live session for this step.
Then populate that file, which needs no existing identity because encrypting only reads recipients:
```console
$ nix run nixpkgs#sops -- secrets/newhost.yaml
```
A host with `modules.ssh.enable` expects one entry per key type, named `ssh-host-<type>-key`, each holding a private key generated with `ssh-keygen -t <type> -N "" -f /tmp/<type>`.
The build fails at evaluation if a declared secret is absent from the file, so a host that enables the daemon without these will not install.
Commit the matching public halves beside the host's configuration in plaintext, since publishing them is their purpose.
Stage everything you changed.
A flake sees only git-tracked files, so an unstaged `secrets/newhost.yaml` is invisible to evaluation even though it exists on disk:
```console
$ git add .sops.yaml secrets/
```
Staging is enough for the build.
The commit comes in step 8, and no push is needed here because the install builds from this local clone.
### 5. Run `disko-install`
Run the install as root from inside the clone:
```console
$ sudo nix --extra-experimental-features 'nix-command flakes' run \
github:nix-community/disko/latest#disko-install -- \
--flake .#neogaia \
--disk main /dev/nvme0n1 \
--write-efi-boot-entries \
--option extra-substituters https://nyx-cache.chaotic.cx/ \
--option extra-trusted-public-keys nyx-cache.chaotic.cx:dJxTrgMC3V3cFfyIiBQDQorG6k1LsqurH/srpMSq7qk=
```
What each part does:
- `--flake .#neogaia` installs the `neogaia` `Host` from the local clone.
- `--disk main /dev/nvme0n1` maps disko's `main` disk to the NVMe device.
It matches the device declared in `hosts/neogaia/disk.nix` and is stated explicitly so there is no doubt about the target.
- `--write-efi-boot-entries` writes the systemd-boot entry into this machine's NVRAM, because the disk stays in the machine it was installed from.
- The two `--option` lines are the important part: they hand the **chaotic binary cache** to the install-time Nix daemon on the live ISO.
The chaotic substituter must be passed here explicitly.
The `nix.settings` in the flake configure the substituters of the *installed* system, not the live ISO's daemon that runs this build.
The ISO's daemon has no `substituters` beyond `cache.nixos.org`.
Without these two `--option` flags, the build cannot fetch the prebuilt CachyOS kernel and **compiles `linuxPackages_cachyos` (and its toolchain) from source on the USB stick** — a very long detour that the cache avoids.
Because the install runs as root, and root is a trusted Nix user, the daemon honours these client-supplied substituter settings.
Partway through, disko formats the LUKS container and **prompts for a disk-encryption passphrase**.
This is the passphrase you will type at every boot to unlock the disk.
Choose it deliberately.
When it finishes it prints `disko-install succeeded`.
`disko-install` unmounts the target filesystem on exit, so nothing is mounted at this point — step 6 remounts it.
### 6. Write the identity onto the installed root
Remount the just-installed system with disko, which reopens the LUKS container (prompting for the passphrase from step 5) and mounts the subvolumes under `/mnt`:
```console
$ sudo nix --extra-experimental-features 'nix-command flakes' run \
github:nix-community/disko/latest#disko -- \
--mode mount --flake .#neogaia
```
Then place the identity generated in step 3, owned by root and readable by nobody else:
```console
$ sudo install -d -m 0755 /mnt/var/lib/sops-nix
$ sudo install -m 0400 -o root -g root /tmp/key.txt /mnt/var/lib/sops-nix/key.txt
```
It goes on the root subvolume rather than anywhere mounted later because the password secret is decrypted before user accounts are created, which is earlier than any other mount.
Confirm the identity matches the recipient you registered before rebooting, since this is the last cheap moment to catch a mismatch:
```console
$ sudo nix shell nixpkgs#age -c age-keygen -y /mnt/var/lib/sops-nix/key.txt
```
### 7. Reboot
Unmount and reboot into the installed system:
```console
$ sudo umount -R /mnt
$ sudo reboot
```
Remove the USB stick.
At boot you are prompted for the LUKS passphrase from step 5.
After unlocking, log in at the console as `alexion` with the password from the shared secrets file, and you have a working system with fish, tmux, nvim, and Claude Code.
If the login is rejected, the identity and the registered recipient disagree — see [recovery](#recovering-a-wrongly-provisioned-machine).
### 8. Commit the recipient change
The re-key from step 4 exists only in the live session's clone, which is gone.
From a machine that is already a recipient of the affected files, repeat the `.sops.yaml` edit and re-key, then commit and push:
```console
$ sudo SOPS_AGE_KEY_FILE=/var/lib/sops-nix/key.txt \
nix run nixpkgs#sops -- updatekeys secrets/shared.yaml
$ git add .sops.yaml secrets/
$ git commit -m "feat(secrets): register newhost as a recipient"
$ git push origin main
```
Until this lands, the repo's copy of each file has one recipient fewer than the copy the new machine was built from, and the next rebuild from the repo would lock it out.
## Provisioning an already-running host
A machine that is up and running gets its identity live.
There is no reimage and no live ISO, because the running generation is the fallback: if activation fails, the rebuild fails and the machine keeps working as it is.
Generate the identity on the machine itself, straight into place:
```console
$ sudo install -d -m 0755 /var/lib/sops-nix
$ sudo nix shell nixpkgs#age -c age-keygen -o /var/lib/sops-nix/key.txt
$ sudo chmod 0400 /var/lib/sops-nix/key.txt
```
Register the printed public recipient in `.sops.yaml` and re-key each file the host must read, exactly as in [step 4](#4-register-the-recipient-and-re-key), using the admin identity.
Then rebuild:
```console
$ sudo nixos-rebuild switch --flake .#neogaia
```
Activation decrypts the secrets with the new identity.
Confirm they materialized before trusting the change:
```console
$ sudo ls -l /run/secrets/ /run/secrets-for-users/
```
Both directories matter.
Ordinary secrets land in `/run/secrets/`, but a secret marked as needed for user creation is decrypted in an earlier stage and lands in `/run/secrets-for-users/` — which is where the login password hash goes, so it is the one to check before rebooting.
Commit and push the recipient change once the rebuild succeeds.
## Editing secrets
Opening a file decrypts it into an editor and re-encrypts on save:
```console
$ sudo SOPS_AGE_KEY_FILE=/var/lib/sops-nix/key.txt \
nix run nixpkgs#sops -- secrets/shared.yaml
```
`sudo` is needed because the identity is mode `0400` and owned by root.
**What the workstation can do alone** is anything to a file it is already a recipient of.
For `neogaia` that is `secrets/shared.yaml` and `secrets/neogaia.yaml`: changing a value, adding a key, and even adding another recipient all work from the host identity, because each only requires decrypting a file the machine can already decrypt.
**What needs the admin identity** is any file the workstation is not a recipient of — another machine's `secrets/<host>.yaml`.
Unlock the admin identity out of Proton Pass for that session and point `SOPS_AGE_KEY_FILE` at it:
```console
$ SOPS_AGE_KEY_FILE=/path/to/admin-identity \
nix run nixpkgs#sops -- secrets/zeus.yaml
```
That friction is the point.
A workstation that could decrypt every machine's material would make the admin identity ceremonial, and a compromised laptop would carry the whole fleet with it.
The admin identity stays a break-glass credential rather than something sitting unlocked on a machine.
Two changes need more than a save.
Rotating the login password means generating a fresh hash with `mkpasswd`, since `users.mutableUsers = false` makes `passwd` inert, and rebuilding.
Re-keying the SSH host keys restarts `sshd`, which is declared and automatic.
## Recovering a wrongly-provisioned machine
A machine whose identity and registered recipient disagree boots but cannot be logged into: the password hash never decrypts, and there is no fallback credential.
Recovery is from the live ISO.
Boot the ISO, join wifi, and clone the repo as in steps 1 and 2.
Then reopen and mount the encrypted root:
```console
$ sudo nix --extra-experimental-features 'nix-command flakes' run \
github:nix-community/disko/latest#disko -- \
--mode mount --flake .#neogaia
```
Read the identity actually on the disk, and compare it against the recipient the repo registered:
```console
$ sudo nix shell nixpkgs#age -c age-keygen -y /mnt/var/lib/sops-nix/key.txt
```
**If the repo's recipient is right and the disk's identity is wrong**, replace the identity with the one that matches and reboot.
Nothing was built against the wrong key, so no rebuild is needed:
```console
$ sudo install -m 0400 -o root -g root /path/to/correct-key.txt /mnt/var/lib/sops-nix/key.txt
$ sudo umount -R /mnt && sudo reboot
```
**If the disk's identity is right and the repo's recipient is wrong**, re-key against the identity on the disk, using the admin identity to decrypt, then rebuild the target from the ISO:
```console
$ SOPS_AGE_KEY_FILE=/path/to/admin-identity \
nix run nixpkgs#sops -- updatekeys secrets/shared.yaml
$ git add .sops.yaml secrets/
$ sudo NIX_CONFIG="experimental-features = nix-command flakes" \
nixos-install --root /mnt --flake .#neogaia --no-root-password \
--option extra-substituters https://nyx-cache.chaotic.cx/ \
--option extra-trusted-public-keys nyx-cache.chaotic.cx:dJxTrgMC3V3cFfyIiBQDQorG6k1LsqurH/srpMSq7qk=
$ sudo umount -R /mnt && sudo reboot
```
The rebuild is required here and not in the first case, because the secrets file is baked into the system closure at build time.
`nixos-install` reuses the already-formatted disk rather than touching the partition table, so the LUKS container and its passphrase are untouched, and it is idempotent if it fails partway.
The substituter flags matter for the same reason they do during the install: without them the CachyOS kernel is compiled from source on the USB stick.
If neither identity is recoverable, generate a new one as in [step 3](#3-generate-the-host-identity), register it, re-key, and rebuild — the machine's own secrets are lost, but everything encrypted to the admin identity survives.

744
flake.lock generated Normal file
View File

@@ -0,0 +1,744 @@
{
"nodes": {
"base16": {
"inputs": {
"fromYaml": "fromYaml"
},
"locked": {
"lastModified": 1755819240,
"narHash": "sha256-qcMhnL7aGAuFuutH4rq9fvAhCpJWVHLcHVZLtPctPlo=",
"owner": "SenchoPens",
"repo": "base16.nix",
"rev": "75ed5e5e3fce37df22e49125181fa37899c3ccd6",
"type": "github"
},
"original": {
"owner": "SenchoPens",
"repo": "base16.nix",
"type": "github"
}
},
"base16-fish": {
"flake": false,
"locked": {
"lastModified": 1765809053,
"narHash": "sha256-XCUQLoLfBJ8saWms2HCIj4NEN+xNsWBlU1NrEPcQG4s=",
"owner": "tomyun",
"repo": "base16-fish",
"rev": "86cbea4dca62e08fb7fd83a70e96472f92574782",
"type": "github"
},
"original": {
"owner": "tomyun",
"repo": "base16-fish",
"rev": "86cbea4dca62e08fb7fd83a70e96472f92574782",
"type": "github"
}
},
"base16-helix": {
"flake": false,
"locked": {
"lastModified": 1776754714,
"narHash": "sha256-E3OAK27smtATTmX45uoTSRsVD+Y+ZiVVfgM/tjpbtYg=",
"owner": "tinted-theming",
"repo": "base16-helix",
"rev": "4d508123037e7851ad36ebf7d9c48b0e9e1eb581",
"type": "github"
},
"original": {
"owner": "tinted-theming",
"repo": "base16-helix",
"type": "github"
}
},
"base16-vim": {
"flake": false,
"locked": {
"lastModified": 1732806396,
"narHash": "sha256-e0bpPySdJf0F68Ndanwm+KWHgQiZ0s7liLhvJSWDNsA=",
"owner": "tinted-theming",
"repo": "base16-vim",
"rev": "577fe8125d74ff456cf942c733a85d769afe58b7",
"type": "github"
},
"original": {
"owner": "tinted-theming",
"repo": "base16-vim",
"rev": "577fe8125d74ff456cf942c733a85d769afe58b7",
"type": "github"
}
},
"chaotic": {
"inputs": {
"flake-schemas": "flake-schemas",
"home-manager": "home-manager",
"nixpkgs": "nixpkgs"
},
"locked": {
"lastModified": 1785327209,
"narHash": "sha256-heXGjUBU1UsTHFzedDzYct9Cblr6FGzQcYyjCykywh8=",
"owner": "chaotic-cx",
"repo": "nyx",
"rev": "90cfa9864fa08c923dddeca965103ad44663dd64",
"type": "github"
},
"original": {
"owner": "chaotic-cx",
"ref": "nyxpkgs-unstable",
"repo": "nyx",
"type": "github"
}
},
"disko": {
"inputs": {
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1781152676,
"narHash": "sha256-RxWs5ND31KzTG7wvMM+PMfUjyNpmIEr999lqNARaM5o=",
"owner": "nix-community",
"repo": "disko",
"rev": "ff8702b4de27f72b4c78573dfb89ec74e36abdf1",
"type": "github"
},
"original": {
"owner": "nix-community",
"repo": "disko",
"type": "github"
}
},
"firefox-addons": {
"inputs": {
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"dir": "pkgs/firefox-addons",
"lastModified": 1785384175,
"narHash": "sha256-sWSJPXpQwKJstL4rdhpAQYCYlHK5wOkEHR8/lNHBVb4=",
"owner": "rycee",
"repo": "nur-expressions",
"rev": "db607f3d0afe811bcb3b16266f28b2fc5af4e74f",
"type": "gitlab"
},
"original": {
"dir": "pkgs/firefox-addons",
"owner": "rycee",
"repo": "nur-expressions",
"type": "gitlab"
}
},
"firefox-gnome-theme": {
"flake": false,
"locked": {
"lastModified": 1782007937,
"narHash": "sha256-PbnJr+eB+9Czol3ReI83dUgEhcn0sDK6TSy6ODTQm88=",
"owner": "rafaelmardojai",
"repo": "firefox-gnome-theme",
"rev": "981bd332015397fb1ca033fa982bd61635160c78",
"type": "github"
},
"original": {
"owner": "rafaelmardojai",
"repo": "firefox-gnome-theme",
"type": "github"
}
},
"flake-parts": {
"inputs": {
"nixpkgs-lib": [
"nixvim",
"nixpkgs"
]
},
"locked": {
"lastModified": 1782949081,
"narHash": "sha256-vp6Y/Grm98ESt6ceOkWiHWyZRDV3J1RID4w+6NWK9yA=",
"owner": "hercules-ci",
"repo": "flake-parts",
"rev": "17c9d6cdfc60c64f4ee8d306f9bc0b4ccb51481e",
"type": "github"
},
"original": {
"owner": "hercules-ci",
"repo": "flake-parts",
"type": "github"
}
},
"flake-parts_2": {
"inputs": {
"nixpkgs-lib": [
"stylix",
"nixpkgs"
]
},
"locked": {
"lastModified": 1782949081,
"narHash": "sha256-vp6Y/Grm98ESt6ceOkWiHWyZRDV3J1RID4w+6NWK9yA=",
"owner": "hercules-ci",
"repo": "flake-parts",
"rev": "17c9d6cdfc60c64f4ee8d306f9bc0b4ccb51481e",
"type": "github"
},
"original": {
"owner": "hercules-ci",
"repo": "flake-parts",
"type": "github"
}
},
"flake-schemas": {
"locked": {
"lastModified": 1780327564,
"narHash": "sha256-HiRPtA0spK+Dkgbhz/1zW9glXxNVB+L4Rj2VYmdawb8=",
"rev": "6cc9bd98891b1fc6bb2b8cb3277df8bc72799ca6",
"revCount": 149,
"type": "tarball",
"url": "https://api.flakehub.com/f/pinned/DeterminateSystems/flake-schemas/0.5.0/019e83cf-9af3-78b1-ac5b-70e68ad1efe1/source.tar.gz"
},
"original": {
"type": "tarball",
"url": "https://flakehub.com/f/DeterminateSystems/flake-schemas/%3D0.5.0.tar.gz"
}
},
"flake-utils": {
"inputs": {
"systems": "systems_2"
},
"locked": {
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"fromYaml": {
"flake": false,
"locked": {
"lastModified": 1731966426,
"narHash": "sha256-lq95WydhbUTWig/JpqiB7oViTcHFP8Lv41IGtayokA8=",
"owner": "SenchoPens",
"repo": "fromYaml",
"rev": "106af9e2f715e2d828df706c386a685698f3223b",
"type": "github"
},
"original": {
"owner": "SenchoPens",
"repo": "fromYaml",
"type": "github"
}
},
"gitea-axi": {
"inputs": {
"home-manager": "home-manager_2",
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1785340481,
"narHash": "sha256-GSxdQ7w8yYfZnfkXUuZ2fYIKibe9ZU8xDGyqpeTb2tE=",
"ref": "refs/heads/main",
"rev": "627fc9a32bb80d97923540fc0d3e9661961462ba",
"revCount": 83,
"type": "git",
"url": "https://git.alexion.dev/alexion/gitea-axi"
},
"original": {
"type": "git",
"url": "https://git.alexion.dev/alexion/gitea-axi"
}
},
"gitea-axi_2": {
"inputs": {
"home-manager": "home-manager_4",
"nixpkgs": [
"skills",
"nixpkgs"
]
},
"locked": {
"lastModified": 1785340481,
"narHash": "sha256-GSxdQ7w8yYfZnfkXUuZ2fYIKibe9ZU8xDGyqpeTb2tE=",
"ref": "refs/heads/main",
"rev": "627fc9a32bb80d97923540fc0d3e9661961462ba",
"revCount": 83,
"type": "git",
"url": "https://git.alexion.dev/alexion/gitea-axi"
},
"original": {
"type": "git",
"url": "https://git.alexion.dev/alexion/gitea-axi"
}
},
"gnome-shell": {
"flake": false,
"locked": {
"host": "gitlab.gnome.org",
"lastModified": 1776175984,
"narHash": "sha256-RJFlFW8GiMei6oqUGrMkGEvVqOH8U7Q8abc1yK4VKD8=",
"owner": "GNOME",
"repo": "gnome-shell",
"rev": "e0fdc4c13250e9a9b8ea9594c83925274f4a5dca",
"type": "gitlab"
},
"original": {
"host": "gitlab.gnome.org",
"owner": "GNOME",
"ref": "50.1",
"repo": "gnome-shell",
"type": "gitlab"
}
},
"home-manager": {
"inputs": {
"nixpkgs": [
"chaotic",
"nixpkgs"
]
},
"locked": {
"lastModified": 1785288465,
"narHash": "sha256-nCkxaGRtyNheNTxoc527gjOG0BN2zovsWDQVBeKDMW8=",
"owner": "nix-community",
"repo": "home-manager",
"rev": "36662afed2fa1c9b69bdd03edb92ad572202ca20",
"type": "github"
},
"original": {
"owner": "nix-community",
"repo": "home-manager",
"type": "github"
}
},
"home-manager_2": {
"inputs": {
"nixpkgs": [
"gitea-axi",
"nixpkgs"
]
},
"locked": {
"lastModified": 1784588016,
"narHash": "sha256-ouZe80aWEhMLVMkqICFDN+JUw+0FJtCr/bh+hHtRtMg=",
"owner": "nix-community",
"repo": "home-manager",
"rev": "deeb6b7eb7e0c44ae1819c051ce175bd92a85100",
"type": "github"
},
"original": {
"owner": "nix-community",
"repo": "home-manager",
"type": "github"
}
},
"home-manager_3": {
"inputs": {
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1785306346,
"narHash": "sha256-DScBkW0fOgpGPK2trNoX3ryLTlaC14+gglFo/BhGJ4g=",
"owner": "nix-community",
"repo": "home-manager",
"rev": "e705714e918c3b11affcdd15db2cbe3a070420a0",
"type": "github"
},
"original": {
"owner": "nix-community",
"repo": "home-manager",
"type": "github"
}
},
"home-manager_4": {
"inputs": {
"nixpkgs": [
"skills",
"gitea-axi",
"nixpkgs"
]
},
"locked": {
"lastModified": 1784588016,
"narHash": "sha256-ouZe80aWEhMLVMkqICFDN+JUw+0FJtCr/bh+hHtRtMg=",
"owner": "nix-community",
"repo": "home-manager",
"rev": "deeb6b7eb7e0c44ae1819c051ce175bd92a85100",
"type": "github"
},
"original": {
"owner": "nix-community",
"repo": "home-manager",
"type": "github"
}
},
"home-manager_5": {
"inputs": {
"nixpkgs": [
"skills",
"nixpkgs"
]
},
"locked": {
"lastModified": 1784725727,
"narHash": "sha256-J5+C9wsO0lhDyUalQzfplDbRjyHDYeEH5+9sdyXtwa8=",
"owner": "nix-community",
"repo": "home-manager",
"rev": "041a999e8c1c5b731913855909e68d30ca69b8e0",
"type": "github"
},
"original": {
"owner": "nix-community",
"repo": "home-manager",
"type": "github"
}
},
"nixos-hardware": {
"inputs": {
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1785232496,
"narHash": "sha256-65EQYIRRpTdpH8lUiB6Mvo5uBkG60aBIzAJuALfx+O0=",
"owner": "NixOS",
"repo": "nixos-hardware",
"rev": "2e790b0a6be8ec2b76174ac0931b8ff11919ec98",
"type": "github"
},
"original": {
"owner": "NixOS",
"repo": "nixos-hardware",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1785090369,
"narHash": "sha256-m0pDuRJG7EDo9ri+4Ksu83VsI+PlxNC9lNBfydejce4=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "624af665418d3c65d544145b4d34ad696439570e",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs-stable": {
"locked": {
"lastModified": 1785133411,
"narHash": "sha256-Yjv0WEg39KRYS0rBdTbu6Fc/or/ihAKk13W9sQ6VWd0=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "2f5a153c270b70cb0f8c11f46d96d6d3bc39f4e3",
"type": "github"
},
"original": {
"owner": "nixos",
"ref": "nixos-26.05",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs-unstable": {
"locked": {
"lastModified": 1785301185,
"narHash": "sha256-eoS3KQTO0aPWXZvIaRbRAzSSHW3l5wdMFXtT1ISfoKA=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "9bc02893134c733dd85de46ee4fb2fac696b5529",
"type": "github"
},
"original": {
"owner": "nixos",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs_2": {
"locked": {
"lastModified": 1785318670,
"narHash": "sha256-dN6Ou5x/+23FZLEpYP3IffO+NyJFzUlGumt1uu3MMaY=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "0954f7ee2f6bb3dc7d4e3d0d8bcb8fd4bde4cfc5",
"type": "github"
},
"original": {
"owner": "nixos",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"nixvim": {
"inputs": {
"flake-parts": "flake-parts",
"nixpkgs": [
"nixpkgs"
],
"systems": "systems"
},
"locked": {
"lastModified": 1785364321,
"narHash": "sha256-BLuHl+nZKb+FDq3GAM6L+UBEiyVepXANA31fT1F56pw=",
"owner": "nix-community",
"repo": "nixvim",
"rev": "acd69cc15d57004e8cb4495034320263a3d362ea",
"type": "github"
},
"original": {
"owner": "nix-community",
"repo": "nixvim",
"type": "github"
}
},
"nur": {
"inputs": {
"flake-parts": [
"stylix",
"flake-parts"
],
"nixpkgs": [
"stylix",
"nixpkgs"
]
},
"locked": {
"lastModified": 1783439237,
"narHash": "sha256-WUr8JF2v3n4Y30E5dxv4sAgNJXpVDBoCQNoQ/V4+n4o=",
"owner": "nix-community",
"repo": "NUR",
"rev": "b70bb66c7bcd162642f3a609bc16843c7059f503",
"type": "github"
},
"original": {
"owner": "nix-community",
"repo": "NUR",
"type": "github"
}
},
"root": {
"inputs": {
"chaotic": "chaotic",
"disko": "disko",
"firefox-addons": "firefox-addons",
"gitea-axi": "gitea-axi",
"home-manager": "home-manager_3",
"nixos-hardware": "nixos-hardware",
"nixpkgs": "nixpkgs_2",
"nixpkgs-stable": "nixpkgs-stable",
"nixpkgs-unstable": "nixpkgs-unstable",
"nixvim": "nixvim",
"skills": "skills",
"sops-nix": "sops-nix",
"stylix": "stylix"
}
},
"skills": {
"inputs": {
"flake-utils": "flake-utils",
"gitea-axi": "gitea-axi_2",
"home-manager": "home-manager_5",
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1785695024,
"narHash": "sha256-DLLk6X5zu3cRT50p18uHVdwjGVtiS0t/661M34q02zU=",
"ref": "refs/heads/main",
"rev": "9b2a6bcd583d7d6bf7e5377c3632f601692df209",
"revCount": 54,
"type": "git",
"url": "https://git.alexion.dev/alexion/skills"
},
"original": {
"type": "git",
"url": "https://git.alexion.dev/alexion/skills"
}
},
"sops-nix": {
"inputs": {
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1783174389,
"narHash": "sha256-aCWC8ngycU7OdJrU2+Je3qf+1a2ykuBvpPhZT/9tXMc=",
"owner": "Mic92",
"repo": "sops-nix",
"rev": "f1406619a3884cd5c47992a70b8b35c9c0fcb4c9",
"type": "github"
},
"original": {
"owner": "Mic92",
"repo": "sops-nix",
"type": "github"
}
},
"stylix": {
"inputs": {
"base16": "base16",
"base16-fish": "base16-fish",
"base16-helix": "base16-helix",
"base16-vim": "base16-vim",
"firefox-gnome-theme": "firefox-gnome-theme",
"flake-parts": "flake-parts_2",
"gnome-shell": "gnome-shell",
"nixpkgs": [
"nixpkgs"
],
"nur": "nur",
"systems": "systems_3",
"tinted-kitty": "tinted-kitty",
"tinted-schemes": "tinted-schemes",
"tinted-tmux": "tinted-tmux",
"tinted-zed": "tinted-zed"
},
"locked": {
"lastModified": 1784676123,
"narHash": "sha256-ndyanKzw90yX2nUVFmTuYqXidUNymtMfgmIHyNdhht0=",
"owner": "danth",
"repo": "stylix",
"rev": "66714e5ce44269ecc58c20d9196da8dbe1b27a31",
"type": "github"
},
"original": {
"owner": "danth",
"repo": "stylix",
"type": "github"
}
},
"systems": {
"locked": {
"lastModified": 1774449309,
"narHash": "sha256-brhZ8DmuGtzkCYHJg4HEd602amKm89Y9ytsFZ5uWD1w=",
"owner": "nix-systems",
"repo": "default",
"rev": "c29398b59d2048c4ab79345812849c9bd15e9150",
"type": "github"
},
"original": {
"owner": "nix-systems",
"ref": "future-26.11",
"repo": "default",
"type": "github"
}
},
"systems_2": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
},
"systems_3": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
},
"tinted-kitty": {
"flake": false,
"locked": {
"lastModified": 1735730497,
"narHash": "sha256-4KtB+FiUzIeK/4aHCKce3V9HwRvYaxX+F1edUrfgzb8=",
"owner": "tinted-theming",
"repo": "tinted-kitty",
"rev": "de6f888497f2c6b2279361bfc790f164bfd0f3fa",
"type": "github"
},
"original": {
"owner": "tinted-theming",
"repo": "tinted-kitty",
"type": "github"
}
},
"tinted-schemes": {
"flake": false,
"locked": {
"lastModified": 1781968807,
"narHash": "sha256-yYO3Vw2M0y3TAUqt+9+Mj0zwP3XDTF5/PXcPhhFQ1ZM=",
"owner": "tinted-theming",
"repo": "schemes",
"rev": "2ccef2f4b22e3cab5a9292811f7133a07eeba4a7",
"type": "github"
},
"original": {
"owner": "tinted-theming",
"repo": "schemes",
"type": "github"
}
},
"tinted-tmux": {
"flake": false,
"locked": {
"lastModified": 1782012462,
"narHash": "sha256-2iDiD8DQLwS1lGuD9TS8WlvNyDoTs6krWntJbtB2zGo=",
"owner": "tinted-theming",
"repo": "tinted-tmux",
"rev": "8c4e750f738a742bd73377ee41d3dadedebedef4",
"type": "github"
},
"original": {
"owner": "tinted-theming",
"repo": "tinted-tmux",
"type": "github"
}
},
"tinted-zed": {
"flake": false,
"locked": {
"lastModified": 1782009766,
"narHash": "sha256-VUhBjpGvWqHI7rWeyMYb/u87YJSXHKfVV6S+IelWeO8=",
"owner": "tinted-theming",
"repo": "base16-zed",
"rev": "5e8350bcd354e3241ab681a265fa6ef060c40be1",
"type": "github"
},
"original": {
"owner": "tinted-theming",
"repo": "base16-zed",
"type": "github"
}
}
},
"root": "root",
"version": 7
}

110
flake.nix Normal file
View File

@@ -0,0 +1,110 @@
{
description = "Alexion's NixOS configuration one flake for every host";
inputs = {
# Base channel.
nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
# Fresher packages, reachable per-package as `unstable.<name>`.
nixpkgs-unstable.url = "github:nixos/nixpkgs/nixpkgs-unstable";
# Latest stable release, reachable per-package as `stable.<name>`.
nixpkgs-stable.url = "github:nixos/nixpkgs/nixos-26.05";
home-manager = {
url = "github:nix-community/home-manager";
inputs.nixpkgs.follows = "nixpkgs";
};
# Signed AMO extensions, pinned by version and hash.
firefox-addons = {
url = "gitlab:rycee/nur-expressions?dir=pkgs/firefox-addons";
inputs.nixpkgs.follows = "nixpkgs";
};
# Follows our nixpkgs so its plugins build against the same package set.
nixvim = {
url = "github:nix-community/nixvim";
inputs.nixpkgs.follows = "nixpkgs";
};
# Declarative disk partitioning.
# Each host declares its own layout.
disko = {
url = "github:nix-community/disko";
inputs.nixpkgs.follows = "nixpkgs";
};
# Upstream per-machine hardware profiles.
# Each host imports its own.
nixos-hardware = {
url = "github:NixOS/nixos-hardware";
inputs.nixpkgs.follows = "nixpkgs";
};
# Decrypts committed secrets at activation, from an age identity on the host.
sops-nix = {
url = "github:Mic92/sops-nix";
inputs.nixpkgs.follows = "nixpkgs";
};
# Themes the graphical layer from one base16 scheme.
# Follows our nixpkgs so it themes the same package set the host builds.
stylix = {
url = "github:danth/stylix";
inputs.nixpkgs.follows = "nixpkgs";
};
# Agent-ergonomic CLI for Gitea, with a home-manager module for the agent context.
gitea-axi = {
url = "git+https://git.alexion.dev/alexion/gitea-axi";
inputs.nixpkgs.follows = "nixpkgs";
};
# Personal agent skills, packaged as per-skill derivations with a home-manager module.
skills = {
url = "git+https://git.alexion.dev/alexion/skills";
inputs.nixpkgs.follows = "nixpkgs";
};
# CachyOS kernel and binary cache.
# Pins its own nixpkgs so its cache stays usable and the kernel is fetched from it.
chaotic.url = "github:chaotic-cx/nyx/nyxpkgs-unstable";
};
outputs =
{ self, nixpkgs, ... }@inputs:
let
inherit (nixpkgs) lib;
my = import ./lib.nix { inherit lib inputs self; };
in
{
# Helper functions for discovering and building hosts.
lib = my;
# Every host under hosts/ is discovered and built.
nixosConfigurations = my.mkHosts (self + "/hosts");
# A project shell for agent-local resources that should travel with this
# checkout rather than the operator's global profile.
devShells.x86_64-linux.default =
let
pkgs = nixpkgs.legacyPackages.x86_64-linux;
in
pkgs.mkShell {
packages = [ inputs.gitea-axi.packages.x86_64-linux.gitea-axi ];
shellHook = inputs.skills.lib.mkSkillsShellHook [
inputs.gitea-axi.packages.x86_64-linux.gitea-axi-skill
];
};
# `nix flake check` builds each host's toplevel.
checks.x86_64-linux = lib.mapAttrs (
name: host:
if host.config.warnings == [] then
host.config.system.build.toplevel
else
throw "Host ${name} has evaluation warnings:\n${lib.concatStringsSep "\n" host.config.warnings}"
) self.nixosConfigurations;
};
}

31
guest.nix Normal file
View File

@@ -0,0 +1,31 @@
{
my,
inputs,
lib,
...
}:
# The guest-base: the slim foundation every nested guest's interior stands on.
# It imports the full modules tree so any module is available to enable inside a
# guest, and stands on the same shared base a host does.
{
imports = my.collectNixFiles (inputs.self + "/modules") ++ [
(inputs.self + "/base.nix")
# The modules tree reaches for these option namespaces, so they must be
# declared for the tree to evaluate even where a guest leaves them off.
inputs.sops-nix.nixosModules.sops
inputs.stylix.nixosModules.stylix
];
# A nested container has no per-host `default.nix` to pin its release.
system.stateVersion = "26.05";
# The baseline toolset and SSH access, so any guest shelled into is a workable
# environment without per-guest wiring.
modules.toolkit.enable = lib.mkDefault true;
modules.ssh.enable = lib.mkDefault true;
# A guest carries no host identity, so it presents a self-generated host key
# rather than restoring one from secrets.
modules.ssh.hostKeys.restore = lib.mkDefault false;
}

11
guests/nesting-sample.nix Normal file
View File

@@ -0,0 +1,11 @@
args@{ my, ... }:
# A sample guest whose interior runs an OCI container on Podman.
# The image is pulled at runtime, so the guest builds with no build-time fetch.
my.guest {
name = "nesting-sample";
interior = {
virtualisation.oci-containers.containers.hello = {
image = "docker.io/library/hello-world";
};
};
} args

5
guests/sample.nix Normal file
View File

@@ -0,0 +1,5 @@
args@{ my, ... }:
# The tracer-bullet guest: the thinnest complete path from discovery to a
# running nested container. Its interior is just the guest-base — the baseline
# toolset and SSH access — so it proves the concept without carrying a service.
my.guest { name = "sample"; } args

76
hosts/neogaia/default.nix Normal file
View File

@@ -0,0 +1,76 @@
{
inputs,
pkgs,
...
}:
# neogaia — Dell XPS 13 9380 laptop.
# Disk layout is in ./disk.nix.
# `fileSystems` are derived from it, none declared here.
{
imports = [
inputs.nixos-hardware.nixosModules.dell-xps-13-9380
./hardware-configuration.nix
./disk.nix
];
system.stateVersion = "26.05";
# systemd-boot on the EFI system partition.
boot.loader.systemd-boot.enable = true;
boot.loader.efi.canTouchEfiVariables = true;
boot.kernelPackages = pkgs.linuxPackages_cachyos;
# Redistributable firmware for the QCA6174 wifi (ath10k blobs).
# Intel microcode updates follow from this, so none is declared here.
hardware.enableRedistributableFirmware = true;
# RAM-backed swap, no on-disk swap partition.
zramSwap.enable = true;
# So wifi can be joined from the console.
networking.networkmanager.enable = true;
# So setup can be driven over the network.
# The matching host public keys sit beside this file in plaintext, since
# publishing them is their purpose.
modules.ssh.enable = true;
modules.ssh.hostKeys.sopsFile = ../../secrets/neogaia.yaml;
modules.ssh.userKey.sopsFile = ../../secrets/neogaia.yaml;
modules.toolkit.enable = true;
# The walking-skeleton guest, enabled like any module: proves the guest path
# end to end through this host's `nix flake check`.
# Modest caps keep the skeleton guest from starving the laptop.
guests.sample.enable = true;
guests.sample.limits = {
memory = "1G";
cpu = "100%";
tasksMax = 512;
};
# The nesting guest, run with `nesting` on: proves an interior OCI container
# on Podman builds end to end through this host's `nix flake check`.
guests.nesting-sample.enable = true;
guests.nesting-sample.nesting = true;
guests.nesting-sample.limits = {
memory = "1G";
cpu = "100%";
tasksMax = 512;
};
modules.agents.claude-code.enable = true;
modules.agents.herdr.enable = true;
modules.agents.tools.gitea-axi.enable = true;
modules.agents.pi.enable = true;
modules.agents.pi.subagents.maxConcurrent = 8;
modules.agents.pi.subagents.recentTerminalTtlMs = 15 * 60 * 1000;
modules.desktop.enable = true;
modules.desktop.obsidian.enable = true;
modules.desktop.steam.enable = true;
time.timeZone = "America/New_York";
i18n.defaultLocale = "en_GB.UTF-8";
}

63
hosts/neogaia/disk.nix Normal file
View File

@@ -0,0 +1,63 @@
{ ... }:
# neogaia's disk layout for disko: one NVMe disk, GPT, with an EFI system
# partition and a LUKS container holding btrfs subvolumes.
# No swap partition, since swap is zram.
# disko derives `fileSystems` and `boot.initrd.luks.devices` from this.
{
disko.devices.disk.main = {
type = "disk";
device = "/dev/nvme0n1";
content = {
type = "gpt";
partitions = {
ESP = {
# Each generation stores a kernel and initrd here and the CachyOS kernel is large.
# An exhausted partition fails bootloader installs.
size = "2G";
type = "EF00";
content = {
type = "filesystem";
format = "vfat";
mountpoint = "/boot";
mountOptions = [ "umask=0077" ];
};
};
luks = {
size = "100%";
content = {
type = "luks";
name = "cryptroot";
settings.allowDiscards = true;
content = {
type = "btrfs";
extraArgs = [ "-f" ];
subvolumes = {
"@root" = {
mountpoint = "/";
mountOptions = [
"compress=zstd"
"noatime"
];
};
"@home" = {
mountpoint = "/home";
mountOptions = [
"compress=zstd"
"noatime"
];
};
"@nix" = {
mountpoint = "/nix";
mountOptions = [
"compress=zstd"
"noatime"
];
};
};
};
};
};
};
};
};
}

View File

@@ -0,0 +1,17 @@
{ lib, modulesPath, ... }:
# Hardware detected by nixos-generate-config on this machine.
# disko derives `fileSystems` and the LUKS device, none declared here.
{
imports = [ (modulesPath + "/installer/scan/not-detected.nix") ];
boot.initrd.availableKernelModules = [
"xhci_pci"
"nvme"
"rtsx_pci_sdmmc"
];
boot.initrd.kernelModules = [ ];
boot.kernelModules = [ "kvm-intel" ];
boot.extraModulePackages = [ ];
nixpkgs.hostPlatform = lib.mkDefault "x86_64-linux";
}

View File

@@ -0,0 +1 @@
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJS+wp7K123+4BT6G4f954R6WyrbWveY7VlpoBUf6I5p neogaia

View File

@@ -0,0 +1 @@
ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQCo2wWUKxyAS4J5TqbWf8glDhJvS5XmdRqFhMeJwG3pOB+4AccZ1T8LU7ZN+RjtRi3j2qXBJvIHuzhtQNtmT59TxocvfobYiqOgJpvVO5K6yD8ZoUJs6ziDkIduI9w9mdRIESoi+dBbVu8n24r61cKDVh+jWX+yjzkOcWcOzqDyQhhkjqblZ1WMAdujEMuEPvif1i2LCxStUaZqRGcx09m/ME2fYcaJrpuxxxvX2+CPJNicoo6Rx9i7ZjAoNuvH+jui4KT62DzlQtQtCl2CFUOM0gCPSa+MbNQ9elfHPvGzEcwOIMo2cuy9KURUkQu+sAgaG8S1PEniDDTecskHtuRdmPZawnQGpIhzo919Q6wUgjT8scK4mmSXRWmGmkMt0GNA2tfj5tDks6r5Q8XsYqtWs4rsOEvfmxVSdM771w+fqDBAil99Jsh0ksPK9+Bwgg8cMDzLLFDn8JA5y2G1HocMMom+u5DYKwPXEKnCILkasB8y24+O3PhSu1EuWw277w6EUEXvU03rCf0Ak/ULjxp9a00EGlloEwSmFI7Aub9XHDr87IdbGInEn+PMqyBYADiN+3h6nE2JO+nMa6i/CHdebmT+T7YJvuTKHD9sjFmQsYaghlq03DZrhHcm4hgUvE1dqGojHrhk/WgA3EWTWtK/+BP0Vy2jXaaz+qAx+EGnhQ== neogaia

54
hosts/pikachu/default.nix Normal file
View File

@@ -0,0 +1,54 @@
{ pkgs, ... }:
# pikachu — AZW ME Pro server.
# Disk layout is in ./disk.nix.
# `fileSystems` for the root disk are derived from it.
{
imports = [
./hardware-configuration.nix
./disk.nix
];
system.stateVersion = "26.05";
boot.loader.systemd-boot.enable = true;
boot.loader.efi.canTouchEfiVariables = true;
hardware.cpu.intel.updateMicrocode = true;
hardware.enableRedistributableFirmware = true;
zramSwap.enable = true;
systemd.network = {
enable = true;
networks."10-uplink" = {
matchConfig.MACAddress = "78:55:36:07:af:49";
networkConfig.DHCP = "yes";
linkConfig.RequiredForOnline = "routable";
};
};
networking.useDHCP = false;
boot.zfs.forceImportRoot = false;
modules.zfs = {
enable = true;
hostId = "2346edbd";
pools.pikachu = { };
};
modules.ssh.enable = true;
modules.ssh.hostKeys.sopsFile = ../../secrets/pikachu.yaml;
modules.ssh.userKey.sopsFile = ../../secrets/pikachu.yaml;
modules.git.enable = true;
modules.toolkit.enable = true;
environment.systemPackages = with pkgs; [
pciutils
smartmontools
usbutils
];
time.timeZone = "America/New_York";
i18n.defaultLocale = "en_GB.UTF-8";
}

32
hosts/pikachu/disk.nix Normal file
View File

@@ -0,0 +1,32 @@
{ ... }:
# pikachu's install layout for disko: one NVMe boot disk with an EFI system partition and ext4 root.
# The existing 8 TB ZFS mirror is imported by name and is never declared here.
{
disko.devices.disk.main = {
type = "disk";
device = "/dev/nvme0n1";
content = {
type = "gpt";
partitions = {
ESP = {
size = "2G";
type = "EF00";
content = {
type = "filesystem";
format = "vfat";
mountpoint = "/boot";
mountOptions = [ "umask=0077" ];
};
};
root = {
size = "100%";
content = {
type = "filesystem";
format = "ext4";
mountpoint = "/";
};
};
};
};
};
}

View File

@@ -0,0 +1,18 @@
{ lib, modulesPath, ... }:
# Hardware detected from the Proxmox inventory for this machine.
# disko derives the root disk filesystems, none declared here.
{
imports = [ (modulesPath + "/installer/scan/not-detected.nix") ];
boot.initrd.availableKernelModules = [
"ahci"
"nvme"
"sd_mod"
"xhci_pci"
];
boot.initrd.kernelModules = [ ];
boot.kernelModules = [ "kvm-intel" ];
boot.extraModulePackages = [ ];
nixpkgs.hostPlatform = lib.mkDefault "x86_64-linux";
}

View File

@@ -0,0 +1 @@
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKljRf4pJO+pqEqjpPz08gOYq3g1PpxvE66xVw7uMEnA root@pikachu

View File

@@ -0,0 +1 @@
ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQCy/riwm7dflA3mT+3a0/2CIoS2LbAsK/vn35kOoNeuzn0yhiF+imexP6tkB3S2t+H5ybRzkbbuNZcynFfeCqthFc8kvbdCnt8Diqoeg96fZ6ecvh5QE5yH9op8534EySetZ/exakFLnF+6EiWMuWUW3DFwsc2kcgDJObqSE8gTx/d7JK953MiTFmSJBFyg1RtQ3ZnMT+iCrvY2dyCLQai7VeF8koVKF2c0leAq2Hc75rb/L9md8MoJa64iPiz7hwTCin3xoFyaY/5hNVvyqFd5PivgR69gLdJkuVsUYO2mJzhur8cYmJD+pGjJ0U45hyE9TMrCFjeJHHuvSt3+2kph62wv95jLNk0WmMlwgyunISxENCSVVtNYdBMXhUh8VhEAW17QpVUg9EnPvxOdTKEjrvfOZYASWUa51JKbgBgexVgFbxdjDZR88DZa31AVBts/cx/59gXTUahFXMYLdZgssx+5uibZQWnvCyfUV9WLbfmK1lgL6hzReg1VkQ87iGr6skjtQYemJxRaFNA1+Q5f3kmG3KncuK/594a3qXYP4gC6A2blf8om1YZ4aXXh6f+GFKLjoEw1vvM2rJ+rjzfymwDX+pxVQ9L13OEtVZc9Ez76pOkbm1hqdbL0gY45+0cpxodhWV0wMQJBDXL1MHP8qcs+/vw0GxVK5l1SnWBGlw== root@pikachu

421
lib.nix Normal file
View File

@@ -0,0 +1,421 @@
{
lib,
inputs,
self,
}:
let
inherit (lib)
attrNames
filterAttrs
genAttrs
flatten
hasSuffix
mapAttrsToList
;
# Recursively collect every `.nix` file under `dir` as a flat list, for a
# module's `imports`.
collectNixFiles =
dir:
flatten (
mapAttrsToList (
name: type:
let
path = dir + "/${name}";
in
if type == "directory" then
collectNixFiles path
else if type == "regular" && hasSuffix ".nix" name then
[ path ]
else
[ ]
) (builtins.readDir dir)
);
# The special arguments every configuration is evaluated with, host and guest
# interior alike.
specialArgs = {
inherit inputs;
my = self.lib;
};
# The name of a tagged VLAN's bridge, kept here as the one definition of a
# convention shared across the flake.
bridgeName = id: "br-vlan${toString id}";
# A guest with no operator-set MAC derives a stable one from its namespace path.
# The first octet 02 marks the address locally-administered and unicast.
# The rest is a slice of the path's hash.
# The same guest therefore always lands on the same address, which the operator can reserve at the router.
deriveMac =
name:
let
hash = builtins.hashString "sha256" name;
octet = i: builtins.substring (i * 2) 2 hash;
in
lib.concatStringsSep ":" ([ "02" ] ++ map octet [ 0 1 2 3 4 ]);
# Build one host: every module and every guest is imported unconditionally
# (inert until its `enable` flag is set), alongside chaotic, the host base,
# and the host's own directory.
mkHost =
{
hostName,
system ? "x86_64-linux",
}:
inputs.nixpkgs.lib.nixosSystem {
inherit system specialArgs;
modules =
(collectNixFiles (self + "/modules"))
++ (collectNixFiles (self + "/guests"))
++ [
inputs.chaotic.nixosModules.default
inputs.disko.nixosModules.disko
inputs.sops-nix.nixosModules.sops
inputs.stylix.nixosModules.stylix
(self + "/system.nix")
(self + "/hosts/${hostName}")
{ networking.hostName = hostName; }
];
};
# Build a guest: a module-shaped definition whose body realizes its interior
# as a nested container standing on the guest-base, keyed by its namespace path.
# `name` is the dotted namespace under `guests.` and `interior` is an extra
# module merged into the container alongside the guest-base.
guest =
{
name,
interior ? { },
}:
{ config, lib, ... }:
let
optionPath = [ "guests" ] ++ lib.splitString "." name;
cfg = lib.getAttrFromPath optionPath config;
machineName = lib.replaceStrings [ "." ] [ "-" ] name;
networked = cfg.vlan != null;
# Host paths the operator maps into the guest, keyed by their in-guest path.
userMounts = lib.mapAttrs (_guestPath: m: {
inherit (m) hostPath;
isReadOnly = m.readOnly;
}) cfg.mounts;
# Each named secret bind-mounted read-only at the same `/run/secrets/<name>`
# path it holds on the host.
# No ownership is set here, since the container's one-to-one identity map
# carries the host file's owner through unchanged.
secretMounts = lib.listToAttrs (
map (
name:
let
path = config.sops.secrets.${name}.path;
in
lib.nameValuePair path {
hostPath = path;
isReadOnly = true;
}
) cfg.secrets
);
# An in-guest path claimed by both a mount and a secret, which the merge
# below would otherwise resolve silently in the secret's favour.
mountCollisions = lib.attrNames (builtins.intersectAttrs userMounts secretMounts);
# The resource caps the operator places on the guest's unit, dropping any
# left unset so systemd keeps its uncapped default for those.
limitConfig = lib.filterAttrs (_: v: v != null) {
MemoryMax = cfg.limits.memory;
CPUQuota = cfg.limits.cpu;
TasksMax = cfg.limits.tasksMax;
};
# A networked guest owns its bridged interface through its own networkd, the only stable MAC pin for a nested container.
# The interface is eth0, the name a nested container gives its bridged veth.
# It takes the placement MAC, and the static address or DHCP when that is unset.
guestNet =
{ lib, ... }:
{
config = lib.mkIf networked {
networking.useNetworkd = true;
# networkd default-enables resolved, which owns the guest's resolv.conf.
# The nested-container default of inheriting the host's file conflicts with that, so the guest keeps its own.
networking.useHostResolvConf = false;
systemd.network.networks."20-eth0" = {
matchConfig.Name = "eth0";
linkConfig.MACAddress = cfg.mac;
networkConfig = lib.mkIf (cfg.address == null) { DHCP = "yes"; };
address = lib.mkIf (cfg.address != null) [ cfg.address ];
};
};
};
in
{
options = lib.setAttrByPath optionPath {
enable = lib.mkEnableOption "the ${name} guest, run in its own nested container";
backend = lib.mkOption {
type = lib.types.enum [
"container"
"microvm"
];
default = "container";
description = ''
How the guest is realized. `container` runs the guest as a
systemd-nspawn nested container. `microvm` is reserved for a future
hard-isolation backend and is not built yet.
'';
};
vlan = lib.mkOption {
type = lib.types.nullOr (lib.types.ints.between 1 4094);
default = null;
example = 10;
description = ''
The tagged VLAN this guest lives on. The guest attaches to its host's
`br-vlan<id>` bridge for that VLAN. Left null, the guest keeps a
private network with no bridge attachment. The id must be one of the
host's `modules.network.vlans`.
'';
};
mac = lib.mkOption {
type = lib.types.str;
default = deriveMac name;
defaultText = lib.literalMD "a stable address derived from the guest's namespace path";
example = "bc:24:11:00:00:01";
description = ''
The guest's MAC address on its VLAN, pinned inside the guest by its
own networkd. Set it to reuse an existing address so a router's DHCP
reservation keeps working. Left unset, a stable address is derived
from the guest's namespace path in the locally-administered range.
'';
};
address = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
example = "10.0.10.5/24";
description = ''
The guest's static address, in CIDR form, on its VLAN. Left null, the
guest takes its address by DHCP, keeping IP management at the router.
'';
};
mounts = lib.mkOption {
type = lib.types.attrsOf (
lib.types.submodule {
options = {
hostPath = lib.mkOption {
type = lib.types.str;
example = "/srv/media";
description = "The path on the host bind-mounted into the guest.";
};
readOnly = lib.mkOption {
type = lib.types.bool;
default = false;
description = ''
Mount the path read-only. Read-write by default, since a
service must write to the pool data it owns.
'';
};
};
}
);
default = { };
example = lib.literalExpression ''
{
"/data/media" = { hostPath = "/srv/media"; };
"/data/config" = {
hostPath = "/srv/config/jellyfin";
readOnly = true;
};
}
'';
description = ''
Host paths bind-mounted into the guest, keyed by the path they appear
at inside the guest, so a guest sees exactly the data it should at any
granularity a single folder or a whole pool. Each mount is
read-write unless `readOnly` is set.
'';
};
secrets = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ ];
example = [ "jellyfin-api-key" ];
description = ''
Names of the secrets this guest needs. The host is the sole
decryptor: it decrypts each named secret from its own sops files and
bind-mounts the plaintext file into the guest read-only at
`/run/secrets/<name>`, the same path it would occupy on a host, so a
service reads its credentials at a predictable location. The guest
names the files it wants and receives exactly those. It holds no age
key and decrypts nothing itself. Ownership carries across unchanged,
since the container maps ids one to one, so a secret owned by a uid on
the host is owned by that same uid inside the guest.
'';
};
limits = {
memory = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
example = "2G";
description = ''
Cap on the guest's memory, applied to its unit as `MemoryMax`.
Accepts systemd size suffixes such as `512M` or `2G`. Left null,
the guest's memory is uncapped.
'';
};
cpu = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
example = "150%";
description = ''
Cap on the guest's CPU, applied to its unit as `CPUQuota`, where
`100%` is one full core. Left null, the guest's CPU is uncapped.
'';
};
tasksMax = lib.mkOption {
type = lib.types.nullOr lib.types.ints.positive;
default = null;
example = 512;
description = ''
Cap on the number of processes and threads the guest may spawn,
applied to its unit as `TasksMax`. Left null, the task count is
uncapped.
'';
};
};
nesting = lib.mkOption {
type = lib.types.bool;
default = false;
description = ''
Grant the guest's interior the prerequisites to run Podman or other
OCI containers of its own. Off by default, so a guest cannot nest
containers. On, the guest's container gains the network-administration
capability its container runtime uses to build bridges and firewall
rules, along with the tun and fuse device nodes such a runtime reaches
for, so the interior's `virtualisation.oci-containers` works with
Podman as its default runtime.
'';
};
autoStart = lib.mkOption {
type = lib.types.bool;
default = true;
description = ''
Start the guest at boot. On by default. Disabled, the guest stays
defined and can be started on demand, but does not come up at boot.
'';
};
};
config = lib.mkIf cfg.enable {
# Declared here so the host is the one that decrypts each named secret.
# The guest carries no age key and decrypts nothing of its own.
sops.secrets = lib.genAttrs cfg.secrets (_: { });
assertions = [
{
assertion = mountCollisions == [ ];
message = ''
guests.${name} maps a mount at ${lib.concatStringsSep ", " mountCollisions}, colliding with a secret bind-mounted at the same path. Rename the mount or the secret so each in-guest path is used once.
'';
}
{
assertion = cfg.backend == "container";
message = ''
guests.${name}.backend = "${cfg.backend}" is not implemented. Only the "container" backend is built; "microvm" is reserved for future work.
'';
}
{
assertion = !networked || lib.elem cfg.vlan config.modules.network.vlans;
message = ''
guests.${name}.vlan = ${toString cfg.vlan} is not among its host's modules.network.vlans (${lib.concatMapStringsSep ", " toString config.modules.network.vlans}). Declare the VLAN on the host or correct the guest's placement.
'';
}
];
# The operator's resource caps land on the guest's own unit, which a
# networked guest also orders after the bridge its veth enslaves to at
# start, since the container backend orders the unit after the network
# is up but not after that specific bridge existing.
systemd.services."container@${machineName}" = lib.mkIf (cfg.backend == "container") (
lib.mkMerge [
{ serviceConfig = limitConfig; }
(lib.mkIf networked (
let
bridgeDevice = "sys-subsystem-net-devices-${lib.replaceStrings [ "-" ] [ "\\x2d" ] (bridgeName cfg.vlan)}.device";
in
{
after = [ bridgeDevice ];
wants = [ bridgeDevice ];
}
))
]
);
containers.${machineName} = lib.mkIf (cfg.backend == "container") {
autoStart = cfg.autoStart;
# The guest gets its own network namespace, so its services — its own
# sshd included — never contend with the host's.
privateNetwork = lib.mkDefault true;
# A networked guest's veth is enslaved to the VLAN's bridge, making it
# a first-class L2 citizen on that segment.
hostBridge = lib.mkIf networked (bridgeName cfg.vlan);
# The container shares the host's uid and gid space one to one.
# A guest process writing as the shared storage group then lands on a bind-mounted pool as that same group, with no permission juggling.
# A private-user mapping would shift the ids and reintroduce those errors, so it stays off.
privateUsers = lib.mkDefault "no";
# A nesting guest runs Podman or other OCI containers in its interior.
# The network-administration capability lets that runtime build its
# bridges and firewall rules.
# The tun and fuse device nodes are what it reaches for to network
# those containers and back their overlay storage.
# The remaining prerequisite, a delegated cgroup subtree for the
# runtime to manage, the container backend already grants every guest.
additionalCapabilities = lib.optionals cfg.nesting [ "CAP_NET_ADMIN" ];
allowedDevices = lib.optionals cfg.nesting [
{
node = "/dev/net/tun";
modifier = "rwm";
}
{
node = "/dev/fuse";
modifier = "rwm";
}
];
bindMounts = userMounts // secretMounts;
inherit specialArgs;
config = {
imports = [
(self + "/guest.nix")
guestNet
interior
];
};
};
};
};
# Discover every host (a subdirectory of `hostsDir`) and build each one.
mkHosts =
hostsDir:
let
hostNames = attrNames (filterAttrs (_name: type: type == "directory") (builtins.readDir hostsDir));
in
genAttrs hostNames (hostName: mkHost { inherit hostName; });
in
{
inherit
collectNixFiles
mkHost
mkHosts
guest
bridgeName
;
}

View File

@@ -0,0 +1,32 @@
# Authenticating Claude Code without a browser
`neogaia` is driven from the console and over SSH, where no local browser can service Claude Code's default OAuth redirect.
Either of the two paths below signs the CLI in from a bare terminal.
Both are one-time actions per machine; the credentials land under `~/.claude`, which home-manager does not overwrite.
## Paste-code flow (Claude subscription or Console OAuth)
Run `claude` and start the login with the `/login` command (the first run offers it automatically).
On a machine with no browser it cannot open the authorization page itself, so it prints the authorization URL and waits.
1. Copy the printed URL to a browser on any other device (phone, another laptop).
2. Sign in and approve the request there.
3. The page returns a short authorization code; paste it back at the `claude` prompt still waiting in the terminal.
The session then completes and the token is stored, so later runs need no further login.
Because the URL is opened on a *different* device, this works unchanged over SSH.
## API key
For non-interactive use, set an Anthropic API key from <https://console.anthropic.com> in the environment before launching `claude`:
```console
$ export ANTHROPIC_API_KEY=sk-ant-...
$ claude
```
Claude Code reads `ANTHROPIC_API_KEY` on startup and skips the interactive login entirely, so this path needs neither a browser nor the paste-code exchange.
Usage is billed to the Console account that owns the key rather than to a Claude subscription.
The key is a secret and is deliberately not baked into this configuration.
Export it from the shell for a one-off, or source it from a secret store once one exists on the Host.

View File

@@ -0,0 +1,65 @@
{
config,
lib,
pkgs,
...
}:
# Claude Code for the primary user, configured through home-manager, which ships
# the package and manages ~/.claude.
# Login credentials are left unmanaged so they survive rebuilds.
let
cfg = config.modules.agents.claude-code;
user = config.user.name;
in
{
options.modules.agents.claude-code.enable = lib.mkEnableOption ''
Claude Code, Anthropic's CLI, configured via home-manager.
Enabling this also widens sudo's credential cache, keying it per user rather
than per terminal and holding it for 60 minutes, so that a single
authentication covers commands the agent issues. No command is made
passwordless, but any process running as the primary user can spend the
cached credential while it lasts. Suitable for a single-user machine'';
config = lib.mkIf cfg.enable {
# Key the credential cache per user rather than per terminal, so one
# authentication covers the agent's terminal-less commands.
security.sudo.extraConfig = ''
Defaults timestamp_type=global
Defaults timestamp_timeout=60
'';
home-manager.users.${user} = {
# jq parses the tool input handed to the sudo guard hook.
home.packages = [ pkgs.jq ];
programs.claude-code = {
enable = true;
# One directory per skill, symlinked under ~/.claude/skills.
skills = ./skills;
# Installed under ~/.claude/hooks, referenced by the settings below.
hooks."agent-sudo-guard.sh" = builtins.readFile ./hooks/agent-sudo-guard.sh;
settings = {
model = "opus";
hooks = {
PreToolUse = [
{
matcher = "Bash";
hooks = [
{
type = "command";
command = "~/.claude/hooks/agent-sudo-guard.sh";
timeout = 10;
}
];
}
];
};
};
};
};
};
}

View File

@@ -0,0 +1,29 @@
#!/bin/sh
# Refuse a privileged command while sudo's credential cache is cold, naming the
# command that warms it.
#
# Commands arrive here from subprocesses holding no terminal, so an uncached
# sudo fails with a bare non-zero exit and no output, reading as an unexplained stall.
# The probe below reads a cache keyed per user rather than per terminal,
# so an authentication made in the operator's own terminal counts.
input=$(cat)
command=$(printf '%s' "$input" | jq -r '.tool_input.command // ""')
# Anchored to a command position so a `sudo` appearing as an argument or inside
# a string does not trip the guard.
if ! printf '%s' "$command" | grep -qE '(^|[;&|(]|&&|\|\|)[[:space:]]*sudo([[:space:]]|$)'; then
exit 0
fi
if sudo -n true 2>/dev/null; then
exit 0
fi
# Exit 2 blocks the call and feeds stderr back to the agent.
echo 'Blocked: sudo has no cached credential, and this command cannot answer a password prompt.
Ask the operator to run `sudo -v` in their own terminal, then retry.
Never attempt to supply a password directly.
If this still blocks immediately after the operator runs `sudo -v`, the cache is
not the cause: check that this hook can reach sudo at all.' >&2
exit 2

View File

@@ -23,11 +23,11 @@ If the request describes a new workflow, capability, or repeated manual process
- Is there already a word — in your prompts, docs, or codebase — that names this behavior? Reach for that **leading word** before coining one.
Done when every axis above has an answer, or the user says to just draft something and iterate.
2. **Write the draft.** First decide where it lives: project-local `.claude/skills/` if the workflow is tied to this one repo, `~/.claude/skills/` if it's general-purpose across projects. Then follow the **information hierarchy**: steps for what the agent does in order, in-file **reference** for facts every branch needs, and disclose the rest behind a pointer — to a sibling file, or to the existing project docs identified in step 1 rather than restating them. Done when every branch from step 1 has somewhere to live, and no sentence fails the no-op test in isolation (see `No-Op` in GLOSSARY.md).
2. **Write the draft.** First decide where it lives: project-local `.claude/skills/` if the workflow is tied to this one repo, the personal set if it's general-purpose across projects. The personal set is not authored in `~/.claude/skills/` — that tree is generated, and every file under it is a read-only symlink into the Nix store. Write it in the dotfiles repo at `modules/claude-code/skills/<name>/` and rebuild to make it live. Creating files directly under `~/.claude/skills/` looks like it works, because the directories themselves are writable, but the result is untracked by the repo and reaches no other machine. Then follow the **information hierarchy**: steps for what the agent does in order, in-file **reference** for facts every branch needs, and disclose the rest behind a pointer — to a sibling file, or to the existing project docs identified in step 1 rather than restating them. Done when every branch from step 1 has somewhere to live, and no sentence fails the no-op test in isolation (see `No-Op` in GLOSSARY.md).
## Audit an existing skill
1. **Locate it.** Check the current project's `.claude/skills/`, then `~/.claude/skills/`, then `~/.claude/skills/library/`, in that order; ask if the name is ambiguous across locations. If it's tracked in a project's `skills-lock.yaml`, mention that editing it here will make it read as locally-customized to `update-skills` — confirm that's actually intended rather than editing the library source.
1. **Locate it.** Check the current project's `.claude/skills/`, then `~/.claude/skills/`, then `~/.claude/skills/library/`, in that order; ask if the name is ambiguous across locations. A hit under `~/.claude/skills/` is a read-only symlink and cannot be edited in place: its source is the dotfiles repo, at `modules/claude-code/skills/<name>/` for a personal skill or `modules/claude-code/skills/library/<name>/` for a library one. Edit there and rebuild. If it's tracked in a project's `skills-lock.yaml`, mention that editing it here will make it read as locally-customized to `update-skills` — confirm that's actually intended rather than editing the library source.
2. **Apply the checklist.** Read the skill and its disclosed files, then check each against GLOSSARY.md, quoting the offending line for anything that fails:
- **Premature completion** — is each completion criterion checkable, and does it demand what the step actually needs?
@@ -44,6 +44,6 @@ If the request describes a new workflow, capability, or repeated manual process
1. Propose one realistic test prompt — reflecting the trigger phrasing gathered (draft) or the skill's existing purpose (audit) — and get it confirmed or adjusted before spending a run on it.
2. Spawn one subagent: give it the skill's path and the confirmed prompt, have it attempt the task using the skill, and report back what happened — including anywhere it hesitated, misread the skill, or did something unexpected.
3. Re-read the draft/rewrite against GLOSSARY.md's failure modes in light of that run, and fix whatever either pass turned up. If the fix is substantial, repeat from step 1; otherwise it's done.
4. Stage the specific changed or created paths — one path per file, never a wildcard — with the host project's own staging convention: plain `git add <path>` normally, or e.g. `dot add <path>` in this dotfiles setup (wrap as `fish -c "dot add <path>"` if the invoking shell isn't fish — `dot` is a fish function, not a binary on `$PATH`). Do not commit; that's left to the user.
4. Stage the specific changed or created paths — one path per file, never a wildcard — with `git add <path>`. Do not commit; that's left to the user.
Done when the subagent's run succeeded without confusion on the confirmed prompt, the checklist raised nothing outstanding, and every changed path is staged.

View File

@@ -0,0 +1,72 @@
---
name: implement
description: Implement a task file produced by /to-tasks on its own branch, review it, close it out, and open a PR.
disable-model-invocation: true
---
Implement a task file end-to-end: branch, build it, review it, close it out, and open a PR.
## Process
### 1. Read the task file and check blockers
The user passes the path to a task file (`.claude/tasks/<NNNN>-slug.md`, as produced by `/to-tasks`) explicitly — don't infer one from context.
If the task's frontmatter has a `blocked-by` field, read each referenced task file and check for any unresolved `- [ ]` acceptance criterion. If any blocker isn't fully resolved, warn the user which one and why, and confirm before proceeding — don't refuse outright.
### 2. Sync `main` and branch off it
Switch to `main`, fast-forward it (`git pull --ff-only`), then create and switch to a branch named `task-<NNNN>-<slug>` — taken verbatim from the task file's basename, so `.claude/tasks/0003-issue-view-and-truncation.md` gives `task-0003-issue-view-and-truncation`.
Use whatever git invocation the project itself uses; a repo may wrap it.
Stop and ask the user before going further if:
- **The working tree has uncommitted changes.** Never stash them automatically.
- **`git pull --ff-only` fails.** Local `main` has diverged; report what diverged. Never `reset --hard`.
- **The task's `blocked-by` work isn't reachable from `main`.** The blocker's PR is likely unmerged; name it.
If the task branch already exists, switch to it and carry on — don't recreate it, and don't rebase it onto the freshly pulled `main`.
Always branch off `main`, never off a sibling task branch.
### 3. Implement
Build the work described in the task's "What to build" section, satisfying its acceptance criteria. Use `/test-driven-development` where possible, at the seams already agreed when the spec or task was written.
Run typechecking regularly, single test files regularly, and the full test suite once at the end.
### 4. Stage the changes
Stage (`git add`) each file you create or modify, specifically — not `git add -A` — so nothing untracked and unrelated gets swept in.
### 5. Review
Run `/review-uncommitted`, passing the task file itself as the spec source — it already links back to its parent spec via its `spec` frontmatter field, if any. Address anything it raises before moving on.
Keep its report — step 7 puts part of it in the PR.
### 6. Close out the task file
Mark every acceptance criterion `[x]` if satisfied or `[-]` if deliberately dropped, so none are left `[ ]`. Append a `## Implementation Notes` section explaining any deviations from the plan — dropped criteria (referencing which, and why), scope changes, decisions made mid-implementation, follow-ups worth flagging. Skip the section only if nothing deviated. Leave the `spec` and `blocked-by` frontmatter fields untouched — they're a permanent record, not a checklist to clear (see `to-tasks`'s `TASK-FORMAT.md`).
Stage the updated task file with the rest.
### 7. Commit, push, and open a PR
Make **one** commit for the whole task, code and task file together.
Match the repo's existing commit convention — read its recent history or its CLAUDE.md, don't assume one — and reference the task in the subject, e.g. `(task 0003)`.
Push the branch (`git push -u origin task-<NNNN>-<slug>`) and open a pull request against `main` with the repo's forge CLI: `tea` for Gitea, `gh` for GitHub.
Never base the PR on a sibling task branch.
Open it ready, not draft.
The PR body carries:
- The task file's path.
- A short summary of what was built, and any deviations — the same ones just written into `## Implementation Notes`.
- A `## Review` section: the `## Risk` block from step 5 verbatim (overall rating plus its six factor lines), then **only** the Standards and Spec findings left unaddressed, each with a one-line reason. Findings that were fixed are already in the diff; leave them out.
Don't ask for confirmation before pushing or opening the PR.
If the repo has no remote, stop after the commit and report that no PR was opened.
Stay on the task branch when done.
Report the branch name, the PR URL, and any unaddressed review findings.

Some files were not shown because too many files have changed in this diff Show More