diff --git a/.agents/README.md b/.agents/README.md new file mode 100644 index 0000000..7c50ce9 --- /dev/null +++ b/.agents/README.md @@ -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. diff --git a/.agents/skills/add-bootstrap-module/SKILL.md b/.agents/skills/add-bootstrap-module/SKILL.md new file mode 100644 index 0000000..c11ab75 --- /dev/null +++ b/.agents/skills/add-bootstrap-module/SKILL.md @@ -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. diff --git a/.agents/skills/add-cli-tool/SKILL.md b/.agents/skills/add-cli-tool/SKILL.md new file mode 100644 index 0000000..7edfc3f --- /dev/null +++ b/.agents/skills/add-cli-tool/SKILL.md @@ -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_ 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/` 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_` function, pinned version, guard, install, stamp: + +```sh +# — one line on what it is. Note any asset-naming quirk. +install_() { + ver=v1.2.3 + have_version "$ver" && { fsay " $ver already installed."; return 0; } + install_tarball_bin \ + "https://github.com///releases/download/$ver/_linux_$DEB.tar.gz" \ + "" + mark_version "$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 ` — the asset *is* the binary (jq, kubectl, yq). +- `install_tarball_bin ` — `.tar.gz` (helm, k9s). +- `install_zip_bin ` — `.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." diff --git a/.agents/skills/add-mcp-server/SKILL.md b/.agents/skills/add-mcp-server/SKILL.md new file mode 100644 index 0000000..2d1d8e1 --- /dev/null +++ b/.agents/skills/add-mcp-server/SKILL.md @@ -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 @`, 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 @ # does the server even start standalone? +``` + +Fix argument-guessing in `AGENTS.md` (give the model the default owner/host), +not in `opencode.gateway.json`. diff --git a/.agents/skills/bump-pinned-version/SKILL.md b/.agents/skills/bump-pinned-version/SKILL.md new file mode 100644 index 0000000..3f395b4 --- /dev/null +++ b/.agents/skills/bump-pinned-version/SKILL.md @@ -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_()` | +| 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 ` 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 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/ # old version +./bin/install.sh # should download, not skip +cat ~/.local/bin/.versions/ # 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. diff --git a/.agents/skills/verify-module/SKILL.md b/.agents/skills/verify-module/SKILL.md new file mode 100644 index 0000000..3bfba75 --- /dev/null +++ b/.agents/skills/verify-module/SKILL.md @@ -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 +.//install.sh # does the work +.//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/` (`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 && .//install.sh' + +docker run --rm -v "$PWD:/repo" -w /repo rockylinux:9 sh -c \ + 'dnf install -y -q sudo curl git >/dev/null && .//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.