Compare commits

...
41 Commits
Author SHA1 Message Date
noconnorandClaude Opus 5.5 41a90a10fb zsh: verify opc/opd against the vault and explain failures
op item edit can exit non-zero with "(404) Not Found" after an edit it
did save (seen with op 2.34.1 and 2.39.0 on an existing item), which
made opc report failure and skip the ops refresh. opc now reads the
field back and opd re-checks the item's labels, then prints either the
op:// reference it changed or what failed, op's message without its
timestamp prefix, and where to look next.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 16:49:10 +00:00
noconnorandClaude Opus 5.5 5f0dec5270 zsh: let opc take NAME=VALUE and prompt when the value is missing
`opc NAME=VALUE` sets a field directly; `opc NAME` or `opc NAME=` prompts
with hidden input, and an empty answer changes nothing. The old
`opc NAME VALUE` form still works.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 16:43:27 +00:00
noconnorandClaude Opus 5.5 fe8b9a3ec0 claude-obsidian: drop committed bytecode; ignore __pycache__
The claude-obsidian merge carried a CPython 3.12 .pyc from running the
sync script in place. Bytecode is a build artifact; ignore it repo-wide.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 13:10:49 +00:00
noconnorandClaude Opus 5 72d85eb9ec vscode: install VS Code into ~/.local/opt from upstream's tarball
The box this was written on had a half-finished install: vscode.repo
present under /etc/yum.repos.d, `code` never installed, and nothing in
this repo recording that VS Code was meant to be there. Microsoft's
rpm/deb would also need root, write outside $HOME, and be wiped by a
Kasm redeploy. Upstream's portable tarball is the same build with none
of that, so the module keeps bin/'s contract: no sudo, nothing outside
$HOME, pinned rather than floating.

The Chromium sandbox survives a userland install — the bundled
chrome-sandbox is not setuid, so Electron falls back to the
unprivileged user-namespace sandbox. Verified by launching without
--no-sandbox, so dropping root costs nothing.

Adds install_tarball_tree to lib/fetch.sh: install_tarball_bin picks a
single binary out of an archive, and VS Code only runs from an intact
Electron tree. It stages beside the target and swaps in after
extraction succeeds, so an interrupted run leaves the old version
intact.

Two things the verification caught rather than the design:

  - argv.json lives at ~/.vscode/argv.json (product.json's
    dataFolderName), not under the user-data-dir. A copy written
    anywhere else is read by nothing, so the first attempt at
    suppressing the keyring prompt silently did nothing.

  - Run as root, `code --version` prints a "super user" warning
    instead of the version and still exits 0, so a bare run-check
    never matches and would re-download 325MB on every bootstrap.
    Root falls back to the .versions stamp.

Sets password-store=basic in argv.json so Electron's safeStorage stops
waking kwalletd, which prompts for an unlock on every launch because
kwallet-pam is not installed. Confirmed with dbus-monitor: no
org.kde.KWallet traffic on launch. The trade-off — tokens obfuscated
on disk rather than sealed behind a wallet password — and the route
back to the wallet are documented in the module and the README.

No-ops on headless hosts; DOTFILES_VSCODE overrides either way.

Verified per verify-module: shellcheck -s sh clean, idempotent re-run
on both paths, and fresh install plus re-run as root on oraclelinux:9
and debian:13.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-28 13:08:49 +00:00
noconnor 7bbd911365 Merge remote-tracking branch 'origin/claude-obsidian-sync' 2026-09-24 17:56:16 -04:00
noconnorandClaude Opus 5 b6d36ba113 hermes: put ~/.local/bin on PATH so the post-install check finds the CLI
Under the Coder agent (no managed zshrc in the parent) a successful
install still failed with "hermes CLI not found after install".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-19 13:46:09 -04:00
noconnorandClaude Opus 5 c0f1fc21b7 hermes: create ~/.hermes before writing .env on a fresh home
On a first run the key-changed branch writes ~/.hermes/.env before the
installer has created ~/.hermes, so it died with "Directory nonexistent"
and failed bootstrap (seen on a fresh Coder workspace).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-19 13:39:59 -04:00
noconnorandClaude Opus 5 86b8e51646 Remove the docker module: dotfiles no longer installs Docker
Installing Docker Engine is a host/image decision, not a per-user
environment one. In Coder workspaces (unprivileged pods, no systemd) the
module ran get.docker.com on every start for a daemon that can never
run there. Where Docker is wanted, install it on the host or bake it
into the image.

Also drops the zshrc newgrp-docker hook that existed only for this
module's group change, and the docs/skills references to docker/.
docker-rootless-cgroup/ (configures Kasm's existing rootless daemon)
and bin/'s kind gate (detects a daemon) stay.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-19 13:39:59 -04:00
noconnorandClaude Opus 5 ffedc84cfb secrets: widen the token mask to 10 stars and label first/last 10
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 11:59:02 -04:00
noconnorandClaude Opus 5 cb7ab17769 secrets: show the token masked (first/last 10 chars + length)
Makes a truncated or doubled paste visible at a glance without printing
the whole token.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 11:57:16 -04:00
noconnorandClaude Opus 5 5bc8a27f60 secrets: fail loudly on a bad 1Password token instead of a LiteLLM prompt
A bad token paste (or a vault/item that did not match the login-name
default) used to surface only as mcp-config asking for a LiteLLM key,
hiding that every vault key was missing. Now:

- the token is verified with `op whoami` and re-prompted on a bad paste
- vault and item are validated, auto-picked when the token sees only one,
  and otherwise prompted with the list it can see
- bootstrap skips mcp-config and hermes, with an error naming the cause,
  when no LITELLM_KEY came from the vault

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 11:55:27 -04:00
noconnorandClaude Opus 5 8ec3269f4a bin: install kubelogin as the kubectl oidc-login plugin
omnictl-generated kubeconfigs authenticate via `kubectl oidc-login
get-token`, so install int128/kubelogin as kubectl-oidc_login.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 11:35:15 -04:00
noconnor 46052cf573 Revert "claude-obsidian: Claude conversations -> Obsidian notes (+ MCP) (#1)"
This reverts commit d3393edfdd.
2026-09-17 16:23:54 -04:00
noconnor d3393edfdd claude-obsidian: Claude conversations -> Obsidian notes (+ MCP) (#1) 2026-09-17 20:18:09 +00:00
noconnorandClaude Opus 5 db3a3e6e82 claude-obsidian: don't count skipped empty sessions as still queued
The first full drain reported "105 written, 0 failed, 9 still queued" when
the 9 were empty sessions already recorded as skipped -- nothing was left.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KF322NzozeaFjpgWvh4F4g
2026-09-17 16:14:45 -04:00
noconnorandClaude Opus 5 376ec03428 claude-obsidian: unattended overnight runs -- lock, URL fallback, config fix
Needed to leave a full backlog drain running unattended:

- Run lock (flock): enabling the timer fired an immediate catch-up run
  (Persistent=true) while a manual run was about to start; without a
  lock the two would race on state.json and usage.jsonl.
- OBSIDIAN_API_URL accepts a comma-separated list, first reachable wins,
  so the permanent address and an interim port-forward share one config.
- Fix: inline "# comments" in config.env were parsed into values, so
  BUDGET_USD_PER_RUN=0.50  # ... crashed float(). The shipped
  config.env.example had exactly that shape on several lines -- anyone
  copying it would have hit the crash. A '#' inside a value (URL fragment)
  still survives. Tested against both the example and a real config.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KF322NzozeaFjpgWvh4F4g
2026-09-17 16:14:45 -04:00
noconnorandClaude Opus 5 2bfa7e9a2d claude-obsidian: meter and cap usage; cut per-session cost ~5.6x
The user asked for usage to be monitored closely, and measuring it turned
up that most of the spend was avoidable.

Metering: every model call is appended to usage.jsonl with BILLED tokens
(modelUsage -- the visible `usage` turn undercounted by half) and the
CLI's list-price cost. `usage` reports 5h/24h/7d. Each call's cost is
estimated first and refused if it would cross the per-run or rolling 24h
budget, so budgets cannot be overshot. Removed --max-budget-usd: it
aborts after spending and discards the result ($0.26 lost in one call).

Cost, measured on one session ($0.057 -> $0.010, same summary content):
- alwaysThinkingEnabled=false: extended thinking was ~85% of output.
- DISABLE_PROMPT_CACHING=1: one-shot prompts were being written to the
  1h cache at 2x input price.
- CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1: a hidden side request
  re-read the whole prompt, doubling billed input (9,638 vs 4,663).

Summaries are cached until their note is written, so a vault failure
never re-pays; --prefetch summarizes while the vault is unreachable.
Offline tests (fake claude binary): budget refusal pre-flight, cache
reuse with zero calls, map-reduce, env/argv levers, billed-token ledger.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KF322NzozeaFjpgWvh4F4g
2026-09-17 16:14:45 -04:00
noconnorandClaude Opus 5 11138d90a9 claude-obsidian: summarize Claude conversations into Obsidian notes
One note per Claude Code session (incremental, hourly user timer) and a
claude.ai data-export importer, written through the Obsidian Local REST API
plugin -- whose built-in /mcp/ server is also how Claude reads them back.

Transcripts are condensed (tool output dropped, one line per tool call,
~60x smaller), redacted, then summarized by headless `claude -p` on the
user's own plan. Three things learned the hard way are encoded here:

- --no-session-persistence, or every summary becomes a transcript that
  the next run summarizes.
- A LiteLLM gateway passing through the caller's Claude subscription has
  no key of its own, so a background job cannot use its claude-* models.
- Summaries share the plan's usage limits. A parallel ~100-session
  backfill exhausted a session limit in minutes. So one job, a per-run
  cap, and the run stops at the first usage-limit error (exit 75) instead
  of failing the whole queue; a stop flag, because Future.cancel() cannot
  stop work a thread already picked up. Covered by an offline test of
  stop / resume / idempotent rerun.

Nothing environment-specific is committed: hosts, keys and paths live in
~/.config/claude-obsidian/ (this repo is public).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KF322NzozeaFjpgWvh4F4g
2026-09-17 16:14:45 -04:00
noconnorandClaude Opus 5 7a7e987268 mcp-config: put the shell hook in ~/.zshrc.local on a fresh box
install.sh chose the rc file from $SHELL. On a fresh box bootstrap runs
from the user's original bash: zsh/ has already switched the login shell
to zsh, but $SHELL in that process still says bash. The hook went into
~/.bashrc, below zsh/'s auto-exec hook, so every new terminal exec'd into
zsh before reaching it, sync.sh never ran, OPENCODE_CONFIG was never
exported, and opencode came up unconfigured. Boxes already on zsh were
fine, which is why it only showed up on a new machine.

Now the dotfiles-managed ~/.zshrc, or zsh as the account's login shell
(getent), selects ~/.zshrc.local regardless of $SHELL. Standalone
`curl | sh` installs without these dotfiles behave as before.

Reproduced and verified in a clean ubuntu:24.04 container (zsh/ then
mcp-config/, dummy key): before, hook in .bashrc and OPENCODE_CONFIG
unset in a new zsh; after, hook in .zshrc.local and OPENCODE_CONFIG
points at a config with the covert-gpt provider and gitea/proxmox MCP
servers. A box already broken this way is repaired by re-running the
module, and re-runs leave a single hook.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 16:53:12 +00:00
noconnorandClaude Opus 5 0426ebc973 git: authenticate to our Gitea from $GITEA_KEY
git never reads GITEA_KEY on its own. With only `credential.helper
store`, auth depended on whatever was typed at a first prompt and cached
in ~/.git-credentials: it went stale when the token was rotated in the
vault, and a box that never pushed had no credential at all, so pushes
failed even with a key exported.

Add a credential helper scoped to https://git.nic-oconnor.com that hands
git $GITEA_KEY as the password (username from GITEA_USER, else the login
name). An empty helper entry first clears the inherited `store` for that
host, so a rejected token is never cached and a stale one can't win.
Other hosts keep `store`. With GITEA_KEY unset it prints nothing and git
prompts as before. Idempotent: re-runs leave one copy.

Verified: `git credential fill` returns exactly $GITEA_KEY for the host,
other hosts are unaffected, ~/.git-credentials stays absent. A dry-run
push still fails, because the key currently in the vault is rejected by
Gitea (401 on the API too); that is the token, not this wiring.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 16:36:20 +00:00
noconnorandClaude Opus 5 1159197e4b secrets: rename the vault helpers from env* to op*
They act on 1Password, not on the environment, so the prefix now says so:
opr (read), opc (create/update), opd (delete), opl (list), ops (refresh).
No behavior change. Checked the new names don't collide with any command
or oh-my-zsh alias, and verified opr/opl/ops against the real vault
(values compared, not printed).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 16:36:20 +00:00
noconnorandClaude Opus 5 c6e5f22acb secrets: read the 1Password vault once into a 0600 env file
Secrets were pulled in each box's hand-written ~/.zshrc.local with one
`op read` per field on EVERY new shell: every terminal waited on
1Password, every shell broke when it was unreachable, and the loop lived
outside the repo.

New secrets/ module (after bin/, which provides op and jq): one vault per
user and one item, both defaulting to the login name, whose field labels
are the variable names. It asks for the service-account token once
(hidden), fetches the item with a single `op item get`, and writes every
valid-named field to ~/.config/dotfiles/secrets.env (0600), replaced
atomically so a failed fetch keeps the previous copy. Values go through
jq @sh, so quotes, $() and newlines in a secret can't break or inject.
The token is kept in op.env (0600), which shells never source.

bootstrap loads secrets.env right after the module, so mcp-config takes
LITELLM_KEY from the env instead of prompting. A LITELLM_KEY rotated in
the vault also updates ~/.config/mcp-sync/key, since sync.sh re-exports
from that file after secrets.env loads and would otherwise win.

The managed zshrc sources secrets.env (no op call at startup) and adds
envr / envc / envd / envl / envs to read, upsert, delete, list and
refresh; they load the token only in a subshell.

Existing boxes still need the old op loop removed from ~/.zshrc.local.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 15:30:24 +00:00
noconnorandClaude Opus 5 1acbd28c6e bin: add the coder CLI
Pinned to v2.36.5 (latest release). Same shape as the other tarball
installs: the asset drops the tag's leading v and the binary sits at the
archive root. Verified on this box: installs, `coder version` reports
v2.36.5, and a re-run skips it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 15:30:24 +00:00
noconnorandClaude Opus 5 5fac9cb63b mcp-config: make install.sh executable, so bootstrap can run it
It was created 100644 for the curl | sh path, where the mode doesn't
matter. Since bootstrap.sh started calling it via run_module, which execs
the script directly, the module failed with "Permission denied" (126) and
the sync hook / LITELLM_KEY never got deployed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 14:09:36 +00:00
noconnorandClaude Opus 5 6ac2a02f1b mcp-config: coerce /model/info numbers, so a string price can't break opencode
The litellm-helm chart renders the proxy config with Go's toYaml, which writes
0.000005 as `5e-06`. LiteLLM loads it with PyYAML (YAML 1.1), which only reads
an exponent float that contains a dot, so /model/info returns input_cost_per_token
as the STRING "5e-06" for opus/sonnet/haiku while 2.5e-05 stays a float.
`"5e-06" * 1_000_000` is string repetition in Python: sync.sh wrote a 20 MB
opencode.gateway.json with a string cost, opencode rejected the whole config
("Expected number"), and every new shell redeployed it.

- num(): accept int/float/numeric strings, drop anything non-finite or non-numeric
- clean_spec(): validate every model spec before writing, whatever its source,
  so the discovery-failed carry-over path heals an already-corrupted file
  instead of preserving it
- refuse to deploy a config over 1 MB; keep the last-good file

Tested against the live gateway: costs render 5/25, 3/15, 1/5 $/1M, and
`opencode models covert-gpt` lists all 7 models; with no key, the 20 MB
corrupted file heals to 2.8 KB.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Duzwo1tmEqD2kYYyNKm7xs
2026-09-14 13:04:28 -04:00
noconnorandClaude Opus 5 d42d0b29ff mcp-config: render gateway context limits into Hermes and pi, not just opencode
OpenAI's /v1/models schema carries no context window, so a client that only
lists models cannot know how large a request it may send, and oversizing one
is a hard ContextWindowExceededError 400 — vLLM counts prompt + max_tokens
against its window. opencode already read the window from /v1/model/info.
Hermes and pi never consult that endpoint: they take it from static config,
which was hand-copied and drifted. Hermes named a model the gateway had
stopped serving while its replacement (the one model with a SMALLER window)
had no entry; pi's reserve was half its maxTokens. Both fail only once a
session grows, which is why it looked like the model or the GPU.

This renders one /v1/model/info response into all three clients. It is not a
plugin per harness and needs no upstream change — it extends the renderer that
already translates the MCP servers into Hermes' schema, with the same
manifest discipline.

SAME SOURCE, THREE DIFFERENT NUMBERS, deliberately — each client means
something different by "context":

  opencode  limit.context = window - 1024   reserves limit.output and targets
                                            context - output, so needs headroom
  Hermes    context_length = window        omits max_tokens entirely; vLLM
                                            bounds output by what is left
  pi        maxTokens <= reserveTokens-2048 compacts at window - reserveTokens
                                            (compaction.js shouldCompact) then
                                            requests maxTokens

pi's maxTokens is derived FROM its reserve rather than the reserve being raised
to fit: reserveTokens is one global setting shared by every provider pi knows,
so raising it for this gateway would shrink everyone's usable context.

Safety properties, each tested in a sandbox against copies of the real
configs before touching live ones (11 cases):

- Ownership is per FIELD. A value is retracted only if this set it and the
  gateway stopped declaring it. Hand-added models and fields are never
  touched; a model removed upstream loses only what was rendered.
- Vision is retracted, not just added. The chart states that supports_vision
  is what makes clients send image parts, so if a backend falls back to
  language-only, the `image` input this added is taken back — but a user's own
  `image` on an entry is left alone.
- Raw /v1/model/info is cached on each successful fetch, because refresh is
  throttled and a throttled shell never fetches. A bad fetch (403 body, HTML
  error page, empty list, rows without model_name) never replaces a good cache.
- Never invents a window: claude-* declares none and is skipped. A gateway
  that suddenly declares no windows is treated as broken, not empty.
- One output cap (MCP_SYNC_OUTPUT_RESERVE_CAP) is read by opencode and pi, so
  the clients cannot drift apart on it. Parses under dash as well as bash,
  since install.sh sources this under /bin/sh.

First live run found a bug nobody knew about: pi's Claude entries carried
maxTokens 64000 against a 34816 reserve, so claude-haiku-4-5 sent
(200000 - 34816) + 64000 = 229184 tokens at a 200000 window — still broken
after the earlier manual reserve fix, which only covered 32768. Now 32768, and
every served model satisfies the invariant. It also gave Hermes limits for the
Claude routes it had none for.

hermes/gateway.yaml: the static map is marked as a first-boot seed that the
next sync overwrites, so nobody hand-edits it believing it is authoritative.
The source of truth is model_info.max_input_tokens in ai-k8s-iac's
charts/inference/values.yaml. README documents all of the above.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FoDK4wwB1Twty5gwiriYLf
2026-09-12 18:50:02 -04:00
noconnorandClaude Opus 5 9519cbe609 mcp-config: request /v1/model/info, so a versioned-route grant actually works
A virtual key's allowed_routes is matched on the exact path: granting
"/v1/model/info" does not also grant "/model/info". MCP_SYNC_GATEWAY has no
/v1 suffix, so this asked for the UNVERSIONED path — meaning a key scoped to
the versioned route still got a 403, still fell back to names-only, and still
produced a config with no context limits, with nothing in the output to say
so. Two correct-looking pieces of config that did not meet.

Tries the versioned path first and falls back to the unversioned one, so it
works whichever way a key happens to be scoped.

With the route granted this now discovers limits live instead of leaning on
the base-config floor. The floor stays: it is what keeps opencode correct if
the key ever loses the route again, and live values override it field by
field. Verified that the two agree — discovery returns exactly the 195584 /
261120 the floor declares — and the Claude models, which the floor does not
cover, picked up 998976 and 198976 for the first time.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FoDK4wwB1Twty5gwiriYLf
2026-09-12 00:18:30 -04:00
noconnorandClaude Opus 5 135112eee5 mcp-config/hermes: make the gateway's context limits actually reach the clients
Folds in the in-flight local-checkout work and fixes the context accounting
on top of it. Both halves are the same bug seen from two ends: clients were
sending requests larger than the backend's window and getting hard
ContextWindowExceededError 400s, intermittently, in a way that looked like
the model or the GPU misbehaving.

Already in flight here (unchanged in intent):
  - sync.sh prefers the LOCAL CHECKOUT over re-downloading from Gitea, and
    install.sh records the checkout path so it can be found. That recording
    is load-bearing: without it sync.sh cannot find the repo, silently falls
    back to Gitea, and every local edit to opencode.gateway.json is ignored.
    Observed on this box — ~/.config/mcp-sync/source did not exist, the
    default ~/.dotfiles is not where the checkout lives, and the Gitea base
    still served models:{}.
  - the hermes custom-provider wiring.

Fixed on top:

opencode had NO context limit on ANY gateway model, not a wrong one. Two
faults compounded. /model/info 403s for a day-to-day virtual key (only
management-scoped keys may read it), which sync.sh treats as best-effort and
falls back to names-only — as designed and as commented. But the merge then
did `prov["models"] = models`, replacing the map wholesale, so limits
declared in the base config were discarded too and there was no floor left.
Base specs are now a floor that live /model/info overrides field by field:
discovery still wins where present, so this adds no drift, and a 403 no
longer strips the limits. Verified: ornith 195584/32768, the qwens
261120/32768.

hermes' model map named `qwen3-vl-30b-a3b` at 262144 — a model the gateway
stopped serving when Ornith replaced it — while ornith-1.0-35b, the model
actually on cg-1 AND the only one with a window smaller than its neighbours
(196608), had no entry at all and inherited Hermes' default. The one model
needing a lower ceiling was the one not declaring one, which is exactly the
shape that fails only once a session grows.

NOTE for other boxes: hermes reads ~/.hermes/config.yaml, which install.sh
produces by MERGING gateway.yaml. Editing gateway.yaml alone changes nothing
until install.sh re-runs — this machine's live config was patched by hand.

Not addressed here, deliberately: the gateway-side context_window_fallback
(ai-k8s-iac). It is wired but inert while cg-2 is scaled down, so preventing
the oversized request client-side is what actually works today.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FoDK4wwB1Twty5gwiriYLf
2026-09-11 16:38:03 -04:00
noconnorandClaude Opus 5 098b1e377f git+zsh: stop tab-completion producing branch deletes that fail
`git push origin --delete <TAB>` completes to `origin/foo`, but `origin/foo` is
a LOCAL remote-tracking name — the ref on the server is just `foo`. So the
command zsh completes for you is the one git rejects, and it rejects it with
"remote ref does not exist", which reads as "that branch is gone" rather than
"you named it wrong".

The second half is worse, because it makes the first half look intermittent:
without pruning, `git branch -r` and the completion keep offering branches that
were deleted on the server weeks ago. Then some completions fail because the
branch is genuinely gone and others fail because of the prefix, with identical
error text.

Two fixes, one per cause:

- `fetch.prune = true` globally, so stale remote-tracking refs are dropped on
  every fetch/pull and dead branches stop being offered at all.
- `grmb`, which takes either form, prunes first, and reports per-branch rather
  than aborting the batch. `grmb origin/foo` and `grmb foo` both work.

Named grmb rather than the more obvious gbd because oh-my-zsh's git plugin
already binds gbd to `git branch --delete`. Aliases resolve before functions,
so the collision would not have errored — it would have silently tried to
delete a LOCAL branch when the whole point is the remote one. Caught by
`type gbd` after installing; check any new g* helper the same way.

grmb deliberately does not wrap `git push` itself — intercepting a git
subcommand to rewrite its arguments is the kind of thing that works until it
silently does the wrong thing to a refspec someone meant literally.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 14:58:22 -04:00
noconnorandClaude Opus 4.8 11f88ad3a9 mcp-config: render the SAME MCP servers into Hermes' config
Hermes had no MCP servers at all -- no mcp_servers: key -- so "use the gitea
MCP" had nothing to call. The model said so ("there's no Gitea-specific skill"),
which was true, and then looped reaching for the browser as the only way in.

The obvious fix -- paste a gitea block into ~/.hermes/config.yaml -- is the
wrong one: it makes a SECOND definition of a server this repo already defines
once, free to drift (one pinned to gitea-mcp@0.0.10, the other to whatever got
typed). README already calls this out: "all MCP servers are defined here", and
listed other clients as "not done yet". So render, don't duplicate: sync.sh
already translates LiteLLM's /model/info into opencode's schema; this is the
same move into Hermes' schema, from opencode.gateway.json's mcp block. Add a
server once, both clients get it within the same TTL.

Not a shared server PROCESS, which is what "use the same one" would imply: MCP
stdio servers are subprocesses spawned per client over stdin/stdout and dying
with it. Each client running its own `npx -y <pkg>` is the design -- same
package, same pinned version, same per-user credential, same identity. Sharing
one process would need a persistent daemon plus a stdio->HTTP bridge, for
nothing. And it stays client-side: the gateway hosts no MCP servers on purpose
(values.yaml:892; user-scoped APIs, no shared tokens), and gitea-mcp hard-exits
without a token so LiteLLM's headerless listing never registers it anyway.

Translation notes: Hermes' `command` must be a STRING (a list stringifies to
"['npx', '-y']" and fails at exec), so opencode's argv list splits into
command + args. `{env:VAR}` becomes `${VAR}` -- both by-name, so no secret is
written to either file. Hermes gives an MCP child only a filtered baseline env,
so every secret must be listed in env:, which the rendered block does. Nothing
is added to platform_toolsets: MCP servers auto-enable by name, and listing some
would turn it into an allowlist.

Rendered names are recorded in ~/.config/mcp-sync/hermes-managed.json so a
server dropped upstream is dropped from Hermes, while servers the user added by
hand -- never in the manifest -- are left alone. Only writes when the result
changes, skips silently when Hermes isn't installed, warns when PyYAML is
missing (an absent MCP block is invisible rather than obviously broken).

Verified via Hermes' own discovery: 72 tools register -- 30 gitea (incl.
issue__list) and 42 proxmox -- with ${GITEA_KEY} / ${PROXMOX_MCP_*} resolving
and no secret in config.yaml.

Also fixes a stale README claim that MCP tools come from the gateway's /mcp
endpoint; they never do, and values.yaml:892 says why.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 11:04:57 -04:00
noconnorandClaude Opus 4.8 e42807c054 hermes: wire into dotfiles; fix opencode's generated context limits
Two client-side fixes against the covert-gpt LiteLLM gateway, both traced
from opencode throwing intermittent ContextWindowExceededError 400s.

opencode (mcp-config/sync.sh):
  spec_for() mapped LiteLLM's /model/info onto opencode's schema 1:1, but the
  two mean different things by "output". LiteLLM's max_output_tokens is the
  whole window (vLLM caps TOTAL context, not output separately, which is
  correct for LiteLLM); opencode's limit.output is a RESERVE it subtracts from
  context to pick a compaction point. Copying one to the other told opencode to
  reserve the entire 262144 window, so it targeted an input of context-output
  and the server counted one token more than opencode did -- landing on
  context+1 and a hard 400, every time (measured: 196609 in + 65536 out =
  262145 vs a 262144 window; the retries shrinking max_tokens by 65 never
  helped because the input grew to compensate).

  Capping the reserve alone would NOT have fixed it -- opencode would still sit
  exactly on the boundary, just at a different input size. So: cap the reserve
  (OUTPUT_RESERVE_CAP) and hold a margin back from context
  (CONTEXT_SAFETY_MARGIN). Generated at sync time, so it fixes every gateway
  model at once, including third-party ones.

hermes (hermes/gateway.yaml, hermes/install.sh):
  Hermes reached the gateway by pretending it WAS OpenRouter -- the LiteLLM key
  in OPENROUTER_API_KEY, the gateway in OPENROUTER_BASE_URL. Requests routed
  fine, but fetch_openrouter_models hardcodes openrouter.ai/api/v1/models and
  ignores OPENROUTER_BASE_URL, so the picker listed OpenRouter's ~342-model
  catalog -- models this gateway cannot serve -- while the gateway's own models,
  including cg-1's, never appeared at all.

  Replaced with a named custom provider. install.sh now merges gateway.yaml into
  ~/.hermes/config.yaml, so a fresh box gets a Hermes that can actually talk to
  the gateway instead of one pointed at nothing. The merge is idempotent and
  narrow: only the covert-gpt entry is touched, model.default/provider are
  seeded only when unset, and a box still on the masquerade is migrated off it
  (fingerprint: provider openrouter + base_url pointing at this gateway; a
  genuine OpenRouter user never has that, and is left alone).

  No secrets: gateway.yaml names the key via key_env, and mcp-config/sync.sh is
  what exports it. Hermes needs no context safety margin -- it omits max_tokens
  entirely, so vLLM bounds output by whatever context is left.

Also folds in the in-flight hermes bootstrap wiring.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 10:38:12 -04:00
noconnorandClaude Opus 4.8 fdc3a71236 docker: install from the docker-ce repo on RHEL-family hosts
bootstrap failed outright on Oracle Linux: docker/install.sh piped
get.docker.com to sh, and that script allowlists distros by /etc/os-release
ID, so it aborted with "ERROR: Unsupported distribution 'ol'".

On a distro it does recognize, the convenience script only adds the repo and
installs the packages -- exactly what install_rhel() now does directly. Doing
it ourselves drops the allowlist, so every EL rebuild (Oracle, Rocky, Alma,
CentOS, RHEL) goes through one code path instead of needing to be enumerated.
Debian-family still uses get.docker.com. containerd.io needs --allowerasing to
replace the runc that Oracle Linux ships.

