.agents/: add skills for working on this repo

Five skills covering this repo's own conventions: adding a bootstrap
module, adding a CLI to bin/, bumping a pinned version, adding an MCP
server, and verifying a module change.

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-13 13:10:18 -04:00
co-authored by Claude Opus 4.8
parent 6dc34c5dbb
commit 485e267565
6 changed files with 431 additions and 0 deletions
@@ -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 (`docker/` before `bin/`
so kind's gate sees the daemon; `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/install.sh`, which detects the situation and no-ops
rather than failing the whole bootstrap run.
## Before you're done
Run the `verify-module` skill. At minimum: `shellcheck`, run standalone, then
run a second time and confirm the output says "already present" rather than
redoing the work.
+72
View File
@@ -0,0 +1,72 @@
---
name: add-cli-tool
description: Use when adding a userland CLI binary to bin/install.sh (jq, kubectl, helm, k9s, stern, yq, tofu, task, op, opencode, …). Covers the install_<name> function pattern, arch tokens, the .versions idempotency stamp, and when a tool does NOT belong in bin/.
---
# Adding a CLI tool to `bin/`
`bin/install.sh` installs self-contained release binaries into `~/.local/bin`:
no sudo, nothing outside `$HOME`, so tools persist across Kasm redeploys.
## Does it belong here?
`bin/` is for **single, statically linked binaries**. If the tool is a tree
(interpreter + libraries), it does not fit `install_*_bin` and needs its own
module that unpacks into `~/.local/opt/<name>` and symlinks the CLIs — that is
exactly why `ansible/` and `node/` exist. Prefer static builds: after a Kasm
redeploy the root filesystem's shared libraries are gone, and a dynamically
linked binary in `$HOME` will break.
## The pattern
One `install_<name>` function, pinned version, guard, install, stamp:
```sh
# <name> — one line on what it is. Note any asset-naming quirk.
install_<name>() {
ver=v1.2.3
have_version <name> "$ver" && { fsay "<name> $ver already installed."; return 0; }
install_tarball_bin \
"https://github.com/<org>/<name>/releases/download/$ver/<name>_linux_$DEB.tar.gz" \
"<path/inside/archive>" <name>
mark_version <name> "$ver"
}
```
Then call it at the bottom of the file with the others.
**Keep `ver` byte-identical to the upstream tag.** Some tools tag `v1.2.3`,
some `1.2.3` (jq uses a bare `1.8.2`). `have_version` does a string compare
against the stamp in `~/.local/bin/.versions`, so a mismatch just means the
tool silently reinstalls on every bootstrap.
## Arch tokens
`bin/install.sh` precomputes both spellings — pick the one the upstream asset
actually uses, don't call `uname` yourself:
- `$ARCH` → `x86_64` / `aarch64` (rust-style release names)
- `$DEB` → `amd64` / `arm64` (Go/Debian-style — most devops tools)
## Picking the helper
- `install_raw_bin <url> <dest>` — the asset *is* the binary (jq, kubectl, yq).
- `install_tarball_bin <url> <path-in-archive> <dest>` — `.tar.gz` (helm, k9s).
- `install_zip_bin <url> <path-in-archive> <dest>` — `.zip` (the 1Password CLI).
Asset names are inconsistent and this is where it usually goes wrong: k9s
capitalizes `Linux`, helm nests under `linux-$DEB/`. Fetch the release page and
read the real asset name rather than guessing — a 404 from `curl -fsSL` will
abort bootstrap under `set -eu`.
## Conditional installs
A tool that only works in some environments gets a gate, like kind — installed
only when a **rootful** Docker daemon answers, because it does not work against
rootless Docker on Kasm. Reuse `docker_full_available()` rather than writing a
new probe.
## Finally
Add the tool to the `bin/` bullet in `README.md`. Then run it twice and confirm
the second run prints "already installed."
+70
View File
@@ -0,0 +1,70 @@
---
name: add-mcp-server
description: Use when adding or changing an MCP server in mcp-config/opencode.gateway.json (gitea, proxmox, …). Covers the {env:VAR} secret rule, the npx/node dependency, the shared-vs-personal boundary, and how to tell a dead server apart from a model that just can't call tools.
---
# Adding an MCP server
`mcp-config/opencode.gateway.json` is the **shared** opencode config, synced to
every covert-gpt user by `mcp-config/sync.sh`. Read `mcp-config/README.md`
before changing it.
## Secrets: `{env:VAR}`, never a literal
```jsonc
"environment": {
"GITEA_HOST": "https://git.nic-oconnor.com",
"GITEA_ACCESS_TOKEN": "{env:GITEA_KEY}" // <- resolved from the user's shell
}
```
This file is shared and committed. A literal token here leaks it to every user
and into git history. The `{env:VAR}` indirection is what lets one shared
config give each person tools that act **as them** — opencode resolves the var
from the invoking user's environment and passes it to the server it spawns.
When you add a server that needs a credential:
1. Reference it as `{env:NEW_VAR}` in the config.
2. Add `NEW_VAR` to `zsh/zshrc.local.example`.
3. Document it in the root `README.md` variable list **and** in
`mcp-config/README.md`.
Users keep the value in `~/.zshrc.local` (mode 0600), which bootstrap never
overwrites. It is never written into any config file.
## Shared vs personal
This tree is identical for every user. A server that only makes sense for one
person's cluster, kubeconfig, or local paths belongs in that user's own
`~/.config/opencode/`, which sync never touches. Same rule as `skills/`.
## It needs `npx`
Servers are spawned as `npx -y <pkg>@<version>`, so `node/install.sh` must have
run. **Pin the version** — `npx -y` otherwise runs whatever the registry serves,
and this process receives the user's tokens. See the `bump-pinned-version` skill.
## Debugging: dead server vs. bad model
The failure modes look alike from the chat window, so distinguish them first:
- **Server dead** — the tool isn't in the model's tool list at all. Usual cause:
`npx` missing from `PATH`, a bad package name, or an unset env var. opencode
reports this only as absent tools, which reads like a config problem.
- **Server fine, model weak** — the tool *is* called and the server answers,
including with errors like `user 'x' does not exist`. That round trip proves
MCP works. A model that invents required arguments (owner, repo) rather than
discovering them, or retries the same wrong call verbatim, is a tool-use
capability limit, not a config bug. Smaller / non-agentic models (e.g. VL or
low-active-param MoE variants) do this; coder-tuned models generally don't.
Check the tool list before touching the config:
```sh
opencode debug skill # skills opencode can see
npx -y <pkg>@<ver> # does the server even start standalone?
```
Fix argument-guessing in `AGENTS.md` (give the model the default owner/host),
not in `opencode.gateway.json`.
@@ -0,0 +1,64 @@
---
name: bump-pinned-version
description: Use when upgrading a pinned version anywhere in this repo — a CLI in bin/install.sh, NODE_VERSION in node/install.sh, or an npm-pinned MCP server in mcp-config/opencode.gateway.json. Covers where the pins live, how to verify the new release actually exists, and the supply-chain reasoning behind pinning.
---
# Bumping a pinned version
Everything in this repo is version-pinned on purpose. Nothing floats.
## Where the pins live
| Pin | Location | Shape |
|---|---|---|
| CLI tools | `bin/install.sh` | `ver=v1.2.3` inside each `install_<name>()` |
| Node.js | `node/install.sh` | `NODE_VERSION="v24.18.0"` |
| MCP servers | `mcp-config/opencode.gateway.json` | `["npx", "-y", "pkg@0.0.10"]` |
| oh-my-zsh, Docker | `zsh/`, `docker/` | installed from upstream scripts — not pinned |
Grep before assuming: `grep -rnE 'ver=|_VERSION=|@[0-9]+\.[0-9]+' --include='*.sh' --include='*.json'`
## Why these are pinned
`npx -y <pkg>` executes whatever the registry serves *at that moment*. The MCP
servers receive your Gitea and Proxmox tokens (`{env:GITEA_KEY}` and friends
are passed into the spawned process), so an unpinned server is a live
supply-chain path to your credentials. `node/install.sh` says this out loud in
its header. A version bump is a **supply-chain decision**, not a chore — when
bumping an MCP server or Node itself, skim the upstream changelog for a
maintainer change or a new network dependency, and say what you found.
## Doing the bump
1. **Verify the release exists before editing.** Don't trust the model's
memory of version numbers — check upstream (`gh release list`, the GitHub
releases page, `npm view <pkg> versions`, or nodejs.org/dist).
2. **Verify the asset URL still resolves.** Projects rename assets between
releases. Build the exact URL the script will request and check it:
```sh
curl -fsSLI "https://github.com/derailed/k9s/releases/download/v0.51.0/k9s_Linux_amd64.tar.gz"
```
A 404 here becomes a hard bootstrap failure under `set -eu`.
3. **Match the upstream tag exactly** — `v1.2.3` vs `1.2.3` matters, because
`have_version` string-compares against the `.versions` stamp. Get it wrong
and the tool reinstalls on every single bootstrap, silently.
4. **Bump one thing per commit.** When a box breaks after a bootstrap, the
commit log should say which upgrade did it.
## Verifying
The `.versions` stamp is what makes the bump observable:
```sh
cat ~/.local/bin/.versions/<tool> # old version
./bin/install.sh # should download, not skip
cat ~/.local/bin/.versions/<tool> # new version
./bin/install.sh # should now say "already installed"
```
If the second run re-downloads, the `ver=` string and the stamp disagree — that
is the tag-format bug from step 3.
For an MCP server bump, restart opencode and confirm the server actually comes
up. A dead MCP server is reported only as missing tools, so it looks like a
model problem rather than a version problem.
+78
View File
@@ -0,0 +1,78 @@
---
name: verify-module
description: Use before committing any change to a module's install.sh or to bootstrap.sh, to prove the change actually works. Covers the idempotency re-run check, shellcheck with the right shell dialect, and testing against both RHEL- and Debian-family containers.
---
# Verifying a module change
Nothing here is verified by a test suite — the only proof a module works is
running it. Three checks, cheapest first.
## 1. shellcheck
Every script is POSIX `sh`, so tell shellcheck that. Its default assumes bash
and will happily wave through `[[ ]]` and `local`, which break on a box where
`/bin/sh` is dash.
```sh
shellcheck -s sh bootstrap.sh */install.sh lib/*.sh
```
`lib/` is sourced, not executed, so keep the `# shellcheck source=../lib/fetch.sh`
directives above each `.` line or shellcheck can't follow them.
## 2. Idempotency — the re-run check
This is the one that catches real bugs. Run the module twice:
```sh
./<module>/install.sh # does the work
./<module>/install.sh # must SKIP, not redo it
```
The second run has to say "already installed" / "already present". If it
re-downloads, the guard is broken — most often a pinned `ver=` that doesn't
match the string stamped in `~/.local/bin/.versions/<tool>` (`v1.2.3` vs
`1.2.3`). That failure is silent: everything still works, it just refetches on
every bootstrap forever.
Guards must check the tool **runs**, not that a path exists. A Kasm redeploy
leaves broken trees behind, and an existence check will happily skip past one.
## 3. Both distro families
`lib/os-detect.sh` branches on dnf/yum vs apt. If your change touches
`pkg_install` or anything OS-conditional, exercise both:
```sh
docker run --rm -v "$PWD:/repo" -w /repo debian:13 sh -c \
'apt-get update -qq && apt-get install -y -qq sudo curl git >/dev/null && ./<module>/install.sh'
docker run --rm -v "$PWD:/repo" -w /repo rockylinux:9 sh -c \
'dnf install -y -q sudo curl git >/dev/null && ./<module>/install.sh'
```
Run each container's command twice in the same shell to get the idempotency
check on both families at once.
Note this runs as root in a container, which is *not* how a real box runs it —
it won't catch a missing-sudo problem. It's a portability check, not a
faithful rehearsal.
## Non-systemd hosts
Kasm has no systemd and no rootful Docker. Anything that would `systemctl` must
no-op rather than abort — you can't fully rehearse this in a container, so read
the code path and confirm the guard exists (`docker/install.sh` and
`docker-rootless-cgroup/install.sh` are the references).
## 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.