# AGENTS.md > Collaboration guide for AI coding agents and human contributors. Read this before touching code. ## Project overview aibox is a lightweight, pure-bash module manager (zero runtime dependencies, compatible with the bash 3.2 that ships with macOS). One-line `curl|bash` install; install / update / uninstall each module on demand. Each "module" is a directory under `tools//` in the repo, shipping its own `install / uninstall / update / svc` hooks and dispatched uniformly by the `aibox` main CLI. ## Repo layout ```text bin/aibox main CLI — GENERATED artifact: edit src/aibox/*.sh, run scripts/bundle.sh src/aibox/*.sh the manager's actual sources (concatenated in lexical order into bin/aibox) scripts/bundle.sh bundler: write / --check (CI gate) / --print / --out (the single-file shape is a RELEASE constraint, not a source one — this is why) install.sh bootstrap (curl|bash install / self-update, idempotent; optional AIBOX_SHA256/AIBOX_VERIFY) tools// module dir: lib.sh + install/uninstall/update/svc.sh + module.yaml shipped: pi-web (macOS launchd service), openmaic (Linux deploy-host ops CLI), windmill (Windmill self-host docker compose ops CLI), clash (Clash subscription proxy pool, mihomo), base (shared PG+Redis) tools/_shared/ shared module library (common.sh: output helpers + docker.io pool) — single source in the repo; ships into each module cache as _common.sh via module.yaml `includes: [common]` docs/module-spec.md the module hook contract (normative; module-system-spec.md is the superseded design draft) .github/workflows/ CI (release automation + lint quality gate: bash -n / shellcheck / gotcha #1 #8 scans / module-lint / port-conflict / bats tests + coverage probe / macos-bash32 job — the full suite executed under /bin/bash 3.2, the platform this project promises) ``` ## Core commands ```text aibox install [--skip-checks] aibox uninstall |self [--purge] [--yes] # --purge: also delete data; self = manager (--purge = cascade full teardown) aibox update |self|--all [--restart|--no-restart] [--skip-checks] # self = aibox itself; --all = modules + self aibox check |self # module preflight; self = environment check aibox status [--available] [] # overview (versions+endpoints+credentials+ports) / catalog / detail+health aibox dashboard [--once|--json|--pane P] [--interval N] # htop-style LIVE view (read-only): modules · # containers · upgrades · residue panes; keys Tab/↑↓/d///s/p/+/-/r/?/q aibox autoclean [...|self] [--apply] [--stop] [--yes] # residue scan/cleanup (dry-run default) aibox [args] # pass-through to module svc.sh aibox proxy {show|set |unset|on|off|check [url]|env} # static proxy config (global; see README "Proxy") aibox --no-proxy # bypass the proxy once aibox clash {set |on|off|status|refresh|select|test|logs|doctor} # clash pool (mihomo; see tools/clash/README) ``` ## Contribution conventions ### Editing the manager (mandatory) `bin/aibox` is a **generated artifact** — never hand-edit it. Sources live in `src/aibox/*.sh` (one file per domain, concatenated in lexical order; `00-head.sh` carries the shebang and `set -euo pipefail`). After any source change run `scripts/bundle.sh` and commit the regenerated artifact; CI runs `scripts/bundle.sh --check` and fails on drift. Rationale: the shipped file must stay a single file (curl|bash), but that is a release-shape constraint — keeping 5k lines in one hand-edited file forced "inline twins" of shared helpers that kept drifting. `tests/bundle.bats` locks the contract (fragments parse standalone, only the head fragment has a shebang, artifact == concatenation). ### Test environment (mandatory) **Never run any aibox functionality on the host.** Not `bats tests/*.bats`, not `bin/aibox …`, not a module hook, not an extracted helper, not "just this one function", not even for a read-only check. The development machine is the operator's live host: a stray install/uninstall/start touches real deployments, and a local run proves nothing about the environment that matters — the project promises bash 3.2 and BSD tools, which a Linux/host run silently does not exercise (that mismatch has produced false "green" more than once, e.g. a `find -quit` that BSD find rejects). Everything runs inside the pinned container image (`tests/docker/Dockerfile`, `aibox-test:local`): ```bash tests/docker/run.sh # gates -> fast suite as root AND non-root -> summary tests/docker/run.sh --fast-only # the fast suite only tests/docker/run.sh --integration # + daemon-backed suites (mounts the docker socket) tests/docker/run.sh --no-build # reuse the aibox-test:local image docker run --rm -v "$PWD":/repo -w /repo aibox-test:local \ bash -c 'cd /repo && bats tests/.bats' # a single suite ``` - **bash 3.2 / BSD-tool behaviour** is covered by CI's `macos-bash32` job, which runs the whole fast suite under `/bin/bash 3.2` — the authoritative runtime gate for the platform this project promises. Never "approximate" it on the host: reproduce a Linux-side concern in the container and let CI cover 3.2. - A CI-only failure is diagnosed from the job log (`gh run view --job --log-failed`) and reproduced in the container — do **not** re-run the failing test on the host. - Verifying a real deploy host (its own `aibox`, explicitly requested by the operator) is a *deployment task step*: it is never a substitute for the suite, and never run from the development machine against `$AIBOX_HOME`. ### bash coding conventions (mandatory) 1. **`set -euo pipefail`** at the top of every script (for `src/aibox/*.sh`: once, in `00-head.sh`). 2. **Always quote variables as `${VAR}` (braces), never bare `$VAR`** — especially when a variable is immediately followed by a **non-ASCII** character (CJK text, full-width punctuation `,。、;:`). This is pitfall #1 below, the project's #1 footgun. 3. **Declare `local` before assigning** (or on two lines). 4. **Idempotent**: `install.sh` / `update.sh` must be safe to re-run without erroring. 5. **One implementation per helper**: anything both the manager and module hooks need lives in `tools/_shared/lib/*.sh` (injected into `bin/aibox` by the bundler); `scripts/check-sources.sh` fails on a duplicate definition. `module.yaml` is read through the shared readers (`meta_field` / `meta_map_value` / `meta_version`), never with a module-local `sed`/`awk`. 6. **No `jq` / `python`**: the registry is shell-sourceable; the main CLI sources it directly, bash 3.2 compatible. (Since v0.4.0 the registry is auto-discovered from `tools/*/module.yaml` via a zero-dependency awk subset parser; yq validates the subset in CI.) 7. **Keep full-width punctuation in Chinese copy** (README.zh.md), but always separate variable boundaries with `${VAR}`. ### Managed vs state files (mandatory) Shipped files land in a deploy root through `install_managed_file` (never a bare `cp`): a user-edited copy is KEPT and the new version is written as `.new`. `.env` and any user-owned path must be declared in `state_files:` — hooks never overwrite them. Module README action/config tables are GENERATED by `scripts/gen-docs.sh` between the `BEGIN GENERATED` markers (CI `--check` gate); edit `module.yaml`, not the table. Spec §File ownership / §Generated README blocks. ### State model (mandatory) State lives in three layers — **host** (`$AIBOX_HOME/config`, `installed.sh`, `registry.cache`, `residue.conf`, `profiles/*.conf`), **deployment** (`apps/[-]/.env`, `/etc//.conf`) and **contract** (`base[-].env`, `redis[-]-.env`, `upgrades/.state`, written by the provider). **No state file is ever sourced** — data is not code: parse it (`cfg_kv_load` / `cfg_kv_get` / `cfg_kv_load_export` in the shared library). `scripts/check-sources.sh` fails on a `. "…"` of a data file; spec §State model. ### Content manifest (mandatory) Any change under `tools/**` (hooks, `lib.sh`, `module.yaml`, compose files, `cli/**`, `tools/_shared/**`) changes what hosts download — regenerate `modules.SHA256SUMS` with `scripts/manifest.sh` in the same commit. CI runs `scripts/manifest.sh --check` and fails on drift; `download_module` verifies every fetched file against it (`AIBOX_VERIFY=0` is the explicit bypass). ### Pushing is not a formality (learned the hard way) **Verify the remote before trusting a push.** A working copy was found with `origin` re-pointed at `.` (the repository itself): `git push origin main` then reports `Everything up-to-date`, exits 0, and pushes **nothing** — the commit and its release tag never reach GitHub, and the release workflow never runs. Two commits were silently swallowed that way. - Before a release push: `git remote get-url origin` must print the GitHub URL (not `.`). - Confirm what the remote ACTUALLY has: `git ls-remote origin refs/heads/main` (a local-looking answer — a bare SHA with no network — is a sign the "remote" is the working copy). - When in doubt, push by URL: `git push https://github.com/lichengwu/aibox.git HEAD:main` (requires credentials; `gh auth setup-git` configures git for them). ### commit style Conventional Commits: `fix:` / `feat:` / `docs:` / `style:` / `chore:`. ### Versioning & release flow - Main CLI version: `AIBOX_VERSION` at the top of `bin/aibox`. **The main CLI and each module version independently** (a module's dispatched CLI carries its own `*_CLI_VERSION`). - Module version: the `version:` field in `tools//module.yaml`. - **A release = bump `AIBOX_VERSION` → push main.** GitHub Actions (`.github/workflows/release.yml`) reads `AIBOX_VERSION`; if the remote has no matching `v` tag, it auto-creates the tag + release — the release notes carry the **curated `CHANGELOG.md` section for that version** (extracted by the workflow) plus the auto-generated commit list — and attaches a `SHA256SUMS` sidecar. Unchanged version → skip (idempotent, safe to re-push). - **Changelog standard (every release, open-source bar — Keep a Changelog + semver):** 1. Header: `## [x.y.z] — YYYY-MM-DD` (the `[x.y.z]` resolves via the compare-link definitions at the file bottom; add the new link when releasing). 2. Body grouped by `### Added / Changed / Deprecated / Removed / Fixed / Security` — only the groups with content. 3. Each entry: **what changed + why it matters to the user** (one or two sentences; link the commit/PR when the entry is a fix with a story, e.g. "live-caught on the deploy host: …"). No bare commit-subject dumps. 4. A **semver rationale** line in the release commit message (why major/minor/patch). 5. **Breaking changes / migration notes** get their own `### Changed` bullet starting with **BREAKING:** — plus the two-gate interaction model applies to any new destructive verb. 6. Module version bumps ride in the release commit message (they are independent of `AIBOX_VERSION`). 7. The `[Unreleased]` section accumulates during development; at release time it is renamed to the version header (date = release day) and a fresh empty `[Unreleased]` is NOT required (start it with the next change). - Self-update still runs `curl install.sh | bash` (idempotent re-install), **independent of releases** — releases are publication/change records. Optional `AIBOX_SHA256`/`AIBOX_VERIFY=1` add checksum verification. ## Pitfall log > The Chinese characters that appear in these examples are deliberate — they are the trigger for the bugs. Do not "fix" them by translating the example strings. ### #1 bash 3.2 + UTF-8 locale: full-width punctuation right after `$VAR` → unbound variable **Symptom**: under `set -u`, a line like `log "当前 $name,继续"` reports `name: unbound variable` (the variable name is followed by garbled bytes) and exits. Only triggers under a UTF-8 locale; the `C` locale doesn't trigger it, so it doesn't reproduce in some environments / CI. **Root cause**: macOS's bash 3.2.57 has a multibyte variable-name parsing defect. When `$VAR` is immediately followed by a UTF-8 multibyte character (CJK text, full-width punctuation `,。、;:!?` etc.), bash erroneously absorbs its bytes into the variable name, producing a non-existent variable, which `set -u` flags as unbound. The `C` locale processes bytes one at a time; a leading byte like `0xEF` isn't a name character, so it terminates the variable name and no error occurs. **Fix**: always delimit variable references with `${VAR}` — `当前 ${name},继续` is fine. **History**: `ad7cfed` swept the repo with perl and fixed 9 occurrences, but `876206d`'s new `cmd_self_update` missed `$before,`; fixed in 2026-09 by `e5311bf`. **Detection**: ```bash sed -n p | xxd # see if full-width bytes like ef bc 8c follow the variable name LC_ALL=zh_CN.UTF-8 bash