Also stop one failed module from killing the whole run: modules were bare
calls under `set -eu`, so docker/'s exit meant bin/, node/ and ansible/
silently never ran -- an unsupported distro string left the box with no CLIs,
no npx and no ansible. run_module warns and continues, then bootstrap exits
non-zero with a summary of what failed. Same guard 35befbe added for
individual tools in bin/install.sh, one level up.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 06:59:18 -04:00
noconnor 836eea6fc6 bin/install.sh: add packer installation 2026-07-13 16:08:53 -04:00
noconnorandClaude Opus 4.8 4cde3f5346 mcp-config: enrich discovered models with limits, cost and capabilities
/v1/models returns ONLY model ids, so every model was deployed as {} and
opencode ran all of them on defaults -- no context window (so it cannot manage
truncation), no cost, and no idea whether the model can call tools. opencode
requires this metadata in the models map: it has no /v1/models discovery of its
own, and models.dev only covers known providers, not a private LiteLLM gateway.

sync.sh now also queries /model/info and maps LiteLLM's metadata onto opencode's
per-model spec:

  max_input_tokens          -> limit.context
  max_output_tokens         -> limit.output
  input/output_cost_per_token -> cost.input/output  (per-token -> $ per 1M)
  supports_function_calling -> tool_call
  supports_reasoning        -> reasoning
  supports_vision           -> attachment

Best-effort and backwards-compatible: /model/info is outside the llm_api_routes
scope most virtual keys have, so an un-updated key 403s and falls back to
names-only -- exactly today's behaviour, no regression.

Note the gateway only reports metadata for models in LiteLLM's registry. The
self-hosted vLLM entries (qwen3-*) carry no model_info in the gateway's
model_list, so they still deploy bare. Fixing that is a gateway-side change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 14:13:24 -04:00
noconnorandClaude Opus 4.8 cd09df07f6 mcp-config: add mcp-sync to force a refresh, ignoring the TTL
The sync runs only at shell start and is throttled to MCP_SYNC_TTL (1h), so a
model added on the gateway can take up to an hour AND a new terminal before
opencode sees it. There was no way to say "fetch it now", so the recurring
symptom was "I added a model and it isn't showing up" -- with a healthy
gateway and a correctly-scoped key.

`mcp-sync` re-sources the hook with the throttle disabled and prints the
resulting model list, so it also answers "what does the client actually have?"
in one step.

README gains a section splitting the two failure modes: a model the gateway
returns but opencode lacks is a stale client; a model the gateway does not
return is a LiteLLM model_list / key-scoping problem.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 13:57:49 -04:00
noconnorandClaude Opus 4.8 35befbe1e3 bootstrap: stop one failed tool from killing the whole run
On a minimal Debian/RHEL image neither python3 nor unzip is present.
lib/fetch.sh's install_zip_bin needs one of them, so the 1Password CLI
install failed -- and under `set -eu` that killed bin/install.sh outright.
install_1p sits immediately before install_opencode and install_zarf, so
neither installed; bin/install.sh's non-zero exit then aborted bootstrap.sh,
so node/ (npx) and ansible/ (python3) never ran either.

Net effect on a fresh box: no opencode, no npx for the MCP servers, and no
python3 for model discovery. It presented as "opencode has no models or
skills". Reproduced in a clean debian:13 container.

Two fixes:

  - bin/install.sh: each tool now installs via try(), which warns and
    continues instead of aborting, and reports a summary of what failed. One
    optional CLI is not worth the rest of the bootstrap. The subshell
    re-enables set -e so a failed download can't fall through to mark_version
    and stamp a version that was never installed.

  - bootstrap.sh: ensure python3 before bin/ runs. It is a hard dependency,
    not an extra -- lib/fetch.sh needs it (or unzip) for .zip assets, and
    mcp-config/sync.sh needs it to inject discovered models.

Verified end to end on fresh debian:13: bootstrap completes, opencode/zarf/
op/npx/node/python3 all present, and opencode resolves all 3 covert-gpt
models.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 13:44:11 -04:00
noconnorandClaude Opus 4.8 cb89478016 mcp-config: write the sync hook to ~/.zshrc.local, not ~/.zshrc
~/.zshrc is MANAGED: zsh/install.sh overwrites it wholesale (cp) on every
bootstrap run. install.sh appended the mcp-sync hook to that same file, so
the next bootstrap silently deleted it -- sync.sh then never ran, so
OPENCODE_CONFIG was never exported and opencode came up with no models and
no skills. It looked like a broken gateway or a bad model; it was a wiped
shell hook.

~/.zshrc.local is the unmanaged file the managed zshrc sources last, so the
hook now survives a re-bootstrap. Verified: hook lands in ~/.zshrc.local
(0600), managed ~/.zshrc is left untouched, and OPENCODE_CONFIG still
exports after simulating the bootstrap overwrite.

Also warn instead of failing silently when python3 is missing: sync.sh then
deploys the base config verbatim with an EMPTY models map, which presents as
"the gateway has no models" rather than "a dependency is absent".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 13:29:07 -04:00
noconnorandClaude Opus 4.8 485e267565 .agents/: add skills for working on this repo
Five skills covering this repo's own conventions: adding a bootstrap
module, adding a CLI to bin/, bumping a pinned version, adding an MCP
server, and verifying a module change.

Lives in .agents/skills/ because opencode discovers that path natively
alongside .opencode/skills/ and .claude/skills/, making it the most
tool-neutral of the three. Distinct from the repo's skills/ tree, which
ships to every covert-gpt user via sync.sh; these stay in the checkout.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 13:10:18 -04:00
noconnorandClaude Opus 4.8 6dc34c5dbb bin/: add zarf
zarf v0.81.0 (current latest), a single static binary for offline/airgapped
Kubernetes. Both Linux release assets verified to resolve; install is
idempotent via the ~/.local/bin/.versions stamp.

Also drops a stray leading space from install_opencode's argument line.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 13:10:11 -04:00
noconnorandClaude Opus 4.8 70b049625a mcp-config: run MCP servers client-side, not on the gateway
Every API worth exposing (Gitea, Proxmox) is user-scoped, so hosting MCP
centrally means one shared token per server: everyone acts as the same
identity and attribution is lost. LiteLLM's per-request BYOK cannot cover
all of them either -- it builds its tool->server map from a headerless
background listing, and gitea-mcp hard-exits without a token, so it never
registers. A split brain (some MCPs server-side, some client-side) is worse
than either, so all of them move client-side.

Servers are declared local, spawned via npx with pinned versions, and read
each user's OWN credentials via {env:VAR}. No token is ever written to the
config; the file stays identical for every user.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 13:10:11 -04:00
noconnorandClaude Opus 4.8 bd883407ec node/: install Node.js for npx
The MCP servers in mcp-config/ are npm packages that opencode spawns as
`npx -y <pkg>`. Without npx on PATH they all fail to start, and opencode
reports that only as a dead server -- easy to misread as a broken config.

