From a8f1045518c81bd30946e8a2f5bb40efe4584ef5 Mon Sep 17 00:00:00 2001 From: Padreug Date: Sun, 28 Jun 2026 06:48:38 +0200 Subject: [PATCH] chore: Claude/agent guidance and workspace files Co-Authored-By: Claude Opus 4.8 --- CLAUDE.md | 173 ++++++++++ INTEGRATION_NOTES.md | 130 ++++++++ files/anki-README.md | 136 ++++++++ files/dev-CLAUDE.md | 728 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 1167 insertions(+) create mode 100644 CLAUDE.md create mode 100644 INTEGRATION_NOTES.md create mode 100644 files/anki-README.md create mode 100644 files/dev-CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..993956d --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,173 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +Omnixient is a declarative NixOS configuration that builds a Hyprland-based desktop environment. It uses Nix flakes, home-manager, and a custom module system with feature flags and presets. + +**Repository**: https://github.com/TheArctesian/omnixy + +## Relationship to `~/dev` and `~/.claude` (read first) + +This dir configures **bohm** (the local dev box). It is NOT +`~/dev/deploy/server-deploy/` — that's a separate NixOS flake for the +deployed server fleet (host1, host2, host3, host4, host5, host6, +…). When the user says "rebuild" / "deploy" without context, ask +whether they mean local (this dir, `omni-rebuild`) or remote +(server-deploy + `deploy.sh`). + +Most files here are user-owned (no sudo needed). `hardware-configuration.nix` +is generated — don't hand-edit. + +### What this config provides to `~/dev` work + +The Omnixient config is the source of everything `~/dev` *expects* to be +on this machine: + +- **Global git pre-commit hook** — `modules/dev-env/scripts/git-hooks/pre-commit`, + wired into every repo via `core.hooksPath`. Secret-scanner with + `pragma: allowlist secret` markers. See user-global + `~/.claude/CLAUDE.md` for the false-positive workflow. +- **Home-manager systemd user units** — declared in `home.nix` + under `systemd.user.{services,timers}.*`. The refs weekly refresh + (`refs-refresh.timer`) lives here. +- **PATH, env, shell tooling** — `modules/dev-env/`, `home.nix` + `programs.*`, and `home.packages` / `environment.systemPackages`. +- **SSH host aliases** — `home.nix` `programs.ssh.matchBlocks` + (or wherever ssh is declared). +- **lazyvim-nix** — pinned in `flake.nix`, configured under + `programs.lazyvim` in `home.nix`. User-global CLAUDE.md has the + full plugin/config conventions. +- **`~/dev/CLAUDE.md`** — workspace-level Claude instructions for + the aiolabs / AIO stack. `~/dev/` itself isn't a git repo, so the + file lives at `files/dev-CLAUDE.md` here and is surfaced as a + mutable symlink via `home.file."dev/CLAUDE.md".source = + config.lib.file.mkOutOfStoreSymlink ...` in `home.nix`. Edit at + either path; commit from this repo. Use `files/` for any other + documents that conceptually belong outside `/etc/nixos` but have + no other repo to live in. + +### Cross-references + +- **`~/.claude/CLAUDE.md`** — user-global preferences, including a + "Machine config — `/etc/nixos`" section that mirrors this overview + but oriented for sessions started *outside* this dir. Loaded in + every session regardless of cwd. +- **`~/dev/CLAUDE.md`** — workspace orientation for the aiolabs / AIO + stack under `~/dev/`. Loaded when working there. + +## Key Commands + +```bash +# Build without switching (validate changes) +nixos-rebuild build --flake .#omni + +# Build and switch +sudo nixos-rebuild switch --flake .#omni + +# Test in VM +nixos-rebuild build-vm --flake .#omni + +# Check flake evaluation +nix flake check + +# Format / lint Nix code +nixfmt *.nix modules/*.nix # or: make fmt (find -name '*.nix' | xargs nixfmt) +statix check . +deadnix . + +# Build ISO +nix build .#iso +``` + +## Sandboxed claude sessions + +Two scripts launch claude under an allowlist+deny policy, so it can +work unattended on a target dir without being able to reach bohm's +running system: + +- **`scripts/sandbox-claude.sh []`** — generalized form. Targets + `` if given (any repo path on the machine), else the parent of + the script dir. Remaining args pass through to claude. Use this when + iterating on a separate repo (e.g. a new public template under + development). +- **`scripts/refactor-claude.sh`** — backwards-compatible wrapper for + the `~/nixos-refactor/` worktree of this repo. Equivalent to + `sandbox-claude.sh` with no args. + +Both bootstrap `/.claude/settings.json` fresh from +`scripts/sandbox-settings.json` on every launch — policy evolves in +the main `/etc/nixos` checkout and propagates on next launch. +Hand-editing the runtime copy is pointless (overwritten on relaunch); +`/.gitignore` should list `.claude/` (the script warns if not). + +### Policy (`scripts/sandbox-settings.json`) + +- `defaultMode=acceptEdits` — file edits silent. +- **allow**: narrow Bash set — `nix build|check|eval|run`, `nixfmt`, + `statix`, `deadnix`, in-worktree git (status/diff/log/add/commit/ + checkout/restore/stash/rev-parse/worktree list), `find`/`xargs`, and + basic inspection (`ls`, `pwd`, `which`, `type`, `env`, `echo`, + `file`, `stat`, `wc`, `head`, `tail`, `cat`). +- **ask**: `rm`, `rmdir`, `mv`, `cp`, `chmod`, `chown` — prompt every + time. +- **deny**: sudo, `nixos-rebuild switch|test|boot`, `omni-rebuild`, + `nh`, `git push`, `git remote`, `git reset --hard`, curl, wget, ssh, + scp, rsync, nc, systemctl, docker, podman, WebFetch, WebSearch. + +`deny` is hard-reject and cannot be overridden mid-session. To loosen, +edit `scripts/sandbox-settings.json` in main and relaunch. + +### Verification signal for an autonomous run + +- `nix flake check -L` — all checks +- `nix build .#checks.x86_64-linux.refactor-smoke -L` — headless VM + boot test scaffold at `tests/refactor-smoke.nix` +- `nixos-rebuild build --flake .#omni` — build only, no switch + +Merging a passing refactor is a manual step done from the main +`/etc/nixos` checkout, not from the sandbox. + +`omni-rebuild` itself now wraps `nh os switch --hostname omni +/etc/nixos` (see `60bd3f75`); the surface for users is unchanged but +`nh` is now also a system-wide command via `programs.nh`. + +## Architecture + +### Entry Points +- **settings.nix** — Single source of truth for user, gitName, gitEmail, hostName, timeZone. Imported by flake.nix and passed via `specialArgs` to all modules. +- **flake.nix** — Inputs (nixpkgs, home-manager, hyprland, nix-colors, stylix, neovim-nightly, NUR), outputs. Passes `settings` to NixOS and home-manager modules. +- **configuration.nix** — Thin entry point: imports all modules, sets per-host nix/networking/locale config, and `omni.*` options. Receives `settings` via specialArgs. +- **home.nix** — Home-manager user config. Uses `osConfig.omni.features` to gate packages by feature flag. Receives `settings` via extraSpecialArgs. +- **iso.nix** — Live ISO config (overrides user to "nixos"). + +### Module System (`modules/`) +All modules use `config.omni.*` options defined in `modules/core.nix`: +- **core.nix** — Defines options: `user`, `theme`, `preset`, `features.*`, `displayManager`, `colorScheme`, `wallpaper`. Presets auto-enable feature combinations via `mkDefault`. +- **lib.nix** — Exposes shared helpers via `config.omni.lib`: `isEnabled`, `userPath`, `getColor`, `withFeature`, `filterPackages`, `makeScript`, `paths`, `colors`. This is the only place helpers are defined — all modules access them through `config.omni.lib`. +- **packages.nix** — System packages gated by `cfg.features.*` and `cfg.packages.categories.*`. +- **services.nix** — Canonical location for all system services (pipewire, openssh, greetd, fstrim, thermald, avahi, etc.). Also owns polkit and AppArmor config. +- **boot.nix** — Boot loader, kernel, Plymouth, console config. +- **hardware/** — Conditional modules for Intel, AMD, NVIDIA, audio, bluetooth, touchpad. `default.nix` is the entry point with common hardware support. + +### Desktop (`modules/desktop/`) +- **hyprland.nix** — Uses home-manager's `wayland.windowManager.hyprland.settings` for structured, mergeable config. Also configures Waybar. +- **hyprland/autostart.nix** — `exec-once`/`exec` lists (themes and feature modules can append). +- **hyprland/bindings.nix** — Keybindings as `bind`/`binde`/`bindm`/`bindl` lists. +- **hyprland/idle.nix** — Uses HM `services.hypridle` and `programs.hyprlock` modules. + +### Theme System (`modules/themes/`) +Each theme is a standalone module that merges config into HM settings: +- Hyprland colors via `wayland.windowManager.hyprland.settings.general."col.active_border"` etc. +- Waybar CSS via `programs.waybar.style` +- Terminal colors (alacritty, kitty), editor themes (neovim, vscode), starship palette, lazygit, mako notifications, fzf colors, Firefox userChrome, GTK +- Only one theme is imported at a time (selected by `currentTheme` in configuration.nix) +- Use tokyo-night.nix as the template for new themes + +### Key Patterns +- **Feature flags**: `config.omni.features.coding`, etc. — guarded with `lib.mkIf` or `lib.optionals`. Both system packages (packages.nix) and user packages (home.nix) respect these. +- **Helpers access**: Always via `config.omni.lib` (never `import ./helpers.nix`). +- **User reference**: Always via `config.omni.user` in NixOS modules, `settings.user` in home.nix/flake.nix. +- **Theme switching**: Change `currentTheme` in configuration.nix, then rebuild. +- **Structured Hyprland config**: Themes merge attrsets into `wayland.windowManager.hyprland.settings` — no raw config files in `/etc/`. diff --git a/INTEGRATION_NOTES.md b/INTEGRATION_NOTES.md new file mode 100644 index 0000000..0d57c47 --- /dev/null +++ b/INTEGRATION_NOTES.md @@ -0,0 +1,130 @@ +# nixos-dev-setup integration notes + +Status: wired in, evaluates cleanly, ready to build once a pre-existing +unrelated bug is fixed. + +## What was integrated + +From `~/Work/tries/2026-04-06-nixos-dev-setup` (tagged `v0.1.0`): + +- `lib/mksystem.nix` — uniform host constructor +- `lib/overlays.nix` — nixpkgs-unstable pinning for fast-moving pkgs +- `modules/cache.nix` — substituters + trusted-public-keys +- `modules/dev-env/` — full dev environment module (options, config, + lib, bash helpers, aiolabs preset, git hooks, runbooks) +- `Makefile` — `switch`/`test`/`build`/`cache`/`check`/`clean`/`fmt` +- `hosts/omni/default.nix` — host-specific dev-env settings +- `users/user/home-manager.nix` — shim forwarder for home.nix + +## What changed in existing omni files + +- `flake.nix` + - Added `nixpkgs-unstable` and `deploy-flake` inputs. + - Outputs rewritten to use `mkSystem` for the `omni` host. + - `omni-iso` left as a direct `nixosSystem` call (variant, not host). + - Overlays (lib/overlays.nix + nur + neovim-nightly) now applied to + nixosConfigurations as well as devShells/packages/apps. + +- Nothing else was edited in place. `configuration.nix` and `home.nix` + are still at the top level, reached via shim files in `hosts/omni/` + and `users/user/`. + +## Integration commit sequence + +``` +2f875a9 enable dev-env + aiolabs preset; fix options.nix interpolation escapes (phase E) +81ecc6c wire flake.nix outputs through mkSystem (phase D) +103fe0e extend mksystem with extraHmArgs for host-specific HM extras +dc9d70b add mksystem shims for hosts/ and users/ (phase C) +2fe2ba0 add nixpkgs-unstable + deploy-flake inputs (phase B) +c9b7db5 import nixos-dev-setup module (phase A: additive copy) +f18d2c0 fix: VM boot support and documentation update ← pre-dev-env-integration tag +``` + +Rollback: `git reset --hard pre-dev-env-integration` + +## Verification + +Last successful eval sequence on current master: + +```bash +# Top-level host name — verifies module tree assembles +nix eval .#nixosConfigurations.omni.config.system.name + # → "omni" + +# Project list from aiolabs preset — verifies dev-env schema loads +nix eval --json \ + .#nixosConfigurations.omni.config.dev-env.projects \ + --apply 'builtins.attrNames' + # → 18 projects incl. lightning-pub, lnbits, webapp, etc. + +# Nested worktree paths — verifies submodule wiring +nix eval --json \ + .#nixosConfigurations.omni.config.dev-env.projects.lightning-pub.worktrees + # → 7 worktree entries with computed path/branch/remote + +# Runtime config file rendering — verifies config.nix +nix build --no-link --print-out-paths \ + '.#nixosConfigurations.omni.config.environment.etc."dev-env/config.sh".source' + # → /nix/store/.../dev-env-config.sh with DEV_ROOT, FORGEJO_*, etc. + +# Project manifest rendering — verifies dev-env-bootstrap input +nix build --no-link --print-out-paths \ + '.#nixosConfigurations.omni.config.environment.etc."dev-env/projects.json".source' + # → /nix/store/.../dev-env-projects.json — full project tree with + # bare paths, remotes, and worktree paths resolved +``` + +All the above work end-to-end. The dev-env pipeline (options → config +rendering → runtime files) is validated. + +## Known pre-existing issue: blocks final build + +`modules/desktop/hyprland.nix:112` references `thunar` as an unqualified +package name inside a `with pkgs;` scope. `thunar` is not a top-level +attribute — it lives at `pkgs.xfce.thunar` — so evaluation of +`config.system.build.toplevel` fails with: + + error: undefined variable 'thunar' + +This bug exists at the `pre-dev-env-integration` tag too. It is +**not caused by this integration**. Verified by: + +```bash +git stash -u +git checkout pre-dev-env-integration -- . +nix eval .#nixosConfigurations.omni.config.system.build.toplevel.drvPath + # → same 'undefined variable thunar' error +git checkout master -- . +git stash pop +``` + +To move past it, fix that line (change `thunar` to `xfce.thunar` or +remove it if unused). That is out of scope for the dev-env integration. + +## After the thunar fix + +Once the pre-existing issue is resolved, run: + +```bash +make test HOST=omni # nixos-rebuild test, no boot entry change +make switch HOST=omni # actual switch +dev-env-bootstrap --dry-run +dev-env-bootstrap +lb dev # navigate to ~/user/dev/lnbits/dev (won't exist until bootstrap runs) +``` + +## Open follow-ups (not blocking) + +- [ ] Fix `thunar` → `xfce.thunar` in `modules/desktop/hyprland.nix` +- [ ] Rename `users/user/` → `users//` once `settings.user` + is set to something other than `"user"` +- [ ] Inline the shim content (move `configuration.nix` body into + `hosts/omni/default.nix`, `home.nix` body into + `users//home-manager.nix`) — cosmetic cleanup +- [ ] Push `optimize-deploys/unified` to forgejo so `deploy-flake.url` + can move from `git+file://` to `git+ssh://forgejo@.../deploy-unified` +- [ ] Set up the `aiolabs-nix` cachix cache and uncomment the + substituter + trusted-public-key lines in `modules/cache.nix` +- [ ] Add a smoke-test flake to the staging repo so options.nix + interpolation bugs (or similar) don't escape again diff --git a/files/anki-README.md b/files/anki-README.md new file mode 100644 index 0000000..9527d83 --- /dev/null +++ b/files/anki-README.md @@ -0,0 +1,136 @@ +# ~/Anki — spaced-repetition source tree + +This directory is a **source/staging area** for Anki cards, not Anki's +collection. The actual collection (SQLite DB, media, scheduling state) lives +at `~/.local/share/Anki2//collection.anki2` and is managed by Anki +itself. Files here are plain-text card sources you author/edit/diff, then +import into Anki when you want to sync them up. + +## Layout + +``` +~/Anki/ +├── README.md this file (symlinked from /etc/nixos/files/anki-README.md) +├── dev/ +│ └── cards.csv developer jargon + concepts (GitHub PM lingo, TLV, …) +├── french/ +│ └── cards.csv French vocab + phrases, tagged for export by topic +├── protocols/ +│ └── cards.csv decentralized protocols (BTC, Lightning, Nostr — BIPs, NIPs, …) +├── exports/ .apkg files generated to share with others +└── sources/ raw clippings/articles you're mining cards from (scratch) +``` + +Add more top-level deck dirs as new topics show up (`mkdir ~/Anki/ && +cp ~/Anki/dev/cards.csv ~/Anki//cards.csv` to clone the header). + +## CSV format + +Each `cards.csv` starts with Anki import directives so File → Import in Anki +needs zero clicks to configure: + +``` +#separator:Comma +#html:false +#deck:Dev +#notetype:Basic +#tags column:3 +``` + +Then one row per card: `Front,Back,Tags` — tags are space-separated within +the column. Quote fields that contain commas. + +Example rows: + +``` +What does "cutting a release" mean?,Creating a new versioned release \ +branch/tag from main — the point at which the codebase is frozen for that \ +version.,jargon github-pm +TLV (in protocol design),"Type-Length-Value: a framing scheme where each \ +record carries its type tag, byte length, then payload. Self-describing, \ +extensible, used in BER/DER, Lightning Network messages, etc.",concepts \ +networking +``` + +## Adding cards via Claude + +Ask Claude in natural language: + +> Claude, add a card explaining "dogfooding" to dev/cards.csv + +Claude appends a properly-escaped row with sensible tags. You review the +diff, commit if you version this dir, then re-import into Anki when ready. + +## Importing into Anki + +1. Open Anki. +2. **File → Import** → pick `~/Anki//cards.csv`. +3. The header directives drive the import — confirm the deck name and + notetype shown in the dialog, then click Import. +4. Anki uses the **first field** (Front) as the duplicate key, so re-importing + the same CSV after appending new rows updates existing notes and adds new + ones — no duplicates. + +## Exporting a tag-filtered subset (e.g. French → farming, for an employee) + +Anki's main File → Export dialog targets whole decks, not tags. To slice by +tag: + +1. **Tools → Create Filtered Deck** (Ctrl+Alt+N). +2. Search: `deck:French tag:farming` (combine tags with `OR` /`-tag:` as + needed). +3. Anki creates a temporary deck containing just those cards. Filtered decks + are references, not copies — originals are untouched. +4. **File → Export** → pick the filtered deck → **Anki Deck Package + (.apkg)** → **uncheck "Include scheduling information"** so the recipient + starts fresh. +5. Save to `~/Anki/exports/french-farming.apkg`. Hand to the recipient; they + double-click or File → Import into their own Anki. +6. Delete the filtered deck after (Decks page → cog → Delete). Originals + stay put. + +## Decks vs tags — which to use + +- **Decks** are the hierarchy you see on Anki's home screen. Cards live in + exactly one deck. Use a deck for **a domain you study as a unit** (Dev, + French). +- **Tags** are orthogonal labels. A card can have many. Use tags for + **slices within a deck** you'll want to filter on: `farming`, `livestock`, + `gardening`, `jargon`, `concepts`, `networking`, `github-pm`. + +Splitting `french/farming/`, `french/livestock/` into separate folders would +duplicate this structure outside Anki and lose the cross-cutting filter +power — keep one CSV per language/topic and let tags do the slicing. + +## Suggested tag vocabulary + +- **dev**: `jargon`, `concepts`, `github-pm`, `networking`, `nix`, `git`, + `security` — add as you go. +- **french**: `general`, `phrases`, `farming`, `livestock`, `gardening`, + `kitchen`, `weather` — pick one per row at minimum, add specifics as + needed. +- **protocols**: tag every card with **at least one ecosystem tag** plus + optionally a **spec tag** and a **topic tag**: + - ecosystem: `bitcoin`, `lightning`, `nostr` (a card on a Lightning BOLT + that touches both Bitcoin and LN can carry both) + - spec: `bip-NN` (e.g. `bip-32`, `bip-39`, `bip-340`), `nip-NN` (e.g. + `nip-01`, `nip-04`, `nip-46`), `bolt-NN` for Lightning BOLTs + - topic: `cryptography`, `consensus`, `mempool`, `script`, `signing`, + `relay`, `keys`, `events`, `bunker` + - Bitcoin + Nostr overlap a lot (Schnorr/`bip-340`, key derivation, hash + functions). Cross-ecosystem cards get both ecosystem tags so they + surface from either filter. + +Be conservative: every new tag is a slice you commit to maintaining. Reuse +existing ones unless a new dimension genuinely needs splitting. + +## Versioning this dir (optional) + +`~/Anki/` is not under version control by default. If you want history: + +``` +cd ~/Anki && git init && echo 'exports/*.apkg' >> .gitignore +``` + +The CSV headers + plain-text rows diff cleanly. `.apkg` exports are +artifacts and don't belong in git. diff --git a/files/dev-CLAUDE.md b/files/dev-CLAUDE.md new file mode 100644 index 0000000..efaf22c --- /dev/null +++ b/files/dev-CLAUDE.md @@ -0,0 +1,728 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +> **Source-of-truth path:** `/etc/nixos/files/dev-CLAUDE.md`. `~/dev/CLAUDE.md` +> is a home-manager `mkOutOfStoreSymlink` to here (`/etc/nixos/home.nix`). +> `~/dev/` is not a git repo. Edits at either path take effect immediately +> (no rebuild needed), but **commit from `/etc/nixos`**. + +## What this directory is + +`~/dev/` is **not** a single repo — it's a workspace of sibling projects for +the aiolabs / atitlan.io stack (Lightning + Nostr). Most projects are +forgejo-hosted at `git.atitlan.io`. Per-project CLAUDE.md files are the +source of truth for each repo; this file holds workspace-level facts that +cross-cut multiple repos. + +## Layout convention + +Bare repos live under `repos/.git/` and are checked out as +**worktrees** named after the branch (or purpose): `lnbits/main/`, +`lnbits/dev/`, `lamassu-next/lightning-pub/`, etc. Single-worktree projects +typically use `/main/` (`host5-home/main`, `quartz-module/main`, +`webapp-module/main`, `fava-module/main`). + +## Per-project CLAUDE.md (defer to these) + +- `webapp/CLAUDE.md` — Vue 3 + Vite + Electron client (Nostr + Lightning). + Heavy guidance on modular DI architecture, Shadcn forms, mobile file-input + defenses. Read before touching anything under `webapp/`. +- `bitspire/bitspire/CLAUDE.md` +- `quartz-module/main/CLAUDE.md` +- `docs/castle-docs/CLAUDE.md` + +## Key cross-cutting locations + +- **`deploy/server-deploy/`** — Unified NixOS infrastructure flake. Consumes + project repos here as flake inputs (`url = "git+ssh://...aiolabs/"`). + See `flake.nix` for the canonical map of which repos feed which hosts. +- **`local/docker/regtest/`** — Local multi-node Lightning regtest stack + (LND/CLN/Eclair + bitcoind/electrs/boltz/fava). See `local/README.md`. + **Default lnbits dev path is FakeWallet — no docker needed.** Only spin up + regtest when testing real channels/payments. + + **`LNBITS_SRC` branch awareness:** the dev compose builds lnbits from + `${LNBITS_SRC:-~/dev/lnbits/main}`. If `LNBITS_SRC` points elsewhere + (e.g. a feature branch), commits to `lnbits/main` **don't reach the + dev image even after `--no-cache` rebuild**. Verify resolved context + with `docker compose config | grep -A2 lnbits` before assuming a + rebuild picked up your patch. + + **Extension folder = LNbits install target:** the compose mounts + `~/dev/shared/extensions/` at `/shared` and sets `LNBITS_EXTENSIONS_PATH= + /shared`, so the extension git checkout *is* the installed extension. + Clicking "Upgrade" in the LNbits UI extracts the catalog archive + directly over the checkout, wiping `.git`. Mitigation: aio semver + MUST beat upstream tag (see catalog rules). +- **`refs/`** — Curated mirrors of upstream reference codebases (Beancount, + Lemmy, LNbits, LND/CLN/Eclair, nostr-tools, khatru, …) plus weekly + digests. Manifest `refs.toml`; refresh via `bin/refresh`, digest via + `bin/digest`. Layout: `repos///`. Use for grep/browse + — *don't* import for builds; pin via flake input or submodule instead. +- **`shared/extensions`, `shared/lamassu-server`, `shared/nix-bitcoin`** — + shared paths used across multiple worktrees. +- **`upstream-prs/`** — branches staged for upstream contribution. +- **`lnbits-extensions/`** — extension catalog (`extensions.json`). + +## Working in this workspace + +- Treat each subdirectory as its own repo with its own conventions, branches, + remotes. Don't assume changes in one project belong with changes in another. +- Many lnbits worktrees share the same bare repo — be mindful which branch + you're on, especially when running migrations or touching shared state. +- The `webapp` repo's `dev` branch is the staging channel for + `demo.aiolabs.dev`; `main` feeds production. Dev = staging, not "demo". + +### Webapp release flow + +1. Push to `aiolabs/webapp` **`dev`** (not main). +2. Bump `webapp-dev` input in `deploy/server-deploy/flake.lock` + (`nix flake lock --update-input webapp-dev`). Tracks `dev`. +3. Deploy to `host4` (`./deploy.sh host4`). Smoke as staging vet. +4. Once vetted: fast-forward `aiolabs/webapp` **`main`** to dev's commit + + bump the `webapp` (not `webapp-dev`) input in `flake.lock`. + +**Never push runtime webapp changes directly to main.** If you find +runtime commits on main that should have gone through dev, **stop and +notify the user** — recovery is destructive and needs explicit auth. + +**Carve-out: non-runtime / tooling-only changes can go direct to main + +rebase dev on top.** Root-level dotfiles and dev tooling that don't ship +in the Vite/Electron bundle (`.mcp.json`, editorconfig, CLAUDE.md, root +README) have nothing to vet on staging. Pattern: commit on main, push, +then `cd ~/dev/webapp/dev && git fetch && git rebase origin/main && +git push --force-with-lease`. Still PR for anything touching `src/`, +`electron/`, `package.json`/`pnpm-lock.yaml`, build config. + +- Production-bound changes need a corresponding `flake.lock` bump in + `deploy/server-deploy/` to actually reach a host. +- **Git staging discipline:** never `git add -A` / `git add .` — list + filenames explicitly. Hard rule from a 2026-05-14 incident that leaked + an LNbits auth key. + +--- + +## aiolabs Forgejo conventions + +### Fork versioning (lnbits + extensions) + +Universal across all LNbits-derived aio forks: + +- **Tag scheme:** `v-aio.`. Examples: + `v1.5.4-aio.1`, `v1.3.0-aio.2`. Bump upstream segment on rebase, reset + patch counter to `1`. +- **Hyphen pre-release suffix on tags + extension `config.json`**, NOT + `+aio.N` build-metadata. Confirmed across multiple bumps. +- **EXCEPTION: lnbits `pyproject.toml` requires PEP 440 → `+aio.N` there + only.** Hyphen-suffix isn't PEP 440-valid. Same release, two spellings: + git tag `v1.5.4-aio.1`, `pyproject.toml` `1.5.4+aio.1`. The latter is + what `importlib.metadata` returns and the UI footer displays. +- **Tag at deploy-ready boundaries, not every commit.** Tags mark vetted + states; direct-commit-to-main is fine, but don't tag broken intermediate + commits. +- **Lint pipeline** for aio forks: black + mypy + prettier + ruff. + +For `aiolabs/lnbits`: `deploy/server-deploy` pulls lnbits as a flake input +pinned by commit. The tag is a human-readable label, not a functional +dependency — but makes `git log --decorate` and archaeology vastly clearer. + +### `aiolabs/lnbits-extensions` catalog rules + +`extensions.json` is consumed at runtime by LNbits via +`LNBITS_EXTENSIONS_MANIFESTS`; archives are sha256-pinned per entry. + +**The catalog repo is NOT tagged.** It's a curated manifest, not a fork. +Deploy reads it live from `…/raw/branch/main/extensions.json`; audit trail +is `git log` on main, rollback is `git revert`. + +- **Production points ONLY at our manifest, never upstream.** The deployed + `LNBITS_EXTENSIONS_MANIFESTS` points at + `git.atitlan.io/aiolabs/lnbits-extensions/raw/branch/main/extensions.json` + (`deploy/server-deploy/modules/services/lnbits.nix:73`). Production + users can only install / upgrade what we've curated. Cycle: upstream + releases → vet in dev → rebase to aio → update our manifest. Never add + `github.com/lnbits/` URLs for an extension with an active aio fork. +- **Don't overwrite a version entry in place.** When bumping, *add a new + entry* alongside the old — instances running the previous version need + it to remain resolvable. +- **Aio semver MUST beat the upstream tag we're forking** (dev hygiene). + Regtest compose doesn't override `LNBITS_EXTENSIONS_MANIFESTS`, so dev + sees LNbits's built-in default catalog AND our manifest. LNbits's + "upgrade" picks highest semver across all visible entries; if upstream + outranks our `-aio.N`, clicking Upgrade wipes our `.git`. Always bump + aio fork to `v-aio.1` immediately on rebase. +- **Catalog bumps don't require a `flake.lock` bump.** LNbits fetches the + manifest live; nix doesn't bake archives in. Explicit exception to the + "production-bound changes need a flake.lock bump" rule. + +### Extension version-bump procedure + +When user says "bump and update lnbits-extensions": + +1. Sanity-check ext repo: clean tree, on `main`, `git log ..main` + matches intended release. +2. Choose semver bump from the diff. +3. **Push the branch first** (force-push after rebase OK for solo-maintainer + forks). Origin/main is recoverable; the tag isn't. +4. **Test locally on dev LNbits BEFORE tagging.** Restart regtest to load + new on-disk code (or uninstall+reinstall via UI); smoke happy paths, + watch logs for migrations + exceptions. Once installs pull the catalog, + bad tags are hand-fix-every-install messy. +5. Tag at HEAD and push: `git tag v… && git push origin v…`. +6. Fetch archive, compute sha256: `curl -sL .../archive/v….zip | sha256sum`. +7. Add a new entry to `aiolabs/lnbits-extensions/extensions.json` (don't + overwrite the old). +8. Commit + push catalog. Live immediately — no flake.lock bump. + +### Forgejo issue labels (aiolabs/webapp) + +When creating issues on `aiolabs/webapp`, apply labels via +`add_issue_labels` immediately on creation. Two axes: +- **App scope:** `app:activities`, `app:webapp`, `app:marketplace`, + `app:wallet`, etc. +- **Issue type:** `type:bug`, `type:feat`, `type:chore`, etc. + +### PR flow on aiolabs-forked extensions (default since 2026-06-12) + +For aio LNbits ext forks (`aiolabs/lnurlp`, `aiolabs/withdraw`, +`aiolabs/events`, `aiolabs/libra`, …), **feature-branch + PR is the +default flow** — these extensions move real money and feed production +instances, putting them in the same review category as `aiolabs/lnbits` +and `aiolabs/webapp`. + +**Why the flip** (was direct-commit-to-main while single-maintainer): +a libra refactor regression (typed entry links breaking the approval +flow, libra-#42) shipped straight to a production-feeding main on +2026-06-12. The PR gate buys a review pause + a place for CI/tests +before main moves. + +Flow: branch → push → open PR (creation via MCP is fine) → **hand off +to the user to merge via the Forgejo web UI** (see PR-merge rule in +user-global CLAUDE.md — the MCP merge endpoint is unreliable and the +user has a final-review ritual). Trivial typo/docs-only commits can +still ride direct-to-main at the user's discretion — ask if unsure. + +(L3 Forgejo branch protection on main for these repos is the parked +enforcement follow-up, same as for lnbits/webapp.) + +### Issue closing — manual, not commit-keyword auto-close + +Do NOT use Forgejo closing keywords (`Closes #N`, `Fixes #N`) in commit +messages on aiolabs repos. Reference issues as prose (`(libra-#42)` in +the subject, plain `#42` in the body) and **close the issue manually** +after the fix lands. + +**Why:** auto-close fires when the commit hits main, but on this stack +"on main" ≠ "deployed" — issues should stay open (or at least be closed +deliberately) until the fix actually reaches the affected instance. +Confirmed by user 2026-06-12 (libra-#42). + +### aiolabs identity vs upstream + +Forks live on Forgejo at `git.atitlan.io/aiolabs/*`. User's handle there +is `padreug`. Fork-internal references (contributors, repo URLs, PR/issue +links) use Forgejo + `padreug`. GitHub handle (`your-github-username`) is separate +— only for upstream PRs. Identity-rewrite details in user-global +`~/.claude/CLAUDE.md`. + +--- + +## LNbits extension development + +### Auth decorators + +| Decorator | Auth scope | Returns | +|---|---|---| +| `require_invoice_key` | Wallet invoice key — read access | Wallet | +| `require_admin_key` | Wallet admin key — write to **own wallet only** | Wallet | +| `check_admin` | **LNbits admin user** (super_user + lnbits_admin_users), Bearer | Account | +| `check_super_user` | LNbits super user only, Bearer | Account | + +`require_admin_key` is **easily confused** with `check_admin` and means +something very different. The first is a wallet-level write key (any user +can have one for their own wallet); the second is LNbits-instance admin +access. Use `check_admin` for cross-user / admin-only operations. + +### Testing + +Use FakeWallet (`LNBITS_BACKEND_WALLET_CLASS=FakeWallet`) for extension +CRUD / API / UI tests. Spin up full regtest only when end-to-end Lightning +payment behavior is actually under test (regtest costs build + container +time and adds no fidelity for non-payment flows). + +### Fork-migrations pattern (`migrations_fork.py`) + +Per `aiolabs/lnbits#8` — fork-only schema deltas go in `migrations_fork.py` +alongside the upstream-tracked `migrations.py`, loaded by our patched +`migrate_extension_database()` under `_fork` in `dbversions`. Keeps +upstream rebases conflict-free for the migration file. + +**Architecture facts:** + +- `dbversions` lives in the **core LNbits DB** (`database.sqlite3`), not + per-extension. Schema: `(db TEXT PRIMARY KEY, version INT)`; + `update_migration_version` is INSERT-OR-UPDATE so a new `_fork` + row appears on first run with no core-side migration. +- Extension data tables live in `ext_.sqlite3` (SQLite) or a Postgres + schema named after ``. Created lazily on first `Database.connect()`. +- **No cross-DB atomicity.** Extension migration commits to + `ext_.sqlite3`; `dbversions` upsert commits to `database.sqlite3`. + If extension write succeeds and dbversions fails, migration is orphaned + and re-runs on next startup. **Every migration MUST be idempotent** + (`_alter_add_column_safe`, `CREATE TABLE IF NOT EXISTS`, etc.). + +**Squash recipe for adopting the pattern on existing forks:** + +1. Restore `migrations.py` to upstream-byte-identical (drop fork-only + functions and any helpers added for them). +2. Create `migrations_fork.py` with a single `m001_aio__schema` + that idempotently applies every fork-only delta the old migrations did. +3. Use `_alter_add_column_safe` per ALTER and `CREATE TABLE IF NOT EXISTS` + per table — no-ops cleanly on installs that already ran old migrations. + +**One-time fix on installs adopting the pattern AFTER previously running +old fork migrations:** their `dbversions['']` row is ahead of upstream +(e.g. `events|11`). After moving to `migrations_fork`, the next upstream +rebase that adds e.g. `m007` would compare `7 > 11 → false` and silently +skip. **Reset the row before the rebase lands:** + +```sql +-- Run against the core DB, not the extension DB. +UPDATE dbversions SET version = WHERE db = ''; +``` + +Containerized: `docker compose exec lnbits python3 -c "..."` since the +file is root-owned inside the container. + +**Upstream-overlap at rebase time:** if upstream eventually adds a schema +change we already carry in `migrations_fork.py`, fresh installs work (our +guards swallow dups) but **existing installs crash** when upstream's +non-idempotent migration runs. Mitigation: + +1. **Prune the redundant block from `migrations_fork.py`** so future fresh + installs get the column from upstream. +2. **Pre-deploy `dbversions` surgery on affected installs:** + `UPDATE dbversions SET version = WHERE db = ''`. + +Don't patch upstream's migration with idempotency guards in our fork — +breaks the "migrations.py == upstream byte-identical" property. + +**Upstream PR follow-up:** the extension loader change in +`migrate_extension_database()` is upstreamable as a sibling to the +in-flight `core_fork` PR (`your-github-username/lnbits @ add-fork-migrations-namespace`). +Fork-internal patch for now. + +### Settings precedence — env seeds DB on first boot, then DB wins + +LNbits has two sources of truth for settings depending on lifecycle, and +the switch happens automatically. Verified 2026-05-24 against `~/dev/lnbits/main`. + +**On boot with `lnbits_admin_ui=True`** (`lnbits/core/services/users.py: +231-247`, `check_admin_settings`): +1. Read DB row via `get_super_settings()`. +2. If DB empty → seed from `.env` via `init_admin_settings()` (first-boot + only). +3. `update_cached_settings(settings_db.dict())` overwrites in-memory + `Settings` with the DB row. **`.env` values loaded by Pydantic at + startup are clobbered.** + +**Practical consequence:** once an instance has booted once, editing +`.env` and restarting **changes nothing** for editable fields. Change via +Admin UI (`PUT /api/v1/settings`, gated by `check_admin`) or by clearing +relevant `system_settings` table rows. + +**Exceptions where `.env` still wins every boot:** +- `super_user` — env overrides DB at `users.py:243-245`. +- `lnbits_admin_ui=False` — DB-load block skipped entirely. +- All `ReadOnlySettings` fields (`settings.py:1132` + parents): `host`, + `port`, `lnbits_extensions_path`, `lnbits_path`, `lnbits_title` (API + title — NOT `lnbits_site_title` which is editable), `lnbits_data_folder`, + `lnbits_database_url`, `auth_secret_key`, `first_install_token`, + `lnbits_admin_ui`, `lnbits_allowed_funding_sources`. + `update_cached_settings` skips any key in `readonly_variables`. Editable + settings (site title/tagline, theme, watchdog, fee defaults, rate limits, + per-funding-source credentials, the whole Admin UI form) get DB-frozen. + +**`LNBITS_FIRST_INSTALL_TOKEN` rotation does NOT reset settings to env.** +It creates a new super_user account with a fresh UUID and sets +`settings.first_install = True`, re-enabling `/first_install` for a +locked-out admin to re-claim the instance. No env values flow back through. + +**Deploy-side consequence for `deploy/server-deploy/modules/services/ +lnbits.nix`:** editable env vars only take effect on fresh install +(empty `settings` table). For existing deploys, "set it in nix, redeploy, +done" only works for `ReadOnlySettings` fields. + +--- + +## Nostr architecture + +### Patterns reference is the source of truth + +Before writing or reviewing any Nostr-related code in `~/dev/webapp/`, +read **`docs/nostr-patterns/`** first. After implementing or refining a +pattern (or fixing a subtle Nostr bug), **update the relevant topic file +in the same commit**. If new, add to the index. Drift between reference +and code defeats the purpose. + +### Reference implementations (curated mirror) + +`~/dev/refs/repos/nostr///` holds upstream nostr codebases +worth grepping for patterns: `nostr-protocol/nips`, `fiatjaf/khatru`, +`fiatjaf/nostr-tools`, etc. Refresh weekly via `~/dev/refs/bin/refresh`; +digest under `~/dev/refs/digests/`. For new features: check +`docs/nostr-patterns/` (internal) → then `refs/repos/nostr/` (external). + +### Key in-flight initiatives + +- **LNbits nostr transport** — `aiolabs/lnbits` PR #4 + (`nostr-native-transport` branch). NIP-44 v2 encrypted kind-21000 RPC + over relays, modeled after Lightning.Pub. Lets HTTP-allergic clients + (ATMs, kiosks behind NAT) reach LNbits through commodity relays. +- **Lightning.Pub reference** — `~/dev/lamassu-next/lightning-pub/`. Key files: + `extension-loader/proto/autogenerated/ts/nostr_transport.ts`, + `extension-loader/src/services/nostr/nostrPool.ts`. Patterns: dual NIP-44 + v1+v2 support, content sharding for large messages. + +### Long-term direction + +The eventual goal is webapp + LNbits extensions communicating **exclusively +over Nostr** — eliminating HTTP. Prefer architectural decisions that move +toward Nostr-native. Don't create HTTP-only patterns that will need to be +ripped out. The nostr transport PR (#4) is the first concrete step. + +### Bunker for everything: no nsec at rest on LNbits + +Once `aiolabs/lnbits#18` (NIP-46 bunker integration) lands, **every nsec +on the LNbits host gets retired** — operator users AND the server identity. +There is no two-tier endgame where users go through bunker and the server +keeps `NOSTR_TRANSPORT_PRIVATE_KEY` on disk. + +The plausible-looking carve-outs (server "conceptually different," boot +bootstrap complexity, latency, failure modes) don't survive the threat +model: the LNbits host runs extension code, payment plumbing, a public API +— disk/root access there must NOT equal Nostr-identity compromise. + +**Concrete rules:** +- Every signing call routes through the signer abstraction + (`lnbits.core.signers.resolve_signer` from `#17`). Impls: `LocalSigner` + (envelope-encrypted at rest, **transitional**), `ClientSideOnlySigner` + (operator-driven, no server signing), `RemoteBunkerSigner` (NIP-46 via + `#18` — endgame). +- No `if server_is_special:` branch anywhere. Server identity is one more + account from the signer's perspective. +- `NOSTR_TRANSPORT_PRIVATE_KEY` is acceptable as a transitional env-pinned + source for a `LocalSigner` adapter, but with an **explicit sunset**. +- For extension code signing on behalf of operators: same abstraction. The + hybrid pattern at `aiolabs/spirekeeper` commit `e13178d` is the + template (try-import `resolve_signer`, fall back to direct prvkey on + pre-`#17` lnbits, both produce identical signed events). + +If tempted to keep static nsec "because it's just the server" or "because +bunker isn't ready yet," push back: the whole point of bunker is removing +static nsec from the LNbits host. + +### Respect protocol semantics over friction reduction + +When picking a transport / event-kind / message-format for a new feature, +the protocol's intended use case wins over "an existing listener happens to +fire." Quick-fix transport choices get locked in by callers and are harder +to migrate later. + +**How to apply** — before wiring a new feature onto an existing listener: + +1. Name the relationship the feature serves (ATM↔Customer, ATM↔LNbits, + ATM↔Operator, Operator↔User, LNbits↔Bunker, …). +2. Ask "what is this protocol / kind / RPC *for*?" +3. If answers don't align, design or pick the right primitive even if a + wrong-purpose listener already exists. + +Exception: explicit, time-bounded stopgaps with a written migration path. + +**Worked example (2026-05-29 / `aiolabs/bitspire#56`).** Cassette-config +publishing was nearly routed through `clink.onManagement` (kind-21003) +because the listener existed. CLINK is a payment-flow protocol; repurposing +would have conflated payment + operator-config concerns. The right +primitive was kind-30078 (NIP-78 replaceable, NIP-44 encrypted, +`["p", atm_npub]`-tagged). + +### Nostr kind allocations — avoid the CLINK band (21001–21003) + +The Nostr ephemeral range (20000–29999) is technically unallocated but +several application-protocol families have squatted on specific kinds. +**CLINK has claimed 21001 / 21002 / 21003** for Offers / Debits / Manage +(see `refs/repos/shocknet/shocknet/CLINK/`). We want CLINK-compat preserved +as we adopt it for ATM cash-in/cash-out flows. + +**Rule: kind:21001–kind:21099 is OFF-LIMITS for aiolabs-specific events.** +CLINK may add adjacent kinds; on-the-wire collisions with +`["clink_version"]`-tagged events aren't worth the namespace-squatting +savings. + +**Suggested aiolabs band: 22000–22099** for non-replaceable application +events specific to our stack. Pick incrementally; document each allocation +here so future sessions don't reuse a number. + +**Settlement-receipt rotation (2026-06-02):** kind:21001 was originally +locked in for bitSpire settlement receipts (`aiolabs/lnbits#22` + +`aiolabs/spirekeeper#11`) before CLINK was in scope. Collision found +during a CLINK primer review. Settlement receipts will land on a non-21001 +kind before either PR ships; rotation plan tracked on `aiolabs/spirekeeper#20`. + +**Replaceable events (kind:30000–39999) are a separate namespace.** +Kind:30078 (NIP-78 application-specific data) is fine for fee_config, +cassette state, fleet roster — by-design replaceable state-of-the-world +events. + +### Standalone app pattern (webapp) + +Modules can be deployed as standalone PWAs alongside the main webapp via a +**second Vite entry point**. Used for `activities` (sortir); may replicate +for marketplace / wallet / others. + +For a new standalone app from an existing module: +1. **HTML entry**: `.html` at project root → `src/-app/main.ts`. +2. **App shell**: `src/-app/{main.ts, App.vue}`. +3. **Vite config**: `vite..config.ts` with its own build target + and PWA scoping (see service worker rules in `webapp/CLAUDE.md`). +4. **npm script**: `dev:` and (for the demo host) a roll-up. + +The standalone Vue frontend lives in `webapp/` alongside other AIO +standalone apps (chat, forum, market, tasks, wallet, activities, libra) — +the corresponding extension repo (`~/dev/shared/extensions//` if any) +holds only the LNbits backend Python code. + +--- + +## Matrix homeserver: Continuwuity + +Self-hosted Matrix homeserver in the Rust/conduwuit lineage. Lives in +`deploy/server-deploy/modules/services/matrix.nix` for castle hosts that +opt into `services.matrix-stack.enable`. Sibling stack: LiveKit SFU, +lk-jwt-service, Element Web, Element Call SPA, `.well-known/matrix/` +delegation on the apex. + +### Required config to avoid quiet breakage + +- **`well_known.client` + `well_known.server`** — without these, + Continuwuity uses `server_name` (the apex) when generating user-facing + URLs like password reset links. Set to `https://matrix.${domain}` and + `matrix.${domain}:443` to mirror the JSON nginx serves at + `/.well-known/matrix/` on the apex. + +- **`allow_registration = true` is safe.** It does NOT mean open + registration. Without `registration_token`/`_file` AND without the + `yes_i_am_very_very_sure_i_want_an_open_registration_server_prone_to_abuse` + flag, Continuwuity rejects self-serve signups. + +### First-user bootstrap + +When no admin exists yet, Continuwuity auto-generates a one-time +registration token at startup and prints it to journalctl. This +**overrides** any `registration_token_file` you might wire up. After the +first user registers, they're auto-promoted to admin. + +### Admin commands + +All admin ops happen in the auto-created control room via `!admin +`. Groups: `token`, `users`, `appservice`, `rooms`, `federation`, +`media`, `server`. Full reference: +. + +Token issuance flags are **mutually exclusive** — pick exactly one of +`--once`, `--max-uses N`, `--max-age <30s|5m|7d>`, `--immortal`. + +### Bridges + bots + +Continuwuity registers appservices via admin commands (`!admin appservice +register` with pasted YAML), not via `app_service_config_files` like +Synapse. The NixOS `services.mautrix-*.registerToSynapse` shortcut does +NOT apply — bridges go through a manual paste step. Bot-only flows +(maubot, custom matrix-nio scripts) don't need appservice registration. + +## Maubot plugin development + +Maubot is the standard Python plugin framework for Matrix bots; one +running maubot daemon hosts many plugin-bots. Plugin source at +`~/dev/maubot-plugins//`. + +### `maubot.yaml` — `database_type` is API style, not storage backend + +Valid values: `asyncpg` (modern, what `mautrix.util.async_db` provides) or +`sqlalchemy` (legacy). NOT `sqlite` or `postgres` — that's the storage +backend, chosen at the daemon level via `plugin_databases.sqlite` / +`plugin_databases.postgres`. Wrong value fails at instance start with +`RuntimeError: Unrecognized database type sqlite`. + +### Parent command + freeform text + subcommands + +For `!journal ` (parent) plus `!journal show [@user]` / `!journal +today` (subcommands), `@command.new` needs **both** flags: + +```python +@command.new("journal", require_subcommand=False, arg_fallthrough=False) +@command.argument("text", pass_raw=True, required=False) +async def journal(self, evt, text=""): ... + +@journal.subcommand("show", help="...") +@command.argument("user", required=False) +async def show(self, evt, user=None): ... +``` + +Without `require_subcommand=False`, non-subcommand input shows auto-help +instead of recording. Without `arg_fallthrough=False`, `pass_raw=True` +greedily consumes the rest, so `!journal show @alice` gets recorded as an +entry. Same pattern as upstream `maubot/reminder`. + +### Multi-line freeform parent commands need `@command.passive` + +The above only works when **everything is on one line**. For multi-line +content (`!journal\n- item one\n- item two`), `@command.new` silently +drops it — maubot's parser only treats *space* as the command/args +delimiter, so a newline immediately after the command name makes maubot +fail to recognise the command at all. No handler invoked, no error logged. + +**Fix:** use `@command.passive` with a regex admitting any whitespace, +then dispatch subcommands manually: + +```python +_JOURNAL_RE = re.compile(r"^!journal(?:[ \t\r\n]+(.*))?$", re.DOTALL) + +@command.passive(regex=_JOURNAL_RE) +async def journal(self, evt, match): + rest = (match[1] or "").strip() + if not rest: ... + first_token, _, after = rest.partition("\n")[0].partition(" ") + if first_token == "show": ... + else: ... # record `rest` verbatim — multi-line preserved +``` + +You lose `@parent.subcommand("...")` ergonomics but gain reliability for +prose-style inputs. **Rule of thumb:** for commands whose dominant use is +free-text that may span lines, default to passive. + +### Database access (asyncpg style) + +```python +from mautrix.util.async_db import UpgradeTable, Connection + +upgrade_table = UpgradeTable() + +@upgrade_table.register(description="Initial schema") +async def upgrade_v1(conn: Connection) -> None: + await conn.execute("CREATE TABLE ...") + +class MyBot(Plugin): + @classmethod + def get_db_upgrade_table(cls) -> UpgradeTable: + return upgrade_table + + @command.new("foo") + async def foo(self, evt): + await self.database.execute("INSERT INTO entries VALUES ($1, $2)", a, b) + rows = await self.database.fetch("SELECT ... LIMIT 10") +``` + +Placeholders are `$1, $2, ...` regardless of backend; `async_db` normalizes +across asyncpg/aiosqlite. + +### Iteration loop + +Edit + bump `version` in `maubot.yaml`, then: + +``` +cd ~/dev/maubot-plugins/ +zip -j ../.mbp maubot.yaml .py +``` + +Upload via Plugins → click existing plugin → upload new `.mbp`. Instance +reload requires hitting **Save** on the instance after upload — toggling +Enabled and walking away doesn't persist. + +### Wiping a plugin's data + +Each plugin has its own SQLite DB under `plugin_databases.sqlite`. Cleanest +reset is the maubot UI's per-instance Database tab — `DELETE FROM ` +runs against the live DB without restart. Nuking the file works but loses +migration version tracking. + +### Pyright false positives + +`maubot` / `mautrix` imports unresolved + `.subcommand` "unknown attribute" +warnings are expected — the SDK is dynamic and pyright can't introspect +the decorators. Ignore. + +--- + +## Upstream lnbits PR conventions + +For PRs to `github.com/lnbits/lnbits`: + +- **Base branch is `dev`, not `main`.** 100% of recent merged PRs target + `dev`. `main` gets release commits (`Merge branch 'dev'` + version bumps). +- **Commit titles: lowercase conventional commits.** `feat:`, `fix:`, + `chore:`, `chore(deps):`, `docs:`, `ci:`. Not capitalized. +- **Identity:** push under GitHub identity (`your-github-username`), not Forgejo. + See user-global `~/.claude/CLAUDE.md` for re-author details. + +Verified against last 25 merged PRs on 2026-04-28. + +--- + +## Documentation discipline + +For any aiolabs repo with a structured `docs/` tree: + +**Any commit that materially changes a database table, an API endpoint, +order/event flow, Nostr publishing convention, or CMS structure must +update the relevant note in `docs/` in the same commit.** Architecture +decisions get ADRs at `docs/adr-NNNN-.md`. + +Drift between docs and code defeats the purpose. Don't park doc updates +for "later" — they get forgotten and docs lose signal. + +--- + +## LNbits + Quasar UMD — frontend gotchas + +Applies to anything rendering into LNbits' page shell: lnbits core +(`~/dev/lnbits/*`) and every extension (`~/dev/shared/extensions/*`, +`~/dev/lnbits-extensions/`). All use **Vue 3 + Quasar 2 as UMD globals** — +no build step, Jinja templates with per-page JS. + +**No self-closing tags.** Per Quasar's UMD rules +(https://quasar.dev/start/umd/#usage), components need explicit-close: + +```html + + + + + +``` + +Self-closing is fine in `.vue` SFCs (build step rewrites them), but +UMD-loaded templates are parsed by the browser's HTML parser, which +doesn't honor self-close on non-void elements — close tag gets implied at +the wrong place, nesting breaks silently, subsequent siblings end up +inside the prior component. + +**CSS specificity trap.** LNbits applies theme overrides on Quasar +typography utilities (`.text-caption`, `.text-grey-*`) with `!important`. +Class-based CSS in an extension — *even with `!important`* — loses unless +the selector is strictly more specific. Inline `style` attrs (static or +via Vue `:style`) win without an arms race. + +**Rule:** for per-element typography/color overrides on LNbits pages, use +Vue `:style` bindings, not `