#!/usr/bin/env sh # mcp-sync — refresh the shared LiteLLM-gateway opencode config on the laptop, # auto-discover the gateway's models, deploy the shared skills, 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: # - FAST and SILENT on success (no output unless something is wrong) # - NON-FATAL: a network/Gitea hiccup must never break the user's shell, so # every failure path just returns, leaving the last-good config in place. # # What it does: # 1. Read the shared opencode.gateway.json (the single source of truth: the # covert-gpt provider + the client-side MCP servers). PREFERRED SOURCE IS # THE LOCAL CHECKOUT — bootstrap.sh already cloned this repo over # authenticated git, so re-downloading a file that is sitting on disk is # both redundant and, historically, the ONLY reason the repo had to be # world-readable. A box with no checkout falls back to the Gitea API with # $GITEA_KEY. The file is identical for all users — EVERY credential # (LiteLLM key, Gitea PAT, Proxmox token) is injected via {env:VAR} and # 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: # they are declared in the config and run locally under `npx`, each with # the user's OWN credentials. The gateway hosts no MCP servers at all — # see mcp-config/README.md for why (user-scoped APIs, no shared tokens). # 3. Deploy the repo's skills/ tree to ~/.config/mcp-sync/skills. Skills are # plain files, so there is nothing on the gateway to discover — the repo # IS the source of truth, and the tree is fetched whole (repo tarball). # They land in mcp-sync's OWN directory, not ~/.config/opencode/skills, # so the atomic replace below can never eat a skill the user wrote by # hand; opencode picks them up via skills.paths in the shared config. # 4. 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. # 5. Export LITELLM_KEY from ~/.config/mcp-sync/key so {env:LITELLM_KEY} # resolves. # --- config (overridable via env before sourcing) ------------------------- # The local checkout is the preferred source for BOTH the config and the # skills; these URLs are the no-checkout fallback only. They are Gitea *API* # endpoints on purpose: the web /raw/ route answers an unauthorized request # with a 200 and an HTML login page, which `curl -f` treats as success and # happily writes over the file it was fetching. The API answers with an honest # 401/403, so -f can be trusted. MCP_SYNC_API_BASE="${MCP_SYNC_API_BASE:-https://git.nic-oconnor.com/api/v1/repos/public/dotfiles}" MCP_SYNC_REF="${MCP_SYNC_REF:-main}" MCP_SYNC_RAW_URL="${MCP_SYNC_RAW_URL:-$MCP_SYNC_API_BASE/raw/mcp-config/opencode.gateway.json?ref=$MCP_SYNC_REF}" MCP_SYNC_HOME="${MCP_SYNC_HOME:-$HOME/.config/mcp-sync}" # Where the checkout is. install.sh records the tree it ran from; failing that # we look where bootstrap.sh clones to. MCP_SYNC_REPO="${MCP_SYNC_REPO:-}" MCP_SYNC_REPO_FILE="${MCP_SYNC_REPO_FILE:-$MCP_SYNC_HOME/source}" MCP_SYNC_DOTFILES_DIR="${MCP_SYNC_DOTFILES_DIR:-$HOME/.dotfiles}" MCP_SYNC_DEST="${MCP_SYNC_DEST:-$HOME/.config/opencode/opencode.gateway.json}" MCP_SYNC_KEYFILE="${MCP_SYNC_KEYFILE:-$MCP_SYNC_HOME/key}" # 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}" # Skills. Taken from the checkout like the config; with no checkout we fall # back to the repo tarball and lift out its skills/ subtree (a skill is a folder # of files, so unlike the config there is no single raw URL to pull). Set # MCP_SYNC_SKILLS_SRC to a local directory (or file://) to pin it to one tree; # see zsh/zshrc.local.example. MCP_SYNC_SKILLS_SRC="${MCP_SYNC_SKILLS_SRC:-$MCP_SYNC_API_BASE/archive/$MCP_SYNC_REF.tar.gz}" MCP_SYNC_SKILLS_SUBDIR="${MCP_SYNC_SKILLS_SUBDIR:-skills}" MCP_SYNC_SKILLS_DEST="${MCP_SYNC_SKILLS_DEST:-$MCP_SYNC_HOME/skills}" # Hermes: the SAME MCP servers, rendered into Hermes' own schema. Set # MCP_SYNC_HERMES_CONFIG to empty to opt out entirely. MCP_SYNC_HERMES_CONFIG="${MCP_SYNC_HERMES_CONFIG-$HOME/.hermes/config.yaml}" # Names we rendered last time, so a server REMOVED upstream can be removed here # without touching MCP servers the user added to Hermes by hand. MCP_SYNC_HERMES_MANIFEST="${MCP_SYNC_HERMES_MANIFEST:-$MCP_SYNC_HOME/hermes-managed.json}" # 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}" # --- 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. LITELLM_KEY="$(tr -d ' \t\r\n' < "$MCP_SYNC_KEYFILE" 2>/dev/null)" [ -n "$LITELLM_KEY" ] && export LITELLM_KEY } # --- locate the local checkout (the preferred source) ---------------------- # Prints the repo root on stdout, or fails. Order: explicit override, the path # install.sh recorded, then bootstrap.sh's clone location. A directory only # counts if it actually holds the config, so a half-deleted tree falls through # to the network rather than deploying nothing. _mcp_sync_find_repo() { for _cand in "$MCP_SYNC_REPO" \ "$([ -f "$MCP_SYNC_REPO_FILE" ] && cat "$MCP_SYNC_REPO_FILE" 2>/dev/null)" \ "$MCP_SYNC_DOTFILES_DIR"; do [ -n "$_cand" ] || continue if [ -f "$_cand/mcp-config/opencode.gateway.json" ]; then printf '%s\n' "$_cand" unset _cand return 0 fi done unset _cand return 1 } # git, authenticated with the Gitea PAT when we have one, so a PRIVATE repo # works. The token goes through the environment rather than `git -c ...` # because argv is world-readable in `ps`. _mcp_sync_git() { if [ -n "${GITEA_KEY:-}" ]; then GIT_CONFIG_COUNT=1 \ GIT_CONFIG_KEY_0=http.extraHeader \ GIT_CONFIG_VALUE_0="Authorization: token $GITEA_KEY" \ git "$@" else git "$@" fi } # Refresh the checkout so a pushed config change actually lands. ONLY for the # bootstrap-managed clone: a recorded DEV checkout (someone's ~/source/dotfiles) # must never be pulled out from under them by a shell hook. Skipped unless the # tree is clean and sitting on its default branch; quiet and non-fatal always. _mcp_sync_pull() { [ "$1" = "$MCP_SYNC_DOTFILES_DIR" ] || return 0 [ -d "$1/.git" ] || return 0 command -v git >/dev/null 2>&1 || return 0 [ -z "$(git -C "$1" status --porcelain 2>/dev/null)" ] || return 0 _branch="$(git -C "$1" symbolic-ref --short -q HEAD 2>/dev/null)" || return 0 _default="$(git -C "$1" symbolic-ref --short -q refs/remotes/origin/HEAD 2>/dev/null)" _default="${_default#origin/}" [ -n "$_default" ] || _default=main [ "$_branch" = "$_default" ] || { unset _branch _default; return 0; } unset _branch _default _mcp_sync_git -C "$1" pull --ff-only --quiet >/dev/null 2>&1 || true } # curl, with the Gitea PAT when we have one. Used only on the no-checkout path. _mcp_sync_curl() { if [ -n "${GITEA_KEY:-}" ]; then curl -fsS -m "${3:-8}" -H "Authorization: token $GITEA_KEY" "$1" -o "$2" 2>/dev/null else curl -fsS -m "${3:-8}" "$1" -o "$2" 2>/dev/null fi } # --- 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 # 1. Get the shared base config. THE CHECKOUT WINS: it is already on disk, # was fetched over authenticated git, and needs no network at all. Only a # box with no checkout goes to Gitea. On total failure we fall back to the # currently deployed file so model discovery can still refresh it. _base="" _repo="$(_mcp_sync_find_repo)" || _repo="" if [ -n "$_repo" ]; then _mcp_sync_pull "$_repo" if [ -s "$_repo/mcp-config/opencode.gateway.json" ]; then _base="$(mktemp 2>/dev/null)" || return 0 cp "$_repo/mcp-config/opencode.gateway.json" "$_base" 2>/dev/null || { rm -f "$_base"; _base=""; } fi fi if [ -z "$_base" ]; then command -v curl >/dev/null 2>&1 || return 0 _base="$(mktemp 2>/dev/null)" || return 0 if ! _mcp_sync_curl "$MCP_SYNC_RAW_URL" "$_base" || [ ! -s "$_base" ]; then rm -f "$_base"; _base="" [ -f "$MCP_SYNC_DEST" ] || return 0 elif [ "$(head -c 1 "$_base")" != "{" ]; then # A 200 that is not the config. The API endpoints we default to return an # honest 401/403, so this only fires for an override still pointing at # Gitea's web /raw/ route, which answers with a 200 HTML login page that # -f cannot detect. The python step below would reject it and keep the # last-good file — correct, but SILENTLY, leaving the config frozen with # nothing to show for it. Say so instead. rm -f "$_base"; _base="" printf 'mcp-sync: %s did not return JSON (Gitea login page?).\n' "$MCP_SYNC_RAW_URL" >&2 printf 'mcp-sync: set GITEA_KEY, or point MCP_SYNC_REPO at a checkout.\n' >&2 # Bump the deployed file's mtime so the TTL throttles this warning to once # an hour instead of printing it at the top of every single shell. [ -f "$MCP_SYNC_DEST" ] && touch "$MCP_SYNC_DEST" 2>/dev/null [ -f "$MCP_SYNC_DEST" ] || return 0 fi 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 # 2b. Enrich with per-model metadata. /v1/models returns ONLY ids — no context # window, no cost, no capability flags — and opencode needs those or it # runs every model on defaults (it cannot manage truncation without a # context limit, and won't know a model can call tools). /model/info has # them. Best-effort: a key still scoped to llm_api_routes 403s here, and # we fall back to names-only, exactly as before. # VERSIONED PATH FIRST. LiteLLM serves this metadata at both /model/info and # /v1/model/info, but a virtual key's allowed_routes is matched on the exact # path — granting "/v1/model/info" does NOT also grant "/model/info". Since # MCP_SYNC_GATEWAY has no /v1 suffix, the unversioned path is what this used # to request, so a key scoped to the versioned route still got a 403 and # still silently fell back to names-only. Try both, versioned first, so it # works whichever way the key is scoped. _info="" if [ -n "${LITELLM_KEY:-}" ]; then for _mi_path in /v1/model/info /model/info; do _info="$(curl -fsS -m 8 -H "Authorization: Bearer $LITELLM_KEY" \ "$MCP_SYNC_GATEWAY$_mi_path" 2>/dev/null)" || _info="" [ -n "$_info" ] && break done unset _mi_path 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_MODELINFO="$_info" \ 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 # LiteLLM /model/info -> opencode's per-model spec. Only fields the gateway # actually reports are emitted; a model LiteLLM knows nothing about (a # self-hosted vLLM entry with no model_info in the gateway's model_list) simply # stays {}, which is what every model got before this existed. # opencode's limit.output is a RESERVE it subtracts from limit.context to decide # when to compact — NOT a server-side cap. LiteLLM reports max_output_tokens as # the whole window for self-hosted vLLM (vLLM caps TOTAL context, not output # separately — see charts/inference/values.yaml model_info), so copying it # verbatim made opencode reserve the entire window and target an input of # context - output. Cap the reserve at a generous agent-output budget instead. OUTPUT_RESERVE_CAP = 32768 # opencode targets input == context - output EXACTLY, and the server counts one # more token than opencode does, landing at context + 1 and getting a hard 400 # (ContextWindowExceededError). Observed on qwen3-coder-next: 196609 in + 65536 # out = 262145 vs a 262144 window, one token over, every time. Hold a margin # back so opencode's target can never sit on the boundary. CONTEXT_SAFETY_MARGIN = 1024 def spec_for(mi): spec = {} limit = {} if mi.get("max_input_tokens"): limit["context"] = max(1, mi["max_input_tokens"] - CONTEXT_SAFETY_MARGIN) if mi.get("max_output_tokens"): limit["output"] = min(mi["max_output_tokens"], OUTPUT_RESERVE_CAP) if limit: spec["limit"] = limit # LiteLLM prices PER TOKEN; opencode (like models.dev) uses $ per 1M tokens. cost = {} for src, dst in (("input_cost_per_token", "input"), ("output_cost_per_token", "output")): if mi.get(src) is not None: cost[dst] = mi[src] * 1_000_000 if cost: spec["cost"] = cost for src, dst in (("supports_function_calling", "tool_call"), ("supports_reasoning", "reasoning"), ("supports_vision", "attachment")): if mi.get(src) is not None: spec[dst] = bool(mi[src]) return spec info = {} try: for entry in (json.loads(os.environ.get("MCP_SYNC_MODELINFO") or "{}") .get("data", [])): name = entry.get("model_name") if name: info[name] = spec_for(entry.get("model_info") or {}) except Exception: pass # Per-model specs declared in the BASE config are a FLOOR; live /model/info # overrides them field by field. This matters because the /model/info fetch # above is best-effort and 403s for any key not scoped to management routes — # which is the normal case for a day-to-day virtual key. Before this merge that # fallback silently produced `{}` for every model, i.e. no context limit at # all, and opencode then ran the whole gateway on its built-in defaults and # walked straight into ContextWindowExceededError 400s on the one model whose # window is smaller than those defaults. Declaring limits in the base file did # not help either, because `prov["models"] = models` replaced the map wholesale. # Live data still wins wherever it is present, so this reintroduces no drift. base_models = {} try: base_models = (cfg.get("provider", {}).get(provider) or {}).get("models") or {} except Exception: pass models = {} try: data = json.loads(os.environ.get("MCP_SYNC_MODELS") or "{}") models = {m["id"]: {**(base_models.get(m["id"]) or {}), **dict(info.get(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 # No python3: the base config is deployed verbatim, so its "models" map # stays EMPTY and opencode shows the provider with zero models. That looks # like a broken gateway rather than a missing dependency, so say so — this # is the one case worth breaking the silence for. mv "$_base" "$MCP_SYNC_DEST" 2>/dev/null || true _base="" printf 'mcp-sync: python3 not found — models cannot be discovered.\n' >&2 printf 'mcp-sync: install python3 (bootstrap.sh does), then open a new shell.\n' >&2 fi [ -n "$_base" ] && rm -f "$_base" } # --- render the SAME MCP servers into Hermes' config (non-fatal) ----------- # # WHY THIS EXISTS: the MCP servers are defined ONCE, in opencode.gateway.json's # `mcp` block (see README: "all MCP servers are defined here"). Hermes speaks a # different schema, so without this it would need a second copy of every server # — two definitions to drift apart, one pinned to gitea-mcp@0.0.10 and the other # to whatever got typed. Instead we translate the one definition into Hermes' # shape, the same way this file already translates LiteLLM's /model/info into # opencode's. Add a server to Gitea once and BOTH clients get it. # # It is NOT a shared server process: MCP stdio servers are subprocesses, spawned # per client over stdin/stdout and dying with it. Each client running its own # `npx -y ` is the design — same package, same pinned version, same # per-user credential, same identity. There is no daemon to share. # # Reads the DEPLOYED opencode config (post-refresh), so it always reflects what # Gitea just served. No-ops silently when Hermes isn't installed. _mcp_sync_hermes() { [ -n "${MCP_SYNC_HERMES_CONFIG:-}" ] || return 0 # Hermes not installed (or never run) — nothing to configure, stay silent. [ -f "$MCP_SYNC_HERMES_CONFIG" ] || return 0 [ -f "$MCP_SYNC_DEST" ] || return 0 command -v python3 >/dev/null 2>&1 || return 0 # PyYAML is needed to touch config.yaml at all. Unlike models, a missing MCP # block is invisible rather than obviously broken, so say something. if ! python3 -c 'import yaml' >/dev/null 2>&1; then printf 'mcp-sync: python3 has no PyYAML — Hermes MCP servers not synced.\n' >&2 return 0 fi MCP_SYNC_PY_SRC="$MCP_SYNC_DEST" \ MCP_SYNC_PY_HERMES="$MCP_SYNC_HERMES_CONFIG" \ MCP_SYNC_PY_MANIFEST="$MCP_SYNC_HERMES_MANIFEST" \ MCP_SYNC_PY_PROVIDER="$MCP_SYNC_PROVIDER" \ python3 - <<'PYEOF' 2>/dev/null || true import json, os, re, tempfile import yaml src = os.environ["MCP_SYNC_PY_SRC"] dest = os.environ["MCP_SYNC_PY_HERMES"] manifest_path = os.environ["MCP_SYNC_PY_MANIFEST"] try: with open(src) as f: oc = json.load(f) except Exception: raise SystemExit(0) # bad/incomplete opencode config: leave Hermes alone # opencode's "{env:VAR}" -> Hermes' "${VAR}". Both are by-NAME references # resolved at runtime, so no secret is ever written to either file. _ENV_REF = re.compile(r"\{env:([A-Za-z_][A-Za-z0-9_]*)\}") def _refs(v): if isinstance(v, str): return _ENV_REF.sub(r"${\1}", v) if isinstance(v, dict): return {k: _refs(x) for k, x in v.items()} if isinstance(v, list): return [_refs(x) for x in v] return v def spec_for(name, s): if not isinstance(s, dict): return None # opencode defaults enabled to true; so does Hermes. Honour an explicit off. if s.get("enabled") is False: return None kind = s.get("type", "local") if kind == "remote": url = s.get("url") if not url: return None out = {"url": url} headers = s.get("headers") if isinstance(headers, dict) and headers: out["headers"] = _refs(headers) return out # local/stdio. opencode: command is a LIST (argv). Hermes: command is a # STRING and args is a separate list -- a list here stringifies to # "['npx', '-y']" and fails at exec, so the split is mandatory. cmd = s.get("command") if not isinstance(cmd, list) or not cmd: return None out = {"command": str(cmd[0])} if len(cmd) > 1: out["args"] = [str(a) for a in cmd[1:]] env = s.get("environment") if isinstance(env, dict) and env: # Hermes hands the child only a filtered baseline env, so every secret # the server needs MUST be passed explicitly here. out["env"] = _refs(env) return out rendered = {} for name, s in (oc.get("mcp") or {}).items(): spec = spec_for(name, s) if spec is not None: rendered[name] = spec if not rendered: raise SystemExit(0) try: with open(dest) as f: cfg = yaml.safe_load(f) or {} except Exception: raise SystemExit(0) # never overwrite a config we cannot parse if not isinstance(cfg, dict): raise SystemExit(0) current = cfg.get("mcp_servers") if not isinstance(current, dict): current = {} merged = dict(current) # Drop servers we rendered before that upstream has since removed. Anything the # user added to Hermes themselves was never in the manifest, so it survives. try: with open(manifest_path) as f: prev = json.load(f) prev = prev if isinstance(prev, list) else [] except Exception: prev = [] for gone in set(prev) - set(rendered): merged.pop(gone, None) merged.update(rendered) if merged != current: cfg["mcp_servers"] = merged d = os.path.dirname(dest) or "." fd, tmp = tempfile.mkstemp(dir=d) with os.fdopen(fd, "w") as f: yaml.safe_dump(cfg, f, sort_keys=False, default_flow_style=False, allow_unicode=True) os.chmod(tmp, 0o600) os.replace(tmp, dest) os.makedirs(os.path.dirname(manifest_path) or ".", exist_ok=True) fd, tmp = tempfile.mkstemp(dir=os.path.dirname(manifest_path) or ".") with os.fdopen(fd, "w") as f: json.dump(sorted(rendered), f) os.replace(tmp, manifest_path) PYEOF } # --- deploy the shared skills tree (throttled, non-fatal) ------------------ _mcp_sync_skills() { # Same TTL as the config, tracked on the deployed directory's own mtime. if [ -d "$MCP_SYNC_SKILLS_DEST" ] && command -v find >/dev/null 2>&1; then if find "$MCP_SYNC_SKILLS_DEST" -maxdepth 0 -mmin -"$(( MCP_SYNC_TTL / 60 ))" 2>/dev/null | grep -q .; then return 0 fi fi mkdir -p "$MCP_SYNC_HOME" 2>/dev/null || return 0 # Stage inside MCP_SYNC_HOME (not /tmp) so the publish below is a same- # filesystem rename rather than a copy that can be seen half-finished. _work="$(mktemp -d "$MCP_SYNC_HOME/.skills.XXXXXX" 2>/dev/null)" || return 0 _tree="" case "$MCP_SYNC_SKILLS_SRC" in /*|file://*) # Working checkout: the source is the skills/ dir itself. _tree="${MCP_SYNC_SKILLS_SRC#file://}" ;; *) # Same rule as the config: the checkout is the source of truth when there # is one, so no tarball download and no anonymous read required. _repo="$(_mcp_sync_find_repo)" || _repo="" if [ -n "$_repo" ] && [ -d "$_repo/$MCP_SYNC_SKILLS_SUBDIR" ]; then _tree="$_repo/$MCP_SYNC_SKILLS_SUBDIR" elif command -v curl >/dev/null 2>&1 && command -v tar >/dev/null 2>&1 && _mcp_sync_curl "$MCP_SYNC_SKILLS_SRC" "$_work/repo.tgz" 15 && [ -s "$_work/repo.tgz" ] && mkdir -p "$_work/x" && tar -xzf "$_work/repo.tgz" -C "$_work/x" 2>/dev/null then # Gitea wraps the archive in a single top-level dir whose name we don't # want to hardcode; find the skills/ subtree instead of assuming depth. _tree="$(find "$_work/x" -maxdepth 2 -type d -name "$MCP_SYNC_SKILLS_SUBDIR" 2>/dev/null | head -1)" fi ;; esac if [ -z "$_tree" ] || [ ! -d "$_tree" ]; then rm -rf "$_work"; return 0; fi mkdir -p "$_work/stage" 2>/dev/null || { rm -rf "$_work"; return 0; } cp -R "$_tree/." "$_work/stage/" 2>/dev/null || { rm -rf "$_work"; return 0; } # Never replace a good tree with an empty one (truncated fetch, empty subdir). if [ -z "$(ls -A "$_work/stage" 2>/dev/null)" ]; then rm -rf "$_work"; return 0; fi # Publish: swap the new tree in, keeping the old one until the move lands so a # failure leaves the previous skills intact rather than no skills at all. rm -rf "$MCP_SYNC_SKILLS_DEST.old" 2>/dev/null if [ -d "$MCP_SYNC_SKILLS_DEST" ]; then mv "$MCP_SYNC_SKILLS_DEST" "$MCP_SYNC_SKILLS_DEST.old" 2>/dev/null || { rm -rf "$_work"; return 0; } fi if ! mv "$_work/stage" "$MCP_SYNC_SKILLS_DEST" 2>/dev/null; then mv "$MCP_SYNC_SKILLS_DEST.old" "$MCP_SYNC_SKILLS_DEST" 2>/dev/null fi rm -rf "$MCP_SYNC_SKILLS_DEST.old" "$_work" 2>/dev/null } _mcp_sync_export_key _mcp_sync_refresh # After refresh: renders the just-deployed MCP servers into Hermes' schema. _mcp_sync_hermes _mcp_sync_skills # 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" # --- mcp-sync: force a refresh now, ignoring the TTL ------------------------ # The automatic sync above runs ONLY at shell start and is throttled to # MCP_SYNC_TTL (1h). So a model or MCP server added on the gateway can take up # to an hour AND a new terminal before it shows up in opencode -- which reads # as "my new model isn't there" when in fact the gateway is fine and the local # config is just stale. Run `mcp-sync` to pull it immediately. # # The name is `mcp-sync`, but the DEFINITION below uses an underscore: a hyphen # is not a legal function name in POSIX sh, and dash rejects it at PARSE time -- # i.e. it aborts the whole file, not just this line. install.sh sources sync.sh # under /bin/sh (dash on Debian-family), so a bare `mcp-sync() {` here silently # killed install.sh's "deploy the config now" step for every install. An alias # is resolved lazily, so it costs nothing to parse and keeps the typed name. MCP_SYNC_SELF="${MCP_SYNC_SELF:-$HOME/.config/mcp-sync/sync.sh}" mcp_sync() { [ -f "$MCP_SYNC_SELF" ] || { printf 'mcp-sync: %s not found\n' "$MCP_SYNC_SELF" >&2; return 1 } # Re-source with the throttle disabled; this redefines and re-runs everything. # shellcheck disable=SC1090 MCP_SYNC_TTL=0 . "$MCP_SYNC_SELF" || return 1 command -v python3 >/dev/null 2>&1 || { printf 'mcp-sync: refreshed.\n' >&2; return 0; } MCP_SYNC_PY_DEST="$MCP_SYNC_DEST" MCP_SYNC_PY_PROVIDER="$MCP_SYNC_PROVIDER" \ python3 - <<'PYEOF' >&2 import json, os cfg = json.load(open(os.environ["MCP_SYNC_PY_DEST"])) models = cfg.get("provider", {}).get(os.environ["MCP_SYNC_PY_PROVIDER"], {}).get("models", {}) print(f"mcp-sync: {len(models)} models -> {', '.join(models) or '(none)'}") PYEOF } alias mcp-sync=mcp_sync 2>/dev/null || true unset -f _mcp_sync_export_key _mcp_sync_refresh _mcp_sync_hermes _mcp_sync_skills \ _mcp_sync_find_repo _mcp_sync_pull _mcp_sync_curl _mcp_sync_git 2>/dev/null || true # This file is SOURCED into the user's interactive shell, so scratch variables # would otherwise linger in their environment for the rest of the session. unset _base _repo _models _info _work _tree _cand _branch _default 2>/dev/null || true