Repository: @jeffreyjyz/opencode-shell-rc — opencode v2 plugin (server half only): makes the agent's non-interactive zsh load the user's aliases and functions via a ZDOTDIR shim. Sibling of cmduse/mpc/reqshape/opencode-context in ~/dev/cmdcode-tools/; no shared code.
src/index.ts server half (v2 `{ id, setup }`): the `shell.create.before` hook
src/shim.ts the shim: stateDir, shimContent, needsRefresh, prepare, refresh
src/aliases.ts the alias index: `~/.zshrc`'s own `alias` lines → a block in global AGENTS.md
src/guards.ts the guard layer: an opted-in name fails in agent shells and names the replacement
src/constants/shell-rc.ts plugin id, linked rc file names, alias block markers, guard defaults
index.js root shim for local-directory plugin loading (see Traps)
No TUI half, no runtime dependencies (node builtins only).
-
ZDOTDIRis the whole mechanism. opencode's shell tool runszsh -c <command>non-interactively, which never reads~/.zshrc; but zsh always reads$ZDOTDIR/.zshenvfirst. PointZDOTDIRat a shim whose.zshenvsources the generated state, so aliases/functions are available before the command string is parsed. -
Curate with zsh, not a regex — for the shell state.
state.zshcomes fromzsh -ic 'alias -L; typeset -f'— zsh's own serializer, so aliases and functions captured exactly. Never hand-parse.zshrclines to build the state. -
Everything the rc prints during startup must be sliced off — the capture emits
CAPTURE_MARKERfirst and keeps only what follows.zsh -icruns the rc before our command, so a themed prompt's stdout message lands ahead of the serializer output; without the slice it was written intostate.zshas if it were a command, and every non-interactive shell died on it (state.zsh:2: bad pattern: ^[[1mspaceship-prompt^[[0m). The usual trigger is a brokenfpath: spaceship's version check callsis-at-least, fails to autoload it, and prints<theme> requires at least zsh v5.2 (you have v5.9)— a quoted"$fpath"in an array assignment (instead of${fpath[@]}) collapses the whole path into one element and produces exactly that. Checkprint -l $fpathbefore blaming the capture. A missing marker keeps the previous state (fail open), never write;STATE_VERSION+ thestate.zsh.versionsidecar revoke an older capture automatically, so an upgrade repairs a poisoned file without waiting for an rc edit. -
Refresh on rc change, not per command.
needsRefreshcompares state file's mtime against~/.zshrc/~/.zshenv/ oh-my-zsh's mtime; thezsh -icgeneration runs only when that changes. An oh-my-zsh update touching onlylib//themes/plugins does NOT refresh (onlyoh-my-zsh.sh's own mtime is watched), so a snapshot can outlive an omz upgrade. -
zcompilethe state after every regeneration. Sourcingstate.zshraw is slow, butzcompilewritesstate.zsh.zwcwhich zsh prefers when newer. Alwaysrmold.zwcfirst (it is read-only) and treat compilation as best effort — a missing.zwcjust falls back to text. -
State is the whole interactive environment, oh-my-zsh included. Counts far larger than the hand-written
~/.zshrcare expected, not a bug — the snapshot faithfully reproduces what sourcing the rc yields. A smaller payload would mean filtering serializer output to names defined in rc files; current choice deliberately "mirror the interactive shell". -
The alias index is rc-scoped by design and is the one deliberate text scan.
src/aliases.tsreports only aliases whosealias …statement is written in~/.zshrcitself, because the state capture cannot say where an alias came from and its hundreds of oh-my-zsh entries are useless as guidance. The division of labour: the rc is read as text to learn which statements exist, zsh is asked for values (zsh -f+alias -L), so quoting stays canonical. An alias defined in a conditional, loop, function or sourced file is not indexed — say so in the README rather than growing a parser. -
The block in the global AGENTS.md is a managed region, never a rewrite.
<!-- shell-rc-aliases-begin -->…<!-- shell-rc-aliases-end -->is replaced in place; everything outside it is preserved byte for byte, and the block is appended when absent. Write only when the rendered text differs — opencode watches that file and injects an instruction update on every change, so a rewrite on every shell command would churn the session for nothing. -
Run the index refresh on the rc's mtime, not per command.
prepare()runs before every command;syncAliases()costs onezsh -fround trip, so it is gated onrefreshIfStale()having fired or the block being absent.unalias -m '*'leads the replay script: zsh shipsrun-help=manandwhich-command=whencein every shell, and those two were leaking into the first version of the output as if the user had written them. -
Fail open. If zsh is missing or generation throws,
ZDOTDIRis not set and shell behaves exactly as before. Never fail the shell command.refreshIfStalecatches missing/failingzshitself and returns false, soprepare()still lays down the shim (and the shim then sources nothing). CI runs on Ubuntu with nozsh; tests depend on this. -
PATH/env deliberately not snapshotted. Agent inherits opencode's start-up environment. Document that a
.zshrcPATHchange needs an opencode restart; alias/function changes self-refresh. -
The guard layer is opt-in, a speed bump, not a jail, and it is agent-shell-only on purpose.
src/guards.tswritesguards.zshwith a failing function per blocked name, taken from~/.config/opencode/shell-rc.json;DEFAULT_BLOCKEDis empty, so a fresh install blocks nothing and no file is written (a config emptied later removesguards.zsh, so the shim's-ftest skips it). What a user opts into is usuallygrep→rg,find→fd,sed→sd. zsh functions are not exported, sogit, a Makefile, a binary or a/bin/shscript still gets the real binary — only the command the model types is shadowed (a#!/bin/zshscript is the exception: it inherits the shim'sZDOTDIRand is guarded too; measured by running both twins).command grepand/usr/bin/grepbypass it by design, while\grepdoes not (backslash skips aliases, never functions); do not "harden" this into a wrapper that execs the real tool, or the block stops forcing the alternative. -
A guard whose replacement is not on PATH is not written. Blocking
findon a machine withoutfdleaves the agent unable to search at all. A malformed config falls back to the defaults — withDEFAULT_BLOCKEDempty that means no guards; never guess a list.catis never on the list — it emits raw bytes, whilebatreformats (headers, line numbers, decoration), so the blocked command would return something that is not the file. A guardednpmmust whiteliststage—npm stage publishis the agent's publish path (approval is the human's), and a name-level guard blocks it; override with astage-awarenpm()in~/.config/opencode/shell.zsh, never in the generated guard.
-
A captured alias whose name a function also uses makes zsh refuse the definition (
state.zsh:6231: defining function based on alias 'ls'/parse error near '()') atzcompile/source time — the user'sshell.zshpairsalias ls='eza …'with anls()fix, and the alias exists when the function is parsed, so the function is silently lost. Harmless per command (the.zwcis already built) but noisy on every regeneration; reorderingtypeset -fbeforealias -Lwould change alias expansion inside function bodies, so it stays. -
aliasexpansion needs the shim sourced before the-cstring is parsed.ZDOTDIR/.zshenvis read at startup, before-cparsed — why the shim works where appendingsource ~/.zshrcto the command string does not (-cstring parsed as a unit). Do not "improve" this into a command-string hack. -
.zshenvis always sourced forzsh -c;.zshrcnever is. That asymmetry is the entire exploit. -
Generation must pin
ZDOTDIRto the realHOME.refreshIfStalerunszsh -icwithenv: { ...process.env, ZDOTDIR: home() }. Without that, an ambientZDOTDIR— plugin's own shim, inherited by a nested shell or bybun testonce plugin is live in the agent shell — makes zsh read shim's rc instead of~/.zshrc: a feedback loop that regenerates the old state. Same pin on thezcompilecall. The test deliberately is not the only guard. -
Do not source the full rc.
source ~/.zshrcdrags in oh-my-zsh/compinit/autosuggestions, interactive-only and useless in a non-interactive agent shell;zsh -icalso emits(eval):1: can't change option: zleunless stderr discarded (2>/dev/null/stdio: ignore). -
ZDOTDIRredirects the lookup for all rc files. A nestedzsh -i(or login shell) under the agent would look for.zshrc/.zprofile/.zloginin shim dir, so those are symlinked back to the real ones. Do not remove the symlinks. -
An interactive shell must NOT source
state.zsh— the shim guards it with! -o interactive. The snapshot is of a loaded interactive shell, so it carries prompt/theme functions (spaceship_*,add-zle-hook-widget) but not the state those need (each section file's top-levelSPACESHIP_*config vars;zstyle zle-hook types). Pre-defined in a fresh interactive shell they silently break the prompt: spaceship'sload_sectionsseesspaceship::definedtrue for every section and sources no section file → allSPACESHIP_*_COLOR/_ORDERunset →%F{}emits\e[30m, so the whole prompt renders black (invisible on a dark background), and the emptySPACESHIP_PACKAGE_ORDERmakesfor manager in "${…[@]}"run once with an empty element →command not found: spaceship_package::; zsh-syntax-highlighting's_zsh_highlight__function_callable_p add-zle-hook-widgetalso succeeds, so it skips theautoloadand calls the captured copy with nohooktypes→Usage: add-zle-hook-widget … / Valid hooks are:(empty list). Interactive shells read the symlinked.zshrcand get the real environment, so nothing is lost by skipping the snapshot. -
Hook mutability.
shell.create.beforereceives the liveinvocationobject; mutatingevent.envis honoured because the spawn readsinvocation.envafterwards (packages/core/src/shell.ts). Hook callback may be async but mutation is synchronous here. -
PTY/terminal path is a different service (
experimental.persistentPty) and is not covered by this hook — onlyShell.createcallers (shell tool, session shell, server shell handler) are. -
biome check .aborts on a nested root configuration when a.delta/directory is present; this repo'sbiome.jsonexcludes**/.delta. -
Local-directory plugins resolve
index/server/tuibeside the package root, not theexportsmap. So repo ships a rootindex.jsshim that re-exportsdist/; npm installs useexports. Keep shim in sync. -
Bare
ls(andls -la) can print NOTHING in an agent shell — that is the shell, not the plugin. Whenlsis anezaalias (ls='eza --icons=always'),ezawith no path operand reads file names from stdin; the agent shell's stdin is empty, so it exits 0 with no output (interactively a tty makes it list the cwd, which is why the alias looks fine). Fix in the escape hatch, not the plugin: als()in~/.config/opencode/shell.zshthatunalias lsand appends.when no argument is a path operand (the shim sources that file for agent shells only). Do NOT push this into the plugin's generated shim — it would need to override every user's alias and sort flags from operands, changing behavior for all installs.
Hermetic: every test points HOME and XDG_STATE_HOME at a temp dir and writes a fake .zshrc, so real user state never touched. The alias-resolution test (and zsh half of the hook test) is skipped when zsh absent. The interactive-guard test overwrites state.zsh with a marker and rms its .zwc first: zsh prefers a .zwc that is not older than the source, and zcompile lands in the same mtime second, so appending to state.zsh alone is silently ignored. Restore saved variables through restoreEnv in test/helpers.ts: process.env.X = undefined stores the string "undefined", which made the alias index write undefined/opencode/AGENTS.md into the package directory for every later test in the process.
bun install
bun test
bun check
bun fmt --check # verify formatting
bunx biome lint # verify linting
bun run buildSame policy as sibling repos: local commits only until user says push. Publishing from this account must go through npm stage publish + user's npm stage approve — a bare npm publish leaves ghost versions (see the root AGENTS.md). Version this package independently. After a publish, opencode's per-package install cache can lag npm: npm cache clean, remove ~/.cache/opencode/npm/@jeffreyjyz/opencode-shell-rc@latest, then user restarts. Never restart or reload opencode yourself.
See opencode-context/AGENTS.md for the E401/dead-.npmrc-token trap on npm stage publish (same account).
Note: npm stage publish leaves a 0.0.0-stage version in the packument. It is the staging placeholder, not a ghost version — after npm stage approve the real version becomes latest and both entries appear in versions. Verify with the raw packument (curl -sS https://registry.npmjs.org/@jeffreyjyz%2Fopencode-shell-rc), not npm view — npm's local cache can lag and briefly report 0.0.0-stage as latest even after a successful publish.