Maintainer notes on what happens between toolbox shell attaching and the zsh prompt rendering: prompt/locale plumbing, init.d/ boot scripts, and per-tool bootstrap.
Every symbol in internal/build/assets/starship.toml must be ASCII, unambiguous-narrow Unicode, or a Nerd Font PUA glyph — never an East Asian Ambiguous character or an emoji-presentation sequence (U+FE0F). Starship's defaults violate this: kubernetes ships ☸ (U+2638, EA-Ambiguous) and gcloud ships ☁️ (U+2601+FE0F). Ghostty measures them with Unicode grapheme-cluster width (mode 2027) → 2 columns, while zsh ZLE lays out the line with libc wcwidth() → 1 column. One column of drift per glyph meant every Backspace left exactly as many ghost characters as ambiguous emoji visible in the prompt — a months-long "intermittent" bug, because the k8s/gcloud modules only render where those contexts are active. Three earlier fixes (autosuggestions rebind, TERM forwarding, terminfo bundling) chased adjacent symptoms; the real confirmation was PROMPT='> ' killing the residue while plugin/RPROMPT/highlighter toggles did nothing. Diagnostic heuristic: any "ghost characters on redraw" report → test PROMPT='> ' first, before suspecting ZLE plugins. The four module symbols (kubernetes, gcloud, terraform, docker_context) are pinned to PUA glyphs with codepoint comments in starship.toml; PUA is width-1 under both width systems by construction, and Nerd Font on the host is already a README prerequisite.
The kubernetes and gcloud starship segments are opt-out via env: in .toolbox.yaml: set PROMPT_HIDE_KUBE (or PROMPT_HIDE_GCLOUD) to any value and the segment disappears; unset, it renders as before. toolbox stop first — env: is applied at ContainerCreate. Implemented with detect_env_vars = ["!VAR"], a veto rather than a trigger: starship's display test is is_project.or(has_env_vars).unwrap_or(true), and a !-prefixed var flips has_env_vars to false when the var is set. This only works for modules with no detect_files/folders/extensions — the two we toggle read a config file, not the working directory. terraform and docker_context are directory-scoped: their scan match makes is_project Some(true), which short-circuits .or(has_env_vars), so an env veto never reaches them. They're already silent outside their project dirs; to drop them entirely, edit format in starship.toml or ship a per-user STARSHIP_CONFIG.
Separately, STARSHIP_LOG=error is baked into the Dockerfile's final ENV. A dangling current-context in the user's ~/.toolbox/kube config (a context name with no matching entry — common with OKE/managed-cluster configs) makes starship log [WARN] - (starship::modules::kubernetes): Invalid KUBECONFIG on every prompt. Starship has no per-module log control, so the fix is the global log floor; it also mutes the residual git bind-mount timeout warnings the command_timeout/scan_timeout bumps in starship.toml already target. Overridable per session via env: { STARSHIP_LOG: warn } for debugging. Note this silences only the log line — the segment still renders the stale context name (verified: the prompt shows <stale-context>), so to make it vanish set PROMPT_HIDE_KUBE or fix the current-context (kubectl config unset current-context).
The image bakes ENV LANG=C.UTF-8 (Dockerfile final stage). debian-slim ships no LANG at all, so the container otherwise runs in the POSIX locale — under which zsh's ZLE cannot decode multibyte input and renders every UTF-8 byte it has to redraw as <ffffffff>. The visible symptom: typing a command that prefix-matches a history entry containing non-ASCII bytes (starship glyphs pasted into heredocs, accented letters, …) makes zsh-autosuggestions' ghost text show up as ➜ cd <ffffffff><ffffffff>. Same ZLE-encoding family as prompt glyph width, different mechanism — that one is width drift, this one is decode failure. C.UTF-8 is compiled into glibc (locale -a lists C.utf8 on a stock bookworm-slim), so no locales package or locale-gen layer is needed. Deliberately LANG only, not LC_ALL: LC_ALL outranks everything, so baking it would override any locale the user forwards via .toolbox.yaml env: passthrough. Smoke test asserts locale charmap = UTF-8.
The image ships a curated Claude Code statusline at /etc/toolbox/statusline-command.sh (baked like starship.toml, never copied into the ~/.claude bind-mount). init.d/35-statusline.sh runs on every shell start and force-sets ~/.claude/settings.json statusLine to bash '/etc/toolbox/statusline-command.sh' via jq, rewriting only that key and leaving the rest of the user's settings intact. It shares the ~/.toolbox-state/.claude-settings.lock flock with 10-rtk.sh / 65-atuin.sh (entrypoint runs init.d scripts in parallel; all three write the same file) and replaces the file atomically (mktemp+mv), seeding {} if settings.json is absent and never truncating a valid file on jq failure. Because the script is image-owned and re-applied every boot, a local edit does not persist — the statusline changes only when the image does, i.e. via a repo PR. This is why it lives baked + enforced rather than as a one-time seed.
Opt-out: managed_statusline: false in .toolbox.yaml makes the host emit the curated TOOLBOX_MANAGED_STATUSLINE=0 env (sessionplan.managedStatuslineEnv); the boot hook sees it and exits before touching settings.json, so the user keeps their own statusLine. The script resolves its own paths against ${CLAUDE_CONFIG_DIR:-$HOME/.claude} so it runs in any environment, and degrades silently when optional inputs are missing: the ponytail/caveman mode badges appear only if those plugins are installed (glob no-match → no badge), and its Nerd Font glyphs render as tofu without a Nerd Font on the host.
init.d/36-mode-flags.sh keeps the badges honest. ponytail/caveman draw their [PONYTAIL]/[CAVEMAN] badge from a ~/.claude/.<mode>-active flag written by the plugin's own SessionStart hook — but a disabled plugin's hook never runs, so a flag left from a previous session goes stale and the badge shows for an inactive mode. This hook runs before claude starts and removes the flag for any mode whose enabledPlugins["<plugin>"] is not true; enabled plugins are left alone (their hook rewrites the flag with the live level on start, so both badges show when both are enabled). On an unreadable settings.json it defaults to "enabled" and deletes nothing — it only removes a flag on a definite false.
Both are registered as system init scripts (systemInitScripts in internal/catalog/init_d_bijection_test.go) — image policy, not catalog tool toggles — and counted in the smoke-test.sh init.d bijection literals.
internal/build/assets/init.d/50-mcp-plugins.sh scans the ~/.claude/plugins/cache/*/*/*/mcp dirs and runs npm install && npm run build for any that has a package.json, declares a build script, and is not yet built (no .toolbox-built marker). First shell after a plugin install is therefore slower; subsequent shells cached via .toolbox-built marker. On failure stderr is captured to .toolbox-build-error.log next to the marker (in the same bind-mounted plugin dir, so it survives container restarts) and the last 5 lines are printed inline; failure stays non-fatal.
internal/build/assets/init.d/40-playwright-cli.sh does two jobs. Besides the per-repo playwright-cli skill refresh (see Per-repo playwright-cli skill below), it syncs the bundled Chromium to the pinned playwright version. The Dockerfile bakes the playwright npm package + playwright install-deps chromium (apt deps) only — the browser binaries are not baked; they live in the ~/.toolbox/playwright-cache bind (host-persisted, kept out of the image). Since nothing else downloads them, a playwright Renovate bump would otherwise leave the cache on the old Chromium revision and break the default headless launch: playwright resolves chromium.launch({headless:true}) to a separate chromium_headless_shell-<rev> binary that a stale cache never fetched (observed: cache held chromium-1224 with no headless shell after a bump to 1.60.0, whose pinned rev is 1223). A version sentinel (<cache>/.toolbox-chromium-version, compared against the playwright package.json version — read via node, not playwright --version, to dodge the rtk wrapper) makes the sync a no-op on every shell except the first after a bump, when it runs playwright install chromium (full + headless shell) once. Best-effort + non-fatal: an offline shell still starts. This rides the existing 40- script (no new init.d → no TestCatalogInitDBijection / smoke-count edit).
cf Cloudflare CLI skill auto-installWhen the cf and claude binaries are present and ~/.claude exists, internal/build/assets/init.d/20-cf.sh writes a Claude Code skill to ~/.claude/skills/cf/SKILL.md if absent. Skill is hand-written and points Claude to cf agent-context <product> for on-demand product context (instead of pre-baking the ~107-product corpus). Idempotent — only re-creates when the file is missing, so user edits persist.
Both graphify (init.d/30-graphify.sh) and codegraph (init.d/31-codegraph.sh) wire themselves into a project only when that project has opted in — neither registers anything globally. Opt-in is a one-time manual step the user runs inside the repo they want indexed:
graphify install --project --platform claude (alias graphify-init) installs the project-scoped /graphify skill into the repo's .claude/skills/graphify/, writes a ## graphify section into the repo's local CLAUDE.md, and registers PreToolUse hooks in the repo's .claude/settings.json; the graph data lives in graphify-out/.codegraph install --target=claude --location=local --yes (alias codegraph-init) writes the per-project MCP config + a marker-fenced section into CLAUDE.md/AGENTS.md; the symbol graph lives in .codegraph/codegraph.db.The *-init aliases (graphify-init, codegraph-init, plus pwcli-init for the playwright-cli skill) are defined in zshrc.sh as shorthands for these one-time opt-in commands.
On every shell each script gates on the presence of the tool's marker dir in $PWD (graphify-out/ resp. .codegraph/) plus the claude binary and ~/.claude. When the dir is present it re-runs the install so the marker/config stays in sync with the bundled tool version after an image upgrade; when absent it exits 0 and writes nothing — so opening an un-opted-in repo never dirties it. Both refreshes are idempotent and non-fatal. Because the workspace is a host bind-mount, the marker dirs, MCP config, and CLAUDE.md edits persist on the host repo across sessions; codegraph has no global DB location, so persistence is exactly the per-repo .codegraph/.
This replaces graphify's previous always-on global graphify install, which refreshed ~/.claude/skills/graphify/SKILL.md (and the /graphify slash skill) under ~/.claude/skills/ on every shell regardless of the repo. The /graphify skill is still installed, but now project-scoped into the repo's .claude/skills/graphify/ (via --project) rather than global; the graphify CLI stays bundled and the whole integration is per-repo via graphify install --project --platform claude.
playwright-cli follows the same per-repo opt-in model as graphify/codegraph above — init.d/40-playwright-cli.sh registers nothing globally. playwright-cli install initialises a workspace in $PWD: run with no cd $HOME wrapper it writes the skill to $PWD/.claude/skills/playwright-cli/ (plus a .playwright/ workspace dir), so opt-in is a one-time manual step the user runs inside the repo they want browser automation in:
playwright-cli install --skills claude writes .claude/skills/playwright-cli/SKILL.md into the repo (--skills also accepts agents for the Codex/~/.agents layout). The pwcli-init shell alias (in zshrc.sh) is a shorthand for exactly this.On every shell the script gates on the presence of $PWD/.claude/skills/playwright-cli/ plus the claude binary and ~/.claude. When the dir is present it re-runs playwright-cli install --skills claude (in CWD, no cd $HOME) so the skill stays in sync with the bundled playwright-cli version after an image upgrade — the SKILL.md + references are copied from the templates bundled in the playwright-cli package (kept in the image; the .md weight-prune is made to spare them — see Node package weight prune), so a Renovate PLAYWRIGHT_CLI_VERSION bump refreshes the skill on the next shell, offline. When the dir is absent it exits 0 and writes nothing, so opening an un-opted-in repo never dirties it. Idempotent and non-fatal; because the workspace is a host bind-mount the skill dir persists on the host repo across sessions.
This replaces the previous always-on (cd "$HOME" && playwright-cli install --skills claude), which forced the otherwise-CWD-local install into ~/.claude/skills/playwright-cli/ on every shell regardless of the repo. The cd $HOME wrapper was the global switch; dropping it makes the integration per-repo. An existing global ~/.claude/skills/playwright-cli/ from the old behaviour is left as-is — it is simply no longer refreshed (matching how graphify left its old global skill in place).
This is unrelated to the global playwright browser-cache sync in the same script, which stays always-on (it tracks the playwright package, not the per-repo playwright-cli opt-in).
Claude Code reads only ~/.claude/skills/<name>/SKILL.md (per docs.claude.com); Codex CLI reads only ~/.agents/skills/<name>/SKILL.md (Agent Skills USER scope per agentskills.io). Despite the shared "Agent Skills" branding, the two locations are NOT mutually compatible. CLI wrappers that ship a SKILL.md need a dual-install pass to be visible in both agents. Reference: internal/build/assets/init.d/60-glab.sh runs glab skills install --path ~/.claude/skills --force for Claude and glab skills install --global --force for Codex, gated on the respective binaries.
When glab auth status succeeds, init.d/60-glab.sh registers !glab auth git-credential as the git credential helper for every host in glab's config (yq '.hosts | keys | .[]' ~/.config/glab-cli/config.yml) — gitlab.com and any self-hosted instance the user has run glab auth login --hostname <host> for; new hosts need zero code changes. Written with sudo git config --system into the container's /etc/gitconfig: the system file is container-local and dies with the AutoRemove container, keeping the helper out of the host's ~/.gitconfig (which is a read-write host-synced mount — writing there would pollute the real host config). Registration is non-fatal — on failure a warning points at the SSH fallback (git@<host>:… keeps working via the RO ~/.ssh mount).
Primary consumer: private Homebrew taps over HTTPS — brew tap <name> https://<gitlab-host>/<group>/homebrew-tap.git clones with the glab token, no prompts, no extra setup (the token already persists in ~/.toolbox/glab). Benefits any in-container git clone/pull of private GitLab repos.
Limitation: the helper covers git transports only. Formulas that download release assets / package-registry artifacts over HTTPS go through brew's curl, which does not consult git credential helpers — such formulas need a custom download strategy reading a token. Revisit if a private tap grows that kind of formula.