omnixient/files/dev-CLAUDE.md
Padreug a8f1045518 chore: Claude/agent guidance and workspace files
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 10:00:14 +02:00

33 KiB
Raw Permalink Blame History

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/<name>.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 <project>/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/<x>"). 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/<group>/<owner>/<name>. 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<upstream-version>-aio.<patch-number>. 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/<ext> 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<upstream>-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 <last-tag>..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 <ext.id>_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 <ext>_fork row appears on first run with no core-side migration.
  • Extension data tables live in ext_<id>.sqlite3 (SQLite) or a Postgres schema named after <id>. Created lazily on first Database.connect().
  • No cross-DB atomicity. Extension migration commits to ext_<id>.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_<ext>_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['<ext>'] 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:

-- Run against the core DB, not the extension DB.
UPDATE dbversions SET version = <upstream-max> WHERE db = '<ext>';

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 = <upstream-max> WHERE db = '<ext>'.

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/<owner>/<repo>/ 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 transportaiolabs/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).

The Nostr ephemeral range (2000029999) 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:21001kind: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: 2200022099 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:3000039999) 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: <app-name>.html at project root → src/<app-name>-app/main.ts.
  2. App shell: src/<app-name>-app/{main.ts, App.vue}.
  3. Vite config: vite.<app-name>.config.ts with its own build target and PWA scoping (see service worker rules in webapp/CLAUDE.md).
  4. npm script: dev:<app-name> 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/<name>/ 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 <group> <cmd>. Groups: token, users, appservice, rooms, federation, media, server. Full reference: https://continuwuity.org/reference/admin/index.html.

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/<name>/.

maubot.yamldatabase_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 <text> (parent) plus !journal show [@user] / !journal today (subcommands), @command.new needs both flags:

@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:

_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)

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/<plugin>
zip -j ../<plugin>.mbp maubot.yaml <plugin>.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 <table> 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-<slug>.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:

<!-- correct -->
<q-input v-model="foo" label="Foo"></q-input>

<!-- wrong — silently broken in UMD/no-build mode -->
<q-input v-model="foo" label="Foo" />

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 <style> blocks targeting utility classes. Background/border tweaks at card-level are fine via classes.

Cache busting. Static assets served with ?v={server_startup_time} (lnbits/helpers.py: static_url_for). Bumping JS requires server restart; Jinja templates re-render every request. If browser keeps serving stale JS after restart, hard-refresh (Ctrl+Shift+R) to bypass HTTP cache.

Dark-mode color discipline. Pale bg-{color}-1 utilities render white-on-cream under dark theme — pair every pale background with an explicit dark text class (bg-red-1 text-grey-9 etc).