Compare commits
48
Commits
eff01b1b0b
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
41a90a10fb | ||
|
|
5f0dec5270 | ||
|
|
fe8b9a3ec0 | ||
|
|
72d85eb9ec | ||
|
|
7bbd911365 | ||
|
|
b6d36ba113 | ||
|
|
c0f1fc21b7 | ||
|
|
86b8e51646 | ||
|
|
ffedc84cfb | ||
|
|
cb7ab17769 | ||
|
|
5bc8a27f60 | ||
|
|
8ec3269f4a | ||
|
|
46052cf573 | ||
|
|
d3393edfdd | ||
|
|
db3a3e6e82 | ||
|
|
376ec03428 | ||
|
|
2bfa7e9a2d | ||
|
|
11138d90a9 | ||
|
|
7a7e987268 | ||
|
|
0426ebc973 | ||
|
|
1159197e4b | ||
|
|
c6e5f22acb | ||
|
|
1acbd28c6e | ||
|
|
5fac9cb63b | ||
|
|
6ac2a02f1b | ||
|
|
d42d0b29ff | ||
|
|
9519cbe609 | ||
|
|
135112eee5 | ||
|
|
098b1e377f | ||
|
|
11f88ad3a9 | ||
|
|
e42807c054 | ||
|
|
fdc3a71236 | ||
|
|
836eea6fc6 | ||
|
|
4cde3f5346 | ||
|
|
cd09df07f6 | ||
|
|
35befbe1e3 | ||
|
|
cb89478016 | ||
|
|
485e267565 | ||
|
|
6dc34c5dbb | ||
|
|
70b049625a | ||
|
|
bd883407ec | ||
|
|
c0d67f023a | ||
|
|
c0df3a1596 | ||
|
|
16e01cb3d0 | ||
|
|
efca892109 | ||
|
|
d9e3ca2565 | ||
|
|
3751836c5c | ||
|
|
ddeb73fd1c |
@@ -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.
|
||||
@@ -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."
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,2 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
@@ -26,31 +26,139 @@ Already have a clone? Just run it directly:
|
||||
## Modules
|
||||
|
||||
- `zsh/` — installs zsh + oh-my-zsh, drops a managed `~/.zshrc`
|
||||
(robbyrussell theme, git plugin), and sets zsh as the login shell.
|
||||
(robbyrussell theme, git plugin), and sets zsh as the login shell. The
|
||||
managed rc sources `~/.zshrc.local` last for per-machine customizations
|
||||
(see below).
|
||||
- `git/` — sets git `user.name`/`user.email` and configures
|
||||
`credential.helper store` (plaintext `~/.git-credentials`, 0600) for
|
||||
HTTPS auth.
|
||||
- `bin/` — installs userland devops CLIs (jq, kubectl, helm, k9s,
|
||||
kubectx/kubens, stern, yq, OpenTofu, terragrunt, plus kind when rootless
|
||||
Docker is available) as self-contained,
|
||||
statically linked release binaries into `~/.local/bin`. No sudo, nothing
|
||||
outside `$HOME`, so they persist across Kasm redeploys with no reinstall.
|
||||
Idempotent: each tool records its version under `~/.local/bin/.versions`
|
||||
and is skipped when the pinned version is already present. `zsh/zshrc`
|
||||
puts `~/.local/bin` on `PATH`.
|
||||
- `bin/` — installs userland devops CLIs as self-contained, statically
|
||||
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
|
||||
Taskfile runner), the 1Password CLI `op` for secrets storage, the
|
||||
opencode coding agent, zarf (DevSecOps for Offline and Airgapped
|
||||
Kubernetes), packer (HashiCorp's infrastructure build tool), and the
|
||||
coder CLI for Coder workspaces.
|
||||
kind is installed only when full (non-rootless) Docker is available —
|
||||
it doesn't work against rootless Docker, so it is skipped on Kasm.
|
||||
Dotfiles does not install Docker itself; install it on the host (or bake
|
||||
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
|
||||
LiteLLM gateway (`https://llm.covert-gpt.com/v1`) for models. MCP tools do
|
||||
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
|
||||
config sync — without it, opencode installs but can't reach the
|
||||
gateway.
|
||||
- `ansible/` — installs Ansible into a virtualenv at `~/.local/opt/ansible`
|
||||
(it's Python, not a single binary) and symlinks its CLIs into
|
||||
`~/.local/bin`. Persists across redeploys; only rebuilt when the venv is
|
||||
missing or broken. Building it may install `python3`/`python3-venv` via
|
||||
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
|
||||
cgroup v2 controllers to the rootless Docker daemon so per-container cgroups
|
||||
work and `kind` can create clusters (rootless Docker otherwise comes up with
|
||||
work (rootless Docker otherwise comes up with
|
||||
`CgroupDriver=none`). Uses sudo for host cgroup writes and restarts the
|
||||
daemon; no-ops on systemd or non-rootless hosts. Re-run at each bootstrap
|
||||
(the cgroup layout resets on redeploy); its PATH shim lives in `$HOME`.
|
||||
- `mcp-config/` — shared MCP gateway config sync for laptops (LiteLLM /
|
||||
opencode). Independent of `bootstrap.sh`; see its own README.
|
||||
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
|
||||
|
||||
`~/.zshrc` is managed — bootstrap overwrites it, so never hand-edit it.
|
||||
Per-machine settings and secrets go in `~/.zshrc.local`, which the managed
|
||||
rc sources last (so it can override anything) and bootstrap never touches:
|
||||
|
||||
```sh
|
||||
cp zsh/zshrc.local.example ~/.zshrc.local && chmod 600 ~/.zshrc.local
|
||||
```
|
||||
|
||||
Variables modules look for. With `secrets/` set up, put these in your vault
|
||||
item instead of `~/.zshrc.local`:
|
||||
|
||||
- `GITEA_KEY` — personal access token for `git.nic-oconnor.com`. Used for
|
||||
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
|
||||
don't set this by hand: run `mcp-config/install.sh` once and its shell
|
||||
hook exports it from `~/.config/mcp-sync/key`. Set it in `~/.zshrc.local`
|
||||
only on a box without mcp-sync.
|
||||
- `DOTFILES_VSCODE` — controls the `vscode/` module. Unset (default) installs
|
||||
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
|
||||
config applied to every box.
|
||||
|
||||
`lib/os-detect.sh` provides shared package-manager detection (`dnf`/`yum`
|
||||
vs `apt`); `lib/fetch.sh` provides arch detection and binary-download
|
||||
|
||||
+146
-24
@@ -20,13 +20,15 @@ REPO_ROOT="$(dirname -- "$SCRIPT_DIR")"
|
||||
ARCH="$(fetch_arch)" # x86_64 / aarch64
|
||||
DEB="$(fetch_arch_deb)" # amd64 / arm64
|
||||
|
||||
# docker_rootless_available — true only if a rootless Docker daemon is up and
|
||||
# reachable. kind needs a container runtime to create clusters; on Kasm we run
|
||||
# Docker rootless (never system Docker), so kind is worth installing only when
|
||||
# that daemon answers. SecurityOptions lists name=rootless under rootless mode.
|
||||
docker_rootless_available() {
|
||||
# docker_full_available — true only if a non-rootless (full) Docker daemon is
|
||||
# up and reachable. kind needs full Docker: we could not get it working against
|
||||
# rootless Docker on Kasm, so it is only worth installing where a rootful
|
||||
# daemon answers. SecurityOptions lists name=rootless under rootless mode.
|
||||
docker_full_available() {
|
||||
command -v docker >/dev/null 2>&1 || return 1
|
||||
docker info -f '{{println .SecurityOptions}}' 2>/dev/null | grep -q 'name=rootless'
|
||||
_secopts="$(docker info -f '{{println .SecurityOptions}}' 2>/dev/null)" || return 1
|
||||
[ -n "$_secopts" ] || return 1
|
||||
! printf '%s' "$_secopts" | grep -q 'name=rootless'
|
||||
}
|
||||
|
||||
# jq — command-line JSON processor (single static binary).
|
||||
@@ -47,6 +49,18 @@ install_kubectl() {
|
||||
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).
|
||||
install_helm() {
|
||||
ver=v4.2.2
|
||||
@@ -68,9 +82,8 @@ install_k9s() {
|
||||
}
|
||||
|
||||
# kind — run local Kubernetes clusters in Docker (single static binary). Gated
|
||||
# on rootless Docker at the call site since it is useless without a runtime.
|
||||
# On systemd-less hosts the rootless daemon also needs cgroup delegation before
|
||||
# kind can create clusters; see the docker-rootless-cgroup/ module.
|
||||
# on full (non-rootless) Docker at the call site: kind could not be made to
|
||||
# work against rootless Docker on Kasm, so it is skipped there.
|
||||
install_kind() {
|
||||
ver=v0.32.0
|
||||
have_version kind "$ver" && { fsay "kind $ver already installed."; return 0; }
|
||||
@@ -126,6 +139,73 @@ install_tofu() {
|
||||
mark_version tofu "$ver"
|
||||
}
|
||||
|
||||
# op — 1Password CLI, used for secrets storage/retrieval (op read, op run,
|
||||
# op inject). Single static binary shipped as a zip. Sign in with
|
||||
# `op signin` or a service-account token (OP_SERVICE_ACCOUNT_TOKEN) —
|
||||
# credentials are per-box and never live in this repo.
|
||||
install_1p() {
|
||||
ver=2.34.1
|
||||
have_version op "$ver" && { fsay "op $ver already installed."; return 0; }
|
||||
install_zip_bin \
|
||||
"https://cache.agilebits.com/dist/1P/op2/pkg/v$ver/op_linux_${DEB}_v$ver.zip" \
|
||||
op op
|
||||
mark_version op "$ver"
|
||||
}
|
||||
|
||||
# opencode — terminal AI coding agent. Points at the covert-gpt LiteLLM
|
||||
# gateway via mcp-config/opencode.gateway.json (synced by mcp-config/).
|
||||
# Release assets use x64/arm64 tokens rather than amd64/x86_64.
|
||||
install_opencode() {
|
||||
ver=v1.17.18
|
||||
have_version opencode "$ver" && { fsay "opencode $ver already installed."; return 0; }
|
||||
case "$DEB" in
|
||||
amd64) _oc_arch=x64 ;;
|
||||
*) _oc_arch=arm64 ;;
|
||||
esac
|
||||
install_tarball_bin \
|
||||
"https://github.com/sst/opencode/releases/download/$ver/opencode-linux-$_oc_arch.tar.gz" \
|
||||
opencode opencode
|
||||
mark_version opencode "$ver"
|
||||
}
|
||||
|
||||
# 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.
|
||||
install_task() {
|
||||
ver=v3.52.0
|
||||
have_version task "$ver" && { fsay "task $ver already installed."; return 0; }
|
||||
install_tarball_bin \
|
||||
"https://github.com/go-task/task/releases/download/$ver/task_linux_$DEB.tar.gz" \
|
||||
task task
|
||||
mark_version task "$ver"
|
||||
}
|
||||
|
||||
# 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).
|
||||
install_terragrunt() {
|
||||
ver=v1.1.0
|
||||
@@ -136,20 +216,62 @@ install_terragrunt() {
|
||||
mark_version terragrunt "$ver"
|
||||
}
|
||||
|
||||
install_jq
|
||||
install_kubectl
|
||||
install_helm
|
||||
install_k9s
|
||||
if docker_rootless_available; then
|
||||
install_kind
|
||||
else
|
||||
fsay "Skipping kind: rootless Docker not available."
|
||||
fi
|
||||
install_kubectx
|
||||
install_kubens
|
||||
install_stern
|
||||
install_yq
|
||||
install_tofu
|
||||
install_terragrunt
|
||||
# coder — CLI for Coder workspaces (coder login, coder ssh, coder config-ssh).
|
||||
# Release tag is vX but asset drops the v; binary is at the archive root.
|
||||
install_coder() {
|
||||
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
|
||||
try install_kind
|
||||
else
|
||||
fsay "Skipping kind: full (non-rootless) Docker not available."
|
||||
fi
|
||||
try install_kubectx
|
||||
try install_kubens
|
||||
try install_stern
|
||||
try install_yq
|
||||
try install_tofu
|
||||
try install_task
|
||||
try install_terragrunt
|
||||
try install_1p
|
||||
try install_opencode
|
||||
try install_zarf
|
||||
try install_packer
|
||||
try install_coder
|
||||
|
||||
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
-7
@@ -4,6 +4,10 @@
|
||||
#
|
||||
# curl -fsSL https://git.nic-oconnor.com/public/dotfiles/raw/branch/main/bootstrap.sh | 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
|
||||
|
||||
REPO_URL="${DOTFILES_REPO:-https://git.nic-oconnor.com/public/dotfiles.git}"
|
||||
@@ -19,20 +23,119 @@ if [ ! -f "$SCRIPT_DIR/zsh/install.sh" ]; then
|
||||
echo "bootstrap: git is required to self-clone; install git first" >&2
|
||||
exit 1
|
||||
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
|
||||
echo "bootstrap: updating existing checkout at $DOTFILES_DIR"
|
||||
git -C "$DOTFILES_DIR" pull --ff-only
|
||||
git_auth -C "$DOTFILES_DIR" pull --ff-only
|
||||
else
|
||||
echo "bootstrap: cloning $REPO_URL into $DOTFILES_DIR"
|
||||
git clone "$REPO_URL" "$DOTFILES_DIR"
|
||||
git_auth clone "$REPO_URL" "$DOTFILES_DIR"
|
||||
fi
|
||||
exec "$DOTFILES_DIR/bootstrap.sh"
|
||||
fi
|
||||
|
||||
"$SCRIPT_DIR/zsh/install.sh"
|
||||
"$SCRIPT_DIR/git/install.sh"
|
||||
"$SCRIPT_DIR/bin/install.sh"
|
||||
"$SCRIPT_DIR/ansible/install.sh"
|
||||
# python3 is a hard dependency of this setup, not an optional extra:
|
||||
# - lib/fetch.sh needs python3 (or unzip) to extract .zip release assets;
|
||||
# without either, the 1Password CLI install fails.
|
||||
# - mcp-config/sync.sh needs it to inject the gateway's discovered models;
|
||||
# without it opencode comes up with an EMPTY model list.
|
||||
# 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
|
||||
# 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
|
||||
|
||||
@@ -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.
|
||||
Executable
+798
@@ -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()
|
||||
@@ -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
|
||||
Executable
+14
@@ -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
|
||||
@@ -1,7 +1,10 @@
|
||||
#!/usr/bin/env sh
|
||||
# docker-rootless-cgroup/install.sh — give rootless Docker cgroup v2 controller
|
||||
# delegation on a systemd-less host (e.g. Kasm), so per-container cgroups work
|
||||
# and `kind` can create clusters.
|
||||
# delegation on a systemd-less host (e.g. Kasm), so per-container cgroups work.
|
||||
# (This was originally done to support `kind`, but kind never worked reliably
|
||||
# against rootless Docker even with delegation — bin/ now only installs kind
|
||||
# where full Docker is available. The delegation itself is still useful for
|
||||
# per-container resource limits.)
|
||||
#
|
||||
# Why: rootless Docker only receives delegated cgroup v2 controllers when
|
||||
# systemd sets `Delegate=yes` on the user session. Kasm images run no systemd
|
||||
@@ -117,7 +120,7 @@ cat > "$tmp" <<'SH'
|
||||
#!/bin/sh
|
||||
# Managed by dotfiles (docker-rootless-cgroup). Shadows /usr/bin/dockerd-rootless.sh
|
||||
# on PATH. Places the rootless Docker daemon in a private, delegated cgroup v2
|
||||
# leaf so it can enable controllers (needed by `kind`), then execs the real
|
||||
# leaf so it can enable controllers (per-container cgroups), then execs the real
|
||||
# launcher. No-op if the delegated slice is absent, so it is safe anywhere.
|
||||
_cg=/sys/fs/cgroup/rootless/daemon
|
||||
if [ -w /sys/fs/cgroup/rootless ] && [ -d "$_cg" ]; then
|
||||
@@ -156,9 +159,9 @@ done
|
||||
driver="$(docker info -f '{{.CgroupDriver}}' 2>/dev/null || true)"
|
||||
if [ "$driver" = "none" ] || [ -z "$driver" ]; then
|
||||
say "docker-rootless-cgroup: WARNING — CgroupDriver is still '${driver:-unreachable}'."
|
||||
say " kind will not work yet. Inspect the new dockerd's cgroup:"
|
||||
say " Per-container cgroups will not work yet. Inspect the new dockerd's cgroup:"
|
||||
say " cat /proc/\$(pgrep -x dockerd | head -1)/cgroup # expect .../rootless/daemon"
|
||||
exit 0
|
||||
fi
|
||||
say "docker-rootless-cgroup: done. CgroupDriver=$driver — rootless Docker now has"
|
||||
say " delegated cgroups; \`kind create cluster\` can run."
|
||||
say " delegated cgroups; per-container resource limits work."
|
||||
|
||||
+34
-1
@@ -12,10 +12,43 @@ git config --global user.name "$GIT_NAME"
|
||||
git config --global user.email "$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
|
||||
# wipe everything outside $HOME). Git creates ~/.git-credentials with 0600
|
||||
# the first time a credential is saved.
|
||||
git config --global credential.helper store
|
||||
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."
|
||||
|
||||
@@ -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.
|
||||
Executable
+275
@@ -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
|
||||
+50
-2
@@ -8,8 +8,8 @@
|
||||
# reset.
|
||||
#
|
||||
# Source this (callers already run `set -eu`), then use fetch_arch /
|
||||
# fetch_arch_deb, have_version + mark_version, install_tarball_bin, and
|
||||
# install_raw_bin.
|
||||
# fetch_arch_deb, have_version + mark_version, install_tarball_bin,
|
||||
# install_tarball_tree, and install_raw_bin.
|
||||
|
||||
BIN_DIR="${BIN_DIR:-$HOME/.local/bin}"
|
||||
VERSION_DIR="$BIN_DIR/.versions"
|
||||
@@ -73,6 +73,54 @@ install_tarball_bin() {
|
||||
fsay "installed $BIN_DIR/$3"
|
||||
}
|
||||
|
||||
# install_zip_bin <url> <path-within-archive> <dest-name> — like
|
||||
# install_tarball_bin but for .zip assets (e.g. 1Password CLI). Uses unzip
|
||||
# when present, else python3's zipfile module, so no hard dependency on
|
||||
# either being installed.
|
||||
install_zip_bin() {
|
||||
mkdir -p "$BIN_DIR"
|
||||
_tmp="$(mktemp -d)"
|
||||
_fetch_dl "$1" "$_tmp/archive.zip"
|
||||
if command -v unzip >/dev/null 2>&1; then
|
||||
unzip -q "$_tmp/archive.zip" -d "$_tmp"
|
||||
elif command -v python3 >/dev/null 2>&1; then
|
||||
python3 -m zipfile -e "$_tmp/archive.zip" "$_tmp"
|
||||
else
|
||||
fsay "fetch: need unzip or python3 to extract zip"; rm -rf "$_tmp"; return 1
|
||||
fi
|
||||
install -m 0755 "$_tmp/$2" "$BIN_DIR/$3"
|
||||
rm -rf "$_tmp"
|
||||
fsay "installed $BIN_DIR/$3"
|
||||
}
|
||||
|
||||
# install_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() {
|
||||
mkdir -p "$BIN_DIR"
|
||||
|
||||
+254
-30
@@ -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
|
||||
gets through the LiteLLM gateway (`llm.covert-gpt.com`). Users install once;
|
||||
their config then refreshes automatically on each new shell.
|
||||
Single source of truth for what the agents see: the **covert-gpt model provider**
|
||||
(LiteLLM at `llm.covert-gpt.com`) and the **MCP servers**, which run
|
||||
**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
|
||||
`ai-k8s-iac` repo under `mcp-config/`; edit it there or here and keep them in
|
||||
@@ -10,44 +122,151 @@ sync — the laptops pull from Gitea's `main`.)
|
||||
|
||||
## How it works
|
||||
|
||||
- `opencode.gateway.json` — the shared server list. **Identical for every
|
||||
user**: the per-user key is injected via `{env:LITELLM_KEY}`, never written
|
||||
into the file. Add a gateway server here and everyone picks it up next shell.
|
||||
- `sync.sh` — sourced from each user's shell rc. On a new shell it pulls
|
||||
`opencode.gateway.json` (throttled ~1h), drops it where opencode merges it,
|
||||
and exports `LITELLM_KEY` from `~/.config/mcp-sync/key`. Silent + non-fatal:
|
||||
a Gitea hiccup never breaks the shell, last-good config stays in place.
|
||||
- `install.sh` — one-time bootstrap (curl-pipe). Fetches `sync.sh`, stores the
|
||||
user's key (0600), and adds the rc hook.
|
||||
- `opencode.gateway.json` — the shared base config:
|
||||
- the `covert-gpt` model provider (LiteLLM's OpenAI-compatible endpoint at
|
||||
`https://llm.covert-gpt.com/v1`, models map left empty — it is filled by
|
||||
discovery at sync time), and
|
||||
- the **MCP servers**, each `type: local`, spawned by opencode as
|
||||
`npx -y <pkg>@<pinned-version>` on the user's own machine.
|
||||
|
||||
opencode **merges** config files, so the synced `opencode.gateway.json`
|
||||
coexists with each user's own `~/.config/opencode/opencode.json` and any
|
||||
**Identical for every user**: every credential (the LiteLLM key, the Gitea
|
||||
PAT, the Proxmox token) is injected via `{env:VAR}` and never written into
|
||||
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
|
||||
~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
|
||||
can't list models from a custom provider itself, so the map is
|
||||
materialized client-side; if discovery fails the previous map is carried
|
||||
over), deploys the repo's **skills** (see below), deploys the config to
|
||||
`~/.config/opencode/opencode.gateway.json`, and exports `LITELLM_KEY`
|
||||
(from `~/.config/mcp-sync/key`) plus `OPENCODE_CONFIG` (pointing at the
|
||||
deployed file — opencode does NOT auto-load extra files in its config dir,
|
||||
so without this export the file is ignored). Silent + non-fatal: a Gitea
|
||||
hiccup never breaks the shell, last-good config stays in place.
|
||||
- `../skills/` — the shared skill folders, deployed to
|
||||
`~/.config/mcp-sync/skills` and wired in through `skills.paths` in the
|
||||
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
|
||||
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
|
||||
atomic replace can never delete a skill the user wrote by hand. See
|
||||
`skills/README.md`.
|
||||
- `install.sh` — one-time bootstrap. Installs `sync.sh` (from the checkout it
|
||||
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
|
||||
`~/.config/opencode/opencode.json` (and under any project `opencode.json`),
|
||||
so the synced config coexists with each user's own settings and any
|
||||
**local-only** MCP servers (e.g. a client-side `kubernetes` pointed at their
|
||||
own kubeconfig). Sync never touches those.
|
||||
|
||||
## 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)
|
||||
|
||||
Each user needs a LiteLLM **virtual key** scoped to the gateway servers (see
|
||||
`charts/inference/MCP-GATEWAY.md` in ai-k8s-iac for how keys are minted). Then:
|
||||
Each user needs a LiteLLM **virtual key** (for models) plus their **own**
|
||||
credentials for each MCP server. Run `bootstrap.sh` (it installs Node, which
|
||||
provides the `npx` the MCP servers need), then:
|
||||
|
||||
```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
|
||||
# (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 (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
|
||||
```
|
||||
|
||||
1. Register it on the gateway (`charts/inference/values.yaml` →
|
||||
`proxy_config.mcp_servers`), deploy, smoke-test.
|
||||
2. Add a matching entry to `opencode.gateway.json` here and push to `main`.
|
||||
3. Users get it automatically within the sync TTL (or immediately on a new
|
||||
shell). No per-user action.
|
||||
Open a new terminal, run `opencode`, and the tools are there — acting as **you**.
|
||||
|
||||
For **per-user / per-target** tools (cluster access, etc.) do NOT add them
|
||||
here — those run client-side with the user's own credentials. See
|
||||
`charts/inference/MCP-GATEWAY.md`.
|
||||
## Adding an MCP server (admin)
|
||||
|
||||
Add it to `mcp.<name>` in `opencode.gateway.json` as `type: local`, with a
|
||||
**pinned** package version and `{env:VAR}` refs for every secret, then push to
|
||||
`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)
|
||||
|
||||
Add a folder under `skills/` and push to `main` — see `skills/README.md`.
|
||||
Verify with `opencode debug skill`.
|
||||
|
||||
## "I added a model on the gateway and opencode doesn't see it"
|
||||
|
||||
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
|
||||
|
||||
@@ -55,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
|
||||
wins. Use distinct names.
|
||||
- **Key rotation**: re-run `install.sh` (or edit `~/.config/mcp-sync/key`).
|
||||
- **Other clients**: this ships an opencode config. Claude Code / open-webui
|
||||
would need their own equivalent file + a parallel sync entry; not done yet.
|
||||
- **Throttle**: override with `MCP_SYNC_TTL=0` to force a pull every shell.
|
||||
- **Other clients**: Hermes is wired (see "One definition, two clients").
|
||||
Claude Code / open-webui would each still need a parallel render step in
|
||||
`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
+119
-7
@@ -14,7 +14,14 @@
|
||||
# After this, every new shell refreshes the gateway config automatically.
|
||||
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_SH="$SYNC_HOME/sync.sh"
|
||||
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"
|
||||
|
||||
# --- 1. fetch the sync hook ------------------------------------------------
|
||||
# --- 1. install the sync hook ----------------------------------------------
|
||||
# Prefer the copy sitting next to this script: when bootstrap.sh runs the
|
||||
# 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..."
|
||||
curl -fsSL -m 15 "$RAW_BASE/sync.sh" -o "$SYNC_SH"
|
||||
# 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"
|
||||
|
||||
# --- 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 -------------------------------------------
|
||||
# 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
|
||||
# Read from the controlling TTY since stdin is the piped script.
|
||||
if [ -r /dev/tty ]; then
|
||||
@@ -53,13 +114,32 @@ chmod 0600 "$KEYFILE"
|
||||
say "Key stored at $KEYFILE (0600)."
|
||||
|
||||
# --- 3. wire the shell rc --------------------------------------------------
|
||||
# Detect the user's interactive rc; default to zsh on macOS, bash elsewhere.
|
||||
case "${SHELL:-}" in
|
||||
*zsh) RC="$HOME/.zshrc" ;;
|
||||
# For zsh the hook goes in ~/.zshrc.local, NOT ~/.zshrc. The dotfiles repo
|
||||
# MANAGES ~/.zshrc and overwrites it wholesale on every bootstrap
|
||||
# (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" ;;
|
||||
*) [ -f "$HOME/.zshrc" ] && RC="$HOME/.zshrc" || RC="$HOME/.bashrc" ;;
|
||||
*) [ -f "$HOME/.zshrc" ] && RC="$HOME/.zshrc.local" || RC="$HOME/.bashrc" ;;
|
||||
esac
|
||||
fi
|
||||
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
|
||||
say "Shell hook already present in $RC."
|
||||
@@ -76,6 +156,38 @@ fi
|
||||
# shellcheck disable=SC1090
|
||||
. "$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 "Done. Open a new terminal (or 'source $RC') and run opencode."
|
||||
say "The gateway config refreshes automatically on each new shell."
|
||||
|
||||
@@ -1,13 +1,69 @@
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"provider": {
|
||||
"covert-gpt": {
|
||||
"npm": "@ai-sdk/openai-compatible",
|
||||
"name": "covert-gpt (LiteLLM)",
|
||||
"options": {
|
||||
"baseURL": "https://llm.covert-gpt.com/v1",
|
||||
"apiKey": "{env:LITELLM_KEY}"
|
||||
},
|
||||
"models": {
|
||||
"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": {
|
||||
"gitea": {
|
||||
"type": "remote",
|
||||
"url": "https://llm.covert-gpt.com/mcp/gitea",
|
||||
"type": "local",
|
||||
"command": [
|
||||
"npx",
|
||||
"-y",
|
||||
"gitea-mcp@0.0.10"
|
||||
],
|
||||
"enabled": true,
|
||||
"headers": {
|
||||
"Authorization": "Bearer {env:LITELLM_KEY}"
|
||||
"environment": {
|
||||
"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": {
|
||||
"paths": [
|
||||
"{env:HOME}/.config/mcp-sync/skills"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+995
-46
File diff suppressed because it is too large
Load Diff
Executable
+62
@@ -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."
|
||||
Executable
+224
@@ -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
|
||||
@@ -0,0 +1,60 @@
|
||||
# skills — shared opencode skills
|
||||
|
||||
Skills every covert-gpt user gets. `mcp-config/sync.sh` deploys this directory
|
||||
to `~/.config/mcp-sync/skills` on each box; the shared `opencode.gateway.json`
|
||||
points opencode at it via `skills.paths`.
|
||||
|
||||
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
|
||||
**this repo is the source of truth**. They are copied from the local checkout
|
||||
(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
|
||||
> Anthropic's Skills API (`?beta=true`), which lists skills on an Anthropic
|
||||
> account. It does not serve skill files to a client, and opencode cannot read
|
||||
> it. `/public/skill_hub` is a marketplace catalog, also not a skill source.
|
||||
|
||||
## Layout
|
||||
|
||||
One folder per skill, each with a `SKILL.md`:
|
||||
|
||||
```
|
||||
skills/
|
||||
my-skill/
|
||||
SKILL.md # required
|
||||
reference.md # optional supporting files
|
||||
```
|
||||
|
||||
`SKILL.md` needs YAML frontmatter with `name` and `description`. `name` must be
|
||||
1–64 chars, lowercase alphanumeric with single-hyphen separators, and match the
|
||||
folder name. `description` is 1–1024 chars and is what the model reads to decide
|
||||
whether to pull the skill in — write it to say *when to use this*, not just what
|
||||
it is.
|
||||
|
||||
```markdown
|
||||
---
|
||||
name: my-skill
|
||||
description: Use when doing X. Covers Y and Z.
|
||||
---
|
||||
|
||||
Body: the instructions the model follows once the skill is loaded.
|
||||
```
|
||||
|
||||
## Don't put per-user things here
|
||||
|
||||
Same rule as the MCP gateway: this tree is **identical for every user**. Skills
|
||||
that depend on one person's credentials, kubeconfig, or local paths belong in
|
||||
that user's own `~/.config/opencode/skills/`, which sync never touches.
|
||||
|
||||
## Verifying
|
||||
|
||||
```sh
|
||||
opencode debug skill # lists every skill opencode can see, with its path
|
||||
```
|
||||
|
||||
If a skill you added here is missing, check that the sync ran (`ls
|
||||
~/.config/mcp-sync/skills`) and that the folder name matches the frontmatter
|
||||
`name`.
|
||||
Executable
+216
@@ -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."
|
||||
@@ -20,3 +20,144 @@ HISTSIZE=10000
|
||||
SAVEHIST=10000
|
||||
setopt SHARE_HISTORY
|
||||
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
|
||||
# ~/.zshrc.local, which bootstrap never touches. See zsh/zshrc.local.example
|
||||
# for the variables modules look for. Loaded last so it can override anything.
|
||||
[ -f "$HOME/.zshrc.local" ] && source "$HOME/.zshrc.local"
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
# ~/.zshrc.local — per-machine customizations. Copy this file to
|
||||
# ~/.zshrc.local and fill in what this box needs. It is sourced at the end
|
||||
# of the managed ~/.zshrc, is never overwritten by bootstrap, and never
|
||||
# lives in the repo. 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
|
||||
|
||||
# --- Gitea -------------------------------------------------------------
|
||||
# Personal access token for git.nic-oconnor.com (API/CLI use; HTTPS git
|
||||
# auth itself goes through credential.helper store, see git/).
|
||||
# export GITEA_KEY=...
|
||||
|
||||
# --- covert-gpt LiteLLM gateway -----------------------------------------
|
||||
# LITELLM_KEY is normally exported by mcp-config's sync hook from
|
||||
# ~/.config/mcp-sync/key — run mcp-config/install.sh once instead of
|
||||
# setting it here. Only set it here on a box without mcp-sync:
|
||||
# export LITELLM_KEY=sk-...
|
||||
#
|
||||
# Hermes Agent uses the same key and endpoint. The base_url is configured
|
||||
# in ~/.hermes/config.yaml to point to your covert-gpt gateway
|
||||
# (https://llm.covert-gpt.com). Run hermes setup or update config.yaml
|
||||
# to set the default model (see ~/.hermes/config.yaml for available models).
|
||||
|
||||
# mcp-config/install.sh records the checkout it ran from, and the sync reads
|
||||
# the config and skills from there — no download, no anonymous read of the repo.
|
||||
# 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.
|
||||
|
||||
# --- 1Password -----------------------------------------------------------
|
||||
# Nothing to set here. Do NOT loop over `op read` in this file: that ran on
|
||||
# every shell. secrets/install.sh stores the service-account token in
|
||||
# ~/.config/dotfiles/op.env and writes the vault's fields to secrets.env, which
|
||||
# the managed ~/.zshrc loads. Use opr / opc / opd (see zsh/zshrc).
|
||||
|
||||
# --- Proxmox -------------------------------------------------------------
|
||||
# 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 -------------------------------------------------------
|
||||
# Aliases, PATH additions, kubeconfig defaults, etc. This file loads last,
|
||||
# so it can override anything the managed zshrc sets.
|
||||
# export KUBECONFIG="$HOME/.kube/homelab.yaml"
|
||||
Reference in New Issue
Block a user