Skip to content

Latest commit

聽

History

History
330 lines (320 loc) 路 30.4 KB

File metadata and controls

330 lines (320 loc) 路 30.4 KB

AGENTS.md

This file provides guidance to AI assistants (Claude, Gemini, Copilot, OpenCode etc.) when working with code in this repository.

Project Overview

devbox provisions a containerised remote development environment on the AI coding workstation: one Docker container running an unprivileged sshd published only on the node's Tailscale address, so a herdr client can attach to it as a saved machine and run OMP and Claude Code agents inside it. The container is the agent sandbox - it reaches the project tree, the internet and a rootless project Docker daemon, never the host filesystem or the host's root Docker daemon.

Stack

  • Host: Ubuntu 26.04 LTS, Docker with compose v2, Tailscale >= 1.98; repo lives at ~/devbox
  • Image: ubuntu:26.04 + pinned herdr, gh, lazygit, wt (worktrunk), terraform, the Docker CLI with the compose and buildx plugins, and Node 24 / pnpm 12 through nvm in /opt/nvm. No op and no gcloud: the container holds no vault and no Google account (see docs/secrets.md)
  • Agents: OMP and Claude Code, installed by bootstrap into ~/.local/bin on the bind mount (unpinned, like moshi-hook: each updates itself there), both started through the agent launchers
  • Runtime: sshd on container port 2222, published as ${BIND_ADDR}:2223 and 127.0.0.1:2223
  • Project Docker: a second, rootless dockerd owned by the dedicated host user dev (uid 1001), socket /run/devbox/docker.sock bind-mounted in; provisioned once by sudo ./bin/devbox docker setup
  • Config: .env (from .env.example), docker-compose.yml, container/*, home/*

Critical Rules

  1. Package manager - apt-get only in scripts and Dockerfiles (never apt): apt has no stable CLI interface and warns on every scripted call
  2. bin/devbox is generated by bashly from cli/ - edit cli/bashly.yml, cli/commands/ and cli/lib/, run make build from the repo root and commit both (bashly formats it with shfmt -i 2, the repo's shell style; make check fails on a stale script); never hand-edit bin/devbox (the workstation has no Ruby, which is why the generated script is committed). Workstation and laptop share this one CLI, each command side-guarded by a bashly filters: entry; the laptop needs bash >= 4.2 (macOS /bin/bash is 3.2, so brew install bash). Bodies keep log_info/log_success/log_warn/log_error from cli/lib/log.sh; container/ scripts stay hand-written bash with set -euo pipefail. Read a flag whose name has an inner dash with a quoted key, ${args['--allow-unguarded']}: shfmt reads an unquoted subscript as arithmetic and rewrites it to --allow - unguarded, a key bashly never sets
  3. Pinned versions only - every external binary comes from an explicit ARG <TOOL>_VERSION and is verified: against upstream's checksum file where it publishes one, else against the SHA-256 GitHub records for the release asset (its release API's digest), else through a signed package repository whose key fingerprint is pinned (the Docker CLI). The one exception is nvm's installer, pinned by tag over TLS only (upstream publishes no checksum file or asset digest for it); nvm then checks Node against nodejs.org's SHASUMS256.txt. Never invent a hash
  4. No root in the container - no privileged, no cap_add, no /var/run/docker.sock mount. sshd runs as dev. Project containers come from the rootless sibling daemon, never from the host's root daemon and never from a nested one: nesting needs setuid newuidmap, which cap_drop: ALL plus no-new-privileges deliberately make impossible (docs/docker.md)
  5. BIND_ADDR is the security boundary - never publish a port without it, never add 0.0.0.0 bindings
  6. Bootstrap stays idempotent - every step in container/bootstrap.sh is guarded so a re-run is a no-op
  7. Commits - Conventional Commits v1.0.0, lowercase, no final punctuation, 100 chars max
  8. No private keys in the container - identities are public keys only; agent git goes over HTTPS through the launcher; manual work forwards the 1Password agent

Key Files

  • README.md - minimal project overview, highlights, quick start, ownership; links to docs/

  • docs/README.md - documentation index; one domain per file in docs/, each ending in an FAQ (follow its conventions - page shape, emoji headings, Prettier at 120 columns - when adding or editing docs). Update the file that owns the domain rather than growing README.md:

    • docs/architecture.md - system map, components, container startup order, ways in, repository layout
    • docs/installation.md - prerequisites, laptop key, ~/.ssh/config, first deploy, laptop agent install, .env reference
    • docs/connecting.md - herdr panes, ssh devbox, Moshi on a phone, ./bin/devbox shell, cloning, port forwarding
    • docs/git.md - the identity registry, manual vs agent git, the credential helper, signing, laptop install
    • docs/toolchain.md - pinned versions, install locations, OMP, Claude Code, Moshi/moshi-hook, agent skills, adding a tool
    • docs/secrets.md - the three secret layers, secrets.env, GH_TOKEN, the Claude Code login, GCP ADC, App credentials
    • docs/cli.md - the bin/devbox reference (cheat sheet, workstation, laptop and doctor commands), devbox-identities, and the maintainer workflow for editing the CLI
    • docs/networking.md - exposure model, why UFW cannot block a published port, tunnels
    • docs/docker.md - the rootless project daemon, path identity, reaching services, devbox-ports
    • docs/operations.md - redeploy, persistence, backup, health, troubleshooting
    • docs/security.md - boundaries, trust assumptions, deliberate limits
    • docs/development.md - toolchain, rules, shipping a change, agent assets
  • .env.example - the only per-host configuration; .env is gitignored and never synced by devbox deploy. Holds no identity: who this box is, per account and per directory tree, lives in ~/.config/devbox/identities.conf instead (see the home/ bullet below) - an enumeration of per-account variables is what that file replaced

  • Dockerfile - pinned toolchain; NVM_DIR=/opt/nvm and COREPACK_HOME=/opt/corepack exist because /home/dev is bind-mounted and would shadow a home-directory install; herdr must land in /usr/local/bin because non-interactive SSH sessions get the default PATH

  • docker-compose.yml - ${BIND_ADDR}:${DEVBOX_SSH_PORT}:2222 is the entire network boundary, written ${BIND_ADDR:?...} so compose itself refuses an empty address instead of publishing on 0.0.0.0; user:, cap_drop: [ALL], no-new-privileges, no root docker socket, no identity in environment: either - same reason as .env.example. ${DEVBOX_DOCKER_SOCKET_DIR:-/run/devbox} mounts the directory because rootlesskit recreates the socket inode on every daemon restart, and network_mode: bridge keeps the container on docker0, the one interface the project-port boundary admits, and which - unlike a compose-managed bridge - is not removed by down

  • container/entrypoint.sh - PID 1 as dev: home skeleton, host key, authorized_keys, bootstrap, the project-port mirror, the moshi-hook daemon, then exec sshd. The order is load-bearing; the mirror comes last of the setup steps because forwards live in this container's netns and are lost on every recreate, and both it and the hook daemon are best-effort because neither an unreachable project daemon nor a missing hook daemon may cost SSH access. moshi-hook serve is backgrounded rather than supervised because there is no systemd here: it becomes a child of sshd and is reaped by tini

  • container/bootstrap.sh - idempotent user setup, sixteen individually-guarded sections: 1 the agents (OMP straight from its latest GitHub release, checked against GitHub's asset digest before it is renamed into place - upstream's omp.sh installer checks nothing - with a download that aborts only when stalled, never on a total time cap; every run, installed or not, first deletes ~/.local/bin/.omp.?????? temp files older than 5 minutes, which only a killed run leaves (a live download writes at least every ~40 s), so a concurrent bootstrap's download survives; plus Claude Code via its native installer, which verifies its own download; both non-fatal, and Claude's one-time /login registered as a manual step until ~/.claude/.credentials.json exists); 2 the identity registry (installs devbox-identities in ~/.local/libexec and symlinks it into ~/.local/bin, since devbox.sh puts only the latter on the PATH and every checklist tells people to run devbox-identities check, then di_checks ~/.config/devbox/identities.conf - which it deliberately does not seed from the example, because the example is valid and would silently become the box's git identity - a broken registry skips every section derived from it as one block, so it costs configuration, never SSH access); 3 SSH identity public keys per identity (no prints its fingerprint to revoke); 4 ~/.ssh/config (rendered from the registry: one plain Host <host> block per forge, plus a Match host <host> tagged <slug> block per identity whose key differs from the plain one); 5 known_hosts (seeded per forge host); 6 ~/.gitconfig (the default identity's name, email and signing key); 7 your identity per tree and per GitHub owner (user-<slug>.gitconfig via includeIf gitdir: for every identity claiming a dir, org-<slug>.gitconfig via includeIf hasconfig:remote.*.url: for every identity claiming orgs - written after the gitdir: includes so the owner wins over the tree - both include lists rewritten from scratch every run so a renamed or dropped identity leaves no stale includeIf); 8 allowed_signers; 9 GitHub App credential directories per identity (created, never fetched - only placed by hand); 10 shell; 11 the box-wide secrets.env; 12 gh (the ~/.local/libexec/devbox-agent shim plus the per-identity token checklist, each identity checked with its own devbox-gh-token --identity <slug> and GH_HOST set to that identity's host, for every identity); 13 the agent git override (agent-launch and both launchers plus their omp/claude symlinks, credential helper, fence, the rendered agent.gitconfig and one agent-<slug>.gitconfig per identity claiming a dir - every one of them, inherited authors included, since git applies every matching includeIf and a nested tree would otherwise keep the outer author; the includes are emitted shortest dir first so the longest match is read last and wins); 14 OMP config (seeded if absent with the preset the laptop shares - model roles, feature flags, secrets and the bash.patterns guardrail - an existing file lacking a bash: block reported, never merged into); 15 moshi-hook plus its OMP extension and Claude Code hooks (--target omp,claude); 16 the printed manual checklist

  • container/sshd_config - unprivileged sshd: UsePAM no, pubkey-only, absolute paths, AllowTcpForwarding yes (dev-server tunnels), AllowAgentForwarding yes (the ssh -A devbox escape hatch only - the container holds no private key of its own) and MaxSessions 32 (herdr channels)

  • container/skills.sh - optional, explicitly invoked (./bin/devbox skills): pinned agent-browser CLI + Chrome build, then agent-browser, skill-creator and find-skills via npx skills add <github tree URL at a pinned ref> --global --agent universal claude-code --yes - each skill pinned like a binary (agent-browser and find-skills at the tag of the CLI version already pinned there, skill-creator at a commit) - ~/.agents/skills for OMP, a symlink per skill in ~/.claude/skills for Claude Code. Chrome's shared libraries are in the Dockerfile because --with-deps needs root. Global npm installs pass --prefix "$HOME/.local" per call so the bins stay on the bind mount; never export NPM_CONFIG_PREFIX - nvm then refuses to activate its default Node

  • home/ - templates installed into /home/dev by bootstrap (and onto the laptop by devbox agent install); generated files, not user-edited. home/.config/devbox/identities.conf.example is the template for ~/.config/devbox/identities.conf - the one file naming who this box is, per account and per directory tree. Neither installer copies it: it validates, so seeding it would hand git and every agent session the placeholder identity instead of failing loudly. home/.local/libexec/devbox-identities is its one reader, used by container/bootstrap.sh, the launchers and the CLI (di_slugs, di_get, di_for, di_check, the di_render_* functions) and installed as the devbox-identities CLI; deliberately bash 3.2 compatible, since the laptop-side launchers and shims run under whatever /usr/bin/env bash macOS provides. home/.local/libexec/devbox-agent/ holds agent-launch, the one body both launchers source (omp-launcher, claude-launcher are three lines each): it exports GIT_CONFIG_GLOBAL, the SSH fence, GIT_TERMINAL_PROMPT=0 and a login-less GH_CONFIG_DIR for its own process tree only - on the laptop, bare gh would otherwise fall back to your OAuth login - and clears what the calling shell carries for you: an IDE terminal's askpass (GIT_ASKPASS, SSH_ASKPASS, VSCODE_GIT_* - it answers git with your own GitHub login), GITHUB_TOKEN and the two enterprise token variables, SSH_AUTH_SOCK, and the GIT_AUTHOR_*/GIT_CONFIG_COUNT/GIT_CONFIG_PARAMETERS overrides that outrank every gitconfig (GH_TOKEN stays: the shim honours an explicit one as deliberate). Each launcher is reached as omp/claude only through a symlink and never as a file named after its tool, because omp update resolves its install target by looking omp up on the PATH and takes a plain file there over in place - it once wrote the release binary onto the launcher, dropping agent sessions back on the user's gitconfig and SSH keys; the launchers drop their own PATH entries for update (the exports still apply, so a misread argv stays fenced) so the updater lands on the real install, and the symlink confines a missed argv shape to omp's shebang refusal. Never put a launcher symlink in ~/.local/bin: Claude's native install owns ~/.local/bin/claude and re-points it on every auto-update - on the devbox the symlinks live in devbox-agent itself, on the laptop in devbox-agent/launchers, a directory holding nothing else that the user puts first on the PATH. Beside them, the gh shim that deliberately shadows the real gh on the PATH: with devbox-gh-token it resolves GH_TOKEN_<SLUG> per invocation from the working directory, on the same dir prefixes in identities.conf that git's includeIf uses, because an agent's cwd is a project while its shell was opened in $HOME, and exports GH_HOST=<host> with it only for an identity off github.com and only when GH_HOST is unset (gh refuses a host it holds no login for otherwise). In an agent session (GIT_CONFIG_GLOBAL is agent.gitconfig), a command about one repository - pr, issue, run, ..., api repos/<o>/<r>/..., api graphql inside a checkout

    • takes that repository's App installation token from devbox-git-credential token instead, so agent PRs carry the bot author their commits do; account-wide commands and every non-agent gh keep the PAT, and a failed App lookup or mint is an error, never a PAT fallback (token exits 1 and the shim stops; a failing get also answers git quit=1, so git asks no askpass program next). The helper caches App answers per repository on tmpfs (devbox-agent-<uid>/, never under $HOME) because a mint is two API round trips; a failure is never cached. Nothing exports GH_TOKEN; gh auth login is rejected by design (docs/secrets.md). home/.config/devbox/git/agent.gitconfig.tpl is the template devbox-identities render agent-gitconfig fills in with the registry's hosts and URL rewrites - edit it here, never the rendered ~/.config/devbox/git/agent.gitconfig or the one agent-<slug>.gitconfig per identity claiming a dir that it produces. Sets the bot author, unsigned commits, the HTTPS credential-helper rewrite and no prompts (credential.interactive = false, an empty core.askPass) for agent git (docs/git.md); both installers leave that directory at mode 500 with 444 files, because GIT_CONFIG_GLOBAL points into it and a git config --global inside a session therefore rewrites the agent's own identity - ~/.extra did exactly that from every login bash, putting the user's name and email on five agent commits, and git's lock file makes the directory mode the only thing that stops such a write. home/.bash_profile exists only to reassert the PATH order for login shells: bash prefers it over ~/.profile, which it sources first, because the distro's file prepends ~/.local/bin after ~/.bashrc and so put the real omp/claude ahead of the launchers in every interactive ssh devbox, herdr pane and ./bin/devbox shell; devbox.sh therefore asserts the order (move to front) instead of prepending only when absent
  • container/devbox-ports - symlinked to /usr/local/bin by the Dockerfile, so a host edit is live without a rebuild; mirrors published project ports onto the container's own 127.0.0.1

  • bashly-settings.yml, cli/ and bin/devbox - the one CLI for both machines. bashly-settings.yml points bashly at cli/ and writes bin/; cli/bashly.yml declares the commands, cli/commands/ holds their bodies, cli/lib/ the shared functions, cli/initialize.sh the shared constants; bin/devbox is the generated, committed result. Commands by group:

    • Workstation (Linux host outside a container only): env, up, down, rebuild, bootstrap, skills, shell, sessions, logs, hook, keys, docker setup
    • Laptop (macOS only): deploy, sync omp, sync identities, agent install
    • Both: install, doctor (host or laptop, defaulting to the side the machine is), completions [bash|zsh] (the [host] of deploy/sync completes from ~/.ssh/config)

    What the commands carry:

    • devbox install - the dot model from the dotfiles: symlinks ~/.local/bin/devbox onto this checkout's bin/devbox (never over a real file) and writes the bash completion to ~/.local/share/bash-completion/completions/devbox, which bash-completion lazy-loads - no rc line. Then devbox_command_checks (cli/lib/devbox_command.sh, also run by both doctors) probes a fresh login shell under env -i, because the calling one proves nothing: deploy runs install over a non-interactive ssh. cli/initialize.sh resolves REPO_ROOT through readlink -f for the symlink
    • up/down/rebuild refuse to drop live SSH sessions without --force; hook restarts the moshi-hook daemon with a detached exec precisely so it does not have to (--update runs moshi-hook update and rewrites the OMP extension and Claude hooks first, aborting before the old daemon is stopped if the update fails); keys prints every identity's installed public keys (or "not set") plus the sshd host-key fingerprint - nothing to paste anywhere, since they are already the laptop's own keys
    • devbox docker setup - needs sudo, idempotent, --check reports only: installs uidmap and slirp4netns, creates the dev:devbox host user with pinned uid/gid 1001 and denies it in the host's sshd (DenyUsers dev in /etc/ssh/sshd_config.d/devbox-docker.conf: its ~/.ssh is the bind mount; sshd -t runs before the drop-in is written, so a pre-existing failure is not blamed on it, and again before the reload, a failure there removing the drop-in; either shows sshd's own stderr and fails that step without aborting the rest of setup; captured sshd -T output confirms it applies), moves DEVBOX_DATA_DIR to /home/dev (path identity), writes /etc/tmpfiles.d/devbox-docker.conf, installs the nftables table plus devbox-docker-firewall.service (ordered before the host's docker.service, which starts the devbox, and with no ExecStop - a restart's nft -f swaps the table atomically) that keeps published project ports off every interface but loopback and docker0 (--ip covers only the default bridge, so the unit also passes --default-network-opt and the table backs both up) and the host's own services out of the devbox's reach (from docker0 only the daemon's sockets answer; host processes running as dev's uid or one of its /etc/subuid subordinate uids - the daemon, rootlesskit, slirp4netns, anything dev's user manager starts and every --network host project container, which shares the host's network namespace and runs a non-root user as a subordinate uid - open nothing on the host but systemd-resolved's stub, 127.0.0.53/127.0.0.54 port 53, loopback included, because a project container can drive that user manager, below; the set is rendered from /etc/subuid at run time, { 1001, 165536-231071 } by default) and both off the Tailnet (no new connection from docker0 or those uids through tailscale0 or to 100.64.0.0/10/fd7a:115c:a1e0::/48, evaluated after Docker's DNAT so a root-daemon port on the host's Tailscale address still works). That covers direct connections to the overlay only: tailscaled's LocalAPI socket is world-accessible and dials Tailnet peers for any local caller, so a project container that bind-mounts /run/tailscale relays through it, and peers stay reachable at their LAN or public addresses (accepted limits, docs/security.md); before 1.98 tailscaled relayed a LocalAPI dial to any address as root, the host's own services included, so doctor host fails when the running tailscaled (tailscale version --daemon, falling back to the CLI's tailscale version) is below 1.98. doctor host also fails until the loaded file matches the ruleset the checkout writes and the installed unit matches the one netfilter_unit writes. Setup also adds the one ufw rule that lets the devbox bridge reach the gateway, and runs a lingering rootless dockerd on /run/devbox/docker.sock from a root-owned unit in /etc/systemd/user, so nothing in the bind mount can rewrite the daemon's command line - plus a user@1001.service drop-in pointing dev's XDG_CONFIG_HOME/XDG_DATA_HOME at root-owned /etc/devbox-docker, since the user manager would otherwise read units, drop-ins, wants links and environment.d from the bind mount (the unit therefore passes --data-root itself, and root makes the wants link systemctl --user enable no longer can). The wants link and the drop-in (then daemon-reload) are written by step_manager, right after the data dir moves and before the firewall and netfilter steps, so this root-owned manager configuration exists before anything - loginctl enable-linger or the firewall unit's Wants=user@1001.service - can start user@1001, and a first provisioning's manager never reads the bind mount; user@1001 is restarted only when the drop-in changed or the running manager's environment lacks XDG_CONFIG_HOME=/etc/devbox-docker/config, which restarts the project daemon and every project container (Ubuntu's TimeoutStopSec=5 SIGKILLs slow ones; one without a restart policy stays down until docker compose up -d) - re-runs are no-ops once current, and doctor host compares the manager's ExecMainStartTimestamp with the drop-in's mtime. The relocation closes only the path the devbox writes directly: through /run/user/1001 (the bus, systemd/private) a project container can still change the daemon's environment or start units as dev in the host network namespace - the rules above on dev's uid and its subordinate uids are what bound such code. Old unit files in the bind mount are removed as dev, never by a root rm through planted symlinks, and every docker CLI call as dev runs with DOCKER_CONFIG outside /home/dev, so the bind mount's config.json and cli-plugins are never read or run on the host. doctor host checks sshd's effective config unprivileged (sshd -T against a throwaway ed25519 host key, as user=dev), after first flagging a stale or missing drop-in file
    • devbox sync omp - applies the repo's preset, home/.omp/agent/config.yml, to both machines: the laptop's ~/.omp/agent/config.yml first (a differing one kept as config.yml.bak, since OMP may have written a change there that never reached the preset), then the devbox's over Host devbox (one .bak kept there); only the preset, never the per-machine OMP state. Refuses (without --allow-unguarded) a preset lacking a top-level bash: block, since the copy replaces each whole file and would drop its seeded guardrail. The flag is not --force on purpose: --force only ever means "past the live-session prompt"
    • devbox sync identities - copies ~/.config/devbox/identities.conf into the devbox over Host devbox, keeping one identities.conf.bak remotely, then re-runs bootstrap.sh there so every identity-derived file catches up; validates locally with devbox-identities check first, the same reader that runs on both sides, so a broken registry never becomes the one the devbox boots from. sync host: argument, then DEVBOX_SSH_HOST (environment or .push.env), then devbox
    • devbox agent install - idempotent: installs the same agent git override the devbox bootstraps (agent-launch, omp-launcher, claude-launcher, gh shim, credential helper, fence, devbox-gh-token, devbox-identities in ~/.local/libexec with a ~/.local/bin symlink so it answers by name, the read-only rendered git/agent*.gitconfig, and omp/claude symlinks to the launchers in ~/.local/libexec/devbox-agent/launchers - removing the pre-Claude ~/.local/bin/omp symlink only once omp already resolves through that directory, since until the PATH line exists the old link is what keeps omp fenced), and prints the cp command for ~/.config/devbox/identities.conf rather than seeding it; regenerates every generated file, reads/edits nothing of the user's own identities.conf or shell rc; seeds ~/.omp/agent/config.yml (the same shared preset) only when absent, reporting an existing one without a bash: block; reports whether omp and claude resolve to their launchers - printing the one PATH line for the user's shell rc until they do - and the remaining manual steps (PATs, App credentials)
    • devbox doctor laptop - read-only counterpart of devbox doctor host: no private key on disk, one id_<slug>.pub/signing_<slug>.pub pair per identity (plus devbox.pub) held by the 1Password agent, each forge host's plain key and one .pub per identity's ssh tag selecting through it, Host <workstation> on the default identity's id_<slug>.pub and the workstation refusing devbox.pub (every ssh -A devbox leaves that key approved for anything in the devbox; asked with ssh -v and no agent, so the server answers before any signature and 1Password never prompts; its remedy is ordered - authorize id_<default>.pub and point Host <workstation> at it until ssh <workstation> true passes, only then remove devbox.pub, so the account is never locked out), ~/.gitconfig's org includes probed with a hasconfig: remote per pattern (email, signing key, core.sshCommand), no clone left on a stale SSH-alias remote, the registry itself (devbox-identities check), every gitconfig signing via op-ssh-sign with the right signing_*.pub (the base [user] name/email being the default identity's), the agent override installed - the static files byte-identical to home/'s templates, the agent gitconfigs byte-identical to a fresh devbox-identities render agent-gitconfig - with omp and claude resolving through devbox-agent/launchers symlinks (a plain file is an omp update takeover) and ~/.local/bin/devbox-identities still a symlink onto the libexec reader (the only hop that makes devbox-identities resolve by name), ~/.config/devbox/git still unwritable and no login shell writing a git identity into it, no agent token in the macOS keychain (a system credential.helper running ahead of ours), every identity's own PAT (devbox-gh-token --identity <slug>, with GH_HOST set to that identity's host, for every identity) accepted by gh, every App pem valid, and every configured connection authenticating - including Host <workstation>, whose hostname the repo names nowhere: it reads DEVBOX_HOST from the environment or .push.env - the same per-laptop value devbox deploy resolves, deliberately one name and one file rather than two - and skips that one check when it is unset. Unsets the launcher's exports and strips its PATH entry first, so it is meaningful from inside an agent session. file_mode (cli/lib/machine.sh) uses perl because BSD and GNU stat disagree and both can be on a macOS PATH
    • devbox deploy - rsync deploy; excludes .git, .env, data/ and .DS_Store, then runs bin/devbox install on the workstation (a failure there warns, never aborts). --up runs the remote up over ssh -t so the live-session prompt is answerable; --force (which needs --up) forwards past it. No default host: it takes the argument, then DEVBOX_HOST from the environment or .push.env (gitignored), then fails - a repo going public must not ship one machine's alias as everyone's default
  • .agents/skills/ - five skills mirroring the docs for agents: devbox-basics (architecture, boundaries, entry routes), devbox-setup (five ordered setup phases plus connection failures), devbox-laptop (keys in 1Password, ssh/git config, the agent override, tokens - devbox doctor laptop as the acceptance test), devbox-deploy (sync vs apply, what a redeploy cannot destroy), devbox-cli (driving bin/devbox: which side runs what, the session/sudo guards an agent leaves in place, reading results, changing cli/). They must stay consistent with docs/; when a command or default changes, update both

  • CLAUDE.md and .claude/skills/ - Claude Code reads neither AGENTS.md (before v2.1.277) nor .agents/skills/, so CLAUDE.md is the single line @AGENTS.md and .claude/skills/<name> are relative symlinks to .agents/skills/<name>. Edit only the originals; a new skill needs its symlink