Compare commits
203 Commits
8d6ec10b74
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 55ed1bf5a9 | |||
| ad2e6f5f4a | |||
| 2738061b5d | |||
| ffc9b331ea | |||
| 729c8fdd5f | |||
| e56b710344 | |||
| d782308b42 | |||
| 8e8752e519 | |||
| 58f6b108c3 | |||
| cb26a044d3 | |||
| af643c452d | |||
| 1e80216b07 | |||
| eb67944e68 | |||
| 7bc0d0772c | |||
| ede3c0583f | |||
| aafc68e911 | |||
| 8c85c00ae9 | |||
| c5828e0591 | |||
| 5fd8de031d | |||
| de99b4a89e | |||
| f5d799c64b | |||
| e143495d6c | |||
| 3c4eaec76b | |||
| 007ba81c02 | |||
| 3977ed6822 | |||
| 289ea1344c | |||
| 63da676b5a | |||
| 8fc816bcd9 | |||
| 585d4919e7 | |||
| 6544d3d8a0 | |||
| 2fed687a00 | |||
| 36d7a53029 | |||
| 6422bb96f2 | |||
| 9ed4809837 | |||
| 6945c29a47 | |||
| 521e4c7fb6 | |||
| b8bb26da75 | |||
| ef1eebda58 | |||
| 06e327ed85 | |||
| adcb7bfd77 | |||
| 2b957c7f09 | |||
| 7a97ee4e31 | |||
| 3e3975c724 | |||
| f218e47814 | |||
| 53a070a59a | |||
| 969737b6b5 | |||
| 0b7d409fbc | |||
| d637d3e7f6 | |||
| ab9b9e9f8f | |||
| 78ab95922f | |||
| 7edc1ce94b | |||
| e6ea8a0060 | |||
| 83106239d4 | |||
| e05adef7b7 | |||
| e7d7eb14e1 | |||
| 8cd59cb292 | |||
| 582800d548 | |||
| 8885ffae67 | |||
| 40ae623094 | |||
| 7409e4e6a0 | |||
| 2cb47ef2cf | |||
| 48a81bb8a2 | |||
| d2fbf78927 | |||
| eca87c74a6 | |||
| bb9a92b25b | |||
| 68e0aafbbb | |||
| f967bc47bc | |||
| 75373ebdc1 | |||
| ac095ba0e4 | |||
| 7f7fc327fd | |||
| bd32795e23 | |||
| 6484f9466c | |||
| e42101e08b | |||
| 09eb9a983d | |||
| e24f808b63 | |||
| 3e64bd0b7e | |||
| ee672d2479 | |||
| 039802b9e2 | |||
| 6f6f0178b1 | |||
| e684ac481e | |||
| 02bb345fd7 | |||
| a3e3e80c83 | |||
| 23b1a30c2a | |||
| ac96639c20 | |||
| ab89ba8391 | |||
| 0e92de7eea | |||
| 111b985d7d | |||
| 37ddf4342a | |||
| 708a3ee963 | |||
| 9963a0dbe4 | |||
| 210a260735 | |||
| 005928ef88 | |||
| 900cb6b8e8 | |||
| 7d9a0dae36 | |||
| aca09d87a1 | |||
| 10e68957b1 | |||
| 5a7593a793 | |||
| ba881998bc | |||
| b21b4ff77e | |||
| 6d1afa41ed | |||
| 27a28b1ae7 | |||
| 5ca717ca4a | |||
| c0ec330024 | |||
| b9749dd6a2 | |||
| 4b6fbfe732 | |||
| f552269cb3 | |||
| fc3380f8ce | |||
| 6f34c95c51 | |||
| 908d7719fb | |||
| 310ff13c77 | |||
| e49fb929d8 | |||
| 78081143cf | |||
| 6b5729b98a | |||
| f18b40091c | |||
| 4ecb86052b | |||
| 62eb6286b4 | |||
| 75c5745cbd | |||
| c9fc17ecf5 | |||
| 0d685ce277 | |||
| c63c3079be | |||
| dcc03155a2 | |||
| 11d7cb053c | |||
| ec40892560 | |||
| a6ada9dac3 | |||
| 42ff195556 | |||
| 60738f65c2 | |||
| 61ce9cc1be | |||
| ce103a7353 | |||
| 7711b841dd | |||
| 66582b498c | |||
| 54c191f801 | |||
| d0bfc7b21b | |||
| e5d8f69f16 | |||
| 42c602b814 | |||
| 431f75aad7 | |||
| 488bb15683 | |||
| 13e5a9bb56 | |||
| 5b3ebdccf3 | |||
| 8c85c02a7d | |||
| f94aaba6c1 | |||
| 25049c8aef | |||
| 4c0d36324c | |||
| f80ea948ea | |||
| 5e254857b9 | |||
| 7cd9370366 | |||
| 066bf467ba | |||
| 7809e079e3 | |||
| bdb6f01934 | |||
| 064971f601 | |||
| 41709bb977 | |||
| 77e853ab49 | |||
| 6a10f760cf | |||
| 91d0a7d8e4 | |||
| 7810425849 | |||
| b7363ed7e1 | |||
| 505002bb2b | |||
| 6f9309d329 | |||
| 98fecc314f | |||
| 80d1587189 | |||
| 7e53ecd946 | |||
| 2d6eb929d7 | |||
| f54d0460ad | |||
| 95869fb11f | |||
| 5a89d4addb | |||
| b91e434c87 | |||
| 16f29bd64a | |||
| 9eb0fe797a | |||
| 8346d63e64 | |||
| 20f5b33e00 | |||
| 194d64dacd | |||
| 8f83c3ca8c | |||
| 053c4de529 | |||
| bfc9e6f75b | |||
| da30375413 | |||
| cfe8d4ff9f | |||
| 25e12f79de | |||
| 9b36cfadd6 | |||
| d5b67947f9 | |||
| 42491d3dd0 | |||
| a9cc1dc309 | |||
| 1020865026 | |||
| b4650c03b8 | |||
| 8d3915ad0e | |||
| 0046a87130 | |||
| 1ca5033bc8 | |||
| aced50f67f | |||
| 08ee0a2f59 | |||
| eee7c8190d | |||
| 8c35889884 | |||
| 62109e993a | |||
| 14f8618c0a | |||
| b8e91ee042 | |||
| d0364d5d9e | |||
| 00edf973fd | |||
| 4af1e967ed | |||
| 2f08adbfb7 | |||
| 3732ccd4d8 | |||
| 1fd8e7e773 | |||
| 540928f24f | |||
| 864d643da9 | |||
| f15713d183 | |||
| 537724989a | |||
| ec43fb2e14 |
@@ -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.
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
/home/alexion/wrk/claude/settings.json
|
||||
1
.claude/skills
Symbolic link
1
.claude/skills
Symbolic link
@@ -0,0 +1 @@
|
||||
../.agents/skills
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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" },
|
||||
]
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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 "`.
|
||||
@@ -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`).
|
||||
@@ -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.
|
||||
@@ -1,60 +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
|
||||
|
||||
- [ ] `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
|
||||
- [ ] 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
|
||||
- [ ] Bare `dot setup` on a fresh scratch `$HOME` runs the `folders` task as
|
||||
part of running everything
|
||||
- [ ] `dot setup folders help` and `dot setup help` print usage and make no
|
||||
filesystem changes
|
||||
- [ ] Re-running `dot setup folders` after a clean migration is a no-op
|
||||
(idempotent)
|
||||
- [ ] `~/.github/README.md` has a command-table row for `dot setup`
|
||||
(and its `folders` task) with paths relative to `$HOME`
|
||||
- [ ] `~/.config/dot/tests/dot.fish` covers the above cases and
|
||||
`fishtape ~/.config/dot/tests/dot.fish` passes
|
||||
@@ -1,30 +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
|
||||
|
||||
- [ ] 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
|
||||
- [ ] The same legacy folder migrates successfully when `--yes` is passed
|
||||
- [ ] 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
|
||||
- [ ] `~/.config/dot/tests/dot.fish` covers the above cases and
|
||||
`fishtape ~/.config/dot/tests/dot.fish` passes
|
||||
@@ -1,32 +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
|
||||
|
||||
- [ ] 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)
|
||||
- [ ] The skipped collision is reported to the user
|
||||
- [ ] 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
|
||||
- [ ] 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)
|
||||
- [ ] `~/.config/dot/tests/dot.fish` covers the above cases and
|
||||
`fishtape ~/.config/dot/tests/dot.fish` passes
|
||||
@@ -1,159 +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.
|
||||
|
||||
## 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.
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -1,426 +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 reports each one whose live value differs from its
|
||||
schema-declared default, tagged declared (present in the manifest)
|
||||
or undeclared. Also reports already-declared freeform and shortcut
|
||||
settings whose live value differs from their default (neither has a
|
||||
schema/mapping table to broad-scan, so both are 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 parse_identifier(identifier):
|
||||
parts = identifier.split(".", 2)
|
||||
if len(parts) != 3:
|
||||
raise ValueError(f"invalid identifier {identifier!r} (expected file.group.key)")
|
||||
return Setting(*parts)
|
||||
|
||||
|
||||
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 f"{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 _resolve_shortcut_action_id(component_unique, action_unique):
|
||||
(actions,) = _kglobalaccel_call("allActionsForComponent", "as", 1, component_unique)
|
||||
for action in actions:
|
||||
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)
|
||||
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)
|
||||
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 identifier in sorted(set(iter_schema_identifiers(kcfg_map))):
|
||||
setting = parse_identifier(identifier)
|
||||
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})")
|
||||
|
||||
# Freeform and shortcuts settings have no schema/mapping table to enumerate,
|
||||
# so unlike the schema-backed loop above, they can only be checked by walking
|
||||
# identifiers already in the manifest -- neither ever surfaces an undeclared
|
||||
# setting via broad scan.
|
||||
for identifier in manifest:
|
||||
try:
|
||||
setting = parse_identifier(identifier)
|
||||
mechanism, default = resolve_mechanism(setting, kcfg_map)
|
||||
if mechanism == "shortcuts":
|
||||
live = read_shortcut_value(setting.group, setting.key)
|
||||
default = read_shortcut_value(setting.group, setting.key, method="defaultShortcutKeys")
|
||||
if live == default:
|
||||
continue
|
||||
elif mechanism == "freeform":
|
||||
live = read_live_value(setting, default)
|
||||
if live == "":
|
||||
continue
|
||||
else:
|
||||
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 identifier in sorted(set(iter_schema_identifiers(kcfg_map))):
|
||||
print(identifier)
|
||||
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:]))
|
||||
@@ -1,2 +0,0 @@
|
||||
kxkbrc.Layout.Options=caps:escape_shifted_capslock
|
||||
kglobalshortcutsrc.ksmserver.Lock Session=Meta+X Screensaver
|
||||
@@ -1,2 +0,0 @@
|
||||
neovim
|
||||
tmux
|
||||
@@ -1,570 +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
|
||||
9
.config/dot/tests/fixtures/kcfg/kwin.kcfg
vendored
9
.config/dot/tests/fixtures/kcfg/kwin.kcfg
vendored
@@ -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>
|
||||
15
.config/dot/tests/fixtures/kcfg/testrc.kcfg
vendored
15
.config/dot/tests/fixtures/kcfg/testrc.kcfg
vendored
@@ -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>
|
||||
@@ -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>
|
||||
@@ -1,29 +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 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)"
|
||||
@@ -1,2 +0,0 @@
|
||||
complete --command fishtape --short v --long version --description "Print version"
|
||||
complete --command fishtape --short h --long help --description "Print help"
|
||||
@@ -1,4 +0,0 @@
|
||||
alias cp='cp -v'
|
||||
alias vi=nvim
|
||||
alias vim=nvim
|
||||
alias tmx='tmux new-session -A -s'
|
||||
@@ -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
|
||||
@@ -1 +0,0 @@
|
||||
jorgebucaran/fishtape
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -1 +0,0 @@
|
||||
vim.opt_local.conceallevel = 2
|
||||
@@ -1,3 +0,0 @@
|
||||
require("config.options")
|
||||
require("config.keymaps")
|
||||
require("config.lazy")
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"lazy.nvim": { "branch": "main", "commit": "85c7ff3711b730b4030d03144f6db6375044ae82" },
|
||||
"nord.nvim": { "branch": "main", "commit": "87394d4fc35c901bbe38326a78d31ab1ead826b6" },
|
||||
"nvim-treesitter": { "branch": "master", "commit": "cf12346a3414fa1b06af75c79faebe7f76df080a" },
|
||||
"render-markdown.nvim": { "branch": "main", "commit": "f422cb5c6855f150e2ddcfaf44e7157b98b34f6a" }
|
||||
}
|
||||
@@ -1,10 +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" })
|
||||
|
||||
map("n", "<leader>e", "<cmd>Lexplore<CR>", { desc = "Toggle file explorer" })
|
||||
@@ -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 },
|
||||
})
|
||||
@@ -1,32 +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
|
||||
|
||||
vim.g.netrw_banner = 0
|
||||
vim.g.netrw_liststyle = 3
|
||||
@@ -1,12 +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,
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
return {
|
||||
"MeanderingProgrammer/render-markdown.nvim",
|
||||
ft = { "markdown" },
|
||||
dependencies = { "nvim-treesitter/nvim-treesitter" },
|
||||
opts = {},
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
return {
|
||||
"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,
|
||||
}
|
||||
@@ -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"
|
||||
@@ -1,3 +0,0 @@
|
||||
[user]
|
||||
name = alexion
|
||||
email = contact@alexion.dev
|
||||
36
.github/README.md
vendored
36
.github/README.md
vendored
@@ -1,36 +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 <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).
|
||||
40
.github/keybindings.md
vendored
40
.github/keybindings.md
vendored
@@ -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`) |
|
||||
14
.gitignore
vendored
14
.gitignore
vendored
@@ -1,6 +1,8 @@
|
||||
.dotfiles
|
||||
.DS_Store
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
Thumbs.db
|
||||
/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
34
.sops.yaml
Normal 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
94
AGENTS.md
Normal 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
5
CLAUDE.md
Normal 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
91
base.nix
Normal 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";
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
57
docs/conventional-commits.md
Normal file
57
docs/conventional-commits.md
Normal 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
386
docs/install.md
Normal 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
744
flake.lock
generated
Normal 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
110
flake.nix
Normal 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
31
guest.nix
Normal 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
11
guests/nesting-sample.nix
Normal 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
5
guests/sample.nix
Normal 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
76
hosts/neogaia/default.nix
Normal 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
63
hosts/neogaia/disk.nix
Normal 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"
|
||||
];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
17
hosts/neogaia/hardware-configuration.nix
Normal file
17
hosts/neogaia/hardware-configuration.nix
Normal 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";
|
||||
}
|
||||
1
hosts/neogaia/ssh_host_ed25519_key.pub
Normal file
1
hosts/neogaia/ssh_host_ed25519_key.pub
Normal file
@@ -0,0 +1 @@
|
||||
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJS+wp7K123+4BT6G4f954R6WyrbWveY7VlpoBUf6I5p neogaia
|
||||
1
hosts/neogaia/ssh_host_rsa_key.pub
Normal file
1
hosts/neogaia/ssh_host_rsa_key.pub
Normal 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
54
hosts/pikachu/default.nix
Normal 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
32
hosts/pikachu/disk.nix
Normal 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 = "/";
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
18
hosts/pikachu/hardware-configuration.nix
Normal file
18
hosts/pikachu/hardware-configuration.nix
Normal 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";
|
||||
}
|
||||
1
hosts/pikachu/ssh_host_ed25519_key.pub
Normal file
1
hosts/pikachu/ssh_host_ed25519_key.pub
Normal file
@@ -0,0 +1 @@
|
||||
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKljRf4pJO+pqEqjpPz08gOYq3g1PpxvE66xVw7uMEnA root@pikachu
|
||||
1
hosts/pikachu/ssh_host_rsa_key.pub
Normal file
1
hosts/pikachu/ssh_host_rsa_key.pub
Normal 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
421
lib.nix
Normal 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
|
||||
;
|
||||
}
|
||||
32
modules/agents/claude-code/authentication.md
Normal file
32
modules/agents/claude-code/authentication.md
Normal 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.
|
||||
65
modules/agents/claude-code/claude-code.nix
Normal file
65
modules/agents/claude-code/claude-code.nix
Normal 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;
|
||||
}
|
||||
];
|
||||
}
|
||||
];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
29
modules/agents/claude-code/hooks/agent-sudo-guard.sh
Executable file
29
modules/agents/claude-code/hooks/agent-sudo-guard.sh
Executable 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
|
||||
@@ -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.
|
||||
72
modules/agents/claude-code/skills/implement/SKILL.md
Normal file
72
modules/agents/claude-code/skills/implement/SKILL.md
Normal 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.
|
||||
@@ -10,6 +10,12 @@ the current project's `.claude/skills/`, tracked in
|
||||
Only ever adds — checking already-installed skills for updates is
|
||||
[`update-skills`](../update-skills/SKILL.md)'s job, not this one's.
|
||||
|
||||
The library is a tree of read-only symlinks into the Nix store, so every
|
||||
copy out of it must dereference (`cp -rL`) and then restore write
|
||||
permission (`chmod -R u+w`). A plain `cp -r` copies the symlinks
|
||||
themselves, putting store paths into the project that break on any other
|
||||
machine.
|
||||
|
||||
## Steps
|
||||
|
||||
1. Read `.claude/skills-lock.yaml` in the current project, if it exists.
|
||||
@@ -35,7 +41,8 @@ Only ever adds — checking already-installed skills for updates is
|
||||
skill already lives there and isn't tracked — remove or rename it
|
||||
first if they want the library version).
|
||||
- Otherwise, copy `~/.claude/skills/library/<name>/` to
|
||||
`.claude/skills/<name>/` in the project, run
|
||||
`.claude/skills/<name>/` in the project with
|
||||
`cp -rL` followed by `chmod -R u+w`, run
|
||||
`~/.claude/skills/setup-skills/hash-dir.sh .claude/skills/<name>`,
|
||||
and append `{name, hash: <output>}` to `.claude/skills-lock.yaml`
|
||||
(create the file, an empty YAML list, if it doesn't exist yet).
|
||||
@@ -28,7 +28,9 @@ This produces **crap tests**:
|
||||
- Tests become insensitive to real changes - they pass when behavior breaks, fail when behavior is fine
|
||||
- You outrun your headlights, committing to test structure before understanding the implementation
|
||||
|
||||
**Correct approach**: Vertical slices via tracer bullets. One test → one implementation → repeat. Each test responds to what you learned from the previous cycle. Because you just wrote the code, you know exactly what behavior matters and how to verify it.
|
||||
**Correct approach**: Vertical slices via tracer bullets. One test → one implementation → repeat. Each test responds to what you learned from the previous cycle.
|
||||
|
||||
The test-writer sub-agent (below) is handed **one behavior at a time** and never sees the behavior backlog, so it can't bulk-write the suite.
|
||||
|
||||
```
|
||||
WRONG (horizontal):
|
||||
@@ -42,6 +44,25 @@ RIGHT (vertical):
|
||||
...
|
||||
```
|
||||
|
||||
## Roles
|
||||
|
||||
Every test is written by a **test-writer sub-agent**. The main agent writes every line of implementation, and never writes or edits a test.
|
||||
|
||||
The sub-agent must not read the implementation source of the module under test — that is what keeps its tests from asserting _how_ instead of _what_. It works from the public interface alone.
|
||||
|
||||
Use one `general-purpose` sub-agent for the whole task: spawn it at the first RED, then continue it with `SendMessage` for each subsequent RED, so it keeps the test file and conventions it established. Cold-spawn a replacement only if its ID is lost.
|
||||
|
||||
### Test-writer sub-agent prompt — include:
|
||||
|
||||
- **One behavior**, quoted verbatim from the acceptance criterion or the agreed behavior list. Never the task file, never the rest of the list.
|
||||
- The **public interface** under test — signatures only.
|
||||
- The existing test file(s) for the module, and the project's test conventions (fixtures, helpers, runner invocation).
|
||||
- [tests.md](tests.md) and [mocking.md](mocking.md).
|
||||
- The **independent source of truth for the expected value** — the spec excerpt, worked example, or known-good literal. Without it the sub-agent recomputes the expected value the way the code would, and the test is tautological.
|
||||
- `.claude/CONTEXT.md` (if it exists) and any ADRs in the area, so test names and interface vocabulary match the project's domain language.
|
||||
- The test-side checklist from [Checklist Per Cycle](#checklist-per-cycle), pasted in full — the sub-agent has no other access to it.
|
||||
- The brief: "Write ONE test for this behavior. Do not read the implementation source of the module under test. Write it to the test file, run it, and confirm it fails with a genuine assertion failure — not an import, syntax, or collection error, which prove nothing. Report the test's name and the exact failure message you saw."
|
||||
|
||||
## Workflow
|
||||
|
||||
### 1. Planning
|
||||
@@ -63,13 +84,15 @@ Ask: "What should the public interface look like? Which behaviors are most impor
|
||||
|
||||
**You can't test everything.** Confirm with the user exactly which behaviors matter most. Focus testing effort on critical paths and complex logic, not every possible edge case.
|
||||
|
||||
Planning stays with the main agent on both paths — exploration, interface, and the order behaviors are tested in. The sub-agent receives behaviors one at a time; it never chooses what to test next.
|
||||
|
||||
### 2. Tracer Bullet
|
||||
|
||||
Write ONE test that confirms ONE thing about the system:
|
||||
ONE test that confirms ONE thing about the system:
|
||||
|
||||
```
|
||||
RED: Write test for first behavior → test fails
|
||||
GREEN: Write minimal code to pass → test passes
|
||||
RED: Spawn the test-writer sub-agent with the first behavior → it writes the test, runs it, reports a genuine failure
|
||||
GREEN: Main agent writes minimal code to pass → test passes
|
||||
```
|
||||
|
||||
This is your tracer bullet - proves the path works end-to-end.
|
||||
@@ -79,8 +102,8 @@ This is your tracer bullet - proves the path works end-to-end.
|
||||
For each remaining behavior:
|
||||
|
||||
```
|
||||
RED: Write next test → fails
|
||||
GREEN: Minimal code to pass → passes
|
||||
RED: SendMessage the same sub-agent the next behavior → it writes the test, runs it, reports a genuine failure
|
||||
GREEN: Main agent writes minimal code to pass → passes
|
||||
```
|
||||
|
||||
Rules:
|
||||
@@ -90,6 +113,13 @@ Rules:
|
||||
- Don't anticipate future tests
|
||||
- Keep tests focused on observable behavior
|
||||
|
||||
### When a test looks wrong
|
||||
|
||||
The main agent never edits a sub-agent-authored test — not to fix an import, not to "simplify" an assertion, not to reach GREEN.
|
||||
|
||||
- **Mechanical defect** — bad import path, a fixture or helper that doesn't exist, doesn't parse. Send the error output back to the sub-agent and let it fix its own test.
|
||||
- **Semantic disagreement** — you believe the expected value or the asserted behavior is wrong. Stop and ask the user. Do not resolve it yourself; this disagreement is the signal the sub-agent exists to surface, and half the time it's the code that's wrong.
|
||||
|
||||
### 4. Refactor
|
||||
|
||||
After all tests pass, look for [refactor candidates](refactoring.md):
|
||||
@@ -102,13 +132,22 @@ After all tests pass, look for [refactor candidates](refactoring.md):
|
||||
|
||||
**Never refactor while RED.** Get to GREEN first.
|
||||
|
||||
A test that breaks during refactor means the refactor broke behavior — fix the code. The one exception is a public interface change you made deliberately (a module deepened, a signature moved, as agreed in the plan): send the interface change to the sub-agent and let it update its own tests. There is no case where the main agent edits the test itself.
|
||||
|
||||
## Checklist Per Cycle
|
||||
|
||||
Test-writer sub-agent, per test — paste into its prompt:
|
||||
|
||||
```
|
||||
[ ] Test describes behavior, not implementation
|
||||
[ ] Test uses public interface only
|
||||
[ ] Test would survive internal refactor
|
||||
[ ] Expected values are independent literals, not recomputed from the code
|
||||
```
|
||||
|
||||
Main agent, per GREEN:
|
||||
|
||||
```
|
||||
[ ] Code is minimal for this test
|
||||
[ ] No speculative features added
|
||||
```
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user