Files
dotfiles/common/zsh/opencode.zsh
T
2026-09-29 16:24:41 -04:00

179 lines
9.4 KiB
Bash

# ---------------------------------------------------------------------------
# OpenCode profile launchers (Phase 5)
#
# Four profiles, one LiteLLM proxy. The profile decides WHICH KEY is used, and
# the key decides which models are reachable -- enforcement lives in LiteLLM
# (Phase 4 access groups), not here. This file only picks a key and a config.
#
# opencode / oc personal anthropic + openai + local
# oc-work work openai + local NO anthropic
# oc-work-strict work-strict openai + local NO anthropic, PRC never
# oc-local local local only, no egress
#
# NO SECRET IS STORED HERE. Keys are read from rbw at launch and passed to the
# process as LITELLM_KEY, which the config files reference as {env:LITELLM_KEY}.
# Nothing is written to a project repo -- there is deliberately no
# opencode.json or .envrc anywhere but ~/.config/opencode.
# ---------------------------------------------------------------------------
# Install prefix is ~/.local/opt/opencode, NOT ~/.local/share/opencode --
# the latter is opencode's OWN data directory (opencode.db, log/, repos/) and
# `opencode uninstall` claims to remove all related files there. Keeping the
# binary out of it means an uninstall or a reinstall cannot take the session
# database with it. Also NOT ~/.local/bin: that is a symlink to
# ~/.dotfiles/scripts, so npm's relative bin symlink resolved to
# ~/.dotfiles/lib/... (broken) and wrote its entry into the dotfiles git repo.
export OPENCODE_BIN="$HOME/.local/opt/opencode/bin"
[[ ":$PATH:" == *":$OPENCODE_BIN:"* ]] || path=("$OPENCODE_BIN" $path)
_oc_launch() {
local profile="$1" entry="$2" cfg="$3"; shift 3
local key
# rbw must be UNLOCKED, and the entry must be non-empty. Both matter: rbw
# needs a TTY to prompt, and a missing entry returns empty rather than
# failing, which would surface later as an opaque 401 from the proxy.
if ! rbw unlocked >/dev/null 2>&1; then
print -u2 "oc: rbw is locked -- run 'rbw unlock' first."
return 1
fi
key=$(rbw get "$entry" 2>/dev/null)
if [[ -z "$key" ]]; then
print -u2 "oc: rbw entry '$entry' is missing or empty."
print -u2 " Populate it with /tmp/store-litellm-keys-rbw.sh (needs a TTY)."
return 1
fi
# ---- where the proxy lives -------------------------------------------
# Always https://llm.rcjohnstone.com, on every host including mainframe --
# never 127.0.0.1 (the default is set in zshenv; see the note there).
# That name is NOT public: no A record, internal CA, and Caddy's (internal)
# snippet source-IP gates it to 10.0.0.0/24, 10.0.1.0/24 and the podman
# subnets. The router's DNS override resolves it to 10.0.0.2, so on the LAN
# the traffic never leaves the network and the gate passes.
#
# The opencode profiles read this as {env:LITELLM_BASE}, so it MUST be
# exported before opencode starts -- there is no default inside the JSON and
# an unset value interpolates to empty, which fails as a confusing URL error
# rather than a missing-variable one.
: ${LITELLM_BASE:=https://llm.rcjohnstone.com}
export LITELLM_BASE
# Preflight the proxy. Without this the first request fails somewhere inside
# the TUI and reads like a model problem instead of a dead backend.
if ! curl -fsS -m 3 -o /dev/null "$LITELLM_BASE/health/liveliness" 2>/dev/null; then
print -u2 "oc: LiteLLM proxy is not answering on $LITELLM_BASE."
print -u2 " Check: on the LAN? DNS resolving to 10.0.0.2? Caddy's"
print -u2 " internal CA trusted by this machine? (a TLS failure looks"
print -u2 " identical to a dead backend here)"
print -u2 " On mainframe, also: sudo podman restart connor_litellm_1"
return 1
fi
# ---- install the profile's OMO roster --------------------------------
# WHY THIS EXISTS. OMO reads exactly ONE config, resolveHomeDir($HOME) +
# "/.omo/omo.jsonc". There is no path override: `profiles`/OMO_PROFILE are in
# the new schema but the OLD loader rejects them, and a config the loaders
# reject fails SILENTLY (agent model bindings just stop applying). Setting
# HOME per profile would relocate opencode's own data and cache dirs too.
#
# It matters because OMO picks its per-model prompt by string-matching the
# LAST PATH SEGMENT of the model name, so the alias has to name the model
# family (see guides/llm/robots.md 5.1). Personal runs Kimi on the thinking
# and worker roles; work may not (PRC exclusion). One shared alias name
# therefore cannot be correct for both -- work was left running Kimi prompts
# on GPT models, 6 of 11 agents mismatched.
#
# The opencode-side fix does NOT work and was tested before this was written:
# OMO builds protectedBuiltinAgentNames from its own agent keys and runs
# filterProtectedAgentOverrides over opencode's `agent` config, so an
# `agent.sisyphus.model` in a profile config is silently DROPPED.
#
# So the roster is swapped here instead. Only omo.jsonc is touched;
# codegraph/, lsp-daemon/ and runtime/ under ~/.omo stay shared.
#
# RACE: two sessions in different profiles started within the same instant
# could read the other's roster. The window is startup-only (OMO reads the
# file once), and the failure is BOUNDED AND LOUD -- the LiteLLM virtual key,
# not this file, is the policy boundary, so a work session that ends up with
# the personal roster gets a 403 on the Kimi models rather than using them.
local roster
case "$profile" in
personal) roster=personal ;;
work|work-strict) roster=work ;;
local) roster= ;; # local.json loads no OMO plugin
esac
if [[ -n "$roster" ]]; then
local src="$HOME/.omo/profiles/${roster}.jsonc"
if [[ ! -r "$src" ]]; then
print -u2 "oc: OMO roster '$src' is missing."
print -u2 " Refusing to launch -- the wrong roster silently mis-prompts every agent."
return 1
fi
# Atomic: never leave a half-written omo.jsonc if this is interrupted.
if ! { cp -- "$src" "$HOME/.omo/.omo.jsonc.$$" &&
mv -f -- "$HOME/.omo/.omo.jsonc.$$" "$HOME/.omo/omo.jsonc" }; then
rm -f -- "$HOME/.omo/.omo.jsonc.$$"
print -u2 "oc: could not install the $roster OMO roster."
return 1
fi
fi
# Make the active mode obvious: banner, terminal title, and a prompt marker
# that survives for the rest of the shell if the profile is exported.
local colour
case "$profile" in
personal) colour=$'\e[1;36m' ;; # cyan
work) colour=$'\e[1;33m' ;; # yellow
work-strict) colour=$'\e[1;31m' ;; # red
local) colour=$'\e[1;32m' ;; # green
esac
print -u2 "${colour}┌─ opencode profile: ${(U)profile} ─┐\e[0m"
printf '\e]0;opencode [%s]\a' "$profile"
LITELLM_KEY="$key" \
OPENCODE_PROFILE="$profile" \
OPENCODE_CONFIG="$cfg" \
command opencode "$@"
}
# NO PROFILE IS NAMED opencode.json, AND THAT IS LOAD-BEARING. Whatever sits at
# ~/.config/opencode/opencode.json is a GLOBAL BASE that opencode merges into
# every other config -- OPENCODE_CONFIG adds to it, it does not replace it, and
# neither does OPENCODE_CONFIG_DIR. Verified: with the personal profile parked
# at that path, `oc-local models` listed the Anthropic models too. So personal
# lives at personal.json like every other profile, and the file at the default
# path is a two-line guard holding only `enabled_providers: ["litellm"]`.
#
# That guard is a safety net, not decoration: without it a bare `opencode` run
# outside these functions falls back to opencode's BUILT-IN providers, which
# include OpenCode Zen -- the account currently under a ToS block. With it,
# a bare `opencode` lists nothing at all and cannot reach Zen.
#
# ⚠ opencode.json IS NOW DOTBOT-LINKED, and was not until 2026-09-21. It sat
# in the repo at common/opencode/opencode.json while being linked nowhere, so
# each machine carried a hand-placed copy and nothing verified they matched --
# or that the Zen guard was present at all. An unlinked guard file is a guard
# you are trusting without checking.
#
# ⚠ TEMPORARY, 2026-09-21: it also carries `agent.plan.disable` plus
# `permission.plan_enter: deny`, which turn OFF opencode's native Plan Mode
# fleet-wide. Plan Mode was firing unbidden and taking the session read-only
# with "Plan mode is active ... This supersedes any other instructions you
# have received." This is a preference, not a fix -- REMOVE THESE TWO KEYS to
# get Plan Mode back.
#
# Note this is NOT an OMO feature, though it looks like one. OMO's agents
# carry only description/model/tools and cannot trigger on prompt text; the
# `plan` agent is native to opencode. Disabling OMO would not have stopped it,
# and would have broken the default model -- `agent/atlas-glm-5.3` is rewritten
# by the OMO plugin at request time and 400s at LiteLLM without it.
#
# Personal is still wrapped under the bare name so `opencode` does the
# expected thing and gets a key.
opencode() { _oc_launch personal litellm-key-personal "$HOME/.config/opencode/personal.json" "$@" }
oc() { _oc_launch personal litellm-key-personal "$HOME/.config/opencode/personal.json" "$@" }
oc-work() { _oc_launch work litellm-key-work "$HOME/.config/opencode/work.json" "$@" }
oc-work-strict() { _oc_launch work-strict litellm-key-work-strict "$HOME/.config/opencode/work-strict.json" "$@" }
oc-local() { _oc_launch local litellm-key-local "$HOME/.config/opencode/local.json" "$@" }