Compare commits

...
6 Commits
Author SHA1 Message Date
noconnorandClaude Opus 4.8 c0df3a1596 README: document docker/, new tools, and ~/.zshrc.local
Covers the docker/ module, task/op/opencode in bin/, the kind gating
change, and the new per-machine secrets section.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 04:40:25 -04:00
noconnorandClaude Opus 4.8 16e01cb3d0 mcp-config: add covert-gpt provider, auto-discover models
Two changes to the synced opencode config:

- Model provider. opencode can't list models from a custom OpenAI-compatible
  provider itself, so sync.sh queries the gateway's /v1/models with the
  user's key and materializes the map client-side. New gateway models land
  on every box at the next sync with no config edit. If discovery fails the
  previous map is carried over, so a gateway hiccup never empties the list.
- Aggregate MCP endpoint. One entry at /mcp/ (trailing slash required — the
  bare path 307-redirects and MCP clients don't follow it) exposes every
  server the key can access, replacing the per-server gitea entry.

sync.sh also exports OPENCODE_CONFIG: opencode does not auto-load extra
files from its config dir, so without it the synced file was ignored.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 04:40:25 -04:00
noconnorandClaude Opus 4.8 efca892109 zsh/: source ~/.zshrc.local for per-machine secrets
~/.zshrc is managed and overwritten on every bootstrap, so there was
nowhere to put a machine-local key. The managed rc now sources
~/.zshrc.local last (so it can override anything) and bootstrap never
touches that file. zshrc.local.example documents the variables modules
look for: GITEA_KEY, LITELLM_KEY, OP_SERVICE_ACCOUNT_TOKEN.

Secrets stay out of the repo — zsh/zshrc is shared config applied to
every box.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 04:40:14 -04:00
noconnorandClaude Opus 4.8 d9e3ca2565 bin/: add 1Password CLI (op) and opencode
op is shipped as a zip rather than a tarball, so lib/fetch.sh grows an
install_zip_bin helper (unzip, falling back to python3's zipfile so
neither is a hard dependency).

Credentials for both stay per-box and out of this repo: op signs in via
`op signin` or OP_SERVICE_ACCOUNT_TOKEN, and opencode reaches the
covert-gpt gateway with the key that mcp-config/ syncs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 04:40:05 -04:00
noconnorandClaude Opus 4.8 3751836c5c docker/: install rootful Docker; gate kind on it
kind never worked reliably against rootless Docker on Kasm, even with the
cgroup delegation from docker-rootless-cgroup/. So install full Docker
where we can, and flip bin/'s kind gate from "rootless is available" to
"a rootful daemon answers" — kind is simply skipped on Kasm now.

docker/install.sh no-ops when a rootless daemon is already running so it
never fights Kasm's setup. It runs before bin/ in bootstrap so the kind
gate can see the daemon it just started. zsh/zshrc re-execs via newgrp
when the account gained the docker group but the live shell predates it.

docker-rootless-cgroup/ keeps its delegation (still useful for
per-container resource limits); only its kind-centric comments change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 04:39:58 -04:00
noconnorandClaude Opus 4.8 ddeb73fd1c bin/: add task (go-task) Taskfile runner
Pinned to v3.52.0, installed as a static release binary into ~/.local/bin
like the other tools. The release tarball has the binary at its root, so
the existing install_tarball_bin helper covers it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 04:28:07 -04:00
11 changed files with 399 additions and 87 deletions
+51 -10
View File
@@ -26,18 +26,35 @@ Already have a clone? Just run it directly:
## Modules
- `zsh/` — installs zsh + oh-my-zsh, drops a managed `~/.zshrc`
(robbyrussell theme, git plugin), and sets zsh as the login shell.
(robbyrussell theme, git plugin), and sets zsh as the login shell. The
managed rc sources `~/.zshrc.local` last for per-machine customizations
(see below).
- `git/` — sets git `user.name`/`user.email` and configures
`credential.helper store` (plaintext `~/.git-credentials`, 0600) for
HTTPS auth.
- `bin/` — installs userland devops CLIs (jq, kubectl, helm, k9s,
kubectx/kubens, stern, yq, OpenTofu, terragrunt, plus kind when rootless
Docker is available) as self-contained,
statically linked release binaries into `~/.local/bin`. No sudo, nothing
outside `$HOME`, so they persist across Kasm redeploys with no reinstall.
Idempotent: each tool records its version under `~/.local/bin/.versions`
and is skipped when the pinned version is already present. `zsh/zshrc`
puts `~/.local/bin` on `PATH`.
- `docker/` — installs full (rootful) Docker Engine via get.docker.com,
enables the service (systemd hosts), and adds the invoking user to the
`docker` group (takes effect at next login). No-ops where a rootless
Docker daemon is already running (e.g. Kasm) so it never fights that
setup. Uses sudo.
- `bin/` — installs userland devops CLIs as self-contained, statically
linked release binaries into `~/.local/bin`: jq, kubectl, helm, k9s,
kubectx/kubens, stern, yq, OpenTofu, terragrunt, task (go-task, the
Taskfile runner), the 1Password CLI `op` for secrets storage, and the
opencode coding agent. kind is installed
only when full (non-rootless) Docker is available — it doesn't work
against rootless Docker, so it is skipped on Kasm. No sudo, nothing
outside `$HOME`, so tools persist across Kasm redeploys with no
reinstall. Idempotent: each tool records its version under
`~/.local/bin/.versions` and is skipped when the pinned version is
already present. `zsh/zshrc` puts `~/.local/bin` on `PATH`.
opencode is configured by `mcp-config/`: it points at the covert-gpt
LiteLLM gateway (`https://llm.covert-gpt.com/v1` for models,
`https://llm.covert-gpt.com/mcp` for MCP tools). Run
`mcp-config/install.sh` once to store your LiteLLM key and enable
config sync — without it, opencode installs but can't reach the
gateway.
- `ansible/` — installs Ansible into a virtualenv at `~/.local/opt/ansible`
(it's Python, not a single binary) and symlinks its CLIs into
`~/.local/bin`. Persists across redeploys; only rebuilt when the venv is
@@ -45,13 +62,37 @@ Already have a clone? Just run it directly:
the system package manager (the one place this module uses sudo).
- `docker-rootless-cgroup/` — on systemd-less hosts (e.g. Kasm), hand-delegates
cgroup v2 controllers to the rootless Docker daemon so per-container cgroups
work and `kind` can create clusters (rootless Docker otherwise comes up with
work (rootless Docker otherwise comes up with
`CgroupDriver=none`). Uses sudo for host cgroup writes and restarts the
daemon; no-ops on systemd or non-rootless hosts. Re-run at each bootstrap
(the cgroup layout resets on redeploy); its PATH shim lives in `$HOME`.
- `mcp-config/` — shared MCP gateway config sync for laptops (LiteLLM /
opencode). Independent of `bootstrap.sh`; see its own README.
## Per-machine customizations and secrets
`~/.zshrc` is managed — bootstrap overwrites it, so never hand-edit it.
Per-machine settings and secrets go in `~/.zshrc.local`, which the managed
rc sources last (so it can override anything) and bootstrap never touches:
```sh
cp zsh/zshrc.local.example ~/.zshrc.local && chmod 600 ~/.zshrc.local
```
Variables modules look for:
- `GITEA_KEY` — personal access token for `git.nic-oconnor.com` (API/CLI
use; HTTPS git auth itself goes through `git/`'s credential helper).
- `LITELLM_KEY` — covert-gpt gateway key used by opencode. Normally you
don't set this by hand: run `mcp-config/install.sh` once and its shell
hook exports it from `~/.config/mcp-sync/key`. Set it in `~/.zshrc.local`
only on a box without mcp-sync.
- `OP_SERVICE_ACCOUNT_TOKEN` — 1Password service-account token for
non-interactive `op read` / `op run`.
Secrets never go in this repo or in `zsh/zshrc` — that file is shared
config applied to every box.
`lib/os-detect.sh` provides shared package-manager detection (`dnf`/`yum`
vs `apt`); `lib/fetch.sh` provides arch detection and binary-download
helpers. Both are used by module install scripts.
+54 -11
View File
@@ -20,13 +20,15 @@ REPO_ROOT="$(dirname -- "$SCRIPT_DIR")"
ARCH="$(fetch_arch)" # x86_64 / aarch64
DEB="$(fetch_arch_deb)" # amd64 / arm64
# docker_rootless_available — true only if a rootless Docker daemon is up and
# reachable. kind needs a container runtime to create clusters; on Kasm we run
# Docker rootless (never system Docker), so kind is worth installing only when
# that daemon answers. SecurityOptions lists name=rootless under rootless mode.
docker_rootless_available() {
# docker_full_available — true only if a non-rootless (full) Docker daemon is
# up and reachable. kind needs full Docker: we could not get it working against
# rootless Docker on Kasm, so it is only worth installing where a rootful
# daemon answers. SecurityOptions lists name=rootless under rootless mode.
docker_full_available() {
command -v docker >/dev/null 2>&1 || return 1
docker info -f '{{println .SecurityOptions}}' 2>/dev/null | grep -q 'name=rootless'
_secopts="$(docker info -f '{{println .SecurityOptions}}' 2>/dev/null)" || return 1
[ -n "$_secopts" ] || return 1
! printf '%s' "$_secopts" | grep -q 'name=rootless'
}
# jq — command-line JSON processor (single static binary).
@@ -68,9 +70,8 @@ install_k9s() {
}
# kind — run local Kubernetes clusters in Docker (single static binary). Gated
# on rootless Docker at the call site since it is useless without a runtime.
# On systemd-less hosts the rootless daemon also needs cgroup delegation before
# kind can create clusters; see the docker-rootless-cgroup/ module.
# on full (non-rootless) Docker at the call site: kind could not be made to
# work against rootless Docker on Kasm, so it is skipped there.
install_kind() {
ver=v0.32.0
have_version kind "$ver" && { fsay "kind $ver already installed."; return 0; }
@@ -126,6 +127,45 @@ install_tofu() {
mark_version tofu "$ver"
}
# op — 1Password CLI, used for secrets storage/retrieval (op read, op run,
# op inject). Single static binary shipped as a zip. Sign in with
# `op signin` or a service-account token (OP_SERVICE_ACCOUNT_TOKEN) —
# credentials are per-box and never live in this repo.
install_1p() {
ver=2.34.1
have_version op "$ver" && { fsay "op $ver already installed."; return 0; }
install_zip_bin \
"https://cache.agilebits.com/dist/1P/op2/pkg/v$ver/op_linux_${DEB}_v$ver.zip" \
op op
mark_version op "$ver"
}
# opencode — terminal AI coding agent. Points at the covert-gpt LiteLLM
# gateway via mcp-config/opencode.gateway.json (synced by mcp-config/).
# Release assets use x64/arm64 tokens rather than amd64/x86_64.
install_opencode() {
ver=v1.17.18
have_version opencode "$ver" && { fsay "opencode $ver already installed."; return 0; }
case "$DEB" in
amd64) _oc_arch=x64 ;;
*) _oc_arch=arm64 ;;
esac
install_tarball_bin \
"https://github.com/sst/opencode/releases/download/$ver/opencode-linux-$_oc_arch.tar.gz" \
opencode opencode
mark_version opencode "$ver"
}
# task — go-task, the Taskfile.yml task runner. Binary is at the archive root.
install_task() {
ver=v3.52.0
have_version task "$ver" && { fsay "task $ver already installed."; return 0; }
install_tarball_bin \
"https://github.com/go-task/task/releases/download/$ver/task_linux_$DEB.tar.gz" \
task task
mark_version task "$ver"
}
# terragrunt — thin wrapper around tofu/terraform (single static binary).
install_terragrunt() {
ver=v1.1.0
@@ -140,16 +180,19 @@ install_jq
install_kubectl
install_helm
install_k9s
if docker_rootless_available; then
if docker_full_available; then
install_kind
else
fsay "Skipping kind: rootless Docker not available."
fsay "Skipping kind: full (non-rootless) Docker not available."
fi
install_kubectx
install_kubens
install_stern
install_yq
install_tofu
install_task
install_terragrunt
install_1p
install_opencode
fsay "Done. Tools in $BIN_DIR (added to PATH by zsh/zshrc)."
+2
View File
@@ -31,6 +31,8 @@ fi
"$SCRIPT_DIR/zsh/install.sh"
"$SCRIPT_DIR/git/install.sh"
# Runs before bin/ so the kind install gate can see the rootful daemon.
"$SCRIPT_DIR/docker/install.sh"
"$SCRIPT_DIR/bin/install.sh"
"$SCRIPT_DIR/ansible/install.sh"
# Runs last: needs rootless Docker already up (started at boot) so it can
+8 -5
View File
@@ -1,7 +1,10 @@
#!/usr/bin/env sh
# docker-rootless-cgroup/install.sh — give rootless Docker cgroup v2 controller
# delegation on a systemd-less host (e.g. Kasm), so per-container cgroups work
# and `kind` can create clusters.
# delegation on a systemd-less host (e.g. Kasm), so per-container cgroups work.
# (This was originally done to support `kind`, but kind never worked reliably
# against rootless Docker even with delegation — bin/ now only installs kind
# where full Docker is available. The delegation itself is still useful for
# per-container resource limits.)
#
# Why: rootless Docker only receives delegated cgroup v2 controllers when
# systemd sets `Delegate=yes` on the user session. Kasm images run no systemd
@@ -117,7 +120,7 @@ cat > "$tmp" <<'SH'
#!/bin/sh
# Managed by dotfiles (docker-rootless-cgroup). Shadows /usr/bin/dockerd-rootless.sh
# on PATH. Places the rootless Docker daemon in a private, delegated cgroup v2
# leaf so it can enable controllers (needed by `kind`), then execs the real
# leaf so it can enable controllers (per-container cgroups), then execs the real
# launcher. No-op if the delegated slice is absent, so it is safe anywhere.
_cg=/sys/fs/cgroup/rootless/daemon
if [ -w /sys/fs/cgroup/rootless ] && [ -d "$_cg" ]; then
@@ -156,9 +159,9 @@ done
driver="$(docker info -f '{{.CgroupDriver}}' 2>/dev/null || true)"
if [ "$driver" = "none" ] || [ -z "$driver" ]; then
say "docker-rootless-cgroup: WARNING — CgroupDriver is still '${driver:-unreachable}'."
say " kind will not work yet. Inspect the new dockerd's cgroup:"
say " Per-container cgroups will not work yet. Inspect the new dockerd's cgroup:"
say " cat /proc/\$(pgrep -x dockerd | head -1)/cgroup # expect .../rootless/daemon"
exit 0
fi
say "docker-rootless-cgroup: done. CgroupDriver=$driver — rootless Docker now has"
say " delegated cgroups; \`kind create cluster\` can run."
say " delegated cgroups; per-container resource limits work."
+62
View File
@@ -0,0 +1,62 @@
#!/usr/bin/env sh
# docker/install.sh — install full (rootful) Docker Engine and let the
# invoking user talk to it without sudo.
#
# What it does:
# 1. Installs Docker Engine via Docker's official convenience script
# (get.docker.com — supports both RHEL- and Debian-family distros and
# sets up the upstream package repo). Skipped when `docker` is already
# installed.
# 2. Enables and starts the service (systemd hosts only).
# 3. Adds the invoking user to the `docker` group so the daemon socket is
# usable without sudo. Takes effect at next login.
#
# Deliberately no-ops when a rootless Docker daemon is detected (e.g. Kasm
# ships its own rootless setup) — installing rootful Docker there would fight
# the existing runtime. See docker-rootless-cgroup/ for the Kasm-side config.
#
# Idempotent and safe to re-run. Uses sudo.
set -eu
say() { printf '%s\n' "$*" >&2; }
# --- skip on rootless-Docker hosts -------------------------------------------
if command -v docker >/dev/null 2>&1 &&
docker info -f '{{println .SecurityOptions}}' 2>/dev/null | grep -q 'name=rootless'; then
say "docker: rootless Docker daemon detected; leaving it alone."
exit 0
fi
# --- 1. install engine -------------------------------------------------------
if command -v docker >/dev/null 2>&1; then
say "docker: engine already installed ($(docker --version 2>/dev/null || echo 'version unknown'))."
else
say "docker: installing Docker Engine via get.docker.com..."
tmp="$(mktemp)"
if command -v curl >/dev/null 2>&1; then
curl -fsSL https://get.docker.com -o "$tmp"
elif command -v wget >/dev/null 2>&1; then
wget -qO "$tmp" https://get.docker.com
else
say "docker: need curl or wget"; exit 1
fi
sudo sh "$tmp"
rm -f "$tmp"
fi
# --- 2. enable + start the service (systemd only) ----------------------------
if [ -d /run/systemd/system ] && command -v systemctl >/dev/null 2>&1; then
sudo systemctl enable --now docker
else
say "docker: no systemd; not managing the service (start dockerd yourself)."
fi
# --- 3. group membership for the invoking user -------------------------------
user="$(id -un)"
if id -nG "$user" | tr ' ' '\n' | grep -qx docker; then
say "docker: $user already in the docker group."
else
sudo usermod -aG docker "$user"
say "docker: added $user to the docker group."
say " Log out and back in (or run 'newgrp docker') for it to take effect."
fi
+20
View File
@@ -73,6 +73,26 @@ install_tarball_bin() {
fsay "installed $BIN_DIR/$3"
}
# install_zip_bin <url> <path-within-archive> <dest-name> — like
# install_tarball_bin but for .zip assets (e.g. 1Password CLI). Uses unzip
# when present, else python3's zipfile module, so no hard dependency on
# either being installed.
install_zip_bin() {
mkdir -p "$BIN_DIR"
_tmp="$(mktemp -d)"
_fetch_dl "$1" "$_tmp/archive.zip"
if command -v unzip >/dev/null 2>&1; then
unzip -q "$_tmp/archive.zip" -d "$_tmp"
elif command -v python3 >/dev/null 2>&1; then
python3 -m zipfile -e "$_tmp/archive.zip" "$_tmp"
else
fsay "fetch: need unzip or python3 to extract zip"; rm -rf "$_tmp"; return 1
fi
install -m 0755 "$_tmp/$2" "$BIN_DIR/$3"
rm -rf "$_tmp"
fsay "installed $BIN_DIR/$3"
}
# install_raw_bin <url> <dest-name> — download a single raw binary.
install_raw_bin() {
mkdir -p "$BIN_DIR"
+32 -15
View File
@@ -10,18 +10,33 @@ sync — the laptops pull from Gitea's `main`.)
## How it works
- `opencode.gateway.json` — the shared server list. **Identical for every
user**: the per-user key is injected via `{env:LITELLM_KEY}`, never written
into the file. Add a gateway server here and everyone picks it up next shell.
- `sync.sh` — sourced from each user's shell rc. On a new shell it pulls
`opencode.gateway.json` (throttled ~1h), drops it where opencode merges it,
and exports `LITELLM_KEY` from `~/.config/mcp-sync/key`. Silent + non-fatal:
a Gitea hiccup never breaks the shell, last-good config stays in place.
- `opencode.gateway.json` — the shared base config: the `covert-gpt` model
provider (LiteLLM's OpenAI-compatible endpoint at
`https://llm.covert-gpt.com/v1`, models map left empty — it is filled by
discovery at sync time) and a single MCP entry for the gateway's
**aggregate** endpoint `https://llm.covert-gpt.com/mcp/` (trailing slash
required; the bare path 307-redirects, which MCP clients don't follow).
That one endpoint exposes every MCP server the user's key can access, so
new gateway servers need no client config change at all. **Identical for
every user**: the per-user key is injected via `{env:LITELLM_KEY}`, never
written into the file.
- `sync.sh` — sourced from each user's shell rc. On a new shell (throttled
~1h) it pulls the base config from Gitea, queries the gateway's
`/v1/models` with the user's key to **auto-discover models** (opencode
can't list models from a custom provider itself, so the map is
materialized client-side; if discovery fails the previous map is carried
over), deploys the result to `~/.config/opencode/opencode.gateway.json`,
and exports `LITELLM_KEY` (from `~/.config/mcp-sync/key`) plus
`OPENCODE_CONFIG` (pointing at the deployed file — opencode does NOT
auto-load extra files in its config dir, so without this export the file
is ignored). Silent + non-fatal: a Gitea hiccup never breaks the shell,
last-good config stays in place.
- `install.sh` — one-time bootstrap (curl-pipe). Fetches `sync.sh`, stores the
user's key (0600), and adds the rc hook.
opencode **merges** config files, so the synced `opencode.gateway.json`
coexists with each user's own `~/.config/opencode/opencode.json` and any
opencode merges the `OPENCODE_CONFIG` file on top of the user's global
`~/.config/opencode/opencode.json` (and under any project `opencode.json`),
so the synced config coexists with each user's own settings and any
**local-only** MCP servers (e.g. a client-side `kubernetes` pointed at their
own kubeconfig). Sync never touches those.
@@ -37,13 +52,15 @@ curl -fsSL https://git.nic-oconnor.com/public/dotfiles/raw/branch/main/mcp-confi
Open a new terminal, run `opencode`, and the gateway tools are there.
## Adding a gateway server (admin)
## Adding a gateway server or model (admin)
1. Register it on the gateway (`charts/inference/values.yaml` →
`proxy_config.mcp_servers`), deploy, smoke-test.
2. Add a matching entry to `opencode.gateway.json` here and push to `main`.
3. Users get it automatically within the sync TTL (or immediately on a new
shell). No per-user action.
Register it on the gateway (`charts/inference/values.yaml` →
`proxy_config.mcp_servers` for MCP servers, `model_list` for models),
deploy, smoke-test. That's it — clients auto-discover both: MCP servers
through the aggregate `/mcp/` endpoint, models via the `/v1/models` query
in `sync.sh`. Users pick them up within the sync TTL (or immediately on a
new shell). `opencode.gateway.json` here only needs editing for structural
changes (a new provider, endpoint URLs).
For **per-user / per-target** tools (cluster access, etc.) do NOT add them
here — those run client-side with the user's own credentials. See
+13 -2
View File
@@ -1,9 +1,20 @@
{
"$schema": "https://opencode.ai/config.json",
"provider": {
"covert-gpt": {
"npm": "@ai-sdk/openai-compatible",
"name": "covert-gpt (LiteLLM)",
"options": {
"baseURL": "https://llm.covert-gpt.com/v1",
"apiKey": "{env:LITELLM_KEY}"
},
"models": {}
}
},
"mcp": {
"gitea": {
"covert-gpt": {
"type": "remote",
"url": "https://llm.covert-gpt.com/mcp/gitea",
"url": "https://llm.covert-gpt.com/mcp/",
"enabled": true,
"headers": {
"Authorization": "Bearer {env:LITELLM_KEY}"
+113 -44
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env sh
# mcp-sync — refresh the shared LiteLLM-gateway MCP config on the laptop and
# export the user's LiteLLM key into the environment.
# mcp-sync — refresh the shared LiteLLM-gateway opencode config on the laptop,
# auto-discover the gateway's models, and export the user's LiteLLM key.
#
# Sourced from the user's shell rc (installed by install.sh) so it runs at the
# start of every interactive shell ("session start"). It is intentionally:
@@ -10,14 +10,21 @@
#
# What it does:
# 1. Fetch the shared opencode.gateway.json from Gitea (the single source of
# truth for which gateway MCP servers exist). The file is identical for
# all users — the per-user key is injected via {env:LITELLM_KEY}, never
# baked into the file.
# 2. Drop it at ~/.config/opencode/opencode.gateway.json. opencode MERGES
# config files, so this coexists with the user's own opencode.json and
# any local-only MCP servers (e.g. a client-side kubernetes pointed at
# their kubeconfig) — those are never touched.
# 3. Export LITELLM_KEY from ~/.config/mcp-sync/key so {env:LITELLM_KEY}
# truth: the covert-gpt provider + the aggregate MCP endpoint). The file
# is identical for all users — the per-user key is injected via
# {env:LITELLM_KEY}, never baked into the file.
# 2. Auto-discover models: query the gateway's /v1/models with the user's
# key and fill the provider's "models" map. New models exposed on the
# gateway appear on every box at the next sync — no config edit anywhere.
# (opencode cannot list models from a custom provider itself; the map
# must be materialized client-side.) MCP servers need no discovery step:
# the single /mcp/ endpoint aggregates every server the key can access.
# 3. Drop the result at ~/.config/opencode/opencode.gateway.json and export
# OPENCODE_CONFIG pointing at it. opencode does NOT auto-load extra files
# in its config dir — it only merges known locations, and OPENCODE_CONFIG
# is merged between the global opencode.json and any project config. The
# user's own opencode.json and local-only MCP servers are never touched.
# 4. Export LITELLM_KEY from ~/.config/mcp-sync/key so {env:LITELLM_KEY}
# resolves.
# --- config (overridable via env before sourcing) -------------------------
@@ -25,40 +32,14 @@ MCP_SYNC_RAW_URL="${MCP_SYNC_RAW_URL:-https://git.nic-oconnor.com/public/dotfile
MCP_SYNC_HOME="${MCP_SYNC_HOME:-$HOME/.config/mcp-sync}"
MCP_SYNC_DEST="${MCP_SYNC_DEST:-$HOME/.config/opencode/opencode.gateway.json}"
MCP_SYNC_KEYFILE="${MCP_SYNC_KEYFILE:-$MCP_SYNC_HOME/key}"
# Throttle: only re-pull if the cached copy is older than this many seconds
# (default 1h). Keeps every new shell from hammering Gitea.
# Provider whose models are auto-discovered, and the gateway base URL.
MCP_SYNC_PROVIDER="${MCP_SYNC_PROVIDER:-covert-gpt}"
MCP_SYNC_GATEWAY="${MCP_SYNC_GATEWAY:-https://llm.covert-gpt.com}"
# Throttle: only re-sync if the deployed copy is older than this many seconds
# (default 1h). Keeps every new shell from hammering Gitea/the gateway.
MCP_SYNC_TTL="${MCP_SYNC_TTL:-3600}"
# --- 1+2. refresh the gateway config (throttled, non-fatal) ---------------
_mcp_sync_fetch() {
mkdir -p "$(dirname "$MCP_SYNC_DEST")" || return 0
# Skip if we pulled recently (mtime within TTL). `find -mmin` is portable
# enough; fall back to always-fetch if find is unavailable.
if [ -f "$MCP_SYNC_DEST" ] && command -v find >/dev/null 2>&1; then
if find "$MCP_SYNC_DEST" -mmin -"$(( MCP_SYNC_TTL / 60 ))" 2>/dev/null | grep -q .; then
return 0
fi
fi
command -v curl >/dev/null 2>&1 || return 0
tmp="$(mktemp 2>/dev/null)" || return 0
# -f: fail on HTTP error; -s: silent; -m: hard timeout so a hung network
# can't stall the shell.
if curl -fsS -m 8 "$MCP_SYNC_RAW_URL" -o "$tmp" 2>/dev/null; then
# Only replace if it's non-empty and valid JSON (don't clobber good config
# with an error page).
if [ -s "$tmp" ] && { ! command -v python3 >/dev/null 2>&1 || python3 -c "import json,sys; json.load(open(sys.argv[1]))" "$tmp" 2>/dev/null; }; then
mv "$tmp" "$MCP_SYNC_DEST" 2>/dev/null || rm -f "$tmp"
else
rm -f "$tmp"
fi
else
rm -f "$tmp"
fi
}
# --- 3. export the per-user key -------------------------------------------
# --- export the per-user key (first: model discovery needs it) -------------
_mcp_sync_export_key() {
[ -f "$MCP_SYNC_KEYFILE" ] || return 0
# Strip whitespace/newline; export so {env:LITELLM_KEY} resolves in opencode.
@@ -66,6 +47,94 @@ _mcp_sync_export_key() {
[ -n "$LITELLM_KEY" ] && export LITELLM_KEY
}
_mcp_sync_fetch
# --- refresh config + discover models (throttled, non-fatal) ---------------
_mcp_sync_refresh() {
mkdir -p "$(dirname "$MCP_SYNC_DEST")" 2>/dev/null || return 0
# Skip if we synced recently (mtime within TTL). `find -mmin` is portable
# enough; fall back to always-sync if find is unavailable.
if [ -f "$MCP_SYNC_DEST" ] && command -v find >/dev/null 2>&1; then
if find "$MCP_SYNC_DEST" -mmin -"$(( MCP_SYNC_TTL / 60 ))" 2>/dev/null | grep -q .; then
return 0
fi
fi
command -v curl >/dev/null 2>&1 || return 0
# 1. Fetch the shared base config. On failure fall back to the currently
# deployed file so model discovery can still refresh it.
_base="$(mktemp 2>/dev/null)" || return 0
# -f: fail on HTTP error; -s: silent; -m: hard timeout so a hung network
# can't stall the shell.
if ! curl -fsS -m 8 "$MCP_SYNC_RAW_URL" -o "$_base" 2>/dev/null || [ ! -s "$_base" ]; then
rm -f "$_base"; _base=""
[ -f "$MCP_SYNC_DEST" ] || return 0
fi
# 2. Discover the gateway's models with the user's key (best-effort).
_models=""
if [ -n "${LITELLM_KEY:-}" ]; then
_models="$(curl -fsS -m 8 -H "Authorization: Bearer $LITELLM_KEY" \
"$MCP_SYNC_GATEWAY/v1/models" 2>/dev/null)" || _models=""
fi
# 3. Merge and deploy. python3 validates the base (don't clobber good config
# with an error page), injects the discovered models, and — when discovery
# failed — carries over the previous deployed models map so a gateway
# hiccup never empties the model list. Without python3, deploy the raw
# base as-is (no discovery).
if command -v python3 >/dev/null 2>&1; then
MCP_SYNC_BASE="$_base" MCP_SYNC_MODELS="$_models" \
MCP_SYNC_PY_DEST="$MCP_SYNC_DEST" MCP_SYNC_PY_PROVIDER="$MCP_SYNC_PROVIDER" \
python3 - <<'PYEOF' 2>/dev/null || true
import json, os, tempfile
dest = os.environ["MCP_SYNC_PY_DEST"]
provider = os.environ["MCP_SYNC_PY_PROVIDER"]
def load(path):
with open(path) as f:
return json.load(f)
base_path = os.environ.get("MCP_SYNC_BASE") or dest
try:
cfg = load(base_path)
except Exception:
raise SystemExit(0) # invalid base: keep last-good deployed config
models = {}
try:
data = json.loads(os.environ.get("MCP_SYNC_MODELS") or "{}")
models = {m["id"]: {} for m in data.get("data", []) if m.get("id")}
except Exception:
pass
if not models:
try: # discovery failed: carry over the previous deployed map
models = load(dest)["provider"][provider]["models"]
except Exception:
pass
prov = cfg.get("provider", {}).get(provider)
if prov is not None and models:
prov["models"] = models
fd, tmp = tempfile.mkstemp(dir=os.path.dirname(dest))
with os.fdopen(fd, "w") as f:
json.dump(cfg, f, indent=2)
os.replace(tmp, dest)
PYEOF
elif [ -n "$_base" ]; then
mv "$_base" "$MCP_SYNC_DEST" 2>/dev/null || true
_base=""
fi
[ -n "$_base" ] && rm -f "$_base"
}
_mcp_sync_export_key
unset -f _mcp_sync_fetch _mcp_sync_export_key 2>/dev/null || true
_mcp_sync_refresh
# opencode only merges config from known locations; point OPENCODE_CONFIG at
# the synced file so it is layered on top of the user's global opencode.json.
[ -f "$MCP_SYNC_DEST" ] && export OPENCODE_CONFIG="$MCP_SYNC_DEST"
unset -f _mcp_sync_export_key _mcp_sync_refresh 2>/dev/null || true
+18
View File
@@ -2,6 +2,19 @@
# overwritten on the next bootstrap run. Edit zsh/zshrc in the dotfiles repo
# instead.
# If this account was added to the docker group (dotfiles/docker/install.sh)
# but this shell's session predates that, re-exec via newgrp so docker works
# without a full logout. Guarded: fires only when the group is on the account
# yet missing from the running shell's groups, so the newgrp'd shell (which
# has the group) cannot loop.
# Only for real terminals ([ -t 0 ]): newgrp replaces the shell and would
# swallow the command in scripted `zsh -i -c ...` invocations.
if [ -t 0 ] && [ -S /var/run/docker.sock ] \
&& id -nG "${USER:-$(id -un)}" 2>/dev/null | grep -qw docker \
&& ! id -Gn 2>/dev/null | grep -qw docker; then
exec newgrp docker
fi
# Userland tools installed by dotfiles/bin/install.sh live here.
case ":$PATH:" in
*":$HOME/.local/bin:"*) ;;
@@ -20,3 +33,8 @@ HISTSIZE=10000
SAVEHIST=10000
setopt SHARE_HISTORY
setopt HIST_IGNORE_DUPS
# Per-machine customizations (secrets, keys, local overrides) live in
# ~/.zshrc.local, which bootstrap never touches. See zsh/zshrc.local.example
# for the variables modules look for. Loaded last so it can override anything.
[ -f "$HOME/.zshrc.local" ] && source "$HOME/.zshrc.local"
+26
View File
@@ -0,0 +1,26 @@
# ~/.zshrc.local — per-machine customizations. Copy this file to
# ~/.zshrc.local and fill in what this box needs. It is sourced at the end
# of the managed ~/.zshrc, is never overwritten by bootstrap, and never
# lives in the repo — this is the ONLY place to put secrets.
#
# cp zsh/zshrc.local.example ~/.zshrc.local && chmod 600 ~/.zshrc.local
# --- Gitea -------------------------------------------------------------
# Personal access token for git.nic-oconnor.com (API/CLI use; HTTPS git
# auth itself goes through credential.helper store, see git/).
# export GITEA_KEY=...
# --- covert-gpt LiteLLM gateway -----------------------------------------
# LITELLM_KEY is normally exported by mcp-config's sync hook from
# ~/.config/mcp-sync/key — run mcp-config/install.sh once instead of
# setting it here. Only set it here on a box without mcp-sync:
# export LITELLM_KEY=sk-...
# --- 1Password CLI -------------------------------------------------------
# Service-account token for non-interactive `op read` / `op run`.
# export OP_SERVICE_ACCOUNT_TOKEN=ops_...
# --- Anything else -------------------------------------------------------
# Aliases, PATH additions, kubeconfig defaults, etc. This file loads last,
# so it can override anything the managed zshrc sets.
# export KUBECONFIG="$HOME/.kube/homelab.yaml"