Node is a tree, not a single binary, so it follows the ansible module:
unpack under ~/.local/opt and symlink the CLIs into ~/.local/bin.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 13:10:11 -04:00
31 changed files with 4034 additions and 222 deletions
+76
View File
@@ -0,0 +1,76 @@
# .agents — skills for working *on* this repo
Task-scoped instructions a coding agent loads on demand when changing this
repo: how to add a module, bump a pin, wire an MCP server, verify a change.
`.agents/skills/` is a real discovery path, not a local convention — opencode
searches it natively, alongside `.opencode/skills/` and `.claude/skills/`. That
makes it the most vendor-neutral of the three, which is why the skills live
here rather than under a tool-specific directory.
## Not to be confused with `skills/`
Two different things, easy to mix up:
| | `skills/` (repo root) | `.agents/skills/` |
|---|---|---|
| Audience | every covert-gpt **user** | whoever is **editing this repo** |
| Shipped? | yes — `mcp-config/sync.sh` deploys it to `~/.config/mcp-sync/skills` | no, stays in the checkout |
| Content | general-purpose skills for daily work | this repo's own conventions |
A skill about *how bootstrap modules are structured* belongs here. A skill
about *how to write a Terraform module* belongs in `skills/`.
## Layout
Per the [opencode skills spec](https://opencode.ai/docs/skills/): one folder
per skill, each containing a `SKILL.md`.
```
.agents/skills/
add-bootstrap-module/SKILL.md
add-cli-tool/SKILL.md
add-mcp-server/SKILL.md
bump-pinned-version/SKILL.md
verify-module/SKILL.md
```
Frontmatter requires `name` and `description`:
- **`name`** — 1–64 chars, `^[a-z0-9]+(-[a-z0-9]+)*$`, and it **must match the
folder name**. A mismatch is the usual reason a skill doesn't show up.
- **`description`** — 1–1024 chars. This is the only part the model sees when
deciding whether to load the skill, so write it to say **when to use this**,
not just what it is.
`license`, `compatibility`, and `metadata` are optional; unknown fields are
ignored. These skills set none of them — `compatibility: opencode` in
particular would undercut the point of using a neutral directory.
## Other agents
opencode needs no configuration. For Claude Code, point its conventional path
at the neutral one:
```sh
mkdir -p .claude && ln -sfn ../.agents/skills .claude/skills
```
Most other agents accept a skills directory; give them `.agents/skills`.
Failing that, the files are plain Markdown — `@`-mention one directly.
## Verifying
```sh
opencode debug skill # lists every skill opencode sees, with its path
```
If one is missing, check the folder name against the frontmatter `name`.
## Always-on conventions
These skills deliberately do *not* restate the repo's baseline rules (POSIX
`sh`, `set -eu`, idempotent, no sudo outside the modules that declare it, no
secrets in-repo). Those apply to every change, so they belong in a root
`AGENTS.md` that is always in context — not in a skill that loads only
sometimes. Each skill assumes them and covers only what is specific to its task.
@@ -0,0 +1,71 @@
---
name: add-bootstrap-module
description: Use when adding a new top-level module to this dotfiles repo (a new directory with an install.sh that bootstrap.sh runs) — e.g. installing a new runtime, configuring a new service, or dropping a new managed rc file. Covers the module contract, ordering in bootstrap.sh, and the README entry.
---
# Adding a bootstrap module
A module is a top-level directory with an `install.sh` that bootstrap runs. It
must be safe to run standalone *and* to re-run on an already-configured box.
## The contract
Every module's `install.sh` satisfies all of these. Copy the shape from
`node/install.sh` — it is the newest and cleanest example.
1. **POSIX `sh`, not bash.** `#!/usr/bin/env sh` + `set -eu`. No `[[`, no
arrays, no `local`. These boxes are RHEL- *and* Debian-family; don't assume
bash is the `/bin/sh`.
2. **A `WHY:` header comment.** Not what the script does — the code says that.
Explain the failure it prevents or the constraint that forced the design.
`node/install.sh` is the model: it explains that a missing `npx` surfaces
only as a dead MCP server, which is easy to misdiagnose.
3. **Locate the repo without depending on `$PWD`:**
```sh
SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
REPO_ROOT="$(dirname -- "$SCRIPT_DIR")"
```
4. **Reuse `lib/`.** `lib/os-detect.sh` for `pkg_install` (handles dnf/yum vs
apt); `lib/fetch.sh` for `fetch_arch`, `have_version`/`mark_version`, and
the `install_*_bin` helpers. Source with a shellcheck directive:
```sh
# shellcheck source=../lib/fetch.sh
. "$REPO_ROOT/lib/fetch.sh"
```
5. **Idempotent, and it verifies rather than assumes.** Check the tool *runs*,
not just that the file exists — a Kasm redeploy can leave a present-but-
broken tree behind. `node/` and `ansible/` both guard this way.
6. **Log to stderr** via a `say()` helper, so stdout stays usable.
7. **Prefer `$HOME`.** Everything under `~/.local` survives a Kasm redeploy
(which wipes `/` but keeps `$HOME`) and needs no sudo. If the module *must*
use sudo, say so in its header comment and in the README — the repo tracks
which modules do.
## Wiring it up
**`bootstrap.sh`** — add the call. Order is load-bearing and each existing
out-of-order entry carries a comment explaining why (`secrets/` after `bin/`
because it needs `op`; `docker-rootless-cgroup/` last because it needs the
rootless daemon already up). If your module has an ordering
constraint, add it *with* a comment; if it doesn't, append it near related
modules.
**`README.md`** — add a bullet to the `## Modules` list. Match the existing
voice: what it installs, where it puts it, whether it uses sudo, and any
host it no-ops on.
**`~/.zshrc.local`** — if the module reads a secret or per-machine value, add
the variable to `zsh/zshrc.local.example` and to the README's variable list.
Never commit the value itself.
## Non-systemd hosts
Kasm has no systemd. Anything that would `systemctl enable` must degrade
gracefully — see `docker-rootless-cgroup/install.sh`, which detects the situation and no-ops
rather than failing the whole bootstrap run.
## Before you're done
Run the `verify-module` skill. At minimum: `shellcheck`, run standalone, then
run a second time and confirm the output says "already present" rather than
redoing the work.
+72
View File
@@ -0,0 +1,72 @@
---
name: add-cli-tool
description: Use when adding a userland CLI binary to bin/install.sh (jq, kubectl, helm, k9s, stern, yq, tofu, task, op, opencode, …). Covers the install_<name> function pattern, arch tokens, the .versions idempotency stamp, and when a tool does NOT belong in bin/.
---
# Adding a CLI tool to `bin/`
`bin/install.sh` installs self-contained release binaries into `~/.local/bin`:
no sudo, nothing outside `$HOME`, so tools persist across Kasm redeploys.
## Does it belong here?
`bin/` is for **single, statically linked binaries**. If the tool is a tree
(interpreter + libraries), it does not fit `install_*_bin` and needs its own
module that unpacks into `~/.local/opt/<name>` and symlinks the CLIs — that is
exactly why `ansible/` and `node/` exist. Prefer static builds: after a Kasm
redeploy the root filesystem's shared libraries are gone, and a dynamically
linked binary in `$HOME` will break.
## The pattern
One `install_<name>` function, pinned version, guard, install, stamp:
```sh
# <name> — one line on what it is. Note any asset-naming quirk.
install_<name>() {
ver=v1.2.3
have_version <name> "$ver" && { fsay "<name> $ver already installed."; return 0; }
install_tarball_bin \
"https://github.com/<org>/<name>/releases/download/$ver/<name>_linux_$DEB.tar.gz" \
"<path/inside/archive>" <name>
mark_version <name> "$ver"
}
```
Then call it at the bottom of the file with the others.
**Keep `ver` byte-identical to the upstream tag.** Some tools tag `v1.2.3`,
some `1.2.3` (jq uses a bare `1.8.2`). `have_version` does a string compare
against the stamp in `~/.local/bin/.versions`, so a mismatch just means the
tool silently reinstalls on every bootstrap.
## Arch tokens
`bin/install.sh` precomputes both spellings — pick the one the upstream asset
actually uses, don't call `uname` yourself:
- `$ARCH` → `x86_64` / `aarch64` (rust-style release names)
- `$DEB` → `amd64` / `arm64` (Go/Debian-style — most devops tools)
## Picking the helper
- `install_raw_bin <url> <dest>` — the asset *is* the binary (jq, kubectl, yq).
- `install_tarball_bin <url> <path-in-archive> <dest>` — `.tar.gz` (helm, k9s).
- `install_zip_bin <url> <path-in-archive> <dest>` — `.zip` (the 1Password CLI).
Asset names are inconsistent and this is where it usually goes wrong: k9s
capitalizes `Linux`, helm nests under `linux-$DEB/`. Fetch the release page and
read the real asset name rather than guessing — a 404 from `curl -fsSL` will
abort bootstrap under `set -eu`.
## Conditional installs
A tool that only works in some environments gets a gate, like kind — installed
only when a **rootful** Docker daemon answers, because it does not work against
rootless Docker on Kasm. Reuse `docker_full_available()` rather than writing a
new probe.
## Finally
Add the tool to the `bin/` bullet in `README.md`. Then run it twice and confirm
the second run prints "already installed."
+70
View File
@@ -0,0 +1,70 @@
---
name: add-mcp-server
description: Use when adding or changing an MCP server in mcp-config/opencode.gateway.json (gitea, proxmox, …). Covers the {env:VAR} secret rule, the npx/node dependency, the shared-vs-personal boundary, and how to tell a dead server apart from a model that just can't call tools.
---
# Adding an MCP server
`mcp-config/opencode.gateway.json` is the **shared** opencode config, synced to
every covert-gpt user by `mcp-config/sync.sh`. Read `mcp-config/README.md`
before changing it.
## Secrets: `{env:VAR}`, never a literal
```jsonc
"environment": {
"GITEA_HOST": "https://git.nic-oconnor.com",
"GITEA_ACCESS_TOKEN": "{env:GITEA_KEY}" // <- resolved from the user's shell
}
```
This file is shared and committed. A literal token here leaks it to every user
and into git history. The `{env:VAR}` indirection is what lets one shared
config give each person tools that act **as them** — opencode resolves the var
from the invoking user's environment and passes it to the server it spawns.
When you add a server that needs a credential:
1. Reference it as `{env:NEW_VAR}` in the config.
2. Add `NEW_VAR` to `zsh/zshrc.local.example`.
3. Document it in the root `README.md` variable list **and** in
`mcp-config/README.md`.
Users keep the value in `~/.zshrc.local` (mode 0600), which bootstrap never
overwrites. It is never written into any config file.
## Shared vs personal
This tree is identical for every user. A server that only makes sense for one
person's cluster, kubeconfig, or local paths belongs in that user's own
`~/.config/opencode/`, which sync never touches. Same rule as `skills/`.
## It needs `npx`
Servers are spawned as `npx -y <pkg>@<version>`, so `node/install.sh` must have
run. **Pin the version** — `npx -y` otherwise runs whatever the registry serves,
and this process receives the user's tokens. See the `bump-pinned-version` skill.
## Debugging: dead server vs. bad model
The failure modes look alike from the chat window, so distinguish them first:
- **Server dead** — the tool isn't in the model's tool list at all. Usual cause:
`npx` missing from `PATH`, a bad package name, or an unset env var. opencode
reports this only as absent tools, which reads like a config problem.
- **Server fine, model weak** — the tool *is* called and the server answers,
including with errors like `user 'x' does not exist`. That round trip proves
MCP works. A model that invents required arguments (owner, repo) rather than
discovering them, or retries the same wrong call verbatim, is a tool-use
capability limit, not a config bug. Smaller / non-agentic models (e.g. VL or
low-active-param MoE variants) do this; coder-tuned models generally don't.
Check the tool list before touching the config:
```sh
opencode debug skill # skills opencode can see
npx -y <pkg>@<ver> # does the server even start standalone?
```
Fix argument-guessing in `AGENTS.md` (give the model the default owner/host),
not in `opencode.gateway.json`.
@@ -0,0 +1,64 @@
---
name: bump-pinned-version
description: Use when upgrading a pinned version anywhere in this repo — a CLI in bin/install.sh, NODE_VERSION in node/install.sh, or an npm-pinned MCP server in mcp-config/opencode.gateway.json. Covers where the pins live, how to verify the new release actually exists, and the supply-chain reasoning behind pinning.
---
# Bumping a pinned version
Everything in this repo is version-pinned on purpose. Nothing floats.
## Where the pins live
| Pin | Location | Shape |
|---|---|---|
| CLI tools | `bin/install.sh` | `ver=v1.2.3` inside each `install_<name>()` |
| Node.js | `node/install.sh` | `NODE_VERSION="v24.18.0"` |
| MCP servers | `mcp-config/opencode.gateway.json` | `["npx", "-y", "pkg@0.0.10"]` |
| oh-my-zsh | `zsh/` | installed from upstream script — not pinned |
Grep before assuming: `grep -rnE 'ver=|_VERSION=|@[0-9]+\.[0-9]+' --include='*.sh' --include='*.json'`
## Why these are pinned
`npx -y <pkg>` executes whatever the registry serves *at that moment*. The MCP
servers receive your Gitea and Proxmox tokens (`{env:GITEA_KEY}` and friends
are passed into the spawned process), so an unpinned server is a live
supply-chain path to your credentials. `node/install.sh` says this out loud in
its header. A version bump is a **supply-chain decision**, not a chore — when
bumping an MCP server or Node itself, skim the upstream changelog for a
maintainer change or a new network dependency, and say what you found.
## Doing the bump
1. **Verify the release exists before editing.** Don't trust the model's
memory of version numbers — check upstream (`gh release list`, the GitHub
releases page, `npm view <pkg> versions`, or nodejs.org/dist).
2. **Verify the asset URL still resolves.** Projects rename assets between
releases. Build the exact URL the script will request and check it:
```sh
curl -fsSLI "https://github.com/derailed/k9s/releases/download/v0.51.0/k9s_Linux_amd64.tar.gz"
```
A 404 here becomes a hard bootstrap failure under `set -eu`.
3. **Match the upstream tag exactly** — `v1.2.3` vs `1.2.3` matters, because
`have_version` string-compares against the `.versions` stamp. Get it wrong
and the tool reinstalls on every single bootstrap, silently.
4. **Bump one thing per commit.** When a box breaks after a bootstrap, the
commit log should say which upgrade did it.
## Verifying
The `.versions` stamp is what makes the bump observable:
```sh
cat ~/.local/bin/.versions/<tool> # old version
./bin/install.sh # should download, not skip
cat ~/.local/bin/.versions/<tool> # new version
./bin/install.sh # should now say "already installed"
```
If the second run re-downloads, the `ver=` string and the stamp disagree — that
is the tag-format bug from step 3.
For an MCP server bump, restart opencode and confirm the server actually comes
up. A dead MCP server is reported only as missing tools, so it looks like a
model problem rather than a version problem.
+78
View File
@@ -0,0 +1,78 @@
---
name: verify-module
description: Use before committing any change to a module's install.sh or to bootstrap.sh, to prove the change actually works. Covers the idempotency re-run check, shellcheck with the right shell dialect, and testing against both RHEL- and Debian-family containers.
---
# Verifying a module change
Nothing here is verified by a test suite — the only proof a module works is
running it. Three checks, cheapest first.
## 1. shellcheck
Every script is POSIX `sh`, so tell shellcheck that. Its default assumes bash
and will happily wave through `[[ ]]` and `local`, which break on a box where
`/bin/sh` is dash.
```sh
shellcheck -s sh bootstrap.sh */install.sh lib/*.sh
```
`lib/` is sourced, not executed, so keep the `# shellcheck source=../lib/fetch.sh`
directives above each `.` line or shellcheck can't follow them.
## 2. Idempotency — the re-run check
This is the one that catches real bugs. Run the module twice:
```sh
./<module>/install.sh # does the work
./<module>/install.sh # must SKIP, not redo it
```
The second run has to say "already installed" / "already present". If it
re-downloads, the guard is broken — most often a pinned `ver=` that doesn't
match the string stamped in `~/.local/bin/.versions/<tool>` (`v1.2.3` vs
`1.2.3`). That failure is silent: everything still works, it just refetches on
every bootstrap forever.
Guards must check the tool **runs**, not that a path exists. A Kasm redeploy
leaves broken trees behind, and an existence check will happily skip past one.
## 3. Both distro families
`lib/os-detect.sh` branches on dnf/yum vs apt. If your change touches
`pkg_install` or anything OS-conditional, exercise both:
```sh
docker run --rm -v "$PWD:/repo" -w /repo debian:13 sh -c \
'apt-get update -qq && apt-get install -y -qq sudo curl git >/dev/null && ./<module>/install.sh'
docker run --rm -v "$PWD:/repo" -w /repo rockylinux:9 sh -c \
'dnf install -y -q sudo curl git >/dev/null && ./<module>/install.sh'
```
Run each container's command twice in the same shell to get the idempotency
check on both families at once.
Note this runs as root in a container, which is *not* how a real box runs it —
it won't catch a missing-sudo problem. It's a portability check, not a
faithful rehearsal.
## Non-systemd hosts
Kasm has no systemd and no rootful Docker. Anything that would `systemctl` must
no-op rather than abort — you can't fully rehearse this in a container, so read
the code path and confirm the guard exists (`docker-rootless-cgroup/install.sh`
is the reference).
## Full run
Finally, `./bootstrap.sh` end to end. It runs modules in a deliberate order and
`set -eu` means any module's failure aborts the rest — a module that passes
standalone can still break the chain if it was inserted at the wrong point.
## Report honestly
If you only ran shellcheck and the re-run check, say that. Don't describe a
change as verified on both distro families unless you actually ran both.
+2
View File
@@ -0,0 +1,2 @@
__pycache__/
*.pyc
+89 -22
View File
@@ -32,26 +32,44 @@ Already have a clone? Just run it directly:
- `git/` — sets git `user.name`/`user.email` and configures - `git/` — sets git `user.name`/`user.email` and configures
`credential.helper store` (plaintext `~/.git-credentials`, 0600) for `credential.helper store` (plaintext `~/.git-credentials`, 0600) for
HTTPS auth. HTTPS auth.
- `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 - `bin/` — installs userland devops CLIs as self-contained, statically
linked release binaries into `~/.local/bin`: jq, kubectl, helm, k9s, linked release binaries into `~/.local/bin`: jq, kubectl, kubelogin (the
`kubectl oidc-login` plugin, for OIDC auth to Omni clusters), helm, k9s,
kubectx/kubens, stern, yq, OpenTofu, terragrunt, task (go-task, the kubectx/kubens, stern, yq, OpenTofu, terragrunt, task (go-task, the
Taskfile runner), the 1Password CLI `op` for secrets storage, and the Taskfile runner), the 1Password CLI `op` for secrets storage, the
opencode coding agent. kind is installed opencode coding agent, zarf (DevSecOps for Offline and Airgapped
only when full (non-rootless) Docker is available — it doesn't work Kubernetes), packer (HashiCorp's infrastructure build tool), and the
against rootless Docker, so it is skipped on Kasm. No sudo, nothing coder CLI for Coder workspaces.
outside `$HOME`, so tools persist across Kasm redeploys with no kind is installed only when full (non-rootless) Docker is available —
reinstall. Idempotent: each tool records its version under it doesn't work against rootless Docker, so it is skipped on Kasm.
`~/.local/bin/.versions` and is skipped when the pinned version is Dotfiles does not install Docker itself; install it on the host (or bake
already present. `zsh/zshrc` puts `~/.local/bin` on `PATH`. it into the image) where you want it.
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`.
- `secrets/` — reads your 1Password vault once and writes every field to
`~/.config/dotfiles/secrets.env` (0600), which the managed `~/.zshrc`
sources, so opening a shell never calls `op`. One vault per user and one
item in it, both named after your login by default; each field's label is
the variable name (`GITEA_KEY`, `LITELLM_KEY`, `PROXMOX_TOKEN_SECRET`, …).
The first run prompts for the service-account token and checks it with
1Password straight away, re-prompting on a bad paste. It then picks the vault
and item: the only one the token can see, or a prompt that lists them. It keeps
all three in `~/.config/dotfiles/op.env` (0600, not loaded into shells). If
that fails, bootstrap skips mcp-config/ and hermes/ with an error rather
than asking for the LiteLLM key by hand.
Runs after `bin/` (needs `op` and `jq`); bootstrap loads the result so later
modules get the keys without prompting. Day to day: `opr NAME` reads a
field, `opc NAME [VALUE]` creates or updates one (prompts, hidden, without
VALUE), `opd NAME` deletes one; both of those refresh the file and this
shell. `opl [VAULT [ITEM]]` shows every field with its current value, and
`ops` refreshes after a change made elsewhere. No sudo.
opencode is configured by `mcp-config/`: it points at the covert-gpt opencode is configured by `mcp-config/`: it points at the covert-gpt
LiteLLM gateway (`https://llm.covert-gpt.com/v1` for models, LiteLLM gateway (`https://llm.covert-gpt.com/v1`) for models. MCP tools do
`https://llm.covert-gpt.com/mcp` for MCP tools). Run NOT come from the gateway — it hosts no MCP servers, deliberately; they run
client-side under your own credentials (see `mcp-config/README.md`). Run
`mcp-config/install.sh` once to store your LiteLLM key and enable `mcp-config/install.sh` once to store your LiteLLM key and enable
config sync — without it, opencode installs but can't reach the config sync — without it, opencode installs but can't reach the
gateway. gateway.
@@ -60,6 +78,14 @@ Already have a clone? Just run it directly:
`~/.local/bin`. Persists across redeploys; only rebuilt when the venv is `~/.local/bin`. Persists across redeploys; only rebuilt when the venv is
missing or broken. Building it may install `python3`/`python3-venv` via missing or broken. Building it may install `python3`/`python3-venv` via
the system package manager (the one place this module uses sudo). the system package manager (the one place this module uses sudo).
- `vscode/` — installs Visual Studio Code from upstream's portable tarball into
`~/.local/opt/vscode`, symlinks `code` into `~/.local/bin`, and writes a
user-level `.desktop` entry so it shows up in the application menu. **No
sudo, nothing outside `$HOME`** — the same contract as `bin/`, so it survives
a Kasm redeploy. Pinned via `VSCODE_VERSION` in the module; bump it to upgrade.
Also sets `password-store: basic` in `~/.vscode/argv.json` so KDE's kwallet
does not prompt for an unlock on every launch (see below). **No-ops on
headless hosts** — it's a ~400MB GUI app — override with `DOTFILES_VSCODE`.
- `docker-rootless-cgroup/` — on systemd-less hosts (e.g. Kasm), hand-delegates - `docker-rootless-cgroup/` — on systemd-less hosts (e.g. Kasm), hand-delegates
cgroup v2 controllers to the rootless Docker daemon so per-container cgroups cgroup v2 controllers to the rootless Docker daemon so per-container cgroups
work (rootless Docker otherwise comes up with work (rootless Docker otherwise comes up with
@@ -67,7 +93,9 @@ Already have a clone? Just run it directly:
daemon; no-ops on systemd or non-rootless hosts. Re-run at each bootstrap 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`. (the cgroup layout resets on redeploy); its PATH shim lives in `$HOME`.
- `mcp-config/` — shared MCP gateway config sync for laptops (LiteLLM / - `mcp-config/` — shared MCP gateway config sync for laptops (LiteLLM /
opencode). Independent of `bootstrap.sh`; see its own README. opencode). Run by `bootstrap.sh`, and standalone too. The config and skills
are read from the **local checkout**, not re-downloaded from Gitea, so this
repo does not have to be public for a shell to work; see its own README.
## Per-machine customizations and secrets ## Per-machine customizations and secrets
@@ -79,16 +107,55 @@ rc sources last (so it can override anything) and bootstrap never touches:
cp zsh/zshrc.local.example ~/.zshrc.local && chmod 600 ~/.zshrc.local cp zsh/zshrc.local.example ~/.zshrc.local && chmod 600 ~/.zshrc.local
``` ```
Variables modules look for: Variables modules look for. With `secrets/` set up, put these in your vault
item instead of `~/.zshrc.local`:
- `GITEA_KEY` — personal access token for `git.nic-oconnor.com` (API/CLI - `GITEA_KEY` — personal access token for `git.nic-oconnor.com`. Used for
use; HTTPS git auth itself goes through `git/`'s credential helper). API/CLI work, and by `bootstrap.sh`/`mcp-config/` to read this repo when it
is **private** and there is no checkout yet. With a checkout on disk nothing
here needs it; HTTPS git auth otherwise goes through `git/`'s credential
helper.
- `LITELLM_KEY` — covert-gpt gateway key used by opencode. Normally you - `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 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` hook exports it from `~/.config/mcp-sync/key`. Set it in `~/.zshrc.local`
only on a box without mcp-sync. only on a box without mcp-sync.
- `OP_SERVICE_ACCOUNT_TOKEN` — 1Password service-account token for - `DOTFILES_VSCODE` — controls the `vscode/` module. Unset (default) installs
non-interactive `op read` / `op run`. VS Code only when a graphical session is detected; `1` forces the install on
a headless host (e.g. for `code tunnel`); `0` skips it everywhere. A plain
shell/env var, not a vault secret.
On the keyring: VS Code's secret storage reaches for the OS keyring through
libsecret, which on KDE makes kwalletd prompt for an unlock before the editor
is usable — every session, since `kwallet-pam` is not installed. The module
sets `"password-store": "basic"` in `~/.vscode/argv.json` to stop that, which
means VS Code's stored tokens are obfuscated on disk rather than sealed
behind a wallet password. To use the wallet instead, install `kwallet-pam` so
it unlocks at login, then change that value to `"kwallet5"`.
- `OP_SERVICE_ACCOUNT_TOKEN` — 1Password service-account token. Not a shell
variable any more: `secrets/install.sh` asks for it once and stores it in
`~/.config/dotfiles/op.env`. Pass it in the env only for an unattended run.
- `GITEA_KEY`, `PROXMOX_TOKEN_ID`, `PROXMOX_TOKEN_SECRET` — **your own**
credentials for the client-side MCP servers in `mcp-config/`. opencode reads
them from your environment (`{env:VAR}`) and passes them to the MCP server it
spawns, so the tools act as *you*. Keep them in `~/.zshrc.local`; they are
never written into any config file.
These are NOT used by `ai-k8s-iac` / `task up`: the LiteLLM gateway hosts no
MCP servers and stores no MCP credentials. See `mcp-config/README.md`.
- `hermes/` — installs Hermes Agent (Nous Research's self-improving AI agent)
via npm. Hermes uses npx to spawn and downloads the Python hermes-agent from
PyPI. Everything under $HOME, so it survives Kasm redeploys.
It then merges `hermes/gateway.yaml` into `~/.hermes/config.yaml`, wiring
Hermes to the covert-gpt LiteLLM gateway as a named custom provider. The merge
is idempotent and touches only that provider entry, so the rest of your
personal config is left alone; `model.default` / `model.provider` are seeded
only on a fresh box. Unlike `mcp-config/`, this deploys at BOOTSTRAP time, not
per-shell — re-run `hermes/install.sh` to pick up a gateway.yaml change.
Depends on `mcp-config/install.sh` having run: gateway.yaml names the key
(`key_env: LITELLM_KEY`) but never contains it, and `mcp-config/sync.sh` is
what exports LITELLM_KEY into your shell. Without it Hermes 401s.
Secrets never go in this repo or in `zsh/zshrc` — that file is shared Secrets never go in this repo or in `zsh/zshrc` — that file is shared
config applied to every box. config applied to every box.
+94 -15
View File
@@ -49,6 +49,18 @@ install_kubectl() {
mark_version kubectl "$ver" mark_version kubectl "$ver"
} }
# kubelogin — kubectl OIDC plugin, so omnictl-generated kubeconfigs can
# authenticate (they exec `kubectl oidc-login get-token`). Installed as
# kubectl-oidc_login, the name kubectl's plugin lookup expects.
install_oidc_login() {
ver=v1.36.4
have_version kubectl-oidc_login "$ver" && { fsay "kubectl-oidc_login $ver already installed."; return 0; }
install_zip_bin \
"https://github.com/int128/kubelogin/releases/download/$ver/kubelogin_linux_$DEB.zip" \
kubelogin kubectl-oidc_login
mark_version kubectl-oidc_login "$ver"
}
# helm — Kubernetes package manager (from get.helm.sh). # helm — Kubernetes package manager (from get.helm.sh).
install_helm() { install_helm() {
ver=v4.2.2 ver=v4.2.2
@@ -156,6 +168,19 @@ install_opencode() {
mark_version opencode "$ver" mark_version opencode "$ver"
} }
# zarf — a DevSecOps tool for Offline and Airgapped Kubernetes (single static binary).
install_zarf() {
ver=v0.81.0
have_version zarf "$ver" && { fsay "zarf $ver already installed."; return 0; }
case "$DEB" in
amd64) _zarf_arch=amd64 ;;
*) _zarf_arch=arm64 ;;
esac
install_raw_bin \
"https://github.com/zarf-dev/zarf/releases/download/$ver/zarf_${ver}_Linux_$_zarf_arch" zarf
mark_version zarf "$ver"
}
# task — go-task, the Taskfile.yml task runner. Binary is at the archive root. # task — go-task, the Taskfile.yml task runner. Binary is at the archive root.
install_task() { install_task() {
ver=v3.52.0 ver=v3.52.0
@@ -166,6 +191,21 @@ install_task() {
mark_version task "$ver" mark_version task "$ver"
} }
# packer — infrastructure as code build tool (single static binary).
install_packer() {
ver=1.15.4
have_version packer "$ver" && { fsay "packer $ver already installed."; return 0; }
case "$(fetch_arch)" in
x86_64) _packer_arch=amd64 ;;
aarch64) _packer_arch=arm64 ;;
*) fsay "packer: unsupported arch $(uname -m)"; return 1 ;;
esac
install_zip_bin \
"https://releases.hashicorp.com/packer/$ver/packer_${ver}_linux_$_packer_arch.zip" \
packer packer
mark_version packer "$ver"
}
# terragrunt — thin wrapper around tofu/terraform (single static binary). # terragrunt — thin wrapper around tofu/terraform (single static binary).
install_terragrunt() { install_terragrunt() {
ver=v1.1.0 ver=v1.1.0
@@ -176,23 +216,62 @@ install_terragrunt() {
mark_version terragrunt "$ver" mark_version terragrunt "$ver"
} }
install_jq # coder — CLI for Coder workspaces (coder login, coder ssh, coder config-ssh).
install_kubectl # Release tag is vX but asset drops the v; binary is at the archive root.
install_helm install_coder() {
install_k9s ver=2.36.5
have_version coder "$ver" && { fsay "coder $ver already installed."; return 0; }
install_tarball_bin \
"https://github.com/coder/coder/releases/download/v$ver/coder_${ver}_linux_$DEB.tar.gz" \
coder coder
mark_version coder "$ver"
}
# try <install_fn> — run one tool's installer; on failure, warn and keep going.
#
# WHY: these installs used to be bare calls, so under `set -eu` the FIRST
# failure killed this script -- and its non-zero exit then aborted bootstrap.sh
# itself, so every later module (node/, ansible/) silently never ran. A single
# tool whose release asset moved could leave a box with no opencode, no npx and
# no python3. One optional CLI is not worth the whole bootstrap.
#
# The subshell re-enables `set -e` inside itself (it is disabled for a command
# being tested by `if`), so a failed download still aborts that tool's function
# before mark_version stamps a version that was never installed.
FAILED=""
try() {
if ( set -e; "$1" ); then return 0; fi
FAILED="$FAILED ${1#install_}"
fsay "WARNING: ${1#install_} failed to install; continuing."
}
try install_jq
try install_kubectl
try install_oidc_login
try install_helm
try install_k9s
if docker_full_available; then if docker_full_available; then
install_kind try install_kind
else else
fsay "Skipping kind: full (non-rootless) Docker not available." fsay "Skipping kind: full (non-rootless) Docker not available."
fi fi
install_kubectx try install_kubectx
install_kubens try install_kubens
install_stern try install_stern
install_yq try install_yq
install_tofu try install_tofu
install_task try install_task
install_terragrunt try install_terragrunt
install_1p try install_1p
install_opencode try install_opencode
try install_zarf
try install_packer
try install_coder
fsay "Done. Tools in $BIN_DIR (added to PATH by zsh/zshrc)." if [ -n "$FAILED" ]; then
fsay ""
fsay "WARNING: these tools did NOT install:$FAILED"
fsay "Everything else is in $BIN_DIR. Fix the cause and re-run bin/install.sh."
else
fsay "Done. Tools in $BIN_DIR (added to PATH by zsh/zshrc)."
fi
+110 -9
View File
@@ -4,6 +4,10 @@
# #
# curl -fsSL https://git.nic-oconnor.com/public/dotfiles/raw/branch/main/bootstrap.sh | sh # curl -fsSL https://git.nic-oconnor.com/public/dotfiles/raw/branch/main/bootstrap.sh | sh
# (or, from a checked-out clone: ./bootstrap.sh) # (or, from a checked-out clone: ./bootstrap.sh)
#
# If the repo is private, export GITEA_KEY first — the self-clone below uses it.
# Without a key the clone falls back to git's credential.helper (set up by
# git/), which prompts once and caches, so the interactive path still works.
set -eu set -eu
REPO_URL="${DOTFILES_REPO:-https://git.nic-oconnor.com/public/dotfiles.git}" REPO_URL="${DOTFILES_REPO:-https://git.nic-oconnor.com/public/dotfiles.git}"
@@ -19,22 +23,119 @@ if [ ! -f "$SCRIPT_DIR/zsh/install.sh" ]; then
echo "bootstrap: git is required to self-clone; install git first" >&2 echo "bootstrap: git is required to self-clone; install git first" >&2
exit 1 exit 1
fi fi
# Authenticated when we have a Gitea PAT, so a PRIVATE dotfiles repo
# bootstraps without the repo having to be world-readable. The token goes
# through the environment rather than `git -c ...` because argv shows up in
# `ps`; it is also not written into .git/config, so the clone leaves no
# credential behind on disk.
git_auth() {
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
}
if [ -d "$DOTFILES_DIR/.git" ]; then if [ -d "$DOTFILES_DIR/.git" ]; then
echo "bootstrap: updating existing checkout at $DOTFILES_DIR" echo "bootstrap: updating existing checkout at $DOTFILES_DIR"
git -C "$DOTFILES_DIR" pull --ff-only git_auth -C "$DOTFILES_DIR" pull --ff-only
else else
echo "bootstrap: cloning $REPO_URL into $DOTFILES_DIR" echo "bootstrap: cloning $REPO_URL into $DOTFILES_DIR"
git clone "$REPO_URL" "$DOTFILES_DIR" git_auth clone "$REPO_URL" "$DOTFILES_DIR"
fi fi
exec "$DOTFILES_DIR/bootstrap.sh" exec "$DOTFILES_DIR/bootstrap.sh"
fi fi
"$SCRIPT_DIR/zsh/install.sh" # python3 is a hard dependency of this setup, not an optional extra:
"$SCRIPT_DIR/git/install.sh" # - lib/fetch.sh needs python3 (or unzip) to extract .zip release assets;
# Runs before bin/ so the kind install gate can see the rootful daemon. # without either, the 1Password CLI install fails.
"$SCRIPT_DIR/docker/install.sh" # - mcp-config/sync.sh needs it to inject the gateway's discovered models;
"$SCRIPT_DIR/bin/install.sh" # without it opencode comes up with an EMPTY model list.
"$SCRIPT_DIR/ansible/install.sh" # Minimal Debian/RHEL images ship neither python3 nor unzip, so install it
# before bin/ runs. Non-fatal: an unsupported OS should degrade, not abort.
if ! command -v python3 >/dev/null 2>&1 && ! command -v unzip >/dev/null 2>&1; then
# shellcheck source=lib/os-detect.sh
. "$SCRIPT_DIR/lib/os-detect.sh"
echo "bootstrap: installing python3 (needed for zip assets and model discovery)"
pkg_install python3 || echo "bootstrap: warning: could not install python3" >&2
fi
# run_module <path> — run one module; on failure, warn and keep going.
#
# WHY: these used to be bare calls, so under `set -eu` the FIRST failing module
# aborted bootstrap and every later module silently never ran — one unsupported
# distro in one module was enough to leave a box with no CLIs, no npx and no
# ansible. The modules are independent, so a failure in one is not a reason to
# skip the rest. (bin/install.sh does the same for individual tools; this is
# that guard one level up.)
FAILED=""
run_module() {
if ( set -e; "$SCRIPT_DIR/$1/install.sh" ); then return 0; fi
FAILED="$FAILED $1"
echo "bootstrap: WARNING: $1 module failed; continuing." >&2
}
run_module zsh
run_module git
run_module bin
# Needs op and jq from bin/. Runs before everything that consumes a secret, and
# its output is loaded into THIS process so later modules see the vault's
# values: mcp-config/ takes LITELLM_KEY from the env instead of prompting for
# it, and GITEA_KEY is there for anything that reads the repo over the API.
run_module secrets
if [ -f "$HOME/.config/dotfiles/secrets.env" ]; then
# shellcheck source=/dev/null
. "$HOME/.config/dotfiles/secrets.env"
fi
# Provides npx, which opencode needs to spawn the npm-based MCP servers in
# mcp-config/. Without it every MCP server silently fails to start.
run_module node
# Deploys the mcp-sync hook and the LiteLLM key. Runs after node/ (the MCP
# servers it configures are npx packages) and BEFORE hermes/, which needs
# LITELLM_KEY exported to point its provider at the gateway.
#
# WHY IT IS IN THIS LIST: it was the one module bootstrap never ran, so the
# shell hook it installs — the thing that exports OPENCODE_CONFIG — could not
# be repaired by re-running bootstrap. Losing ~/.zshrc.local (or the hook line
# in it) left opencode with no covert-gpt provider and no way to get it back
# short of curl-ing this module by hand.
#
# Both it and hermes/ are SKIPPED when the vault did not deliver LITELLM_KEY
# (and no box-local copy exists): mcp-config/ would otherwise fall back to a
# "paste your LiteLLM key" prompt, which reads like a LiteLLM problem and hides
# the real one — a bad 1Password token, vault or item, which also means every
# other vault key is missing.
if [ -z "${LITELLM_KEY:-}" ] && [ ! -s "$HOME/.config/mcp-sync/key" ]; then
echo "" >&2
echo "bootstrap: ERROR: no LITELLM_KEY from 1Password — skipping mcp-config and hermes." >&2
case " $FAILED " in
*" secrets "*) echo "bootstrap: the secrets module failed (see its messages above)." >&2 ;;
*) echo "bootstrap: the vault item has no LITELLM_KEY field; add it with: opc LITELLM_KEY" >&2 ;;
esac
echo "bootstrap: fix it with $SCRIPT_DIR/secrets/install.sh, then re-run bootstrap." >&2
echo "" >&2
FAILED="$FAILED mcp-config hermes"
else
run_module mcp-config
# Hermes Agent (Nous Research) is distributed as an npm package with a Python
# dependency. Runs after node/ ensures npx is available.
run_module hermes
fi
run_module ansible
# GUI editor, installed from upstream's tarball into ~/.local/opt (no sudo, same
# contract as bin/). No ordering constraints — it needs nothing from bin/,
# secrets/ or node/ — and no-ops on headless hosts.
run_module vscode
# Runs last: needs rootless Docker already up (started at boot) so it can # Runs last: needs rootless Docker already up (started at boot) so it can
# delegate cgroups to it. No-ops where that does not apply. # delegate cgroups to it. No-ops where that does not apply.
"$SCRIPT_DIR/docker-rootless-cgroup/install.sh" run_module docker-rootless-cgroup
if [ -n "$FAILED" ]; then
echo "" >&2
echo "bootstrap: WARNING: these modules did NOT complete:$FAILED" >&2
echo "bootstrap: everything else was applied. Fix the cause and re-run." >&2
exit 1
fi
echo "bootstrap: all modules applied." >&2
+113
View File
@@ -0,0 +1,113 @@
# claude-obsidian — Claude conversations → Obsidian notes
Summarizes every Claude conversation into one searchable Obsidian note: what happened, what
was decided and why, the facts and gotchas worth remembering, and what was left open.
- **Claude Code** sessions sync automatically and incrementally (hourly timer).
- **claude.ai** chats import from a data export (claude.ai has no API, so each export is manual).
Notes are written through the [Obsidian Local REST API](https://github.com/coddingtonbear/obsidian-local-rest-api)
plugin, so the vault can live anywhere the plugin is reachable. The same plugin exposes a
**built-in MCP server** (`/mcp/`), which is how Claude reads the notes back — no separate MCP
process.
```
~/.claude/projects/*/*.jsonl ─┐ ┌─ Claude/Code/2026/…md
claude.ai export .zip ────────┼─ condense → redact → summarize → PUT ────┼─ Claude/Web/2026/…md
└─ (headless claude -p, no transcript) └─ Claude/Index.md
```
## Install
1. In Obsidian, install **Local REST API**, enable its **HTTP server**, and make it reachable
from this machine. Put the API key in `~/.config/claude-obsidian/api-key` (`chmod 600`).
2. `./install.sh` — links the binary into `~/.local/bin`, enables the hourly user timer, and
creates `~/.config/claude-obsidian/config.env` from `config.env.example` if absent.
3. Edit `config.env` (at minimum `OBSIDIAN_API_URL`), then preview one note:
```bash
claude-obsidian-sync stats # sizes, no network
claude-obsidian-sync sync --dry-run --limit 1 # prints the note, writes nothing
```
## Use
```bash
claude-obsidian-sync sync # what the timer runs
claude-obsidian-sync sync --session <session-id> # one session, now, ignoring the settle window
claude-obsidian-sync import-claude-ai ~/Downloads/data-2026-09-13.zip
claude-obsidian-sync sync --prefetch --limit 3 # summarize into the local cache, vault untouched
claude-obsidian-sync usage # calls, billed tokens, spend: last 5h / 24h / 7d
```
Claude reads the vault through the plugin's MCP server:
```bash
claude mcp add --scope user --transport http obsidian http://<host>:27123/mcp/ \
--header "Authorization: Bearer $(cat ~/.config/claude-obsidian/api-key)"
```
## How it behaves — and why
- **Summaries run on your own Claude plan** via headless `claude -p` (`LLM_BACKEND=claude-cli`).
No API key to manage. An OpenAI-compatible backend (`LLM_BACKEND=openai`) exists for local
models. Note a LiteLLM gateway that fronts Claude by *passing through the caller's*
subscription has no key of its own, so a background job cannot use its `claude-*` models.
- **`--no-session-persistence` is load-bearing.** Without it every summary writes a new
transcript into `~/.claude/projects`, and the next run summarizes its own summarizing.
- **Usage is metered and capped.** Every model call is appended to
`~/.local/state/claude-obsidian/usage.jsonl` with *billed* tokens (the CLI's `modelUsage`, not
the visible turn) and `total_cost_usd` — API list-price equivalent, a faithful proxy for how much
was consumed; the plan's own meter is only visible in Claude Code's `/usage`. Before each call
its cost is **estimated** (+20% margin) and the call is refused if it would cross
`BUDGET_USD_PER_RUN` or the rolling `BUDGET_USD_PER_DAY` — so budgets are never overshot. Not
`claude -p --max-budget-usd`: that aborts *after* the tokens are spent and discards the result
(measured: $0.26 for nothing). Every run ends with a one-line usage report.
- **Three cost levers, measured on one session — $0.057 → $0.010, same summary content:**
`alwaysThinkingEnabled=false` (extended thinking was ~85% of output tokens),
`DISABLE_PROMPT_CACHING=1` (the CLI writes each prompt to the 1-hour cache at 2× input price, and
a one-shot call never reads it back), and `CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1` (otherwise a
hidden side request re-reads the entire prompt, doubling billed input). A whole ~110-session
backlog is about $2 at list price with all three.
- **Summaries are cached until their note is written**, so a vault outage never pays for the same
summary twice, and `--prefetch` can do the summarizing while the vault is unreachable.
- **It shares your plan's usage limits.** A first backfill of ~100 sessions in parallel hit a
session limit within minutes — which also limits *interactive* use. So: one job at a time,
`MAX_PER_RUN` sessions per run (newest first), and the run **stops at the first usage-limit
error** (exit 75, which the systemd unit treats as success) instead of failing the rest of
the queue. The next hourly run resumes. A 100-session backlog drains in about ten hours.
- **Secrets are redacted before anything leaves the machine**, and again in the note: private
keys, bearer tokens, `password=`-style assignments, JWTs, provider tokens, ≥48-char hex, and
random mixed-case strings. It over-redacts on purpose; git SHAs, md5s and paths survive.
The summarizer is also told never to reproduce secrets.
- **One run at a time** (a `flock` on `run.lock`): an hourly timer firing during a long manual run
exits immediately instead of racing it on the state and ledger files.
- **Several vault URLs** can be listed; the first that answers wins, so one config covers both a
permanent address and a temporary `kubectl port-forward`.
- **Incremental and idempotent.** State (`~/.local/state/claude-obsidian/state.json`) records
each session's size+mtime. A resumed session is re-summarized into the *same* note path. A
session is left alone until idle for `SETTLE_MINUTES`, so a live conversation isn't
summarized mid-flight. Empty sessions are recorded and skipped.
- **Condensed, not truncated.** Tool output is dropped and each tool call becomes one line;
prose is kept. Typical compression is ~60× (271 MB of transcripts → 4.4 MB). Sessions over
300 KB condensed are summarized in parts, then combined.
- **Subagent transcripts** (`<session>/subagents/**`) are part of their parent session and are
not synced separately.
## Note format
```yaml
type: claude-conversation
source: claude-code # or claude-ai
session_id: 3f9c2a1e-…
title: Fix flaky CI cache after runner upgrade
project: my-service # basename of the working directory
branch: …
started / ended: …
prompts: 1 # user prompts, not tool-loop records
summarized_by: claude-haiku-4-5-20251001
tags: [claude, claude-code, ci, caching, …]
```
followed by a summary callout and **Outcomes / Decisions / Facts & gotchas / Open threads**,
and a `claude --resume <id>` line. `Claude/Index.md` lists every note, newest first.
+798
View File
@@ -0,0 +1,798 @@
#!/usr/bin/env python3
"""claude-obsidian-sync -- summarize Claude conversations into an Obsidian vault.
Reads Claude Code transcripts (~/.claude/projects/<project>/<session>.jsonl) and claude.ai
data exports, condenses each conversation, redacts secrets, summarizes it with an
OpenAI-compatible LLM endpoint (e.g. LiteLLM), and writes one note per conversation into
Obsidian through the Local REST API plugin. Standard library only.
claude-obsidian-sync stats # sizes and counts, no network
claude-obsidian-sync sync [--dry-run] # Claude Code sessions (incremental)
claude-obsidian-sync import-claude-ai EXPORT.zip|conversations.json
Configuration comes from the environment, falling back to ~/.config/claude-obsidian/config.env
(KEY=VALUE lines). Nothing environment-specific lives in this file. See README.md.
"""
from __future__ import annotations
import argparse
import concurrent.futures as cf
import datetime as dt
import fcntl
import hashlib
import json
import os
import pathlib
import re
import shutil
import subprocess
import sys
import threading
import time
import urllib.error
import urllib.parse
import urllib.request
import zipfile
HOME = pathlib.Path.home()
CONFIG_DIR = HOME / ".config" / "claude-obsidian"
# --------------------------------------------------------------------------- config
def load_config() -> dict:
cfg: dict[str, str] = {}
f = CONFIG_DIR / "config.env"
if f.exists():
for line in f.read_text().splitlines():
line = line.strip()
if line and not line.startswith("#") and "=" in line:
k, v = line.split("=", 1)
v = re.split(r"\s+#", v, maxsplit=1)[0] # inline comment; a '#' inside a value survives
cfg[k.strip()] = v.strip().strip('"').strip("'")
cfg.update({k: v for k, v in os.environ.items() if k in DEFAULTS or k.startswith("LLM_") or k.startswith("OBSIDIAN_")})
for k, v in DEFAULTS.items():
cfg.setdefault(k, v)
return cfg
DEFAULTS = {
"OBSIDIAN_API_URL": "http://127.0.0.1:27123",
"OBSIDIAN_API_KEY_FILE": str(CONFIG_DIR / "api-key"),
"LLM_BACKEND": "claude-cli", # claude-cli | openai
"CLAUDE_BIN": "claude",
"LLM_BASE_URL": "",
"LLM_API_KEY_FILE": "",
"LLM_API_KEY_ENV": "LITELLM_KEY",
"LLM_MODEL": "haiku",
"VAULT_FOLDER": "Claude",
"SETTLE_MINUTES": "30",
"CLAUDE_PROJECTS_DIR": str(HOME / ".claude" / "projects"),
"STATE_FILE": str(HOME / ".local" / "state" / "claude-obsidian" / "state.json"),
"JOBS": "1",
# Cap per run. Summaries draw on the same plan limits as interactive Claude use, so the
# timer drips a backlog instead of bursting it (a 107-session burst exhausted a session limit).
"MAX_PER_RUN": "10",
# Spend guards, in API list-price USD as reported by `claude -p` (total_cost_usd). Not the
# plan's own meter -- that is only visible in Claude Code's /usage -- but a faithful proxy
# for how much was consumed. Hitting one stops the run like a usage limit (exit 75).
"BUDGET_USD_PER_RUN": "0.50",
"BUDGET_USD_PER_DAY": "2.00",
# Pre-call estimate prices (USD per million tokens, list). A call is refused BEFORE it runs
# if the estimate would cross a budget. Deliberately NOT `claude -p --max-budget-usd`: that
# aborts after the tokens are spent and throws the result away (measured: $0.26 for nothing).
"LLM_PRICE_IN_PER_M": "1.0",
"LLM_PRICE_OUT_PER_M": "5.0",
}
def secret(cfg: dict, file_key: str, env_key: str | None = None) -> str:
path = cfg.get(file_key)
if path:
p = pathlib.Path(os.path.expanduser(path))
if p.exists():
return p.read_text().strip()
if env_key and os.environ.get(cfg.get(env_key, "") or ""):
return os.environ[cfg[env_key]].strip()
raise SystemExit(f"missing secret: set {file_key} (or {env_key})")
# --------------------------------------------------------------------------- redaction
# Applied to everything sent to the LLM AND to the note written. Over-redaction is the
# accepted failure mode: a summary missing a token is fine, a vault holding one is not.
_REDACTIONS = [
(re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----.*?-----END [A-Z ]*PRIVATE KEY-----", re.S), "[REDACTED PRIVATE KEY]"),
(re.compile(r"(?i)\b(bearer|token)\s+[A-Za-z0-9._~+/=-]{16,}"), r"\1 [REDACTED]"),
(re.compile(r"(?i)((?:password|passwd|pwd|secret|token|api[_-]?key|access[_-]?key|private[_-]?key|client[_-]?secret|NB_API_KEY|PAT)[\"']?\s*[:=]\s*[\"']?)([^\s\"'`,;]{6,})"), r"\1[REDACTED]"),
(re.compile(r"\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}"), "[REDACTED JWT]"),
(re.compile(r"\b(?:sk|pk|rk)-[A-Za-z0-9_-]{20,}|\bgh[pousr]_[A-Za-z0-9]{30,}|\bglpat-[A-Za-z0-9_-]{20,}|\bxox[abpr]-[A-Za-z0-9-]{10,}"), "[REDACTED TOKEN]"),
# >=48 hex: API keys / sha256 tokens. Deliberately leaves 40-hex git SHAs and 32-hex md5s.
(re.compile(r"\b[a-fA-F0-9]{48,}\b"), "[REDACTED HEX]"),
# Random mixed-case+digit strings (generated passwords). No '/' so paths survive.
(re.compile(r"(?<![A-Za-z0-9_./-])(?=[A-Za-z0-9+]*[0-9])(?=[A-Za-z0-9+]*[a-z])(?=[A-Za-z0-9+]*[A-Z])[A-Za-z0-9+]{20,}={0,2}(?![A-Za-z0-9_./-])"), "[REDACTED]"),
]
def redact(text: str) -> str:
for pat, repl in _REDACTIONS:
text = pat.sub(repl, text)
return text
# --------------------------------------------------------------------------- parsing
_NOISE_BLOCKS = re.compile(
r"<(system-reminder|local-command-stdout|local-command-stderr|local-command-caveat|task-notification)>.*?</\1>",
re.S,
)
_CMD = re.compile(r"<command-name>(.*?)</command-name>.*?(?:<command-args>(.*?)</command-args>)?", re.S)
MAX_MSG = 6000
def _clip(text: str) -> str:
return text if len(text) <= MAX_MSG else text[:4000] + "\n[...clipped...]\n" + text[-1500:]
def _clean_user(text: str) -> str:
text = _NOISE_BLOCKS.sub("", text)
m = _CMD.search(text)
if m:
args = (m.group(2) or "").strip()
text = f"{m.group(1).strip()} {args}".strip()
text = re.sub(r"</?command-(message|args|name)>", "", text)
return text.strip()
def _tool_line(block: dict) -> str:
name = block.get("name", "tool")
inp = block.get("input") or {}
desc = inp.get("description") or inp.get("command") or inp.get("file_path") or inp.get("query") \
or inp.get("url") or inp.get("prompt") or inp.get("pattern") or ""
desc = " ".join(str(desc).split())[:160]
return f"[{name}: {desc}]" if desc else f"[{name}]"
def parse_claude_code(path: pathlib.Path) -> dict:
meta = {"session_id": path.stem, "cwd": None, "branch": None, "first": None, "last": None,
"title": None, "version": None, "prompts": 0}
turns: list[list[str]] = []
for line in path.open(encoding="utf-8", errors="replace"):
try:
r = json.loads(line)
except json.JSONDecodeError:
continue
t = r.get("type")
if t == "ai-title" and r.get("aiTitle"):
meta["title"] = r["aiTitle"]
if t == "custom-title" and r.get("customTitle"):
meta["title"] = r["customTitle"]
if t not in ("user", "assistant") or r.get("isSidechain") or r.get("isMeta"):
continue
ts = r.get("timestamp")
if ts:
meta["first"] = meta["first"] or ts
meta["last"] = ts
meta["cwd"] = meta["cwd"] or r.get("cwd")
meta["branch"] = r.get("gitBranch") or meta["branch"]
meta["version"] = r.get("version") or meta["version"]
content = (r.get("message") or {}).get("content")
if t == "user":
if isinstance(content, str):
text = content
elif isinstance(content, list):
text = "\n".join(b.get("text", "") for b in content if b.get("type") == "text")
else:
continue
text = _clean_user(text)
if text:
role = "SUMMARY-OF-EARLIER-CONTEXT" if r.get("isCompactSummary") else "USER"
turns.append([role, _clip(text)])
meta["prompts"] += role == "USER"
else:
parts = []
for b in content if isinstance(content, list) else []:
if b.get("type") == "text" and b.get("text", "").strip():
parts.append(_clip(b["text"].strip()))
elif b.get("type") == "tool_use":
parts.append(_tool_line(b))
if not parts:
continue
if turns and turns[-1][0] == "ASSISTANT": # a tool loop is many records
turns[-1][1] += "\n" + "\n".join(parts)
else:
turns.append(["ASSISTANT", "\n".join(parts)])
meta["transcript"] = "\n\n".join(f"{role}: {text}" for role, text in turns)
return meta
def parse_claude_ai_export(src: pathlib.Path) -> list[dict]:
"""claude.ai 'Export data' -> conversations.json. Tolerant of field-name drift."""
if src.suffix == ".zip":
with zipfile.ZipFile(src) as z:
name = next((n for n in z.namelist() if n.endswith("conversations.json")), None)
if not name:
raise SystemExit(f"no conversations.json inside {src}")
data = json.loads(z.read(name))
else:
data = json.loads(src.read_text())
out = []
for c in data if isinstance(data, list) else data.get("conversations", []):
turns, count = [], 0
for m in c.get("chat_messages") or c.get("messages") or []:
text = m.get("text") or ""
if not text and isinstance(m.get("content"), list):
text = "\n".join(b.get("text", "") for b in m["content"] if b.get("type") == "text")
for a in m.get("attachments") or []:
if a.get("extracted_content"):
text += f"\n[attachment {a.get('file_name', '')}]: {a['extracted_content'][:1500]}"
if not text.strip():
continue
role = "USER" if (m.get("sender") or m.get("role")) in ("human", "user") else "ASSISTANT"
turns.append(f"{role}: {_clip(text.strip())}")
count += role == "USER"
if not turns:
continue
out.append({
"session_id": c.get("uuid") or hashlib.sha1(json.dumps(c, sort_keys=True).encode()).hexdigest(),
"title": c.get("name") or None, "first": c.get("created_at"), "last": c.get("updated_at"),
"cwd": None, "branch": None, "version": None, "prompts": count,
"transcript": "\n\n".join(turns),
})
return out
# --------------------------------------------------------------------------- LLM
SYSTEM = """You write concise knowledge-base notes summarizing a conversation between a user and Claude.
The reader is the same user, months later, searching their notes. Capture what happened and
what was learned, not a play-by-play. Be specific: name the systems, files, commands, errors
and decisions. Never include passwords, API keys, tokens or other secrets, even if present;
refer to them generically ("the API key"). Text marked [REDACTED] must stay redacted.
Reply with ONLY a JSON object, no prose, with these keys:
"title": short specific title (<= 70 chars)
"summary": 3-6 sentences
"outcomes": list of what was actually built, fixed or answered
"decisions": list of choices made and why
"facts": list of reusable facts, gotchas, root causes worth remembering
"open_threads": list of unfinished work or follow-ups
"tags": 3-8 lowercase kebab-case topic tags
Use empty lists where nothing applies."""
CHUNK_SYSTEM = """You are condensing one part of a long conversation between a user and Claude so that a
later step can summarize the whole. Output dense bullet notes: goals, actions taken, results,
errors and their causes, decisions, open questions. Be specific. Never reproduce secrets."""
CHUNK_CHARS = 300_000
class Ledger:
"""Append-only record of every model call: tokens and list-price cost. Also enforces budgets."""
def __init__(self, cfg: dict):
self.path = pathlib.Path(os.path.expanduser(cfg["STATE_FILE"])).parent / "usage.jsonl"
self.run_budget = float(cfg["BUDGET_USD_PER_RUN"])
self.day_budget = float(cfg["BUDGET_USD_PER_DAY"])
self.lock = threading.Lock()
self.run_cost = 0.0
self.run_calls = 0
self.run_in = self.run_out = 0
def entries(self, since: dt.datetime | None = None) -> list[dict]:
if not self.path.exists():
return []
out = []
for line in self.path.read_text().splitlines():
try:
e = json.loads(line)
except json.JSONDecodeError:
continue
if since is None or dt.datetime.fromisoformat(e["ts"]) >= since:
out.append(e)
return out
def spent_since(self, hours: float) -> float:
since = dt.datetime.now(dt.timezone.utc) - dt.timedelta(hours=hours)
return sum(e.get("cost_usd") or 0 for e in self.entries(since))
def check(self, estimate: float = 0.0) -> None:
"""Called BEFORE each model call with that call's estimated cost -- so a budget is never overshot."""
with self.lock:
if self.run_cost + estimate > self.run_budget:
raise BudgetExhausted(f"run budget ${self.run_budget:.2f}: ${self.run_cost:.3f} spent, "
f"next call est. ${estimate:.3f}")
day = self.spent_since(24)
if day + estimate > self.day_budget:
raise BudgetExhausted(f"24h budget ${self.day_budget:.2f}: ${day:.3f} spent, next call est. ${estimate:.3f}")
def record(self, label: str, model: str, usage: dict, cost: float | None) -> None:
e = {"ts": dt.datetime.now(dt.timezone.utc).isoformat(timespec="seconds"), "item": label, "model": model,
"input": (usage.get("input_tokens") or 0) + (usage.get("cache_creation_input_tokens") or 0)
+ (usage.get("cache_read_input_tokens") or 0),
"output": usage.get("output_tokens") or 0, "cost_usd": cost}
with self.lock:
self.path.parent.mkdir(parents=True, exist_ok=True)
with self.path.open("a") as f:
f.write(json.dumps(e) + "\n")
self.run_calls += 1
self.run_cost += cost or 0
self.run_in += e["input"]
self.run_out += e["output"]
def report(self) -> str:
return (f"usage this run: {self.run_calls} model calls, {self.run_in:,} in / {self.run_out:,} out tokens, "
f"${self.run_cost:.3f} list-price equiv | last 24h ${self.spent_since(24):.3f} of ${self.day_budget:.2f}")
class BudgetExhausted(RuntimeError):
"""A configured spend budget is reached -- stop the run the same way as a usage limit."""
class QuotaExhausted(RuntimeError):
"""The plan/API usage limit is hit. Every further call would fail the same way -- stop the run."""
_QUOTA = re.compile(r"(?i)hit your (?:session |usage |weekly )?limit|usage limit|quota|credit balance|resets? (?:at )?\d")
class LLM:
"""Two backends.
claude-cli (default): headless Claude Code on the user's own plan -- no API key to manage.
--no-session-persistence is load-bearing: without it every summary writes a new transcript
into ~/.claude/projects and the next sync would summarize its own summarizing.
openai: any OpenAI-compatible /chat/completions endpoint (LiteLLM, local models).
"""
def __init__(self, cfg: dict, ledger: "Ledger | None" = None):
self.ledger = ledger or Ledger(cfg)
self.price_in = float(cfg["LLM_PRICE_IN_PER_M"]) / 1e6
self.price_out = float(cfg["LLM_PRICE_OUT_PER_M"]) / 1e6
self.label = threading.local()
self.backend = cfg["LLM_BACKEND"]
self.model = cfg["LLM_MODEL"]
self.resolved = cfg["LLM_MODEL"]
if self.backend == "claude-cli":
self.bin = shutil.which(os.path.expanduser(cfg["CLAUDE_BIN"]))
if not self.bin:
raise SystemExit(f"claude CLI not found ({cfg['CLAUDE_BIN']})")
self.workdir = pathlib.Path(os.path.expanduser(cfg["STATE_FILE"])).parent # no project CLAUDE.md here
self.workdir.mkdir(parents=True, exist_ok=True)
elif self.backend == "openai":
if not cfg["LLM_BASE_URL"]:
raise SystemExit("LLM_BASE_URL is not set (see config.env.example)")
self.url = cfg["LLM_BASE_URL"].rstrip("/") + "/chat/completions"
self.key = secret(cfg, "LLM_API_KEY_FILE", "LLM_API_KEY_ENV")
else:
raise SystemExit(f"unknown LLM_BACKEND {self.backend!r}")
def estimate(self, system: str, user: str, max_tokens: int) -> float:
# ~3 chars/token over-estimates input (measured 3.1 on transcripts); assume the full output;
# +20% margin for anything unmeasured. Errs high on purpose: refusing early beats overshooting.
return 1.2 * ((len(system) + len(user)) / 3.0 * self.price_in + max_tokens * self.price_out)
def chat(self, system: str, user: str, max_tokens: int = 2500) -> str:
self.ledger.check(self.estimate(system, user, max_tokens))
return self._chat_cli(system, user) if self.backend == "claude-cli" else self._chat_openai(system, user, max_tokens)
def _chat_cli(self, system: str, user: str) -> str:
# Cost levers, measured on one session: $0.057 -> $0.015 with identical summary content.
# alwaysThinkingEnabled=false -- extended thinking was ~85% of output tokens
# DISABLE_PROMPT_CACHING=1 -- Claude Code writes prompts to the 1h cache at 2x input
# price; a one-shot call never reads it back
# CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 -- otherwise a hidden side request re-reads the
# whole prompt (~20 tokens out, title-generation shaped),
# doubling billed input: 9,638 vs 4,663 visible tokens.
cmd = [self.bin, "-p", "--model", self.model, "--no-session-persistence", "--tools", "",
"--strict-mcp-config", "--output-format", "json",
"--settings", json.dumps({"alwaysThinkingEnabled": False}), "--system-prompt", system]
env = dict(os.environ, DISABLE_PROMPT_CACHING="1", CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC="1")
last = ""
for attempt in range(5):
try:
proc = subprocess.run(cmd, input=user, capture_output=True, text=True, timeout=900, cwd=self.workdir, env=env)
except subprocess.TimeoutExpired:
last = "timeout"
else:
try:
d = json.loads(proc.stdout)
except json.JSONDecodeError:
d = {"is_error": True, "result": (proc.stdout or proc.stderr)[-300:]}
if d.get("usage") or d.get("total_cost_usd"): # failed calls can still cost
# modelUsage is what was BILLED across every request the CLI made; `usage` is only
# the visible turn and undercounted by half before the nonessential-traffic fix.
mu = list((d.get("modelUsage") or {}).values())
billed = {"input_tokens": sum((v.get("inputTokens") or 0) + (v.get("cacheReadInputTokens") or 0)
+ (v.get("cacheCreationInputTokens") or 0) for v in mu),
"output_tokens": sum(v.get("outputTokens") or 0 for v in mu)} if mu else (d.get("usage") or {})
self.ledger.record(getattr(self.label, "v", "?"), self.model, billed, d.get("total_cost_usd"))
if not d.get("is_error") and d.get("result"):
used = list((d.get("modelUsage") or {}).keys())
if used:
self.resolved = used[0]
return d["result"]
last = str(d.get("result") or proc.stderr)[-300:]
if _QUOTA.search(last):
raise QuotaExhausted(last.strip())
if not re.search(r"(?i)rate.?limit|overloaded|429|529|timeout|temporar", last):
break # not transient -- do not hammer
time.sleep(2 ** attempt * 10)
raise RuntimeError(f"claude -p failed: {last}")
def _chat_openai(self, system: str, user: str, max_tokens: int) -> str:
body = json.dumps({"model": self.model, "temperature": 0.2, "max_tokens": max_tokens,
"messages": [{"role": "system", "content": system},
{"role": "user", "content": user}]}).encode()
for attempt in range(5):
req = urllib.request.Request(self.url, data=body, method="POST", headers={
"Authorization": f"Bearer {self.key}", "Content-Type": "application/json"})
try:
with urllib.request.urlopen(req, timeout=600) as r:
d = json.load(r)
u = d.get("usage") or {}
self.ledger.record(getattr(self.label, "v", "?"), self.model,
{"input_tokens": u.get("prompt_tokens"), "output_tokens": u.get("completion_tokens")}, None)
return d["choices"][0]["message"]["content"]
except urllib.error.HTTPError as e:
detail = e.read()[:300].decode(errors="replace")
if _QUOTA.search(detail):
raise QuotaExhausted(detail) from None
if e.code in (429, 500, 502, 503, 504, 529) and attempt < 4:
time.sleep(2 ** attempt * 5)
continue
raise RuntimeError(f"LLM HTTP {e.code}: {detail}") from None
except (urllib.error.URLError, TimeoutError) as e:
if attempt < 4:
time.sleep(2 ** attempt * 5)
continue
raise RuntimeError(f"LLM unreachable: {e}") from None
raise RuntimeError("LLM retries exhausted")
def summarize(self, meta: dict) -> dict:
text = redact(meta["transcript"])
if len(text) > CHUNK_CHARS: # map-reduce for long sessions
chunks, cur = [], []
size = 0
for part in text.split("\n\n"):
if size + len(part) > CHUNK_CHARS and cur:
chunks.append("\n\n".join(cur)); cur, size = [], 0
cur.append(part); size += len(part) + 2
chunks.append("\n\n".join(cur))
notes = [self.chat(CHUNK_SYSTEM, f"Part {i + 1} of {len(chunks)}:\n\n{c}", 3000)
for i, c in enumerate(chunks)]
text = "Condensed notes from each part of a long conversation, in order:\n\n" + \
"\n\n---\n\n".join(notes)
header = f"Working directory: {meta.get('cwd')}\nGit branch: {meta.get('branch')}\n" \
f"Existing title: {meta.get('title')}\n\n"
raw = self.chat(SYSTEM, header + text)
m = re.search(r"\{.*\}", raw, re.S)
if not m:
raise RuntimeError(f"LLM did not return JSON: {raw[:200]!r}")
out = json.loads(m.group(0))
for k in ("outcomes", "decisions", "facts", "open_threads", "tags"):
out[k] = [redact(str(x)) for x in (out.get(k) or []) if str(x).strip()]
out["summary"] = redact(str(out.get("summary", "")))
out["title"] = redact(str(out.get("title") or meta.get("title") or meta["session_id"]))
return out
# --------------------------------------------------------------------------- vault
class Vault:
def __init__(self, cfg: dict):
# OBSIDIAN_API_URL may list several URLs, comma-separated, tried in order at ping() -- e.g. the
# permanent address first and a port-forward second, so one config works either way.
self.candidates = [u.strip().rstrip("/") for u in cfg["OBSIDIAN_API_URL"].split(",") if u.strip()]
self.base = self.candidates[0]
self.key = secret(cfg, "OBSIDIAN_API_KEY_FILE")
def put(self, path: str, content: str) -> None:
req = urllib.request.Request(f"{self.base}/vault/{urllib.parse.quote(path)}",
data=content.encode(), method="PUT", headers={
"Authorization": f"Bearer {self.key}",
"Content-Type": "text/markdown"})
with urllib.request.urlopen(req, timeout=60) as r:
if r.status not in (200, 201, 204):
raise RuntimeError(f"vault PUT {path}: HTTP {r.status}")
def ping(self) -> None:
"""Checked BEFORE any summarizing, so an unreachable vault never spends usage limits."""
errors = []
for base in self.candidates:
req = urllib.request.Request(f"{base}/", headers={"Authorization": f"Bearer {self.key}"})
try:
with urllib.request.urlopen(req, timeout=10) as r:
info = json.load(r)
self.base = base
break
except (urllib.error.URLError, TimeoutError, OSError, ValueError) as e:
errors.append(f"{base}: {getattr(e, 'reason', e)}")
else:
raise SystemExit("Obsidian REST API unreachable -- " + "; ".join(errors))
print(f"vault: {self.base}", flush=True)
if not info.get("authenticated"):
raise SystemExit("Obsidian REST API reachable but the API key was rejected")
_BAD = re.compile(r'[\\/:*?"<>|#^\[\]]+')
def note_path(cfg: dict, kind: str, meta: dict, title: str) -> str:
when = (meta.get("first") or dt.datetime.now(dt.timezone.utc).isoformat())[:10]
slug = " ".join(_BAD.sub(" ", title).split())[:60].strip() or "untitled"
return f"{cfg['VAULT_FOLDER']}/{kind}/{when[:4]}/{when} {slug} ({meta['session_id'][:8]}).md"
def render(meta: dict, s: dict, source: str, model: str, src_file: str | None) -> str:
project = pathlib.Path(meta["cwd"]).name if meta.get("cwd") else None
tags = ["claude", source] + [t for t in s["tags"] if t not in ("claude", source)]
fm = {
"type": "claude-conversation", "source": source, "session_id": meta["session_id"],
"title": s["title"], "project": project, "cwd": meta.get("cwd"), "branch": meta.get("branch"),
"started": meta.get("first"), "ended": meta.get("last"), "prompts": meta.get("prompts"),
"summarized_by": model, "synced": dt.datetime.now(dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
"tags": tags,
}
lines = ["---"] + [f"{k}: {json.dumps(v)}" for k, v in fm.items() if v not in (None, "")] + ["---", "",
f"# {s['title']}", "", "> [!summary]"] + [f"> {l}" for l in s["summary"].splitlines() or [""]]
for heading, key in (("Outcomes", "outcomes"), ("Decisions", "decisions"),
("Facts & gotchas", "facts"), ("Open threads", "open_threads")):
if s[key]:
lines += ["", f"## {heading}", ""] + [f"- {x}" for x in s[key]]
lines += ["", "---"]
if source == "claude-code":
lines.append(f"Resume: `claude --resume {meta['session_id']}`" + (f" · transcript `{src_file}`" if src_file else ""))
else:
lines.append(f"claude.ai conversation `{meta['session_id']}`")
return "\n".join(lines) + "\n"
# --------------------------------------------------------------------------- state
class State:
def __init__(self, path: str):
self.path = pathlib.Path(os.path.expanduser(path))
self.lock = threading.Lock()
self.data = json.loads(self.path.read_text()) if self.path.exists() else {}
def save(self) -> None:
self.path.parent.mkdir(parents=True, exist_ok=True)
tmp = self.path.with_suffix(".tmp")
tmp.write_text(json.dumps(self.data, indent=1, sort_keys=True))
tmp.replace(self.path)
def record(self, key: str, value: dict) -> None:
with self.lock:
self.data[key] = value
self.save()
def write_index(cfg: dict, vault: Vault, state: State) -> None:
rows = sorted((v for v in state.data.values() if v.get("note")), key=lambda v: v.get("started") or "", reverse=True)
out = ["---", 'type: "claude-index"', "---", "", "# Claude conversations", "",
f"{len(rows)} conversations. Regenerated by claude-obsidian-sync.", "",
"| Date | Title | Project | Source |", "|---|---|---|---|"]
for v in rows:
link = v["note"][:-3]
title = v.get("title", "").replace("|", "-")
out.append(f"| {(v.get('started') or '')[:10]} | [[{link}\\|{title}]] | {v.get('project') or ''} | {v.get('source')} |")
vault.put(f"{cfg['VAULT_FOLDER']}/Index.md", "\n".join(out) + "\n")
# --------------------------------------------------------------------------- commands
def top_level_sessions(cfg: dict) -> list[pathlib.Path]:
root = pathlib.Path(os.path.expanduser(cfg["CLAUDE_PROJECTS_DIR"]))
return sorted(root.glob("*/*.jsonl"), key=lambda p: p.stat().st_mtime) # nested = subagents
def process(cfg, llm, vault, state, key, meta, source, kind, fingerprint, src_file, dry_run) -> str:
# Cache the summary until the note is written, so a vault failure never pays for it twice.
cache = pathlib.Path(os.path.expanduser(cfg["STATE_FILE"])).parent / "cache" / \
f"{hashlib.sha1(key.encode()).hexdigest()[:16]}-{hashlib.sha1(fingerprint.encode()).hexdigest()[:8]}.json"
llm.label.v = key
if cache.exists():
s = json.loads(cache.read_text())
else:
s = llm.summarize(meta)
cache.parent.mkdir(parents=True, exist_ok=True)
cache.write_text(json.dumps(s))
prev = state.data.get(key, {})
path = prev.get("note") or note_path(cfg, kind, meta, s["title"]) # stable across re-syncs
body = render(meta, s, source, llm.resolved, src_file)
if dry_run == "prefetch":
return f"summary cached ({path})"
if dry_run:
print(f"\n===== {path} =====\n{body}")
return path
vault.put(path, body)
cache.unlink(missing_ok=True)
state.record(key, {"note": path, "title": s["title"], "fingerprint": fingerprint, "source": source,
"started": meta.get("first"),
"project": pathlib.Path(meta["cwd"]).name if meta.get("cwd") else None})
return path
SKIPPED = "(empty, skipped)"
EX_TEMPFAIL = 75 # "try again later" -- systemd logs it, the next timer run retries
def run_jobs(cfg, work, fn) -> tuple[int, int, str | None, int]:
ok = failed = skipped = 0
quota = None
stop = threading.Event() # Future.cancel() cannot stop a job a worker already picked up
def guarded(*w):
if stop.is_set():
raise cf.CancelledError()
try:
return fn(*w)
except (QuotaExhausted, BudgetExhausted):
stop.set()
raise
with cf.ThreadPoolExecutor(max_workers=int(cfg["JOBS"])) as ex:
futs = {ex.submit(guarded, *w): w for w in work}
for fut in cf.as_completed(futs):
label = futs[fut][0]
try:
result = fut.result()
if result == SKIPPED:
print(f"skip {label} (no conversation content)", flush=True); skipped += 1
else:
print(f"ok {label} -> {result}", flush=True); ok += 1
except (QuotaExhausted, BudgetExhausted) as e:
if quota is None:
quota = str(e)
for f in futs: # don't launch the rest
f.cancel()
except cf.CancelledError:
pass
except Exception as e: # keep going; next run retries
print(f"FAIL {label}: {e}", file=sys.stderr, flush=True); failed += 1
return ok, failed, quota, skipped
def finish(ok: int, failed: int, quota: str | None, dry_run, remaining: int, ledger: "Ledger | None" = None) -> None:
verb = {"prefetch": "summarized into cache (vault untouched)"}.get(dry_run) or \
("rendered (dry run, nothing written)" if dry_run else "written")
print(f"done: {ok} {verb}, {failed} failed, {remaining} still queued")
if ledger:
print(ledger.report())
if quota:
print(f"stopped: {quota}", file=sys.stderr)
sys.exit(EX_TEMPFAIL)
sys.exit(1 if failed else 0)
def cmd_usage(cfg, args) -> None:
led = Ledger(cfg)
rows = led.entries()
if not rows:
print("no model calls recorded yet"); return
for label, hours in (("last 5h", 5), ("last 24h", 24), ("last 7d", 168)):
since = dt.datetime.now(dt.timezone.utc) - dt.timedelta(hours=hours)
es = [e for e in rows if dt.datetime.fromisoformat(e["ts"]) >= since]
print(f"{label:>9}: {len(es):4d} calls {sum(e['input'] for e in es):>10,} in "
f"{sum(e['output'] for e in es):>8,} out ${sum(e.get('cost_usd') or 0 for e in es):.3f}")
print(f"budgets: ${led.run_budget:.2f}/run, ${led.day_budget:.2f}/24h (list-price equiv; plan meter: /usage in Claude Code)")
def cmd_stats(cfg, args) -> None:
total = 0
rows = []
for p in top_level_sessions(cfg):
m = parse_claude_code(p)
n = len(redact(m["transcript"]))
total += n
rows.append((n, p.stat().st_size, m["prompts"], m["title"] or p.stem))
rows.sort(reverse=True)
for n, raw, msgs, title in rows[: args.top]:
print(f"{raw / 1e6:7.2f} MB raw -> {n / 1e3:7.1f} KB condensed {msgs:4d} prompts {title[:60]}")
print(f"\n{len(rows)} sessions, {total / 1e6:.1f} MB condensed (~{total / 4 / 1e6:.2f}M tokens in), "
f"{sum(1 for r in rows if r[0] > CHUNK_CHARS)} need map-reduce")
def run_lock(cfg: dict):
"""One run at a time: a timer run overlapping a manual one would race on state and ledger files."""
path = pathlib.Path(os.path.expanduser(cfg["STATE_FILE"])).parent / "run.lock"
path.parent.mkdir(parents=True, exist_ok=True)
fh = path.open("w")
try:
fcntl.flock(fh, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
print("another claude-obsidian-sync run is in progress -- exiting")
sys.exit(0)
return fh # held until the process exits
def cmd_sync(cfg, args) -> None:
_lock = run_lock(cfg)
state = State(cfg["STATE_FILE"])
settle = dt.timedelta(minutes=int(cfg["SETTLE_MINUTES"]))
now = time.time()
work = []
for p in top_level_sessions(cfg):
if args.session and p.stem != args.session:
continue
st = p.stat()
fp = f"{st.st_size}:{int(st.st_mtime)}"
key = f"claude-code:{p.stem}"
if not args.force and state.data.get(key, {}).get("fingerprint") == fp:
continue
if not (args.force or args.session) and now - st.st_mtime < settle.total_seconds():
continue # still being written
work.append((p, fp, key))
total = len(work)
cap = args.limit or int(cfg["MAX_PER_RUN"])
work = work[-cap:] if not args.oldest else work[:cap] # newest first by default
print(f"{total} session(s) pending, summarizing {len(work)} this run", flush=True)
if not work:
return
prefetch = getattr(args, "prefetch", False)
llm, vault = LLM(cfg), (None if args.dry_run or prefetch else Vault(cfg))
if vault:
vault.ping()
mode = "prefetch" if prefetch else args.dry_run
def one(p, fp, key):
meta = parse_claude_code(p)
if not meta["transcript"].strip():
if not args.dry_run and not prefetch:
state.record(key, {"note": None, "fingerprint": fp, "source": "claude-code"})
return SKIPPED
src = str(p).replace(str(HOME), "~")
return process(cfg, llm, vault, state, key, meta, "claude-code", "Code", fp, src, mode)
ok, failed, quota, skipped = run_jobs(cfg, [(w[0].name, *w) for w in work], lambda _l, p, fp, key: one(p, fp, key))
if vault and ok:
write_index(cfg, vault, state)
finish(ok, failed, quota, mode, total - ok - skipped, llm.ledger)
def cmd_import(cfg, args) -> None:
_lock = run_lock(cfg)
state = State(cfg["STATE_FILE"])
convs = parse_claude_ai_export(pathlib.Path(args.export))
work = []
for m in convs:
key = f"claude-ai:{m['session_id']}"
fp = m.get("last") or str(len(m["transcript"]))
if args.force or state.data.get(key, {}).get("fingerprint") != fp:
work.append((m, fp, key))
total = len(work)
work = work[: args.limit or int(cfg["MAX_PER_RUN"])]
print(f"{len(convs)} conversations in export, {total} new or changed, summarizing {len(work)} this run", flush=True)
if not work:
return
llm, vault = LLM(cfg), (None if args.dry_run else Vault(cfg))
if vault:
vault.ping()
ok, failed, quota, skipped = run_jobs(cfg, [((m.get("title") or m["session_id"])[:50], m, fp, key) for m, fp, key in work],
lambda _l, m, fp, key: process(cfg, llm, vault, state, key, m, "claude-ai", "Web",
fp, None, args.dry_run))
if vault and ok:
write_index(cfg, vault, state)
finish(ok, failed, quota, args.dry_run, total - ok - skipped, llm.ledger)
def main() -> None:
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
sub = ap.add_subparsers(dest="cmd", required=True)
sub.add_parser("usage", help="model calls, tokens and spend from the local ledger")
s = sub.add_parser("stats", help="condensed sizes, no network")
s.add_argument("--top", type=int, default=15)
s = sub.add_parser("sync", help="summarize Claude Code sessions")
s.add_argument("--dry-run", action="store_true", help="print notes instead of writing them")
s.add_argument("--prefetch", action="store_true",
help="summarize into the local cache without touching the vault; a later sync writes them at no extra cost")
s.add_argument("--force", action="store_true", help="ignore state and the settle window")
s.add_argument("--session", help="only this session id")
s.add_argument("--limit", type=int, help="max sessions this run (default MAX_PER_RUN)")
s.add_argument("--oldest", action="store_true", help="take the oldest pending sessions instead of the newest")
s = sub.add_parser("import-claude-ai", help="import a claude.ai data export")
s.add_argument("export", help="the export .zip or its conversations.json")
s.add_argument("--dry-run", action="store_true")
s.add_argument("--force", action="store_true")
s.add_argument("--limit", type=int, help="max conversations this run (default MAX_PER_RUN)")
args = ap.parse_args()
cfg = load_config()
{"stats": cmd_stats, "usage": cmd_usage, "sync": cmd_sync, "import-claude-ai": cmd_import}[args.cmd](cfg, args)
if __name__ == "__main__":
main()
+28
View File
@@ -0,0 +1,28 @@
# ~/.config/claude-obsidian/config.env (chmod 600; never commit the real one)
# Every key can also be set as an environment variable, which wins.
# Obsidian Local REST API plugin -- the plain-HTTP server (Settings -> Local REST API ->
# Enable HTTP server). Point this at wherever that port is reachable from this machine.
# Several URLs, comma-separated, are tried in order -- e.g. a permanent address, then a port-forward.
OBSIDIAN_API_URL=http://obsidian.example.internal:27123,http://127.0.0.1:27123
OBSIDIAN_API_KEY_FILE=~/.config/claude-obsidian/api-key
# Summarizer. claude-cli = headless `claude -p` on your own Claude plan (no API key needed).
LLM_BACKEND=claude-cli
LLM_MODEL=haiku # sonnet writes noticeably sharper notes for ~1-3x the usage; set prices to match below
# LLM_PRICE_IN_PER_M=1.0 # used only for the pre-call budget estimate (haiku list; sonnet 3.0 / 15.0)
# LLM_PRICE_OUT_PER_M=5.0
# CLAUDE_BIN=claude
# Or any OpenAI-compatible endpoint (LiteLLM, a local model):
# LLM_BACKEND=openai
# LLM_BASE_URL=https://litellm.example/v1
# LLM_MODEL=some-model
# LLM_API_KEY_FILE=~/.config/claude-obsidian/llm-key # or LLM_API_KEY_ENV=LITELLM_KEY
VAULT_FOLDER=Claude
SETTLE_MINUTES=30 # leave sessions alone until they have been idle this long
JOBS=1
MAX_PER_RUN=10 # summaries share your plan's usage limits; the timer drips a backlog
BUDGET_USD_PER_RUN=0.50 # list-price USD, refused pre-flight; see `claude-obsidian-sync usage`
BUDGET_USD_PER_DAY=2.00
+14
View File
@@ -0,0 +1,14 @@
#!/bin/sh
# Install claude-obsidian-sync for the current user: binary into ~/.local/bin, systemd user
# timer enabled. Idempotent. Does not create config or secrets -- see README.md.
set -eu
HERE="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
mkdir -p "$HOME/.local/bin" "$HOME/.config/systemd/user" "$HOME/.config/claude-obsidian"
chmod 700 "$HOME/.config/claude-obsidian"
ln -sf "$HERE/claude-obsidian-sync" "$HOME/.local/bin/claude-obsidian-sync"
cp "$HERE/systemd/claude-obsidian-sync.service" "$HERE/systemd/claude-obsidian-sync.timer" "$HOME/.config/systemd/user/"
[ -f "$HOME/.config/claude-obsidian/config.env" ] || { cp "$HERE/config.env.example" "$HOME/.config/claude-obsidian/config.env"; chmod 600 "$HOME/.config/claude-obsidian/config.env"; echo "created config.env from the example -- edit it"; }
systemctl --user daemon-reload
systemctl --user enable --now claude-obsidian-sync.timer
echo "installed. check: systemctl --user list-timers claude-obsidian-sync.timer"
echo "timers only run while logged in unless lingering is on: loginctl enable-linger \"\$USER\""
@@ -0,0 +1,11 @@
[Unit]
Description=Summarize Claude conversations into Obsidian
After=network-online.target
[Service]
Type=oneshot
ExecStart=%h/.local/bin/claude-obsidian-sync sync
Environment=PATH=%h/.local/bin:/usr/local/bin:/usr/bin:/bin
# 75 = usage limit reached. The run stopped cleanly and the next timer run resumes -- not a failure.
SuccessExitStatus=75
Nice=10
@@ -0,0 +1,10 @@
[Unit]
Description=Hourly Claude -> Obsidian conversation sync
[Timer]
OnCalendar=hourly
RandomizedDelaySec=10m
Persistent=true
[Install]
WantedBy=timers.target
-62
View File
@@ -1,62 +0,0 @@
#!/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
+34 -1
View File
@@ -12,10 +12,43 @@ git config --global user.name "$GIT_NAME"
git config --global user.email "$GIT_EMAIL" git config --global user.email "$GIT_EMAIL"
say "git identity: $GIT_NAME <$GIT_EMAIL>" say "git identity: $GIT_NAME <$GIT_EMAIL>"
# Auto-prune stale remote-tracking refs on every fetch/pull.
#
# Without this, `git branch -r` keeps listing branches that were deleted on the
# server, and zsh's git completion happily offers them — so you tab-complete a
# branch name into `git push origin --delete` and get "remote ref does not
# exist" for something that was already gone. The prefix makes it worse: the
# completion offers `origin/foo`, but the ref on the server is just `foo`, so
# even a live branch fails unless you strip it by hand. See the `gbd` helper in
# zsh/zshrc (the grmb helper) for that half.
git config --global fetch.prune true
say "fetch.prune = true (stale remote-tracking refs cleaned on every fetch)"
# Plaintext credential cache in $HOME (survives container/VM rebuilds that # Plaintext credential cache in $HOME (survives container/VM rebuilds that
# wipe everything outside $HOME). Git creates ~/.git-credentials with 0600 # wipe everything outside $HOME). Git creates ~/.git-credentials with 0600
# the first time a credential is saved. # the first time a credential is saved.
git config --global credential.helper store git config --global credential.helper store
say "credential.helper = store (~/.git-credentials, created on first auth)" say "credential.helper = store (~/.git-credentials, created on first auth)"
say "Done. On the next HTTPS push/pull to a new host, git prompts once and caches the credential in ~/.git-credentials." # Our own Gitea authenticates from $GITEA_KEY (exported by secrets/ from the
# vault), not from ~/.git-credentials.
#
# WHY: git never reads GITEA_KEY by itself. With only `store`, the token was
# whatever got typed at the first prompt, copied into ~/.git-credentials, and
# it silently went stale when the vault's key was rotated — or, on a box that
# never pushed, there was no credential at all and the push just failed. Now
# `opc GITEA_KEY` is the only step when the token changes.
#
# The empty helper first clears the inherited `store` for this host, so a
# rejected token is never cached and a stale cached one can never win. Gitea
# accepts the token as the password; the username is informational (GITEA_USER
# overrides it). With GITEA_KEY unset the helper prints nothing and git falls
# back to prompting, as before.
GITEA_URL="https://git.nic-oconnor.com"
git config --global --unset-all "credential.$GITEA_URL.helper" 2>/dev/null || true
git config --global --add "credential.$GITEA_URL.helper" ''
git config --global --add "credential.$GITEA_URL.helper" \
'!f() { test "$1" = get && test -n "${GITEA_KEY:-}" && printf "username=%s\npassword=%s\n" "${GITEA_USER:-$(id -un)}" "$GITEA_KEY"; }; f'
say "credential helper for $GITEA_URL = \$GITEA_KEY (from secrets.env)"
say "Done. $GITEA_URL uses \$GITEA_KEY; other HTTPS hosts prompt once and cache in ~/.git-credentials."
+77
View File
@@ -0,0 +1,77 @@
# hermes/gateway.yaml — the covert-gpt LiteLLM gateway, as a Hermes custom
# provider. Merged into ~/.hermes/config.yaml by hermes/install.sh.
#
# NO SECRETS, EVER: the key is referenced by NAME via `key_env: LITELLM_KEY`,
# never by value. mcp-config/sync.sh exports LITELLM_KEY into every interactive
# shell from ~/.config/mcp-sync/key (0600), and Hermes — launched from such a
# shell — resolves it at runtime. So this file is safe in a PUBLIC repo, and
# `hermes/install.sh` has no key to prompt for. Run mcp-config/install.sh first;
# without it LITELLM_KEY is unset and Hermes falls back to "no-key-required",
# which the gateway rejects with a 401.
#
# WHY a custom provider instead of `model.provider: openrouter` + a base_url:
# that older shape pointed Hermes at the gateway by pretending it WAS OpenRouter
# (OPENROUTER_API_KEY held the LiteLLM key, OPENROUTER_BASE_URL held the gateway
# URL). Requests reached the gateway, but the MODEL PICKER did not: Hermes'
# fetch_openrouter_models hardcodes openrouter.ai/api/v1/models and ignores
# OPENROUTER_BASE_URL, so the picker listed OpenRouter's ~342-model catalog —
# models this gateway cannot serve — while the gateway's own models never
# appeared. A named custom provider probes THIS base_url instead.
#
# NOTE ON COMMENTS: Hermes rewrites ~/.hermes/config.yaml itself (save_config)
# and does not preserve comments, so none of this survives into the deployed
# file. That is exactly why the reasoning lives HERE, in the repo, rather than
# inline in the generated config.
custom_providers:
- name: covert-gpt
base_url: https://llm.covert-gpt.com/v1
key_env: LITELLM_KEY
api_mode: chat_completions
# `discover_models` is deliberately left at its default (true): with a key
# present Hermes probes the gateway's /v1/models and REPLACES the picker's
# list with the live catalog, so new gateway models show up with no edit
# here. The `models:` map below is NOT the picker list — it exists purely to
# carry per-model context_length overrides, which are read from config
# independently of discovery.
models:
# NO LONGER HAND-MAINTAINED. mcp-config/sync.sh renders this map from the
# gateway's /v1/model/info on every sync (_mcp_sync_hermes_models), so the
# single source of truth is `model_info.max_input_tokens` in ai-k8s-iac's
# charts/inference/values.yaml. Fix a wrong window THERE, not here: an
# edit to these entries is overwritten by the next sync.
#
# What follows is only a first-boot seed, so a brand-new box has sane
# limits before its first sync. Anything you add that the gateway does
# not serve is left alone — the renderer only touches fields it set.
#
# Unlike opencode, Hermes needs NO safety margin here. opencode reserves
# `limit.output` and targets an input of exactly context - output, landing
# one token over the window (a hard 400); Hermes omits max_tokens entirely
# unless model.max_tokens is set, so vLLM just bounds output by whatever
# context remains. Do NOT add model.max_tokens to work around anything: it
# is GLOBAL, would apply to every provider (including Anthropic), and only
# shrinks the effective input budget Hermes compacts against.
# CORRECTED 2026-09-11. This map had drifted badly and the drift was
# not visible from inside Hermes: it listed `qwen3-vl-30b-a3b` at
# 262144, a model the gateway stopped serving when Ornith replaced it,
# while Ornith — the model actually on cg-1, and a SMALLER window at
# 196608 — had no entry at all. A model with no entry gets whatever
# default Hermes carries, so the one model on this gateway that needs a
# lower ceiling than its neighbours was the one model not declaring one.
# That is the right shape to produce exactly the reported symptom: fine
# on short turns, hard 400 once a task grows.
ornith-1.0-35b: # cg-1 — reasoning + tool calling, multimodal
# 196608, NOT 262144. Lower than the others on purpose — see
# vllm.nodes[cg1] --max-model-len.
context_length: 196608
supports_vision: true
qwen3.8-27b: # cg-2 — general, NVFP4
context_length: 262144
qwen3-coder-next: # cg-2 — agentic coder
# Shares cg-2 with qwen3.8-27b; only one is enabled at a time, so at
# most one of these two is ever live. Both are listed because which
# one it is changes without touching this file.
context_length: 262144
# Models the gateway proxies to third parties (kimi, glm, …) are
# intentionally absent: discovery still lists them, and inventing a
# context_length we have not verified is worse than letting Hermes probe.
+275
View File
@@ -0,0 +1,275 @@
#!/usr/bin/env sh
# hermes/install.sh — install Hermes Agent by Nous Research, and point it at
# the covert-gpt LiteLLM gateway.
#
# WHY: Hermes requires Python 3.11+ and creates a self-contained venv at
# ~/.hermes. The official installer handles the complexity of managing its
# own uv-based Python environment. This module simply runs the official
# installer with --skip-setup so it completes in non-interactive mode.
# Everything under $HOME, survives Kasm redeploys.
#
# It then merges hermes/gateway.yaml into ~/.hermes/config.yaml, so a fresh box
# gets a Hermes that can actually talk to the gateway. Without that step Hermes
# installs and then points at nothing — the asymmetry mcp-config/ does not have,
# since opencode rebuilds its config from Gitea on every shell via sync.sh.
#
# No secrets are handled here: gateway.yaml references the key by NAME
# (key_env: LITELLM_KEY) and mcp-config/sync.sh exports it. Run
# mcp-config/install.sh first.
set -eu
SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
REPO_ROOT="$(dirname -- "$SCRIPT_DIR")"
# shellcheck source=../lib/fetch.sh
. "$REPO_ROOT/lib/fetch.sh"
say() { printf '%s\n' "$*" >&2; }
# The installer links the CLI into ~/.local/bin, which is only on PATH in
# shells that sourced the managed ~/.zshrc. bootstrap run by a non-shell
# parent (the Coder agent's dotfiles script) has no such PATH, so the
# post-install `command -v hermes` failed on a successful install.
PATH="$HOME/.local/bin:$PATH"
# Hermes Agent version (synced with PyPI/npm).
HERMES_VERSION="v0.18.2"
# --- 1. the binary --------------------------------------------------------
# Check if hermes is already installed at the correct version using the
# .versions stamp pattern from bin/install.sh. NOTE: this only skips the
# DOWNLOAD — the config merge below still runs, so a config fix ships to an
# already-installed box on the next bootstrap.
#
# BUT: if the LiteLLM key has changed, Hermes may have the old key cached in its
# config or local state. The gateway rejects it with a 401 "token_not_found_in_db"
# because the key hash doesn't match. Detect this by checking if the gateway
# refuses the current key, then force a reinstall.
CURRENT_KEY_HASH=""
if [ -f "$HOME/.config/mcp-sync/key" ]; then
CURRENT_KEY_HASH="$(sha256sum "$HOME/.config/mcp-sync/key" 2>/dev/null | cut -d' ' -f1)"
fi
# Check if we need to force reinstall due to key change (compare against last-seen hash)
PREVIOUS_KEY_HASH=""
if [ -f "$HOME/.local/bin/.versions/hermes-key-hash" ]; then
PREVIOUS_KEY_HASH="$(cat "$HOME/.local/bin/.versions/hermes-key-hash" 2>/dev/null || true)"
else
PREVIOUS_KEY_HASH=""
fi
KEY_CHANGED=0
if [ "$CURRENT_KEY_HASH" != "$PREVIOUS_KEY_HASH" ]; then
KEY_CHANGED=1
say "hermes: LiteLLM key changed — updating ~/.hermes/.env and restarting Hermes."
fi
# Update the .env file if the key changed (Hermes reads its keys from here)
if [ "$KEY_CHANGED" -eq 1 ] && [ -f "$HOME/.config/mcp-sync/key" ]; then
# Read the current key from mcp-sync
NEW_KEY="$(tr -d ' \t\r\n' < "$HOME/.config/mcp-sync/key")"
# Update LITELLM_KEY in .env (or add it if missing)
if [ -f "$HOME/.hermes/.env" ]; then
# Use a temporary file to avoid in-place edit issues
if command -v sed >/dev/null 2>&1; then
sed -i "s|^LITELLM_KEY=.*$|LITELLM_KEY=$NEW_KEY|" "$HOME/.hermes/.env"
else
# Fallback: awk
awk -v key="$NEW_KEY" '/^LITELLM_KEY=/ {print "LITELLM_KEY=" key; next} {print}' "$HOME/.hermes/.env" >"$HOME/.hermes/.env.tmp" && mv "$HOME/.hermes/.env.tmp" "$HOME/.hermes/.env"
fi
say "hermes: Updated LITELLM_KEY in ~/.hermes/.env"
else
# Create .env if it doesn't exist. On a fresh home this runs BEFORE the
# installer below has created ~/.hermes, so make the directory first.
mkdir -p "$HOME/.hermes"
printf 'LITELLM_KEY=%s\n' "$NEW_KEY" >"$HOME/.hermes/.env"
say "hermes: Created ~/.hermes/.env with LITELLM_KEY"
fi
chmod 0600 "$HOME/.hermes/.env"
fi
# Kill any running Hermes process so it restarts with the new key
if [ "$KEY_CHANGED" -eq 1 ]; then
if [ -f "$HOME/.hermes/.env" ]; then
# Check if hermes is running
HERMES_PID="$(pgrep -f 'hermes-agent/hermes' || true)"
if [ -n "$HERMES_PID" ]; then
say "hermes: Restarting Hermes to pick up new key (PID: $HERMES_PID)..."
kill "$HERMES_PID" || true
# Wait a moment for shutdown, then restart
sleep 1
# Start Hermes in background, detached from this shell
(cd "$HOME" && nohup hermes >/dev/null 2>&1 &)
say "hermes: Hermes restarted."
fi
fi
fi
if [ -x "$HOME/.local/bin/hermes" ] && [ -f "$HOME/.local/bin/.versions/hermes" ] && [ "$(cat "$HOME/.local/bin/.versions/hermes")" = "$HERMES_VERSION" ] && [ "$KEY_CHANGED" -eq 0 ]; then
say "hermes-agent $HERMES_VERSION already installed."
else
say "Installing hermes-agent via official installer..."
# Run the official installer in non-interactive mode, skipping the setup
# wizard. The installer creates ~/.hermes/venvs/hermes with its own
# uv-managed Python.
curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash -s -- --non-interactive --skip-setup
# Verify installation by checking the binary exists AND produces expected output.
if command -v hermes >/dev/null 2>&1; then
mkdir -p "$HOME/.local/bin/.versions"
printf '%s\n' "$HERMES_VERSION" >"$HOME/.local/bin/.versions/hermes"
say "Installed hermes-agent $HERMES_VERSION successfully."
else
say "ERROR: hermes CLI not found after install"
exit 1
fi
fi
# Record the current key hash for next time
mkdir -p "$HOME/.local/bin/.versions"
printf '%s\n' "$CURRENT_KEY_HASH" >"$HOME/.local/bin/.versions/hermes-key-hash"
# --- point it at the gateway ----------------------------------------------
# Merge hermes/gateway.yaml into ~/.hermes/config.yaml. Idempotent, and
# deliberately NON-DESTRUCTIVE: config.yaml is a large personal file
# (personalities, display prefs, toolsets) that Hermes also rewrites itself, so
# this replaces ONLY the covert-gpt custom_providers entry and never rewrites
# the rest. model.default / model.provider are seeded only when unset, so a
# deliberate model choice is preserved across bootstraps.
#
# PyYAML is needed to merge. Prefer the system python3; fall back to Hermes' own
# venv, which always has it. If neither works the config step is skipped with a
# warning rather than failing bootstrap — the binary is still installed, and a
# missing provider is a far smaller problem than a broken box.
GATEWAY_YAML="$SCRIPT_DIR/gateway.yaml"
HERMES_CONFIG="$HOME/.hermes/config.yaml"
PY=""
for _c in python3 "$HOME/.hermes/venvs/hermes/bin/python" "$HOME/.hermes/hermes-agent/venv/bin/python"; do
if command -v "$_c" >/dev/null 2>&1 && "$_c" -c 'import yaml' >/dev/null 2>&1; then PY="$_c"; break; fi
done
if [ -z "$PY" ]; then
say "WARNING: no python3 with PyYAML found — skipping Hermes gateway config."
say " Install PyYAML and re-run $0 to finish wiring the gateway."
elif [ ! -f "$GATEWAY_YAML" ]; then
say "WARNING: $GATEWAY_YAML missing — skipping Hermes gateway config."
else
HERMES_GATEWAY_YAML="$GATEWAY_YAML" HERMES_CONFIG_PATH="$HERMES_CONFIG" "$PY" - <<'PYEOF'
import os, sys, tempfile
import yaml
gw_path = os.environ["HERMES_GATEWAY_YAML"]
cfg_path = os.environ["HERMES_CONFIG_PATH"]
with open(gw_path) as f:
gw = yaml.safe_load(f) or {}
entries = gw.get("custom_providers") or []
if not entries:
print("hermes: gateway.yaml has no custom_providers — nothing to do", file=sys.stderr)
raise SystemExit(0)
cfg = {}
if os.path.exists(cfg_path):
try:
with open(cfg_path) as f:
cfg = yaml.safe_load(f) or {}
except Exception as e:
# A corrupt config is the user's to fix; never overwrite it blind.
print(f"hermes: {cfg_path} is not valid YAML ({e}) — skipping", file=sys.stderr)
raise SystemExit(0)
if not isinstance(cfg, dict):
print(f"hermes: {cfg_path} is not a mapping — skipping", file=sys.stderr)
raise SystemExit(0)
existing = cfg.get("custom_providers")
if not isinstance(existing, list):
existing = []
changed = []
for entry in entries:
name = entry.get("name")
if not name:
continue
for i, cur in enumerate(existing):
if isinstance(cur, dict) and cur.get("name") == name:
if cur != entry:
existing[i] = entry
changed.append(f"updated custom_providers[{name}]")
break
else:
existing.append(entry)
changed.append(f"added custom_providers[{name}]")
cfg["custom_providers"] = existing
# `default` is a BARE model id and `provider` is the custom:<name> form; both
# are the user's to change, so they are seeded on a fresh box and otherwise left
# alone — EXCEPT to migrate the old masquerade, below.
primary = entries[0]["name"]
model = cfg.get("model")
if not isinstance(model, dict):
model = {}
gw_url = (entries[0].get("base_url") or "").rstrip("/")
cur_provider = (model.get("provider") or "").strip()
cur_base = (model.get("base_url") or "").rstrip("/")
# MIGRATE the old masquerade: `provider: openrouter` + `base_url: <this
# gateway>`. That shape routed requests here but left the picker listing
# openrouter.ai's catalog, so leaving it in place would silently preserve the
# exact bug this module exists to fix. `base_url` pointing at OUR gateway is the
# fingerprint — a genuine OpenRouter user never has that. Any OTHER provider is
# a deliberate choice and is never touched.
if gw_url and cur_base == gw_url and cur_provider in ("", "openrouter", "custom"):
if cur_provider != f"custom:{primary}":
model["provider"] = f"custom:{primary}"
changed.append(
f"migrated model.provider {cur_provider or '<unset>'} -> custom:{primary}"
)
elif not cur_provider:
model["provider"] = f"custom:{primary}"
changed.append(f"set model.provider=custom:{primary}")
if not model.get("default"):
first_model = next(iter((entries[0].get("models") or {})), None)
if first_model:
model["default"] = first_model
changed.append(f"set model.default={first_model}")
# model.base_url is ignored once a named custom provider supplies it, and a
# stale value here is an active footgun — drop it if it points at this gateway.
if gw_url and cur_base == gw_url:
model.pop("base_url", None)
changed.append("removed dead model.base_url")
cfg["model"] = model
if not changed:
print("hermes: gateway config already current", file=sys.stderr)
raise SystemExit(0)
d = os.path.dirname(cfg_path) or "."
os.makedirs(d, exist_ok=True)
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, cfg_path)
for c in changed:
print(f"hermes: {c}", file=sys.stderr)
PYEOF
# The provider is useless without the key. mcp-config/sync.sh exports it; warn
# rather than fail, since bootstrap ordering may put mcp-config after this.
if [ -z "${LITELLM_KEY:-}" ] && [ ! -f "$HOME/.config/mcp-sync/key" ]; then
say "NOTE: LITELLM_KEY is unset and ~/.config/mcp-sync/key is missing."
say " Run mcp-config/install.sh, or Hermes will 401 against the gateway."
fi
# The old OpenRouter masquerade actively breaks the model picker (it lists
# openrouter.ai's catalog, not the gateway's), so flag it if it lingers.
if [ -n "${OPENROUTER_API_KEY:-}" ] && [ "${OPENROUTER_API_KEY:-}" = "${LITELLM_KEY:-x}" ]; then
say "NOTE: OPENROUTER_API_KEY is set to the LiteLLM key in your environment."
say " That is the old masquerade; unset it in ~/.hermes/.env so the"
say " model picker shows the gateway's models instead of openrouter.ai's."
fi
fi
+30 -2
View File
@@ -8,8 +8,8 @@
# reset. # reset.
# #
# Source this (callers already run `set -eu`), then use fetch_arch / # Source this (callers already run `set -eu`), then use fetch_arch /
# fetch_arch_deb, have_version + mark_version, install_tarball_bin, and # fetch_arch_deb, have_version + mark_version, install_tarball_bin,
# install_raw_bin. # install_tarball_tree, and install_raw_bin.
BIN_DIR="${BIN_DIR:-$HOME/.local/bin}" BIN_DIR="${BIN_DIR:-$HOME/.local/bin}"
VERSION_DIR="$BIN_DIR/.versions" VERSION_DIR="$BIN_DIR/.versions"
@@ -93,6 +93,34 @@ install_zip_bin() {
fsay "installed $BIN_DIR/$3" fsay "installed $BIN_DIR/$3"
} }
# install_tarball_tree <url> <root-within-archive> <dest-dir> — download a
# .tar.gz that holds a whole application directory rather than one binary, and
# swap it into <dest-dir>. install_tarball_bin cannot be reused here: VS Code
# is an Electron bundle of thousands of files that only runs from an intact
# tree, not a single binary that can be picked out of the archive.
#
# The new tree is staged beside the target and moved into place only once the
# download and extraction have both succeeded, so an interrupted run leaves the
# previously installed version intact instead of a half-extracted directory.
install_tarball_tree() {
_dest="$3"
_tmp="$(mktemp -d)"
_fetch_dl "$1" "$_tmp/archive.tar.gz"
tar -xzf "$_tmp/archive.tar.gz" -C "$_tmp"
if [ ! -d "$_tmp/$2" ]; then
fsay "fetch: '$2' not found in archive"; rm -rf "$_tmp"; return 1
fi
mkdir -p "$(dirname "$_dest")"
rm -rf "$_dest.new" "$_dest.old"
mv "$_tmp/$2" "$_dest.new"
if [ -e "$_dest" ]; then
mv "$_dest" "$_dest.old"
fi
mv "$_dest.new" "$_dest"
rm -rf "$_dest.old" "$_tmp"
fsay "installed $_dest"
}
# install_raw_bin <url> <dest-name> — download a single raw binary. # install_raw_bin <url> <dest-name> — download a single raw binary.
install_raw_bin() { install_raw_bin() {
mkdir -p "$BIN_DIR" mkdir -p "$BIN_DIR"
+230 -36
View File
@@ -1,8 +1,120 @@
# mcp-config — shared MCP gateway config for laptops # mcp-config — shared client config for laptops
Single source of truth for the **shared** MCP servers every covert-gpt user Single source of truth for what the agents see: the **covert-gpt model provider**
gets through the LiteLLM gateway (`llm.covert-gpt.com`). Users install once; (LiteLLM at `llm.covert-gpt.com`) and the **MCP servers**, which run
their config then refreshes automatically on each new shell. **client-side**. Users install once; their config then refreshes automatically
on each new shell.
Two clients consume it: **opencode** (deployed to
`~/.config/opencode/opencode.gateway.json`) and **Hermes** (its `mcp_servers:`
block in `~/.hermes/config.yaml` is rendered from the same definitions — see
"One definition, two clients" below).
## MCP runs on the CLIENT, never on the gateway
The LiteLLM gateway hosts **no MCP servers** — deliberately. Every API worth
exposing (Gitea, Proxmox, UniFi, NetBird, k8s) is **user-scoped**, so hosting it
centrally means one shared token per server: every user acts as the same
identity and attribution is gone.
LiteLLM *can* do per-request BYOK (env values written `${X-Header}` are filled
from the caller's headers — verified working for proxmox on v1.87.0), but only
for servers that tolerate a credential-less spawn. LiteLLM builds its
tool→server mapping from a **headerless** background listing, and `gitea-mcp`
hard-exits without a token, so it never registers and its tool calls 404. The
gateway could therefore host only *some* servers — and a split brain (some MCPs
server-side, some client-side) is worse than either. So **all** MCP servers are
defined here, each reading the user's **own** credentials from their own
environment. The config is identical for everyone; only the secrets differ.
## One definition, two clients
The servers are defined **once**, in `opencode.gateway.json`'s `mcp` block.
Hermes speaks a different schema, so `sync.sh` *renders* that one definition into
`mcp_servers:` in `~/.hermes/config.yaml` rather than keeping a second copy — two
copies would drift, one pinned to `gitea-mcp@0.0.10` and the other to whatever
got typed. Add a server here once and **both** clients get it, within the same
TTL. The translation is mechanical:
| opencode | Hermes |
|---|---|
| `command: ["npx","-y","pkg@1.2.3"]` (argv list) | `command: npx` + `args: ["-y","pkg@1.2.3"]` — Hermes' `command` must be a **string**; a list stringifies and fails at exec |
| `environment: {K: "{env:VAR}"}` | `env: {K: "${VAR}"}` |
| `type: remote`, `url`, `headers` | `url`, `headers` |
| `enabled: false` | entry omitted |
Both placeholder forms are by-NAME references resolved at runtime, so no secret
is written to either file. Hermes hands an MCP child only a filtered baseline
environment, so every secret a server needs **must** be listed in `env:` — which
is exactly what the rendered block does.
Servers you add to Hermes by hand are left alone: `sync.sh` records the names it
rendered in `~/.config/mcp-sync/hermes-managed.json`, so a server dropped from
this repo is dropped from Hermes too, while anything not in that manifest is
never touched. Rendering is skipped silently when Hermes isn't installed, and
needs PyYAML (it warns if that's missing). Nothing needs adding to Hermes'
`platform_toolsets` — MCP servers are auto-enabled by name; listing *some*
would turn it into an allowlist, and `no_mcp` disables them all.
### Context limits — discovered, not hand-copied
OpenAI's `/v1/models` carries **no context window**, so a client that only
lists models cannot know how big a request it may send, and oversizing one is a
hard `ContextWindowExceededError` 400 (vLLM counts `prompt + max_tokens` against
its window). The window lives in LiteLLM's `/v1/model/info`, and `sync.sh`
renders it into every client from that one response:
| client | reads | what `sync.sh` writes | why that number |
|---|---|---|---|
| opencode | `opencode.gateway.json` | `limit.context = window − 1024`, `limit.output = min(out, cap)` | reserves `output` and targets `context − output`, so needs headroom |
| Hermes | `~/.hermes/config.yaml` | `context_length = window` | omits `max_tokens`; vLLM bounds output by what is left, so no margin |
| pi | `~/.pi/agent/models.json` | `contextWindow = window`, `maxTokens = min(out, cap, reserveTokens − 2048)` | compacts at `window − reserveTokens` then asks for `maxTokens`, so `maxTokens` must fit inside the reserve |
Same source, three different numbers — each client means something different by
"context". Do not "simplify" them into one.
**The single source of truth is `model_info.max_input_tokens` in ai-k8s-iac's
`charts/inference/values.yaml`.** A wrong window is fixed there and propagates on
the next sync. Editing a rendered value in a client config is overwritten.
Requirements and behaviour:
- **The key needs `/v1/model/info`.** A virtual key scoped to `llm_api_routes`
gets a 403 and every client silently falls back to its own defaults. Grant it
in the LiteLLM UI (Virtual Keys → key → Allowed Routes). The match is on the
exact path: `/v1/model/info` does not also grant `/model/info`.
- **Cached** at `~/.config/mcp-sync/model-info.json` on each successful fetch, so
a throttled shell still renders. A bad fetch (403, error page, empty list)
never replaces a good cache.
- **Ownership is tracked, per field.** `hermes-models-managed.json` and
`pi-managed.json` record what was set, so a value is retracted only if `sync.sh`
set it and the gateway stopped declaring it. That includes vision: if a backend
falls back to language-only and `supports_vision` flips off, the `image` input
`sync.sh` added is taken back rather than left to send image parts the backend
rejects. Models and fields you add by hand are never touched.
- **Never invents a window.** A wildcard route like `claude-*` declares none, so
it is skipped. A gateway that suddenly declares no windows at all is treated as
broken, not empty — nothing is retracted.
- **`pi`'s `maxTokens` is derived from its reserve**, not the other way round:
`reserveTokens` is one global setting shared by every provider pi knows, so
raising it to suit this gateway would shrink everyone's context. Raise
`compaction.reserveTokens` in pi's settings to buy back output. The shared
output cap is `MCP_SYNC_OUTPUT_RESERVE_CAP` (default 32768), read by opencode
and pi alike.
- Opt out per client with `MCP_SYNC_HERMES_CONFIG=` or `MCP_SYNC_PI_MODELS=`.
Credentials are referenced as `{env:VAR}` and are **never** written into the
config. Each user exports them in `~/.zshrc.local`:
| MCP server | env vars |
|---|---|
| `gitea` | `GITEA_KEY` |
| `proxmox` | `PROXMOX_TOKEN_ID`, `PROXMOX_TOKEN_SECRET` |
**Requires `npx`** — the MCP servers are npm packages that opencode spawns as
`npx -y <pkg>@<version>`. `bootstrap.sh` installs Node for this (`node/`).
Without it every server dies with "command not found", which opencode surfaces
only as a dead server — easy to misread as a broken config.
This repo lives in Gitea at `public/dotfiles`. (It is mirrored from the This repo lives in Gitea at `public/dotfiles`. (It is mirrored from the
`ai-k8s-iac` repo under `mcp-config/`; edit it there or here and keep them in `ai-k8s-iac` repo under `mcp-config/`; edit it there or here and keep them in
@@ -10,18 +122,20 @@ sync — the laptops pull from Gitea's `main`.)
## How it works ## How it works
- `opencode.gateway.json` — the shared base config: the `covert-gpt` model - `opencode.gateway.json` — the shared base config:
provider (LiteLLM's OpenAI-compatible endpoint at - 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 `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 discovery at sync time), and
**aggregate** endpoint `https://llm.covert-gpt.com/mcp/` (trailing slash - the **MCP servers**, each `type: local`, spawned by opencode as
required; the bare path 307-redirects, which MCP clients don't follow). `npx -y <pkg>@<pinned-version>` on the user's own machine.
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 **Identical for every user**: every credential (the LiteLLM key, the Gitea
every user**: the per-user key is injected via `{env:LITELLM_KEY}`, never PAT, the Proxmox token) is injected via `{env:VAR}` and never written into
written into the file. the file. Versions are pinned — `npx` runs whatever the registry serves, and
that process gets your tokens.
- `sync.sh` — sourced from each user's shell rc. On a new shell (throttled - `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 ~1h) it reads the base config **from the local checkout** (see "Where the
config comes from" below), queries the gateway's
`/v1/models` with the user's key to **auto-discover models** (opencode `/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 can't list models from a custom provider itself, so the map is
materialized client-side; if discovery fails the previous map is carried materialized client-side; if discovery fails the previous map is carried
@@ -35,12 +149,15 @@ sync — the laptops pull from Gitea's `main`.)
`~/.config/mcp-sync/skills` and wired in through `skills.paths` in the `~/.config/mcp-sync/skills` and wired in through `skills.paths` in the
config above. Skills are plain files, so — unlike models and MCP servers — config above. Skills are plain files, so — unlike models and MCP servers —
there is nothing on the gateway to discover and **the repo is the source of there is nothing on the gateway to discover and **the repo is the source of
truth**; the tree is fetched whole from the repo tarball. They deploy to truth**; the tree is copied from the checkout (or, with no checkout, fetched
whole from the repo tarball). They deploy to
mcp-sync's own directory rather than `~/.config/opencode/skills` so the mcp-sync's own directory rather than `~/.config/opencode/skills` so the
atomic replace can never delete a skill the user wrote by hand. See atomic replace can never delete a skill the user wrote by hand. See
`skills/README.md`. `skills/README.md`.
- `install.sh` — one-time bootstrap (curl-pipe). Fetches `sync.sh`, stores the - `install.sh` — one-time bootstrap. Installs `sync.sh` (from the checkout it
user's key (0600), and adds the rc hook. was run out of, else the Gitea API), records that checkout in
`~/.config/mcp-sync/source`, stores the user's key (0600), and adds the rc
hook.
opencode merges the `OPENCODE_CONFIG` file on top of the user's global opencode merges the `OPENCODE_CONFIG` file on top of the user's global
`~/.config/opencode/opencode.json` (and under any project `opencode.json`), `~/.config/opencode/opencode.json` (and under any project `opencode.json`),
@@ -48,36 +165,108 @@ 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 **local-only** MCP servers (e.g. a client-side `kubernetes` pointed at their
own kubeconfig). Sync never touches those. own kubeconfig). Sync never touches those.
## Where the config comes from
**The local checkout is the source.** `bootstrap.sh` already clones this repo
over authenticated git, so the per-shell hook reads `opencode.gateway.json` and
`skills/` straight off disk. No HTTP, no anonymous read, works offline.
This is deliberate. The hook used to re-download both from Gitea's public
`/raw/` URL on every sync, which meant **the repo had to be world-readable for
a shell hook to work** — publishing every internal hostname and the whole MCP
server inventory as the price of a config refresh. Worse, Gitea answers an
unauthorized `/raw/` request with a **200 and an HTML login page**, so `curl -f`
reported success and wrote HTML over the config; the failure was silent and the
config simply froze at its last good copy.
Resolution order, for both the config and the skills:
1. `MCP_SYNC_REPO` / `MCP_SYNC_RAW_URL` / `MCP_SYNC_SKILLS_SRC` if set — the
dev-checkout workflow in `zsh/zshrc.local.example`.
2. The checkout recorded in `~/.config/mcp-sync/source` by `install.sh`,
falling back to `$DOTFILES_DIR` (`~/.dotfiles`).
3. The **Gitea API** with `$GITEA_KEY` — for a box with no checkout at all.
API endpoints, not the web `/raw/` route, because the API returns an honest
401/403 that `curl -f` can actually detect.
To pick up a pushed change, the hook runs `git pull --ff-only` on the checkout
— but **only** the bootstrap-managed one at `$DOTFILES_DIR`, and only when its
tree is clean and on the default branch. A checkout you work in is read, never
pulled out from under you.
Model discovery is unaffected: it still queries the gateway's `/v1/models`
live on every sync, so a new model appears without touching the repo.
## User setup (one time) ## User setup (one time)
Each user needs a LiteLLM **virtual key** scoped to the gateway servers (see Each user needs a LiteLLM **virtual key** (for models) plus their **own**
`charts/inference/MCP-GATEWAY.md` in ai-k8s-iac for how keys are minted). Then: credentials for each MCP server. Run `bootstrap.sh` (it installs Node, which
provides the `npx` the MCP servers need), then:
```sh ```sh
# From a checkout (what bootstrap.sh does — no network needed for the config):
./mcp-config/install.sh
# Or standalone on a box with no checkout:
curl -fsSL https://git.nic-oconnor.com/public/dotfiles/raw/branch/main/mcp-config/install.sh | sh curl -fsSL https://git.nic-oconnor.com/public/dotfiles/raw/branch/main/mcp-config/install.sh | sh
# (prompts for the sk-... key; or pass it: ... | LITELLM_KEY=sk-... sh) # (prompts for the sk-... key; or pass it: ... | LITELLM_KEY=sk-... sh)
# If this repo is private, export GITEA_KEY first so the fallback can read it.
``` ```
Open a new terminal, run `opencode`, and the gateway tools are there. Then export your own MCP credentials in `~/.zshrc.local`:
## Adding a gateway server or model (admin) ```sh
export GITEA_KEY=... # your Gitea PAT (repo + issue scopes)
export PROXMOX_TOKEN_ID=... # e.g. alice@pve!mcp
export PROXMOX_TOKEN_SECRET=... # the token UUID
```
Register it on the gateway (`charts/inference/values.yaml` → Open a new terminal, run `opencode`, and the tools are there — acting as **you**.
`proxy_config.mcp_servers` for MCP servers, `model_list` for models),
deploy, smoke-test. That's it — clients auto-discover both: MCP servers ## Adding an MCP server (admin)
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 Add it to `mcp.<name>` in `opencode.gateway.json` as `type: local`, with a
new shell). `opencode.gateway.json` here only needs editing for structural **pinned** package version and `{env:VAR}` refs for every secret, then push to
changes (a new provider, endpoint URLs). `main`. Users pick it up within the sync TTL (or immediately on a new shell).
Two hard requirements:
- It must be an **npm package** (opencode spawns it with `npx`).
- Its credentials must be **per-user**, via `{env:VAR}`. Never commit a token,
and never introduce a shared one — that is the whole reason MCP is not on the
gateway.
Models are different: they live on the gateway
(`charts/inference/values.yaml` → `model_list`) and are auto-discovered by the
`/v1/models` query in `sync.sh`. No client change needed.
## Adding a skill (admin) ## Adding a skill (admin)
Skills have no gateway side. Add a folder under `skills/` and push to `main` — Add a folder under `skills/` and push to `main` — see `skills/README.md`.
see `skills/README.md`. Verify with `opencode debug skill`. Verify with `opencode debug skill`.
For **per-user / per-target** tools (cluster access, etc.) do NOT add them ## "I added a model on the gateway and opencode doesn't see it"
here — those run client-side with the user's own credentials. See
`charts/inference/MCP-GATEWAY.md`. Almost always a stale local config, not a gateway or key problem. The sync runs
**only at shell start** and is throttled to `MCP_SYNC_TTL` (1h), so a new model
can take up to an hour *and* a new terminal to appear.
Pull it immediately:
```sh
mcp-sync # force a refresh now, ignoring the TTL; prints the model list
```
If the model still isn't listed, it isn't reaching your key — check the gateway,
not the client:
```sh
curl -fsS -H "Authorization: Bearer $LITELLM_KEY" \
https://llm.covert-gpt.com/v1/models | jq -r '.data[].id'
```
Whatever that prints is exactly what `sync.sh` will deploy. A model present here
but missing in opencode is a sync problem; a model missing here is a LiteLLM
`model_list` or key-scoping problem.
## Notes / caveats ## Notes / caveats
@@ -85,6 +274,11 @@ here — those run client-side with the user's own credentials. See
conflicts. If a user names a local server the same as a gateway one, theirs conflicts. If a user names a local server the same as a gateway one, theirs
wins. Use distinct names. wins. Use distinct names.
- **Key rotation**: re-run `install.sh` (or edit `~/.config/mcp-sync/key`). - **Key rotation**: re-run `install.sh` (or edit `~/.config/mcp-sync/key`).
- **Other clients**: this ships an opencode config. Claude Code / open-webui - **Other clients**: Hermes is wired (see "One definition, two clients").
would need their own equivalent file + a parallel sync entry; not done yet. Claude Code / open-webui would each still need a parallel render step in
- **Throttle**: override with `MCP_SYNC_TTL=0` to force a pull every shell. `sync.sh`; not done yet.
- **Throttle**: `mcp-sync` forces one refresh; `MCP_SYNC_TTL=0` in the
environment makes every new shell pull.
- **python3 is required** for model discovery. Without it `sync.sh` deploys the
base config verbatim, so the provider comes up with an EMPTY model list. It
warns when this happens; `bootstrap.sh` installs python3.
Regular → Executable
+121 -9
View File
@@ -14,7 +14,14 @@
# After this, every new shell refreshes the gateway config automatically. # After this, every new shell refreshes the gateway config automatically.
set -eu set -eu
RAW_BASE="${MCP_SYNC_RAW_BASE:-https://git.nic-oconnor.com/public/dotfiles/raw/branch/main/mcp-config}" # Fallback source, used ONLY when this script is not run from a checkout.
# A Gitea *API* endpoint, not the web /raw/ route: /raw/ answers an
# unauthorized request with a 200 and an HTML login page, which `curl -f`
# reports as success. The API returns an honest 401/403. Set GITEA_KEY to read
# a private repo.
API_BASE="${MCP_SYNC_API_BASE:-https://git.nic-oconnor.com/api/v1/repos/public/dotfiles}"
REF="${MCP_SYNC_REF:-main}"
SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd 2>/dev/null)" || SCRIPT_DIR=""
SYNC_HOME="$HOME/.config/mcp-sync" SYNC_HOME="$HOME/.config/mcp-sync"
SYNC_SH="$SYNC_HOME/sync.sh" SYNC_SH="$SYNC_HOME/sync.sh"
KEYFILE="$SYNC_HOME/key" KEYFILE="$SYNC_HOME/key"
@@ -26,12 +33,66 @@ command -v curl >/dev/null 2>&1 || { say "error: curl is required"; exit 1; }
mkdir -p "$SYNC_HOME" mkdir -p "$SYNC_HOME"
# --- 1. fetch the sync hook ------------------------------------------------ # --- 1. install the sync hook ----------------------------------------------
say "Fetching mcp-sync hook..." # Prefer the copy sitting next to this script: when bootstrap.sh runs the
curl -fsSL -m 15 "$RAW_BASE/sync.sh" -o "$SYNC_SH" # module from a checkout there is no reason to go to the network, and it makes
# the module work on a box that cannot reach Gitea at all.
if [ -n "$SCRIPT_DIR" ] && [ -f "$SCRIPT_DIR/sync.sh" ]; then
say "Installing mcp-sync hook from $SCRIPT_DIR/sync.sh"
cp "$SCRIPT_DIR/sync.sh" "$SYNC_SH"
else
SYNC_URL="$API_BASE/raw/mcp-config/sync.sh?ref=$REF"
say "Fetching mcp-sync hook..."
# Fetch to a temp file and CHECK IT before overwriting the installed hook.
# The API endpoint gives an honest 4xx, but keep the shebang check as a
# backstop: if anything ever writes an HTML login page over sync.sh, the shell
# fails to parse it, the hook silently does nothing, OPENCODE_CONFIG is never
# exported, and opencode comes up with no covert-gpt provider at all.
_tmp="$(mktemp)" || { say "error: mktemp failed"; exit 1; }
if [ -n "${GITEA_KEY:-}" ]; then
set -- -H "Authorization: token $GITEA_KEY"
else
set --
fi
if ! curl -fsSL -m 15 "$@" "$SYNC_URL" -o "$_tmp" || [ ! -s "$_tmp" ]; then
rm -f "$_tmp"
say "error: could not fetch $SYNC_URL"
[ -n "${GITEA_KEY:-}" ] ||
say " The repo may be private — export GITEA_KEY and re-run."
exit 1
fi
case "$(head -c 2 "$_tmp")" in
'#!') mv "$_tmp" "$SYNC_SH" ;;
*)
rm -f "$_tmp"
say "error: $SYNC_URL did not return a script."
exit 1
;;
esac
fi
chmod 0644 "$SYNC_SH" chmod 0644 "$SYNC_SH"
# --- 1b. record the checkout as the config source --------------------------
# THE POINT OF THIS FILE: bootstrap.sh already cloned this repo over
# authenticated git, so the per-shell hook must never have to re-fetch the
# gateway config anonymously over HTTP. Needing that was the only reason the
# repo ever had to be world-readable. Record where we ran from and sync.sh
# reads both the config and the skills straight off disk.
if [ -n "$SCRIPT_DIR" ] && [ -f "$SCRIPT_DIR/opencode.gateway.json" ]; then
if REPO_DIR="$(CDPATH= cd -- "$SCRIPT_DIR/.." && pwd 2>/dev/null)"; then
printf '%s\n' "$REPO_DIR" > "$SYNC_HOME/source"
say "Config source: $REPO_DIR (local checkout, no network needed)."
fi
fi
# --- 2. capture the LiteLLM key ------------------------------------------- # --- 2. capture the LiteLLM key -------------------------------------------
# An already-stored key is as good as one in the env: bootstrap.sh re-runs this
# module on every pass, and prompting a box that is already configured would
# make an otherwise unattended bootstrap hang on a TTY read.
if [ "${LITELLM_KEY:-}" = "" ] && [ -f "$KEYFILE" ]; then
LITELLM_KEY="$(cat "$KEYFILE" 2>/dev/null || true)"
[ -n "$LITELLM_KEY" ] && say "Reusing the key already stored at $KEYFILE."
fi
if [ "${LITELLM_KEY:-}" = "" ]; then if [ "${LITELLM_KEY:-}" = "" ]; then
# Read from the controlling TTY since stdin is the piped script. # Read from the controlling TTY since stdin is the piped script.
if [ -r /dev/tty ]; then if [ -r /dev/tty ]; then
@@ -53,13 +114,32 @@ chmod 0600 "$KEYFILE"
say "Key stored at $KEYFILE (0600)." say "Key stored at $KEYFILE (0600)."
# --- 3. wire the shell rc -------------------------------------------------- # --- 3. wire the shell rc --------------------------------------------------
# Detect the user's interactive rc; default to zsh on macOS, bash elsewhere. # For zsh the hook goes in ~/.zshrc.local, NOT ~/.zshrc. The dotfiles repo
case "${SHELL:-}" in # MANAGES ~/.zshrc and overwrites it wholesale on every bootstrap
*zsh) RC="$HOME/.zshrc" ;; # (zsh/install.sh), so a hook written there survives only until the next
# bootstrap run — and then models and skills silently vanish, because sync.sh
# never runs and OPENCODE_CONFIG is never exported. ~/.zshrc.local is the
# unmanaged file the managed zshrc sources last, so the hook persists.
#
# Do NOT pick the file from $SHELL alone. On a fresh box bootstrap runs this
# from the user's ORIGINAL shell: zsh/ has just switched the login shell to zsh,
# but $SHELL in this process still says bash. The hook then went into ~/.bashrc,
# below zsh/'s auto-exec hook, so every new terminal exec'd into zsh before
# reaching it, zsh never sourced sync.sh, and opencode came up unconfigured.
# The dotfiles-managed ~/.zshrc (or zsh as the account's login shell) wins.
_login_shell="$(getent passwd "$(id -un)" 2>/dev/null | cut -d: -f7)"
if grep -qs 'Managed by dotfiles/zsh/install.sh' "$HOME/.zshrc"; then
RC="$HOME/.zshrc.local"
else
case "${_login_shell:-${SHELL:-}}" in
*zsh) RC="$HOME/.zshrc.local" ;;
*bash) RC="$HOME/.bashrc" ;; *bash) RC="$HOME/.bashrc" ;;
*) [ -f "$HOME/.zshrc" ] && RC="$HOME/.zshrc" || RC="$HOME/.bashrc" ;; *) [ -f "$HOME/.zshrc" ] && RC="$HOME/.zshrc.local" || RC="$HOME/.bashrc" ;;
esac esac
fi
touch "$RC" touch "$RC"
# It sits alongside the user's secrets; keep it non-readable by others.
[ "$RC" = "$HOME/.zshrc.local" ] && chmod 0600 "$RC" 2>/dev/null || true
if grep -qF "$HOOK_MARKER" "$RC" 2>/dev/null; then if grep -qF "$HOOK_MARKER" "$RC" 2>/dev/null; then
say "Shell hook already present in $RC." say "Shell hook already present in $RC."
@@ -76,6 +156,38 @@ fi
# shellcheck disable=SC1090 # shellcheck disable=SC1090
. "$SYNC_SH" || true . "$SYNC_SH" || true
# If this is the initial install (key just written now), restart Hermes.
# The key file mtime changed, but we need to compare hashes to know if it's
# actually new (not just a touch). Only restart if we're not in a re-run.
if [ -f "$KEYFILE" ]; then
KEY_HASH="$(sha256sum "$KEYFILE" 2>/dev/null | cut -d' ' -f1)"
PREV_KEY_HASH=""
if [ -f "$HOME/.config/mcp-sync/.key-hash" ]; then
PREV_KEY_HASH="$(cat "$HOME/.config/mcp-sync/.key-hash" 2>/dev/null || true)"
fi
if [ "$KEY_HASH" != "$PREV_KEY_HASH" ]; then
# Key changed - update .env and restart Hermes if running
if [ -f "$HOME/.hermes/.env" ]; then
say "hermes: Key changed during install — updating ~/.hermes/.env"
if command -v sed >/dev/null 2>&1; then
sed -i "s|^LITELLM_KEY=.*$|LITELLM_KEY=$LITELLM_KEY|" "$HOME/.hermes/.env"
else
awk -v key="$LITELLM_KEY" '/^LITELLM_KEY=/ {print "LITELLM_KEY=" key; next} {print}' "$HOME/.hermes/.env" >"$HOME/.hermes/.env.tmp" && mv "$HOME/.hermes/.env.tmp" "$HOME/.hermes/.env"
fi
chmod 0600 "$HOME/.hermes/.env"
fi
if pgrep -f 'hermes-agent/hermes' >/dev/null 2>&1; then
HERMES_PID="$(pgrep -f 'hermes-agent/hermes')"
say "hermes: Restarting to pick up new key (PID: $HERMES_PID)..."
kill "$HERMES_PID" || true
sleep 1
(cd "$HOME" && nohup hermes >/dev/null 2>&1 &)
say "hermes: Hermes restarted."
fi
fi
printf '%s\n' "$KEY_HASH" >"$HOME/.config/mcp-sync/.key-hash"
fi
say "" say ""
say "Done. Open a new terminal (or 'source $RC') and run opencode." say "Done. Open a new terminal (or 'source $RC') and run opencode."
say "The gateway config refreshes automatically on each new shell." say "The gateway config refreshes automatically on each new shell."
+49 -7
View File
@@ -8,20 +8,62 @@
"baseURL": "https://llm.covert-gpt.com/v1", "baseURL": "https://llm.covert-gpt.com/v1",
"apiKey": "{env:LITELLM_KEY}" "apiKey": "{env:LITELLM_KEY}"
}, },
"models": {} "models": {
"ornith-1.0-35b": {
"limit": {
"context": 195584,
"output": 32768
}
},
"qwen3.8-27b": {
"limit": {
"context": 261120,
"output": 32768
}
},
"qwen3-coder-next": {
"limit": {
"context": 261120,
"output": 32768
}
}
}
} }
}, },
"mcp": { "mcp": {
"covert-gpt": { "gitea": {
"type": "remote", "type": "local",
"url": "https://llm.covert-gpt.com/mcp/", "command": [
"npx",
"-y",
"gitea-mcp@0.0.10"
],
"enabled": true, "enabled": true,
"headers": { "environment": {
"Authorization": "Bearer {env:LITELLM_KEY}" "GITEA_HOST": "https://git.nic-oconnor.com",
"GITEA_ACCESS_TOKEN": "{env:GITEA_KEY}"
}
},
"proxmox": {
"type": "local",
"command": [
"npx",
"-y",
"proxmox-mcp-server@0.2.0"
],
"enabled": true,
"environment": {
"PROXMOX_HOST": "https://prox-01.covert-cloud.com:8006",
"PROXMOX_TOKEN_ID": "{env:PROXMOX_TOKEN_ID}",
"PROXMOX_TOKEN_SECRET": "{env:PROXMOX_TOKEN_SECRET}",
"PROXMOX_INSECURE_TLS": "true",
"PVE_READONLY": "false"
} }
} }
}, },
"skills": { "skills": {
"paths": ["{env:HOME}/.config/mcp-sync/skills"] "paths": [
"{env:HOME}/.config/mcp-sync/skills"
]
} }
} }
+837 -23
View File
@@ -10,16 +10,23 @@
# every failure path just returns, leaving the last-good config in place. # every failure path just returns, leaving the last-good config in place.
# #
# What it does: # What it does:
# 1. Fetch the shared opencode.gateway.json from Gitea (the single source of # 1. Read the shared opencode.gateway.json (the single source of truth: the
# truth: the covert-gpt provider + the aggregate MCP endpoint). The file # covert-gpt provider + the client-side MCP servers). PREFERRED SOURCE IS
# is identical for all users — the per-user key is injected via # THE LOCAL CHECKOUT — bootstrap.sh already cloned this repo over
# {env:LITELLM_KEY}, never baked into the file. # 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 # 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 # 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. # gateway appear on every box at the next sync — no config edit anywhere.
# (opencode cannot list models from a custom provider itself; the map # (opencode cannot list models from a custom provider itself; the map
# must be materialized client-side.) MCP servers need no discovery step: # must be materialized client-side.) MCP servers need no discovery step:
# the single /mcp/ endpoint aggregates every server the key can access. # 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 # 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 # 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). # IS the source of truth, and the tree is fetched whole (repo tarball).
@@ -35,20 +42,55 @@
# resolves. # resolves.
# --- config (overridable via env before sourcing) ------------------------- # --- config (overridable via env before sourcing) -------------------------
MCP_SYNC_RAW_URL="${MCP_SYNC_RAW_URL:-https://git.nic-oconnor.com/public/dotfiles/raw/branch/main/mcp-config/opencode.gateway.json}" # 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}" 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_DEST="${MCP_SYNC_DEST:-$HOME/.config/opencode/opencode.gateway.json}"
MCP_SYNC_KEYFILE="${MCP_SYNC_KEYFILE:-$MCP_SYNC_HOME/key}" MCP_SYNC_KEYFILE="${MCP_SYNC_KEYFILE:-$MCP_SYNC_HOME/key}"
# Provider whose models are auto-discovered, and the gateway base URL. # Provider whose models are auto-discovered, and the gateway base URL.
MCP_SYNC_PROVIDER="${MCP_SYNC_PROVIDER:-covert-gpt}" MCP_SYNC_PROVIDER="${MCP_SYNC_PROVIDER:-covert-gpt}"
MCP_SYNC_GATEWAY="${MCP_SYNC_GATEWAY:-https://llm.covert-gpt.com}" MCP_SYNC_GATEWAY="${MCP_SYNC_GATEWAY:-https://llm.covert-gpt.com}"
# Skills. A skill is a folder of files, so unlike the config there is no single # Skills. Taken from the checkout like the config; with no checkout we fall
# raw URL to pull — we take the repo tarball and lift out its skills/ subtree. # back to the repo tarball and lift out its skills/ subtree (a skill is a folder
# Set MCP_SYNC_SKILLS_SRC to a local directory (or file://) to sync from a # of files, so unlike the config there is no single raw URL to pull). Set
# working checkout instead; see zsh/zshrc.local.example. # MCP_SYNC_SKILLS_SRC to a local directory (or file://) to pin it to one tree;
MCP_SYNC_SKILLS_SRC="${MCP_SYNC_SKILLS_SRC:-https://git.nic-oconnor.com/public/dotfiles/archive/main.tar.gz}" # 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_SUBDIR="${MCP_SYNC_SKILLS_SUBDIR:-skills}"
MCP_SYNC_SKILLS_DEST="${MCP_SYNC_SKILLS_DEST:-$MCP_SYNC_HOME/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}"
# Model limits for the non-opencode clients. The gateway's /v1/model/info is
# cached here on every successful fetch, because the refresh above is throttled
# and a throttled shell never fetches — the renderers must still have something
# to render from.
MCP_SYNC_MODELINFO_CACHE="${MCP_SYNC_MODELINFO_CACHE:-$MCP_SYNC_HOME/model-info.json}"
# Per-model FIELDS we set in Hermes last time, so a field can be retracted
# without touching fields the user set by hand. Separate from the MCP manifest.
MCP_SYNC_HERMES_MODELS_MANIFEST="${MCP_SYNC_HERMES_MODELS_MANIFEST:-$MCP_SYNC_HOME/hermes-models-managed.json}"
# pi. Set MCP_SYNC_PI_MODELS to empty to opt out entirely.
MCP_SYNC_PI_MODELS="${MCP_SYNC_PI_MODELS-$HOME/.pi/agent/models.json}"
MCP_SYNC_PI_SETTINGS="${MCP_SYNC_PI_SETTINGS:-$HOME/.pi/agent/settings.json}"
MCP_SYNC_PI_MANIFEST="${MCP_SYNC_PI_MANIFEST:-$MCP_SYNC_HOME/pi-managed.json}"
# The most output any client is told it may reserve/request. ONE value, read by
# the opencode, Hermes and pi renderers alike, so they cannot drift apart on it.
MCP_SYNC_OUTPUT_RESERVE_CAP="${MCP_SYNC_OUTPUT_RESERVE_CAP:-32768}"
# Throttle: only re-sync if the deployed copy is older than this many seconds # 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. # (default 1h). Keeps every new shell from hammering Gitea/the gateway.
MCP_SYNC_TTL="${MCP_SYNC_TTL:-3600}" MCP_SYNC_TTL="${MCP_SYNC_TTL:-3600}"
@@ -61,6 +103,67 @@ _mcp_sync_export_key() {
[ -n "$LITELLM_KEY" ] && export LITELLM_KEY [ -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) --------------- # --- refresh config + discover models (throttled, non-fatal) ---------------
_mcp_sync_refresh() { _mcp_sync_refresh() {
mkdir -p "$(dirname "$MCP_SYNC_DEST")" 2>/dev/null || return 0 mkdir -p "$(dirname "$MCP_SYNC_DEST")" 2>/dev/null || return 0
@@ -73,16 +176,42 @@ _mcp_sync_refresh() {
fi fi
fi fi
command -v curl >/dev/null 2>&1 || return 0 # 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
# 1. Fetch the shared base config. On failure fall back to the currently # box with no checkout goes to Gitea. On total failure we fall back to the
# deployed file so model discovery can still refresh it. # 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 _base="$(mktemp 2>/dev/null)" || return 0
# -f: fail on HTTP error; -s: silent; -m: hard timeout so a hung network cp "$_repo/mcp-config/opencode.gateway.json" "$_base" 2>/dev/null ||
# can't stall the shell. { rm -f "$_base"; _base=""; }
if ! curl -fsS -m 8 "$MCP_SYNC_RAW_URL" -o "$_base" 2>/dev/null || [ ! -s "$_base" ]; then 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="" rm -f "$_base"; _base=""
[ -f "$MCP_SYNC_DEST" ] || return 0 [ -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 fi
# 2. Discover the gateway's models with the user's key (best-effort). # 2. Discover the gateway's models with the user's key (best-effort).
@@ -92,14 +221,65 @@ _mcp_sync_refresh() {
"$MCP_SYNC_GATEWAY/v1/models" 2>/dev/null)" || _models="" "$MCP_SYNC_GATEWAY/v1/models" 2>/dev/null)" || _models=""
fi 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
# Cache it for the Hermes and pi renderers — but only a response that really
# is model info. curl -f already rejects a 403, yet a 200 carrying an error
# page or an empty list is possible, and caching that would strip every
# client's limits until the next good fetch. A bad fetch leaves the last good
# cache in place.
if [ -n "$_info" ] && command -v python3 >/dev/null 2>&1; then
MCP_SYNC_PY_INFO="$_info" MCP_SYNC_PY_CACHE="$MCP_SYNC_MODELINFO_CACHE" \
python3 - <<'PYEOF' 2>/dev/null || true
import json, os, tempfile
try:
data = json.loads(os.environ["MCP_SYNC_PY_INFO"])
rows = data.get("data")
except Exception:
raise SystemExit(0)
if not isinstance(rows, list) or not any(
isinstance(r, dict) and r.get("model_name") for r in rows):
raise SystemExit(0)
cache = os.environ["MCP_SYNC_PY_CACHE"]
d = os.path.dirname(cache) or "."
os.makedirs(d, exist_ok=True)
fd, tmp = tempfile.mkstemp(dir=d)
with os.fdopen(fd, "w") as f:
json.dump({"data": rows}, f)
os.replace(tmp, cache)
PYEOF
fi
# 3. Merge and deploy. python3 validates the base (don't clobber good config # 3. Merge and deploy. python3 validates the base (don't clobber good config
# with an error page), injects the discovered models, and — when discovery # with an error page), injects the discovered models, and — when discovery
# failed — carries over the previous deployed models map so a gateway # failed — carries over the previous deployed models map so a gateway
# hiccup never empties the model list. Without python3, deploy the raw # hiccup never empties the model list. Without python3, deploy the raw
# base as-is (no discovery). # base as-is (no discovery).
if command -v python3 >/dev/null 2>&1; then if command -v python3 >/dev/null 2>&1; then
MCP_SYNC_BASE="$_base" MCP_SYNC_MODELS="$_models" \ 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" \ MCP_SYNC_PY_DEST="$MCP_SYNC_DEST" MCP_SYNC_PY_PROVIDER="$MCP_SYNC_PROVIDER" \
MCP_SYNC_PY_OUTPUT_CAP="$MCP_SYNC_OUTPUT_RESERVE_CAP" \
python3 - <<'PYEOF' 2>/dev/null || true python3 - <<'PYEOF' 2>/dev/null || true
import json, os, tempfile import json, os, tempfile
@@ -116,10 +296,109 @@ try:
except Exception: except Exception:
raise SystemExit(0) # invalid base: keep last-good deployed config 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 = int(os.environ.get("MCP_SYNC_PY_OUTPUT_CAP") or 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
# NEVER trust /model/info's number TYPES. The litellm-helm chart renders the
# proxy config with Go's toYaml, which writes 0.000005 as `5e-06`; LiteLLM
# parses it with PyYAML (YAML 1.1), which only reads an exponent float if it has
# a dot. So `5e-06` arrives as the STRING "5e-06" while `2.5e-05` is a float.
# `"5e-06" * 1_000_000` is not an error in Python — it is the string repeated a
# million times. That wrote a 20 MB opencode.gateway.json with a string where
# opencode requires a number, opencode refused to start, and every new shell
# redeployed it. Coerce every number through here; anything that is not a
# finite number is dropped, never passed through.
def num(v):
if isinstance(v, bool):
return None
if isinstance(v, (int, float)):
f = float(v)
elif isinstance(v, str) and len(v) <= 64:
try:
f = float(v.strip())
except ValueError:
return None
else:
return None
if f != f or f in (float("inf"), float("-inf")):
return None
return int(f) if isinstance(v, int) or f.is_integer() else f
def spec_for(mi):
spec = {}
limit = {}
max_in, max_out = num(mi.get("max_input_tokens")), num(mi.get("max_output_tokens"))
if max_in and max_in > 0:
limit["context"] = int(max(1, max_in - CONTEXT_SAFETY_MARGIN))
if max_out and max_out > 0:
limit["output"] = int(min(max_out, 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")):
per_token = num(mi.get(src))
if per_token is not None and per_token >= 0:
# round() so 5e-06 * 1e6 is 5.0, not 4.999999999999999.
cost[dst] = round(per_token * 1_000_000, 6)
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 = {} models = {}
try: try:
data = json.loads(os.environ.get("MCP_SYNC_MODELS") or "{}") data = json.loads(os.environ.get("MCP_SYNC_MODELS") or "{}")
models = {m["id"]: {} for m in data.get("data", []) if m.get("id")} 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: except Exception:
pass pass
if not models: if not models:
@@ -128,22 +407,509 @@ if not models:
except Exception: except Exception:
pass pass
# Validate EVERY spec before it is written, whatever its source. The carry-over
# path above re-reads the deployed file, so a bad value that was ever written
# (e.g. the 20 MB repeated-string cost) would otherwise be preserved forever;
# this makes the next sync heal it instead. opencode rejects the WHOLE config
# over one wrongly typed field, so dropping a bad field is always the better
# failure.
def clean_spec(spec):
if not isinstance(spec, dict):
return {}
out = {}
for group, keys, cast in (("limit", ("context", "output"), int),
("cost", ("input", "output", "cache_read", "cache_write"), float)):
vals = {}
for k in keys:
v = num((spec.get(group) or {}).get(k)) if isinstance(spec.get(group), dict) else None
if v is not None and v >= 0:
vals[k] = cast(v)
if vals:
out[group] = vals
for k, v in spec.items():
if k in ("limit", "cost"):
continue
if isinstance(v, (bool, int, float)) or (isinstance(v, str) and len(v) <= 256):
out[k] = v
elif isinstance(v, (dict, list)) and len(json.dumps(v)) <= 4096:
out[k] = v
return out
models = {k: clean_spec(v) for k, v in (models or {}).items()
if isinstance(k, str) and len(k) <= 256}
prov = cfg.get("provider", {}).get(provider) prov = cfg.get("provider", {}).get(provider)
if prov is not None and models: if prov is not None and models:
prov["models"] = models prov["models"] = models
# Last line of defence: the shared config is a few KB. Anything wildly larger is
# a bug upstream of here, and deploying it is what breaks opencode — keep the
# last-good file (or none) instead.
if len(json.dumps(cfg)) > 1_000_000:
raise SystemExit(0)
fd, tmp = tempfile.mkstemp(dir=os.path.dirname(dest)) fd, tmp = tempfile.mkstemp(dir=os.path.dirname(dest))
with os.fdopen(fd, "w") as f: with os.fdopen(fd, "w") as f:
json.dump(cfg, f, indent=2) json.dump(cfg, f, indent=2)
os.replace(tmp, dest) os.replace(tmp, dest)
PYEOF PYEOF
elif [ -n "$_base" ]; then 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 mv "$_base" "$MCP_SYNC_DEST" 2>/dev/null || true
_base="" _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 fi
[ -n "$_base" ] && rm -f "$_base" [ -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 <pkg>` 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
}
# --- render the gateway's MODEL LIMITS into Hermes' config (non-fatal) ------
#
# WHY THIS EXISTS: OpenAI's /v1/models schema carries no context window, so a
# client that only lists models cannot know how big a request it may send.
# opencode gets the window from /v1/model/info above. Hermes does not consult
# that endpoint at all — it takes context_length from a static `models:` map
# under its custom provider — so the number used to be hand-copied into
# hermes/gateway.yaml and kept "in sync" by nobody. It drifted: the map named a
# model the gateway had stopped serving, while the model that replaced it (and
# the only one with a SMALLER window than its neighbours) had no entry at all.
# Short turns were fine; long tasks hit ContextWindowExceededError 400s.
#
# NO MARGIN, deliberately, and this is the one number that differs from the
# opencode renderer. opencode reserves limit.output and targets an input of
# context - output, so it needs headroom. Hermes omits max_tokens entirely, so
# vLLM bounds the output by whatever context is left: the raw window is right.
#
# OWNERSHIP IS PER FIELD, not per model. The manifest records which fields this
# set on which model. A field is retracted only if WE set it and the gateway no
# longer declares it (a model removed upstream, or supports_vision flipped off
# because a backend fell back to language-only — see the drift warning on
# supports_vision in ai-k8s-iac's values.yaml). Anything the user typed into the
# map by hand was never in the manifest and is never touched.
#
# Only fills in the provider install.sh created; never creates one.
_mcp_sync_hermes_models() {
[ -n "${MCP_SYNC_HERMES_CONFIG:-}" ] || return 0
[ -f "$MCP_SYNC_HERMES_CONFIG" ] || return 0
[ -f "$MCP_SYNC_MODELINFO_CACHE" ] || return 0
command -v python3 >/dev/null 2>&1 || return 0
python3 -c 'import yaml' >/dev/null 2>&1 || return 0 # warned about by _mcp_sync_hermes
MCP_SYNC_PY_CACHE="$MCP_SYNC_MODELINFO_CACHE" \
MCP_SYNC_PY_HERMES="$MCP_SYNC_HERMES_CONFIG" \
MCP_SYNC_PY_MANIFEST="$MCP_SYNC_HERMES_MODELS_MANIFEST" \
MCP_SYNC_PY_PROVIDER="$MCP_SYNC_PROVIDER" \
python3 - <<'PYEOF' 2>/dev/null || true
import json, os, tempfile
import yaml
provider = os.environ["MCP_SYNC_PY_PROVIDER"]
dest = os.environ["MCP_SYNC_PY_HERMES"]
manifest_path = os.environ["MCP_SYNC_PY_MANIFEST"]
try:
with open(os.environ["MCP_SYNC_PY_CACHE"]) as f:
rows = json.load(f).get("data") or []
except Exception:
raise SystemExit(0)
rendered = {}
for r in rows:
if not isinstance(r, dict):
continue
name = r.get("model_name")
mi = r.get("model_info") or {}
window = mi.get("max_input_tokens")
# A wildcard route (claude-*) declares no window. Never invent one: a guessed
# context_length is worse than letting Hermes use its own default.
if not name or not isinstance(window, int) or window <= 0:
continue
fields = {"context_length": window}
if mi.get("supports_vision") is True:
fields["supports_vision"] = True
rendered[name] = fields
# A gateway that suddenly declares no windows at all is broken, not empty. Do
# not treat that as "retract everything".
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) or not isinstance(cfg.get("custom_providers"), list):
raise SystemExit(0)
target = next((p for p in cfg["custom_providers"]
if isinstance(p, dict) and p.get("name") == provider), None)
if target is None:
raise SystemExit(0)
models = target.get("models")
if not isinstance(models, dict):
models = {}
before = json.dumps(models, sort_keys=True, default=str)
try:
with open(manifest_path) as f:
prev = json.load(f)
prev = prev if isinstance(prev, dict) else {}
except Exception:
prev = {}
for name, owned in prev.items():
entry = models.get(name)
if not isinstance(owned, list) or not isinstance(entry, dict):
continue
still = rendered.get(name, {})
for field in owned:
if field not in still:
entry.pop(field, None)
if not entry:
models.pop(name, None)
for name, fields in rendered.items():
entry = models.get(name)
entry = entry if isinstance(entry, dict) else {}
entry.update(fields)
models[name] = entry
if json.dumps(models, sort_keys=True, default=str) != before:
target["models"] = models
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)
d = os.path.dirname(manifest_path) or "."
os.makedirs(d, exist_ok=True)
fd, tmp = tempfile.mkstemp(dir=d)
with os.fdopen(fd, "w") as f:
json.dump({n: sorted(fl) for n, fl in rendered.items()}, f, sort_keys=True)
os.replace(tmp, manifest_path)
PYEOF
}
# --- render the gateway's MODEL LIMITS into pi's models.json (non-fatal) ----
#
# Same reason as Hermes: pi reads a static models.json and never asks the
# gateway, so contextWindow/maxTokens were hand-copied and drifted.
#
# THE INVARIANT, because getting this wrong is a guaranteed 400 rather than a
# degraded answer. pi compacts when contextTokens > contextWindow - reserveTokens
# (compaction.js shouldCompact) and then sends max_tokens = maxTokens. vLLM
# counts prompt + max_tokens against its window. So the largest request pi can
# make is (window - reserveTokens) + maxTokens, and it fits only if
#
# maxTokens <= reserveTokens - margin
#
# This used to be violated outright: pi's default reserve is 16384 while
# maxTokens was set to 32768, so a full context sent 196608 - 16384 + 32768 =
# 212992 tokens at a 196608 window.
#
# maxTokens is DERIVED FROM the reserve, rather than the reserve being raised to
# fit maxTokens. reserveTokens is one global setting shared by every provider pi
# knows about; raising it to suit this gateway would silently shrink usable
# context for all of them. Capping maxTokens per model keeps the fix local, and
# means raising reserveTokens in settings.json is how you buy back output.
#
# OWNERSHIP: the manifest records models this ADDED and models it added "image"
# to. contextWindow and maxTokens are always managed on gateway models, because
# a stale value there is the bug. Models the user created are never removed, and
# a user's own "image" is never taken away — only one this added.
#
# Only fills in the provider the user configured; never creates one, because a
# provider needs a baseUrl and key this does not own.
_mcp_sync_pi() {
[ -n "${MCP_SYNC_PI_MODELS:-}" ] || return 0
[ -f "$MCP_SYNC_PI_MODELS" ] || return 0
[ -f "$MCP_SYNC_MODELINFO_CACHE" ] || return 0
command -v python3 >/dev/null 2>&1 || return 0
MCP_SYNC_PY_CACHE="$MCP_SYNC_MODELINFO_CACHE" \
MCP_SYNC_PY_PI="$MCP_SYNC_PI_MODELS" \
MCP_SYNC_PY_PI_SETTINGS="$MCP_SYNC_PI_SETTINGS" \
MCP_SYNC_PY_MANIFEST="$MCP_SYNC_PI_MANIFEST" \
MCP_SYNC_PY_PROVIDER="$MCP_SYNC_PROVIDER" \
MCP_SYNC_PY_OUTPUT_CAP="$MCP_SYNC_OUTPUT_RESERVE_CAP" \
python3 - <<'PYEOF' 2>/dev/null || true
import json, os, tempfile
provider = os.environ["MCP_SYNC_PY_PROVIDER"]
dest = os.environ["MCP_SYNC_PY_PI"]
manifest_path = os.environ["MCP_SYNC_PY_MANIFEST"]
cap = int(os.environ.get("MCP_SYNC_PY_OUTPUT_CAP") or 32768)
# Headroom between pi's compaction point and the server's hard limit. pi counts
# from the server's own usage.totalTokens, so this only has to absorb the new
# message appended since the last measured turn.
MARGIN = 2048
# pi's built-in default when compaction.reserveTokens is unset.
PI_DEFAULT_RESERVE = 16384
try:
with open(os.environ["MCP_SYNC_PY_CACHE"]) as f:
rows = json.load(f).get("data") or []
except Exception:
raise SystemExit(0)
reserve = PI_DEFAULT_RESERVE
try:
with open(os.environ["MCP_SYNC_PY_PI_SETTINGS"]) as f:
r = (json.load(f).get("compaction") or {}).get("reserveTokens")
if isinstance(r, int) and r > 0:
reserve = r
except Exception:
pass
ceiling = reserve - MARGIN
if ceiling <= 0:
raise SystemExit(0) # no output is provably safe under this reserve; do not guess
rendered = {}
for r in rows:
if not isinstance(r, dict):
continue
name = r.get("model_name")
mi = r.get("model_info") or {}
window = mi.get("max_input_tokens")
if not name or not isinstance(window, int) or window <= 0:
continue
out = mi.get("max_output_tokens")
# LiteLLM's max_output_tokens is the WHOLE window for a vLLM backend (it caps
# total context, not output), so it is an upper bound, never a value to copy.
out = out if isinstance(out, int) and out > 0 else cap
rendered[name] = {
"contextWindow": window,
"maxTokens": min(out, cap, ceiling),
"vision": mi.get("supports_vision") is True,
}
if not rendered:
raise SystemExit(0)
try:
with open(dest) as f:
cfg = json.load(f)
except Exception:
raise SystemExit(0)
prov = (cfg.get("providers") or {}).get(provider) if isinstance(cfg, dict) else None
if not isinstance(prov, dict) or not isinstance(prov.get("models"), list):
raise SystemExit(0)
before = json.dumps(cfg, sort_keys=True)
try:
with open(manifest_path) as f:
prev = json.load(f)
prev = prev if isinstance(prev, dict) else {}
except Exception:
prev = {}
added_prev = set(prev.get("added") or [])
image_prev = set(prev.get("image") or [])
# Drop models WE added that the gateway no longer serves. A user-created entry
# is never removed, even if the gateway stops listing it.
models = [m for m in prov["models"]
if not (isinstance(m, dict) and m.get("id") in added_prev
and m.get("id") not in rendered)]
by_id = {m.get("id"): m for m in models if isinstance(m, dict)}
added_now, image_now = set(), set()
for name, f in rendered.items():
m = by_id.get(name)
if m is None:
m = {"id": name, "name": name, "reasoning": False, "input": ["text"],
"cost": {"input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0}}
models.append(m)
by_id[name] = m
added_now.add(name)
elif name in added_prev:
added_now.add(name)
m["contextWindow"] = f["contextWindow"]
m["maxTokens"] = f["maxTokens"]
inp = m.get("input") if isinstance(m.get("input"), list) else ["text"]
if f["vision"]:
if "image" not in inp:
inp = inp + ["image"]
image_now.add(name)
elif name in image_prev:
image_now.add(name)
elif name in image_prev and "image" in inp:
# We added it and the gateway no longer claims vision: take it back, or
# pi keeps sending image parts the backend will reject.
inp = [x for x in inp if x != "image"]
m["input"] = inp
prov["models"] = models
if json.dumps(cfg, sort_keys=True) != before:
d = os.path.dirname(dest) or "."
fd, tmp = tempfile.mkstemp(dir=d)
with os.fdopen(fd, "w") as f:
json.dump(cfg, f, indent=2)
f.write("\n")
os.chmod(tmp, 0o600) # the provider block can carry an apiKey
os.replace(tmp, dest)
d = os.path.dirname(manifest_path) or "."
os.makedirs(d, exist_ok=True)
fd, tmp = tempfile.mkstemp(dir=d)
with os.fdopen(fd, "w") as f:
json.dump({"added": sorted(added_now), "image": sorted(image_now)}, f)
os.replace(tmp, manifest_path)
PYEOF
}
# --- deploy the shared skills tree (throttled, non-fatal) ------------------ # --- deploy the shared skills tree (throttled, non-fatal) ------------------
_mcp_sync_skills() { _mcp_sync_skills() {
# Same TTL as the config, tracked on the deployed directory's own mtime. # Same TTL as the config, tracked on the deployed directory's own mtime.
@@ -165,8 +931,13 @@ _mcp_sync_skills() {
_tree="${MCP_SYNC_SKILLS_SRC#file://}" _tree="${MCP_SYNC_SKILLS_SRC#file://}"
;; ;;
*) *)
if command -v curl >/dev/null 2>&1 && command -v tar >/dev/null 2>&1 && # Same rule as the config: the checkout is the source of truth when there
curl -fsS -m 15 "$MCP_SYNC_SKILLS_SRC" -o "$_work/repo.tgz" 2>/dev/null && # 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" ] && [ -s "$_work/repo.tgz" ] &&
mkdir -p "$_work/x" && tar -xzf "$_work/repo.tgz" -C "$_work/x" 2>/dev/null mkdir -p "$_work/x" && tar -xzf "$_work/repo.tgz" -C "$_work/x" 2>/dev/null
then then
@@ -197,10 +968,53 @@ _mcp_sync_skills() {
_mcp_sync_export_key _mcp_sync_export_key
_mcp_sync_refresh _mcp_sync_refresh
# After refresh: renders the just-deployed MCP servers into Hermes' schema.
_mcp_sync_hermes
# Then the gateway's model limits into the two clients that do not discover
# them on their own. Both run from the cache, so a throttled shell still
# converges on the last known good values.
_mcp_sync_hermes_models
_mcp_sync_pi
_mcp_sync_skills _mcp_sync_skills
# opencode only merges config from known locations; point OPENCODE_CONFIG at # 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. # 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" [ -f "$MCP_SYNC_DEST" ] && export OPENCODE_CONFIG="$MCP_SYNC_DEST"
unset -f _mcp_sync_export_key _mcp_sync_refresh _mcp_sync_skills 2>/dev/null || true # --- 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
+62
View File
@@ -0,0 +1,62 @@
#!/usr/bin/env sh
# node/install.sh — install Node.js (and with it npx) into userland.
#
# WHY: the MCP servers in mcp-config/opencode.gateway.json are npm packages and
# opencode spawns them as `npx -y <pkg>@<version>`. Without npx on PATH every
# MCP server fails to start with "command not found" — and opencode reports that
# only as a dead server, so it is easy to misread as a broken config. The MCP
# ecosystem is overwhelmingly npx-based, so this also unblocks future servers.
#
# Node is a tree (bin/node + lib/node_modules/npm), not a single binary, so it
# cannot use bin/install.sh's install_tarball_bin. It follows the ansible module
# instead: unpack under ~/.local/opt and symlink the CLIs into ~/.local/bin.
# Everything is under $HOME — no sudo, and it survives a Kasm redeploy.
set -eu
SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
REPO_ROOT="$(dirname -- "$SCRIPT_DIR")"
# shellcheck source=../lib/fetch.sh
. "$REPO_ROOT/lib/fetch.sh"
say() { printf '%s\n' "$*" >&2; }
# Pinned LTS. Bump deliberately: npx executes whatever the registry serves, so
# the runtime is part of the supply chain that sees your Gitea/Proxmox tokens.
NODE_VERSION="v24.18.0"
PREFIX="$HOME/.local/opt/node"
BIN_DIR="$HOME/.local/bin"
NODE_CLIS="node npm npx"
case "$(fetch_arch)" in
x86_64) NODE_ARCH=x64 ;;
aarch64) NODE_ARCH=arm64 ;;
*) say "node: unsupported arch $(fetch_arch)"; exit 1 ;;
esac
# Verify it RUNS, not just that the file exists: a Kasm redeploy can leave a
# present-but-broken tree behind (same failure the ansible venv guards against).
if [ "$("$PREFIX/bin/node" --version 2>/dev/null || true)" = "$NODE_VERSION" ]; then
say "node $NODE_VERSION already present at $PREFIX."
else
TARBALL="node-$NODE_VERSION-linux-$NODE_ARCH.tar.gz"
URL="https://nodejs.org/dist/$NODE_VERSION/$TARBALL"
TMP="$(mktemp -d)"
say "Installing node $NODE_VERSION ($NODE_ARCH) into $PREFIX..."
_fetch_dl "$URL" "$TMP/node.tar.gz"
rm -rf "$PREFIX"
mkdir -p "$PREFIX"
# The archive has a single top-level dir; strip it so bin/ lands at $PREFIX/bin.
tar -xzf "$TMP/node.tar.gz" -C "$PREFIX" --strip-components=1
rm -rf "$TMP"
say "Installed node $NODE_VERSION."
fi
# Symlink the CLIs into ~/.local/bin (on PATH via zsh/zshrc). npx must be here:
# opencode spawns MCP servers with a bare `npx`, resolved from PATH.
mkdir -p "$BIN_DIR"
for cli in $NODE_CLIS; do
[ -x "$PREFIX/bin/$cli" ] && ln -sf "$PREFIX/bin/$cli" "$BIN_DIR/$cli"
done
say "Linked node CLIs ($NODE_CLIS) into $BIN_DIR."
+224
View File
@@ -0,0 +1,224 @@
#!/usr/bin/env sh
# secrets/install.sh — write this user's 1Password vault item to a local,
# 0600 env file that the managed ~/.zshrc sources.
#
# WHY: secrets used to be pulled live in ~/.zshrc.local, one `op read` per
# field on EVERY new shell. That made each terminal wait on 1Password, broke
# every shell when the network or the service account was down, and meant each
# box carried its own hand-written copy of the loop. Now the vault is read once
# here (at bootstrap, or on demand with `ops`), and shells read a local file.
#
# Layout: one vault per user (default: the login name), holding one item
# (default: also the login name) whose field LABELS are the variable names —
# GITEA_KEY, LITELLM_KEY, PROXMOX_TOKEN_SECRET, ... Every such field is
# exported. Add a secret by adding a field (`opc NAME`), not by editing
# this repo.
#
# Files, all under ~/.config/dotfiles (0700), none in the repo:
# op.env OP_SERVICE_ACCOUNT_TOKEN, OP_VAULT, OP_ITEM (0600). Kept so a
# refresh needs no prompt. Deliberately NOT sourced by shells:
# the token stays out of every process's environment, and only
# the op* helpers in zsh/zshrc load it, in a subshell.
# secrets.env the exported fields (0600), replaced atomically, so a failed
# fetch leaves the previous copy in place.
#
# Prompts for the token on the controlling TTY the first time (bootstrap is
# often piped into sh, so stdin is not the terminal). Without a TTY, pass
# OP_SERVICE_ACCOUNT_TOKEN (and optionally OP_VAULT / OP_ITEM) in the env.
# Needs op and jq, which bin/ installs. No sudo.
set -eu
say() { printf '%s\n' "$*" >&2; }
CONF_DIR="${DOTFILES_CONFIG_DIR:-$HOME/.config/dotfiles}"
OP_ENV="$CONF_DIR/op.env"
SECRETS_ENV="$CONF_DIR/secrets.env"
MCP_SYNC_KEYFILE="$HOME/.config/mcp-sync/key"
umask 077
mkdir -p "$CONF_DIR"
chmod 0700 "$CONF_DIR"
PATH="$HOME/.local/bin:$PATH"
for _tool in op jq; do
if ! "$_tool" --version >/dev/null 2>&1; then
say "secrets: $_tool is not installed or does not run; run bin/install.sh first."
exit 1
fi
done
# --- 1. resolve the service account, vault and item -------------------------
# Precedence: the environment, then op.env, then a prompt / auto-detection.
#
# WHY each piece is checked on its own: the vault holds every key later
# modules need (LITELLM_KEY, GITEA_KEY, ...). A bad token paste used to fall
# through as a generic "could not read item", bootstrap carried on, and the
# first visible symptom was mcp-config/ asking for a LiteLLM key by hand —
# which hid the real problem. Now a rejected token, a wrong vault and a wrong
# item each get their own message, and on a TTY a re-prompt.
_env_token="${OP_SERVICE_ACCOUNT_TOKEN:-}"
_token_src="from OP_SERVICE_ACCOUNT_TOKEN"
[ -n "$_env_token" ] || _token_src="stored in $OP_ENV"
_env_vault="${OP_VAULT:-}"
_env_item="${OP_ITEM:-}"
if [ -f "$OP_ENV" ]; then
# shellcheck source=/dev/null
. "$OP_ENV"
fi
OP_SERVICE_ACCOUNT_TOKEN="${_env_token:-${OP_SERVICE_ACCOUNT_TOKEN:-}}"
OP_VAULT="${_env_vault:-${OP_VAULT:-}}"
OP_ITEM="${_env_item:-${OP_ITEM:-}}"
_user="$(id -un)"
# Probe in a subshell: in POSIX sh a failed redirect on a special builtin
# like `:` exits the whole script.
have_tty() { ( : < /dev/tty ) 2>/dev/null; }
# ask <prompt> <default> — read one line from the TTY; echoes the default on empty.
ask() {
printf '%s [%s]: ' "$1" "$2" >&2
IFS= read -r _ans < /dev/tty || _ans=""
printf '%s' "${_ans:-$2}"
}
# mask <secret> — first 10 and last 10 characters plus the length, so a
# truncated or doubled paste is visible without printing the whole token.
mask() {
printf '%s' "$1" | awk '{ n = length($0)
if (n <= 20) printf "%s (%d chars)", "(too short)", n
else printf "%s**********%s (first 10 + last 10 of %d chars)", substr($0, 1, 10), substr($0, n - 9), n }'
}
if [ -z "$OP_SERVICE_ACCOUNT_TOKEN" ] && [ -f "$SECRETS_ENV" ]; then
say "secrets: no service-account token; keeping existing $SECRETS_ENV."
exit 0
fi
# The token: `op whoami` is the cheapest call that fails on a bad token and
# nothing else. Up to three pastes before giving up.
_tries=0
while :; do
if [ -n "$OP_SERVICE_ACCOUNT_TOKEN" ]; then
export OP_SERVICE_ACCOUNT_TOKEN
op whoami >/dev/null 2>&1 && break
if [ "$_tries" -eq 0 ]; then
say "secrets: 1Password REJECTED the service-account token $_token_src (bad paste, revoked or expired?)."
say " token: $(mask "$OP_SERVICE_ACCOUNT_TOKEN")"
else
say "secrets: 1Password REJECTED that token — most likely a truncated or bad paste."
say " token: $(mask "$OP_SERVICE_ACCOUNT_TOKEN")"
fi
fi
if ! have_tty; then
say "secrets: no valid OP_SERVICE_ACCOUNT_TOKEN and no TTY to prompt."
say " re-run as: OP_SERVICE_ACCOUNT_TOKEN=ops_... $0"
exit 1
fi
if [ "$_tries" -ge 3 ]; then
say "secrets: giving up after $_tries attempts. Every key in the vault (LITELLM_KEY,"
say " GITEA_KEY, ...) is missing until this works: re-run $0"
exit 1
fi
_tries=$((_tries + 1))
printf '1Password service-account token (ops_..., input hidden): ' >&2
stty -echo < /dev/tty 2>/dev/null || true
IFS= read -r OP_SERVICE_ACCOUNT_TOKEN < /dev/tty || true
stty echo < /dev/tty 2>/dev/null || true
printf '\n' >&2
OP_SERVICE_ACCOUNT_TOKEN="$(printf '%s' "$OP_SERVICE_ACCOUNT_TOKEN" | tr -d ' \t\r\n')"
[ -n "$OP_SERVICE_ACCOUNT_TOKEN" ] || say "secrets: no token entered."
done
say "secrets: service-account token OK: $(mask "$OP_SERVICE_ACCOUNT_TOKEN")"
# pick <kind> <current> <default> <check-fn> <list-cmd...>
# Resolve a vault or item name: keep <current> if it checks out; otherwise use
# the only one the token can see, or ask (TTY) showing what it can see.
pick() {
_kind="$1" _cur="$2" _def="$3"; shift 3
_check="$1"; shift
_names="$("$@" --format json 2>/dev/null | jq -r '.[] | .name // .title')"
if [ -n "$_cur" ]; then
$_check "$_cur" && { printf '%s' "$_cur"; return 0; }
say "secrets: $_kind '$_cur' not found. This token can see:"
printf '%s\n' "$_names" | sed 's/^/ - /' >&2
fi
if [ "$(printf '%s\n' "$_names" | grep -c .)" -eq 1 ]; then
say "secrets: using the only $_kind this token can see: $_names"
printf '%s' "$_names"; return 0
fi
have_tty || { say "secrets: set OP_$(printf '%s' "$_kind" | tr a-z A-Z) and re-run."; return 1; }
printf '%s\n' "$_names" | grep -Fxq -- "$_def" || _def="$(printf '%s\n' "$_names" | head -n 1)"
while :; do
_cur="$(ask "$_kind" "$_def")"
$_check "$_cur" && { printf '%s' "$_cur"; return 0; }
say "secrets: $_kind '$_cur' not found. This token can see:"
printf '%s\n' "$_names" | sed 's/^/ - /' >&2
done
}
vault_ok() { op vault get "$1" >/dev/null 2>&1; }
item_ok() { op item get "$1" --vault "$OP_VAULT" >/dev/null 2>&1; }
OP_VAULT="$(pick vault "$OP_VAULT" "$_user" vault_ok op vault list)" || exit 1
OP_ITEM="$(pick item "$OP_ITEM" "$_user" item_ok op item list --vault "$OP_VAULT")" || exit 1
# --- 2. fetch the item once and render it -----------------------------------
# One `op item get` instead of one `op read` per field. Labels that are not
# valid shell identifiers (and the item's built-in notes field) are skipped
# rather than eval'd; values go through jq's @sh, so quotes, $ and newlines in
# a secret cannot break or inject into the file.
if ! _json="$(op item get "$OP_ITEM" --vault "$OP_VAULT" --reveal --format json)"; then
say "secrets: could not read item '$OP_ITEM' in vault '$OP_VAULT'."
[ -f "$SECRETS_ENV" ] && say "secrets: keeping the previous $SECRETS_ENV."
exit 1
fi
_skipped="$(printf '%s' "$_json" | jq -r '
[.fields[] | select(.purpose != "NOTES")
| select((.label // "") | test("^[A-Za-z_][A-Za-z0-9_]*$") | not) | .label]
| join(", ")')"
[ -z "$_skipped" ] || say "secrets: skipped fields whose labels are not variable names: $_skipped"
_tmp="$(mktemp "$CONF_DIR/secrets.env.XXXXXX")"
trap 'rm -f "$_tmp"' EXIT
{
printf '# Generated by dotfiles secrets/install.sh from op://%s/%s — do not edit.\n' "$OP_VAULT" "$OP_ITEM"
printf '# Refresh with: ops (change the vault with: opc NAME / opd NAME)\n'
printf '%s' "$_json" | jq -r '
.fields[]
| select(.purpose != "NOTES")
| select((.label // "") | test("^[A-Za-z_][A-Za-z0-9_]*$"))
| select((.value // "") != "")
| "export \(.label)=\(.value | @sh)"'
} > "$_tmp"
chmod 0600 "$_tmp"
_count="$(grep -c '^export ' "$_tmp" || true)"
if [ "$_count" -eq 0 ]; then
say "secrets: item '$OP_ITEM' has no exportable fields; keeping the previous file."
exit 1
fi
mv -f "$_tmp" "$SECRETS_ENV"
trap - EXIT
say "secrets: wrote $_count variables to $SECRETS_ENV (0600)."
grep -q '^export LITELLM_KEY=' "$SECRETS_ENV" \
|| say "secrets: WARNING: op://$OP_VAULT/$OP_ITEM has no LITELLM_KEY field; mcp-config/ and hermes/ need it."
# Remember the account only after it has actually worked.
{
printf 'OP_SERVICE_ACCOUNT_TOKEN=%s\n' "$(printf '%s' "$OP_SERVICE_ACCOUNT_TOKEN" | jq -Rr @sh)"
printf 'OP_VAULT=%s\n' "$(printf '%s' "$OP_VAULT" | jq -Rr @sh)"
printf 'OP_ITEM=%s\n' "$(printf '%s' "$OP_ITEM" | jq -Rr @sh)"
} > "$OP_ENV"
chmod 0600 "$OP_ENV"
# --- 3. keep mcp-sync's copy of the gateway key in step ----------------------
# mcp-config/sync.sh exports LITELLM_KEY from its own key file on every shell,
# AFTER secrets.env is loaded, so a key rotated in the vault would otherwise be
# silently overridden by the stale copy. Only update a file that already exists:
# creating it is mcp-config/install.sh's job.
_litellm="$(printf '%s' "$_json" | jq -r '.fields[] | select(.label == "LITELLM_KEY") | .value // empty' | head -n 1)"
if [ -n "$_litellm" ] && [ -f "$MCP_SYNC_KEYFILE" ] \
&& [ "$(tr -d ' \t\r\n' < "$MCP_SYNC_KEYFILE")" != "$_litellm" ]; then
printf '%s' "$_litellm" > "$MCP_SYNC_KEYFILE"
chmod 0600 "$MCP_SYNC_KEYFILE"
say "secrets: LITELLM_KEY changed; updated $MCP_SYNC_KEYFILE (re-run hermes/install.sh for Hermes)."
fi
+5 -2
View File
@@ -6,8 +6,11 @@ points opencode at it via `skills.paths`.
Unlike models and MCP servers — which are discovered from the LiteLLM gateway Unlike models and MCP servers — which are discovered from the LiteLLM gateway
at sync time — skills are plain files with nothing on the gateway to query, so at sync time — skills are plain files with nothing on the gateway to query, so
**this repo is the source of truth**. Add a skill here, push to `main`, and it **this repo is the source of truth**. They are copied from the local checkout
lands everywhere within the sync TTL (~1h, or immediately on a new shell). (the same source as the gateway config — see "Where the config comes from" in
`mcp-config/README.md`), so no anonymous read of this repo is required. Add a
skill here, push to `main`, and it lands everywhere within the sync TTL (~1h,
or immediately on a new shell) once the checkout picks the commit up.
> LiteLLM's `/v1/skills` is *not* an alternative: it is a passthrough to > LiteLLM's `/v1/skills` is *not* an alternative: it is a passthrough to
> Anthropic's Skills API (`?beta=true`), which lists skills on an Anthropic > Anthropic's Skills API (`?beta=true`), which lists skills on an Anthropic
+216
View File
@@ -0,0 +1,216 @@
#!/usr/bin/env sh
# vscode/install.sh — install Visual Studio Code into ~/.local/opt/vscode.
#
# WHY: VS Code is the one GUI app this repo installs, and the obvious way to get
# it — Microsoft's rpm/deb repo — needs root, writes outside $HOME, and is wiped
# by a Kasm redeploy, so the editor quietly disappears on every rebuild. The box
# this was written for had exactly that half-state: /etc/yum.repos.d/vscode.repo
# present, `code` never installed, and nothing in this repo recording that VS
# Code was meant to be there at all. Upstream's portable tarball is the same
# build with none of that: everything lands under $HOME, no sudo, and it
# survives a redeploy. The only thing given up is dnf/apt-driven updates, which
# is what the pinned VSCODE_VERSION below replaces.
#
# It is not in bin/install.sh because that script installs single self-contained
# binaries; VS Code is an Electron tree of thousands of files plus a menu entry
# and an icon. It follows node/ and ansible/ instead: unpack under ~/.local/opt
# and symlink the CLI into ~/.local/bin.
#
# The Chromium sandbox still works from a userland install: the bundled
# chrome-sandbox helper is not setuid here, so Electron falls back to the
# unprivileged user-namespace sandbox. That needs user namespaces enabled
# (user.max_user_namespaces > 0, the default on every distro this repo targets).
# Verified by launching without --no-sandbox. Nothing is given up for dropping
# root; if a host ever has userns disabled, VS Code says so on startup.
#
# Idempotent and safe to re-run. To upgrade, bump VSCODE_VERSION.
set -eu
SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
REPO_ROOT="$(dirname -- "$SCRIPT_DIR")"
# shellcheck source=../lib/fetch.sh
. "$REPO_ROOT/lib/fetch.sh"
say() { printf '%s\n' "$*" >&2; }
# Pinned deliberately. VS Code auto-updates itself on other platforms; a tarball
# install does not, which is the point — the editor runs your code and holds
# your Git and extension credentials, so the version is a supply-chain decision
# rather than whatever upstream served today. `code --version` prints this
# string verbatim, so it must match with no leading "v".
VSCODE_VERSION="1.139.0"
PREFIX="$HOME/.local/opt/vscode"
DESKTOP_DIR="$HOME/.local/share/applications"
ARGV_JSON="$HOME/.vscode/argv.json"
# has_desktop — true when this host has a graphical session to run a GUI editor
# in. VS Code is ~400MB unpacked and $HOME is the thing that persists across
# redeploys, so it is not worth carrying on a box that will never display it.
# Desktop images ship session files under /usr/share/{x,wayland-}sessions.
has_desktop() {
[ -n "${XDG_CURRENT_DESKTOP:-}" ] && return 0
for d in /usr/share/xsessions /usr/share/wayland-sessions; do
[ -d "$d" ] && [ -n "$(ls -A "$d" 2>/dev/null)" ] && return 0
done
return 1
}
# --- skip on headless hosts --------------------------------------------------
# DOTFILES_VSCODE=1 forces the install anyway (e.g. for `code tunnel` on a
# server); DOTFILES_VSCODE=0 skips it even on a desktop.
case "${DOTFILES_VSCODE:-auto}" in
0) say "vscode: DOTFILES_VSCODE=0; skipping."; exit 0 ;;
1) ;;
*) if ! has_desktop; then
say "vscode: no graphical session detected; skipping (DOTFILES_VSCODE=1 to force)."
exit 0
fi ;;
esac
# Upstream keys its download URLs on x64/arm64 rather than amd64/arm64.
case "$(fetch_arch)" in
x86_64) VSCODE_ARCH=x64 ;;
aarch64) VSCODE_ARCH=arm64 ;;
*) say "vscode: unsupported arch $(fetch_arch)"; exit 1 ;;
esac
# --- install the app tree ----------------------------------------------------
# Verify it RUNS, not just that the tree exists: a Kasm redeploy can leave a
# present-but-broken directory behind (the same failure node/ and ansible/
# guard against).
#
# The root fallback is not optional. Run as root, `code --version` refuses --
# it prints "You are trying to start Visual Studio Code as a super user"
# instead of the version, and still exits 0 -- so a bare run-check never
# matches and would re-download 325MB on every single bootstrap, silently.
# That is the forced-install path (DOTFILES_VSCODE=1 in a container or on a
# server), so for root only, fall back to the recorded version stamp.
installed_version="$("$PREFIX/bin/code" --version 2>/dev/null | head -1 || true)"
if [ "$installed_version" = "$VSCODE_VERSION" ]; then
say "vscode: $VSCODE_VERSION already present at $PREFIX."
elif [ "$(id -u)" = "0" ] && have_version code "$VSCODE_VERSION"; then
say "vscode: $VSCODE_VERSION already present at $PREFIX (stamped; root cannot query --version)."
else
say "vscode: installing $VSCODE_VERSION into $PREFIX (this is a ~325MB download)..."
install_tarball_tree \
"https://update.code.visualstudio.com/$VSCODE_VERSION/linux-$VSCODE_ARCH/stable" \
"VSCode-linux-$VSCODE_ARCH" "$PREFIX"
# bin/code is upstream's CLI wrapper (`code .`, `code --install-extension`);
# the Electron binary at the tree root is what the menu entry launches.
mkdir -p "$BIN_DIR"
ln -sf "$PREFIX/bin/code" "$BIN_DIR/code"
mark_version code "$VSCODE_VERSION"
say "vscode: installed $VSCODE_VERSION and linked $BIN_DIR/code."
fi
# --- menu entry --------------------------------------------------------------
# Icon= is an absolute path rather than a theme name: the bundled icon is a
# single 1024x1024 PNG and hicolor tops out at 512x512, so there is no correct
# themed directory to drop it in. The spec allows an absolute path, and it needs
# no icon-cache rebuild to take effect.
mkdir -p "$DESKTOP_DIR"
cat >"$DESKTOP_DIR/code.desktop" <<DESKTOP
[Desktop Entry]
Type=Application
Name=Visual Studio Code
GenericName=Text Editor
Comment=Code Editing. Redefined.
Exec=$PREFIX/code %F
Icon=$PREFIX/resources/app/resources/linux/code.png
Terminal=false
StartupNotify=true
StartupWMClass=Code
Categories=TextEditor;Development;IDE;
MimeType=text/plain;inode/directory;
Keywords=vscode;editor;
Actions=new-window;
[Desktop Action new-window]
Name=New Empty Window
Exec=$PREFIX/code --new-window %F
Icon=$PREFIX/resources/app/resources/linux/code.png
DESKTOP
say "vscode: wrote $DESKTOP_DIR/code.desktop"
# Refresh the per-user menu caches so the entry appears without a re-login.
if command -v update-desktop-database >/dev/null 2>&1; then
update-desktop-database "$DESKTOP_DIR" 2>/dev/null || true
fi
if command -v kbuildsycoca5 >/dev/null 2>&1; then
kbuildsycoca5 --noincremental >/dev/null 2>&1 || true
fi
# --- keyring prompt suppression ----------------------------------------------
# WHY: on first launch Electron's safeStorage reaches for the OS keyring through
# libsecret. On a KDE session that wakes kwalletd, which pops an unlock/create
# dialog before the editor is usable -- and since kwallet-pam is not installed
# on these hosts, nothing unlocks the wallet at login, so it prompts every
# session. "basic" keeps VS Code's secrets in its own store and never reaches
# for the wallet at all. Verified with dbus-monitor: no org.kde.KWallet traffic
# on launch.
#
# TRADE-OFF: basic is weaker than a real keyring. Secrets VS Code stores (Git
# and GitHub auth tokens, extension credentials) end up obfuscated on disk
# rather than sealed behind a wallet password. To move to the wallet later,
# install kwallet-pam so it unlocks at login, then change the value to
# "kwallet5" and restart VS Code.
#
# The path comes from product.json's dataFolderName (".vscode"), NOT the
# user-data-dir: argv.json lives at ~/.vscode/argv.json and a copy written
# anywhere else is read by nothing and silently ignored.
if [ ! -e "$ARGV_JSON" ]; then
mkdir -p "$(dirname "$ARGV_JSON")"
cat >"$ARGV_JSON" <<'ARGV'
// Runtime arguments for VS Code. See dotfiles vscode/install.sh.
{
// Keep secrets in VS Code's own store rather than the OS keyring, so KDE's
// kwallet does not prompt for an unlock on every launch.
"password-store": "basic"
}
ARGV
say "vscode: wrote $ARGV_JSON (password-store=basic)"
elif grep -q '"password-store"' "$ARGV_JSON"; then
say "vscode: $ARGV_JSON already sets password-store; leaving it alone."
else
# Merge rather than overwrite: this file is JSON-with-comments that VS Code
# and the user both own, and it carries a crash-reporter-id that should
# survive. Insert the key after the opening brace and keep the rest byte for
# byte.
cp -p "$ARGV_JSON" "$ARGV_JSON.bak"
if awk '
BEGIN { done = 0 }
{
print
if (!done && $0 ~ /^[[:space:]]*\{[[:space:]]*$/) {
print "\t// Added by dotfiles vscode/install.sh: keep secrets in VS Code'"'"'s"
print "\t// own store so kwallet does not prompt for an unlock on every launch."
print "\t\"password-store\": \"basic\","
done = 1
}
}
END { exit(done ? 0 : 1) }
' "$ARGV_JSON.bak" >"$ARGV_JSON.new"; then
mv "$ARGV_JSON.new" "$ARGV_JSON"
say "vscode: set password-store=basic in $ARGV_JSON (backup: $ARGV_JSON.bak)"
else
rm -f "$ARGV_JSON.new"
say "vscode: WARNING: could not find the object opening in $ARGV_JSON."
say " Add '\"password-store\": \"basic\"' by hand to stop the keyring prompt."
fi
fi
# A system-wide VS Code (from Microsoft's rpm/deb repo) shadows this one in the
# menu and shows a duplicate entry. Flag it rather than touching it -- removing
# it needs the root this module deliberately avoids.
if [ -e /usr/share/applications/com.microsoft.VSCode.desktop ]; then
say ""
say "vscode: NOTE: a system-wide VS Code package is also installed."
say " You will see two menu entries until it is removed:"
say " sudo dnf remove code # or: sudo apt remove code"
say ""
fi
say "vscode: ready."
+136 -13
View File
@@ -2,19 +2,6 @@
# overwritten on the next bootstrap run. Edit zsh/zshrc in the dotfiles repo # overwritten on the next bootstrap run. Edit zsh/zshrc in the dotfiles repo
# instead. # 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. # Userland tools installed by dotfiles/bin/install.sh live here.
case ":$PATH:" in case ":$PATH:" in
*":$HOME/.local/bin:"*) ;; *":$HOME/.local/bin:"*) ;;
@@ -34,6 +21,142 @@ SAVEHIST=10000
setopt SHARE_HISTORY setopt SHARE_HISTORY
setopt HIST_IGNORE_DUPS setopt HIST_IGNORE_DUPS
# Delete remote branches without fighting tab-completion.
#
# `git push origin --delete <TAB>` completes to `origin/foo`, but `origin/foo`
# is a LOCAL remote-tracking name — the ref on the server is just `foo`. So the
# command zsh completes for you is the one git rejects, with "remote ref does
# not exist", which reads like the branch is missing rather than misnamed.
#
# grmb takes either form. It fetches with --prune first so branches already
# deleted on the server stop being offered at all (that is the other half of
# this annoyance, and `fetch.prune = true` from dotfiles/git/install.sh makes it
# automatic everywhere else).
#
# Named grmb, not gbd: oh-my-zsh's git plugin already binds gbd to
# `git branch --delete`, aliases resolve before functions, and the collision
# fails in the worst possible way — `gbd origin/foo` would quietly try to
# delete a LOCAL branch instead of the remote one. Check with `type <name>`
# before adding any g* helper here.
#
# grmb origin/my-branch # completed form, works
# grmb my-branch other # bare form, several at once
grmb() {
emulate -L zsh
local remote=origin b rc=0
if [ "$#" -eq 0 ]; then
print -u2 "grmb: usage: grmb <branch>... (origin/ prefix optional)"
return 2
fi
git fetch --prune --quiet "$remote" || return 1
for b in "$@"; do
b=${b#${remote}/}
if git push "$remote" --delete "$b"; then
print "deleted ${remote}/${b}"
else
print -u2 "grmb: could not delete ${b} (already gone? check: git ls-remote --heads ${remote})"
rc=1
fi
done
return $rc
}
# Secrets from this user's 1Password vault, written by dotfiles/secrets/install.sh.
# Reading a local 0600 file keeps shell startup off the network: nothing here
# calls op. opc/opd refresh the file themselves; after editing the vault
# anywhere else (the 1Password app, another box), run `ops`.
_DOTFILES_CONF="$HOME/.config/dotfiles"
[ -f "$_DOTFILES_CONF/secrets.env" ] && source "$_DOTFILES_CONF/secrets.env"
# opr NAME read one field straight from the vault
# opc NAME=VALUE create or update a field, then refresh and reload this
# opc NAME shell; with no VALUE (NAME or NAME=) it prompts, hidden,
# which keeps the secret out of shell history.
# `opc NAME VALUE` also still works.
# opd NAME delete a field, refresh, and unset it in this shell
# opl [VAULT [ITEM]] show every field and its current value (default: your
# vault and item from op.env)
# ops re-fetch the vault into secrets.env and reload this shell
#
# The service-account token lives in op.env and is loaded only inside these
# subshells, so it never lands in the environment of everything you run.
_dotfiles_op() {
[ -f "$_DOTFILES_CONF/op.env" ] || { print -u2 "no $_DOTFILES_CONF/op.env; run: ops"; return 1; }
( set -a; source "$_DOTFILES_CONF/op.env"; set +a; "$@" )
}
_dotfiles_env_name() {
[[ "$1" =~ '^[A-Za-z_][A-Za-z0-9_]*$' ]] && return 0
print -u2 "$2: '$1' is not a valid variable name"; return 2
}
opr() {
_dotfiles_env_name "$1" opr || return
_dotfiles_op sh -c 'op read "op://$OP_VAULT/$OP_ITEM/$1"' opr "$1"
}
opc() {
emulate -L zsh
local name=$1 value
if [[ $1 == *=* ]]; then
# NAME=VALUE: split on the first '=' so values may contain '='
name=${1%%=*} value=${1#*=}
elif (( $# >= 2 )); then
value=$2
fi
_dotfiles_env_name "$name" opc || return
if [[ -z $value ]]; then
read -rs "value?$name: " && print
[[ -n $value ]] || { print -u2 "opc: empty value for $name; nothing changed"; return 1; }
fi
# [concealed] makes it a password field; an existing field of that name is updated.
# op can exit non-zero with "(404) Not Found" after an edit it did save (seen with
# op 2.34.1 and 2.39.0), so its exit code is ignored: the field is read back instead.
_dotfiles_op sh -c '
clean() { printf "%s" "${1:-(no output)}" | sed "s/^\[ERROR\] [0-9/]* [0-9:]* //"; }
ref="op://$OP_VAULT/$OP_ITEM/$1"
err=$(op item edit "$OP_ITEM" --vault "$OP_VAULT" "$1[concealed]=$2" 2>&1 >/dev/null)
if [ "$(op read "$ref" 2>/dev/null)" = "$2" ]; then
printf "opc: saved %s\n" "$ref" >&2
else
printf "opc: could not save %s\n op said: %s\n check vault, item and service-account write access with: opl\n" \
"$ref" "$(clean "$err")" >&2
exit 1
fi' opc "$name" "$value" && ops
}
opd() {
_dotfiles_env_name "$1" opd || return
# As in opc, op's exit code is unreliable here: check the item's field labels instead.
_dotfiles_op sh -c '
clean() { printf "%s" "${1:-(no output)}" | sed "s/^\[ERROR\] [0-9/]* [0-9:]* //"; }
ref="op://$OP_VAULT/$OP_ITEM/$1"
err=$(op item edit "$OP_ITEM" --vault "$OP_VAULT" "$1[delete]" 2>&1 >/dev/null)
if ! item=$(op item get "$OP_ITEM" --vault "$OP_VAULT" --format json 2>&1); then
printf "opd: could not re-read %s to confirm the delete\n op said: %s\n" "$ref" "$(clean "$item")" >&2
exit 1
elif printf "%s" "$item" | jq -r ".fields[].label" | grep -qxF "$1"; then
printf "opd: could not delete %s\n op said: %s\n" "$ref" "$(clean "$err")" >&2
exit 1
fi
printf "opd: deleted %s\n" "$ref" >&2' opd "$1" \
&& unset "$1" && ops
}
opl() {
_dotfiles_op sh -c '
vault=${1:-$OP_VAULT} item=${2:-$OP_ITEM}
printf "op://%s/%s\n" "$vault" "$item" >&2
op item get "$item" --vault "$vault" --reveal --format json' opl "$@" \
| jq -r '
[.fields[] | select(.purpose != "NOTES")] as $f
| ([$f[] | .label // "" | length] | max // 0) as $w
| $f[]
| ((.label // "") + " " * ($w - ((.label // "") | length))) as $name
| ((.value // "") | split("\n")) as $lines
| "\($name) \($lines[0] // "")",
($lines[1:][] | (" " * ($w + 2)) + .)'
}
ops() {
"${DOTFILES_DIR:-$HOME/.dotfiles}/secrets/install.sh" \
&& source "$_DOTFILES_CONF/secrets.env"
}
# Per-machine customizations (secrets, keys, local overrides) live in # Per-machine customizations (secrets, keys, local overrides) live in
# ~/.zshrc.local, which bootstrap never touches. See zsh/zshrc.local.example # ~/.zshrc.local, which bootstrap never touches. See zsh/zshrc.local.example
# for the variables modules look for. Loaded last so it can override anything. # for the variables modules look for. Loaded last so it can override anything.
+34 -17
View File
@@ -1,7 +1,9 @@
# ~/.zshrc.local — per-machine customizations. Copy this file to # ~/.zshrc.local — per-machine customizations. Copy this file to
# ~/.zshrc.local and fill in what this box needs. It is sourced at the end # ~/.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 # of the managed ~/.zshrc, is never overwritten by bootstrap, and never
# lives in the repo — this is the ONLY place to put secrets. # lives in the repo. Secrets normally come from your 1Password vault instead
# (secrets/install.sh -> ~/.config/dotfiles/secrets.env); set one here only on
# a box without 1Password, or to override the vault's value on this machine.
# #
# cp zsh/zshrc.local.example ~/.zshrc.local && chmod 600 ~/.zshrc.local # cp zsh/zshrc.local.example ~/.zshrc.local && chmod 600 ~/.zshrc.local
@@ -16,24 +18,39 @@
# setting it here. Only set it here on a box without mcp-sync: # setting it here. Only set it here on a box without mcp-sync:
# export LITELLM_KEY=sk-... # export LITELLM_KEY=sk-...
# #
# To develop mcp-config/ or skills/ against a working checkout instead of # Hermes Agent uses the same key and endpoint. The base_url is configured
# Gitea's main, point the sync at it and source it directly: # in ~/.hermes/config.yaml to point to your covert-gpt gateway
# MCP_SYNC_RAW_URL="file://$HOME/source/dotfiles/mcp-config/opencode.gateway.json" # (https://llm.covert-gpt.com). Run hermes setup or update config.yaml
# MCP_SYNC_SKILLS_SRC="$HOME/source/dotfiles/skills" # to set the default model (see ~/.hermes/config.yaml for available models).
# . "$HOME/source/dotfiles/mcp-config/sync.sh"
# --- 1Password CLI ------------------------------------------------------- # mcp-config/install.sh records the checkout it ran from, and the sync reads
# Service-account token for non-interactive `op read` / `op run`. # the config and skills from there — no download, no anonymous read of the repo.
# export OP_SERVICE_ACCOUNT_TOKEN=ops_... # To point it at a DIFFERENT checkout (e.g. develop against a feature branch
# while ~/.dotfiles stays on main), set one variable:
# MCP_SYNC_REPO="$HOME/source/dotfiles"
#
# Only the bootstrap-managed checkout (~/.dotfiles) is ever `git pull`-ed by the
# hook, and only when clean — a tree you work in is read, never touched.
# --- Proxmox MCP gateway (ai-k8s-iac) ------------------------------------ # --- 1Password -----------------------------------------------------------
# Credentials for the Proxmox MCP server registered on the covert-gpt # Nothing to set here. Do NOT loop over `op read` in this file: that ran on
# LiteLLM gateway. `task up` in ai-k8s-iac reads these env vars and stores # every shell. secrets/install.sh stores the service-account token in
# them in the inference-mcp Secret; they are never committed. # ~/.config/dotfiles/op.env and writes the vault's fields to secrets.env, which
# export PROXMOX_MCP_HOST="https://pve.local:8006" # the managed ~/.zshrc loads. Use opr / opc / opd (see zsh/zshrc).
# export PROXMOX_MCP_TOKEN_ID="root@pam!terragrunt-proxmox"
# export PROXMOX_MCP_TOKEN_SECRET="00000000-0000-0000-0000-000000000000" # --- Proxmox -------------------------------------------------------------
# export PROXMOX_MCP_INSECURE_TLS="true" # One pair of credentials, several consumers: the proxmox MCP server
# (mcp-config/opencode.gateway.json) and the terraform layer in
# rke2-devops-airgap, whose Taskfile composes PROXMOX_VE_API_TOKEN from them.
# Unprefixed on purpose — these are Proxmox credentials, not MCP ones.
#
# secrets.env already exports them from your vault item, so these lines are
# only needed without 1Password. Never committed.
# export PROXMOX_TOKEN_ID="root@pam!terragrunt-proxmox"
# export PROXMOX_TOKEN_SECRET="00000000-0000-0000-0000-000000000000"
#
# The host and TLS setting are hardcoded per-server in the MCP config and read
# from variables.yaml by terraform, so there is nothing to export for them.
# --- Anything else ------------------------------------------------------- # --- Anything else -------------------------------------------------------
# Aliases, PATH additions, kubeconfig defaults, etc. This file loads last, # Aliases, PATH additions, kubeconfig defaults, etc. This file loads last,