Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6838baa071 | ||
|
|
f031c2b77d | ||
|
|
1328dcd8f3 | ||
|
|
6cc224f151 | ||
|
|
8359d61e33 | ||
|
|
400d2b9b6b | ||
|
|
4edf42ce9f | ||
|
|
2f53346b8c | ||
|
|
dcc0677270 | ||
|
|
9fd5a054b3 | ||
|
|
da76a6b3d2 | ||
|
|
fc6d5628a7 | ||
|
|
1f7f53e6c0 | ||
|
|
bedb6d278d | ||
|
|
e9ad692418 | ||
|
|
86529d2ac5 |
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "skills",
|
"name": "skills",
|
||||||
"description": "Mathias's engineering skills library, plus the hooks and MCP servers that make it enforceable: TDD, clean code, debugging, specs, discovery, CI, and secret hygiene.",
|
"description": "Mathias's engineering skills library, plus the hooks and MCP servers that make it enforceable: TDD, clean code, debugging, specs, discovery, CI, and secret hygiene.",
|
||||||
"version": "0.17.1",
|
"version": "0.19.5",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "Mathias",
|
"name": "Mathias",
|
||||||
"url": "https://git.d-ma.be/mathias"
|
"url": "https://git.d-ma.be/mathias"
|
||||||
|
|||||||
@@ -133,6 +133,33 @@ jobs:
|
|||||||
git add "$manifest"
|
git add "$manifest"
|
||||||
# The trailer below stops this push from re-triggering the tagger.
|
# The trailer below stops this push from re-triggering the tagger.
|
||||||
git commit -m "chore(release): plugin.json version $version" -m "Skip-Release: true"
|
git commit -m "chore(release): plugin.json version $version" -m "Skip-Release: true"
|
||||||
|
|
||||||
|
# skills#46. This commit is pushed with secrets.GITEA_TOKEN, and a
|
||||||
|
# push authenticated with the Actions token does NOT trigger
|
||||||
|
# workflows -- deliberate platform behaviour to stop CI recursing.
|
||||||
|
# `Skip-Release: true` stops the tagger recursing; nothing stopped
|
||||||
|
# the whole run, `gate` included, from never firing on this commit.
|
||||||
|
#
|
||||||
|
# So every release left main's HEAD with zero CI runs. Measured:
|
||||||
|
# 6cc224f (0.19.4) and f031c2b (0.19.5) each have none, and the
|
||||||
|
# koala cluster's CIHeadUncovered{repo=skills} had been firing on it
|
||||||
|
# for 85 hours -- an alert that can only be cleared by an unrelated
|
||||||
|
# human push.
|
||||||
|
#
|
||||||
|
# The commit is not cosmetic: plugin.json's version is what Claude
|
||||||
|
# Code gates plugin updates on, `task check` asserts it is not
|
||||||
|
# behind the latest tag, and the sed-and-retry logic above already
|
||||||
|
# shipped a mismatched manifest once (v0.10.0 reading 0.9.0).
|
||||||
|
#
|
||||||
|
# Gating it HERE rather than re-triggering CI afterwards: the commit
|
||||||
|
# is produced here, so it can be validated here, at the cost of one
|
||||||
|
# `task check` instead of a second full run -- and with no recursion
|
||||||
|
# risk to reintroduce.
|
||||||
|
if ! task check; then
|
||||||
|
echo "::error::the release-bump commit fails task check -- not pushing it" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
if git push origin HEAD:main; then
|
if git push origin HEAD:main; then
|
||||||
echo "bumped $manifest to $version"
|
echo "bumped $manifest to $version"
|
||||||
exit 0
|
exit 0
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"_comment": "Tailscale Serve on koala, not the public d-ma.be hostnames. The public path exits to Cloudflare and NPM to reach a k3s service on the same machine most sessions run on, which imposes rate limits and message-size caps on tool results. Serve terminates TLS with a real Tailscale cert and proxies to the NodePort over loopback, so traffic reaches the service from koala itself and satisfies the default-deny-ingress NetworkPolicy with no cluster change. Clients need 'tailscale set --accept-dns=true' for the MagicDNS name to resolve; see mathias/skills#24.",
|
"_comment": "Tailscale Serve on koala, not the public d-ma.be hostnames. The public path exits to Cloudflare and NPM to reach a k3s service on the same machine most sessions run on, which imposes rate limits and message-size caps on tool results. Serve terminates TLS with a real Tailscale cert and proxies to the NodePort over loopback, so traffic reaches the service from koala itself and satisfies the default-deny-ingress NetworkPolicy with no cluster change. Clients need 'tailscale set --accept-dns=true' for the MagicDNS name to resolve; see mathias/skills#24. Every ${VAR} below must be one ~/.op-env defines -- bin/check-mcp-env enforces that, because an undefined one sends an empty Authorization header and reports as 'requires authentication', which is indistinguishable from a revoked credential.",
|
||||||
"mcpServers": {
|
"mcpServers": {
|
||||||
"brain": {
|
"brain": {
|
||||||
"type": "http",
|
"type": "http",
|
||||||
@@ -11,23 +11,24 @@
|
|||||||
"infra": {
|
"infra": {
|
||||||
"type": "http",
|
"type": "http",
|
||||||
"url": "https://koala.tail30472e.ts.net:8444/mcp"
|
"url": "https://koala.tail30472e.ts.net:8444/mcp"
|
||||||
|
},
|
||||||
|
"gitea": {
|
||||||
|
"_comment": "gitea-mcp authenticates by PASS-THROUGH: the bearer is a Gitea PAT, which the server validates against Gitea and then acts as. So no dedicated MCP token is needed, and the AgentSecrets blocker that parked this entry (skills#32, #33) never applied to it -- DMABE_GITEA_API_TOKEN is op://HomeLab and already in the session environment. Verified 2026-09-07: initialize 200 (serverInfo gitea-mcp) and tools/list returns 41 tools. Two DIFFERENT PATs both authenticate while GITEA_MCP_TOKEN 401s, which is what proves this is pass-through validation and not a static-token comparison.",
|
||||||
|
"type": "http",
|
||||||
|
"url": "https://koala.tail30472e.ts.net:8446/mcp",
|
||||||
|
"headers": {
|
||||||
|
"Authorization": "Bearer ${DMABE_GITEA_API_TOKEN}"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"_parked": {
|
"_parked": {
|
||||||
"_reason": "cad and gitea-mcp are proxied, reachable and AUTHENTICATING: tailscale serve 8445 -> 127.0.0.1:31877 and 8446 -> 127.0.0.1:30340; an MCP initialize with the correct bearer returns 200 from both (serverInfo gitea-mcp 0.1.0, cad aed2aaf). They stay parked only because nothing puts those two tokens into the session environment. Both live in the 1Password AgentSecrets vault, and by deliberate design (infra#224, closed) no single identity on koala reads both vaults: koala-homelab is HomeLab-only and resolves ~/.op-env, koala-agentsecrets-write is AgentSecrets-only and lives in ~/.op-agent-write.env. Adding an AgentSecrets reference to ~/.op-env does NOT degrade gracefully -- `op run` fails wholesale and every other token, BRAIN_MCP_TOKEN included, goes unresolved with it. Enabling needs a read-scoped AgentSecrets identity in the session-env path; see mathias/skills#32, #33.",
|
"_reason": "cad is proxied, reachable and AUTHENTICATING: tailscale serve 8445 -> 127.0.0.1:31877, and an MCP initialize with the correct bearer returns 200 (serverInfo cad aed2aaf). It stays parked only because nothing puts CAD_MCP_TOKEN into the session environment. It lives in the 1Password AgentSecrets vault, and by deliberate design (infra#224, closed) no single identity on koala reads both vaults: koala-homelab is HomeLab-only and resolves ~/.op-env, koala-agentsecrets-write is AgentSecrets-only and lives in ~/.op-agent-write.env. Adding an AgentSecrets reference to ~/.op-env does NOT degrade gracefully -- `op run` fails wholesale and every other token, BRAIN_MCP_TOKEN included, goes unresolved with it. Enabling needs a read-scoped AgentSecrets identity in the session-env path; see mathias/skills#32, #33. gitea was parked here for the same stated reason and has been unparked: it needs no AgentSecrets token at all, because gitea-mcp is pass-through-authenticated.",
|
||||||
"cad": {
|
"cad": {
|
||||||
"type": "http",
|
"type": "http",
|
||||||
"url": "https://koala.tail30472e.ts.net:8445/mcp",
|
"url": "https://koala.tail30472e.ts.net:8445/mcp",
|
||||||
"headers": {
|
"headers": {
|
||||||
"Authorization": "Bearer ${CAD_MCP_TOKEN}"
|
"Authorization": "Bearer ${CAD_MCP_TOKEN}"
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"gitea": {
|
|
||||||
"type": "http",
|
|
||||||
"url": "https://koala.tail30472e.ts.net:8446/mcp",
|
|
||||||
"headers": {
|
|
||||||
"Authorization": "Bearer ${GITEA_MCP_STATIC_TOKEN}"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -100,9 +100,11 @@ default to point into `~/dev`.
|
|||||||
| `AGENTS_SKILLS_DIR` | `$HOME/.agents/skills` |
|
| `AGENTS_SKILLS_DIR` | `$HOME/.agents/skills` |
|
||||||
| `CRUSH_SKILLS_DIR` | `$HOME/.config/crush/skills` |
|
| `CRUSH_SKILLS_DIR` | `$HOME/.config/crush/skills` |
|
||||||
| `OPENCODE_SKILLS_DIR` | `$HOME/.config/opencode/skills` |
|
| `OPENCODE_SKILLS_DIR` | `$HOME/.config/opencode/skills` |
|
||||||
|
| `ANTIGRAVITY_SKILLS_JSON` | `$HOME/.gemini/config/skills.json` (registers `AGENTS_SKILLS_DIR` as a path entry; needs `jq`) |
|
||||||
| `SKILLS_EXTRA_SOURCES` | unset (colon-separated extra source directories) |
|
| `SKILLS_EXTRA_SOURCES` | unset (colon-separated extra source directories) |
|
||||||
|
|
||||||
Antigravity and Mistral Vibe targets were removed — unused.
|
The Mistral Vibe target was removed — unused. Antigravity's was removed for
|
||||||
|
the same reason and re-added once it came back into use.
|
||||||
|
|
||||||
## Checking a host
|
## Checking a host
|
||||||
|
|
||||||
|
|||||||
@@ -138,6 +138,26 @@ tasks:
|
|||||||
echo "ERROR: missing .claude-plugin/plugin.json" >&2; fail=1
|
echo "ERROR: missing .claude-plugin/plugin.json" >&2; fail=1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Every ${VAR} a live .mcp.json server interpolates must be one the
|
||||||
|
# session env-file defines. An undefined one is silent: the header
|
||||||
|
# goes out empty and the server reports "requires authentication",
|
||||||
|
# which reads as a revoked credential. Exit 2 means the check could
|
||||||
|
# not run (no jq, or no env-file — CI has neither); that is reported
|
||||||
|
# and not fatal, but it is never treated as a pass.
|
||||||
|
if [ -x "{{.TASKFILE_DIR}}/bin/check-mcp-env" ]; then
|
||||||
|
# `|| rc=$?` is load-bearing, not style. Task runs this block under
|
||||||
|
# `sh -e`, so a bare invocation exiting non-zero kills the shell
|
||||||
|
# before `case` is reached -- the tolerance below never ran, and the
|
||||||
|
# gate died with `exit status 2` on the commit that added it.
|
||||||
|
rc=0
|
||||||
|
"{{.TASKFILE_DIR}}/bin/check-mcp-env" "{{.TASKFILE_DIR}}/.mcp.json" "$HOME/.op-env" || rc=$?
|
||||||
|
case $rc in
|
||||||
|
0) ;;
|
||||||
|
2) echo "NOTE: MCP env wiring unverified on this host" >&2 ;;
|
||||||
|
*) fail=1 ;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
if [ $fail -ne 0 ]; then
|
if [ $fail -ne 0 ]; then
|
||||||
echo "check FAILED" >&2; exit 1
|
echo "check FAILED" >&2; exit 1
|
||||||
fi
|
fi
|
||||||
|
|||||||
Executable
+99
@@ -0,0 +1,99 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# check-mcp-env — assert every variable a live MCP server interpolates is one
|
||||||
|
# the session env-file actually defines.
|
||||||
|
#
|
||||||
|
# Why this is a gate and not a comment: the failure is silent. A header of
|
||||||
|
# "Bearer ${FOO}" where FOO is undefined does not error -- it goes out empty,
|
||||||
|
# the server answers 401, and Claude Code reports "requires authentication",
|
||||||
|
# which reads exactly like a revoked credential. On 2026-09-07 that cost a
|
||||||
|
# full investigation on koala before the credential turned out to be fine.
|
||||||
|
#
|
||||||
|
# Servers under `_parked` are exempt by design. Parked means "reachable, but
|
||||||
|
# nothing puts its token in the environment yet" -- flagging those would
|
||||||
|
# report the intended state as a fault.
|
||||||
|
#
|
||||||
|
# Usage: check-mcp-env <mcp.json> <env-file>
|
||||||
|
#
|
||||||
|
# Exit 0 every live server's variables are defined
|
||||||
|
# 1 at least one is not, named on stderr
|
||||||
|
# 2 could not verify (bad usage, unreadable input, jq absent).
|
||||||
|
# Deliberately distinct from 0: an unverified config is not a clean
|
||||||
|
# one, and a gate that cannot run should say so rather than pass.
|
||||||
|
|
||||||
|
set -u
|
||||||
|
|
||||||
|
CONFIG=${1:-}
|
||||||
|
ENVFILE=${2:-}
|
||||||
|
|
||||||
|
if [ -z "$CONFIG" ] || [ -z "$ENVFILE" ]; then
|
||||||
|
echo "usage: check-mcp-env <mcp.json> <env-file>" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! command -v jq >/dev/null 2>&1; then
|
||||||
|
echo "SKIP: jq not installed -- MCP env wiring unverified" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -f "$CONFIG" ]; then
|
||||||
|
echo "ERROR: no such config: $CONFIG" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -f "$ENVFILE" ]; then
|
||||||
|
# EXPECTED IN CI, and permanently so. The env-file holds live credentials, so
|
||||||
|
# it is not on the runner and must not be -- which means this check can only
|
||||||
|
# ever verify on a host that has one. infra#368 added jq to the runner image
|
||||||
|
# believing that would let CI verify; it removed the jq branch below and
|
||||||
|
# changed nothing else, because this branch is the binding one.
|
||||||
|
#
|
||||||
|
# Said plainly so nobody concludes CI covers this. It does not, it cannot,
|
||||||
|
# and putting ~/.op-env on the runner to make it would be trading a real
|
||||||
|
# secret for a green tick.
|
||||||
|
echo "ERROR: no such env-file: $ENVFILE -- cannot verify MCP variables" >&2
|
||||||
|
echo " (expected in CI: this check only verifies on a host with ~/.op-env)" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Every "<server>\t<VAR>" pair a live server interpolates. `..` walks the whole
|
||||||
|
# value, so a variable is found wherever it is placed, not only under headers.
|
||||||
|
refs=$(jq -r '
|
||||||
|
.mcpServers // {}
|
||||||
|
| to_entries[]
|
||||||
|
| .key as $server
|
||||||
|
| (.value | .. | strings)
|
||||||
|
| select(test("\\$\\{"))
|
||||||
|
| [ scan("\\$\\{([A-Za-z_][A-Za-z0-9_]*)\\}")[] ]
|
||||||
|
| .[]
|
||||||
|
| "\($server)\t\(.)"
|
||||||
|
' "$CONFIG" 2>/dev/null) || {
|
||||||
|
echo "ERROR: $CONFIG is not valid JSON -- MCP env wiring unverified" >&2
|
||||||
|
exit 2
|
||||||
|
}
|
||||||
|
|
||||||
|
if [ -z "$refs" ]; then
|
||||||
|
echo "check-mcp-env: no interpolated variables in live servers"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
fail=0
|
||||||
|
checked=0
|
||||||
|
for pair in $(printf '%s\n' "$refs" | tr '\t' ':'); do
|
||||||
|
server=${pair%%:*}
|
||||||
|
var=${pair#*:}
|
||||||
|
checked=$((checked + 1))
|
||||||
|
if grep -qE "^[[:space:]]*(export[[:space:]]+)?$var=" "$ENVFILE"; then
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
printf 'ERROR: MCP server %s interpolates %s, which %s does not define.\n' \
|
||||||
|
"$server" "$var" "$ENVFILE" >&2
|
||||||
|
printf ' It will send an empty Authorization header and report as "requires authentication".\n' >&2
|
||||||
|
fail=1
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ "$fail" -ne 0 ]; then
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf 'check-mcp-env: %d interpolated variable(s) across live MCP servers, all defined in %s\n' \
|
||||||
|
"$checked" "$ENVFILE"
|
||||||
+38
@@ -327,6 +327,44 @@ if [ "$ONLINE" -eq 1 ]; then
|
|||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# --- toolchain: golangci-lint must be built with Go >= GOROOT ---------------
|
||||||
|
# infra#375, second recurrence. golangci-lint embeds the go/types export-data
|
||||||
|
# reader from the Go it was BUILT with. When GOROOT is newer, that reader
|
||||||
|
# cannot decode the stdlib and the linter dies:
|
||||||
|
#
|
||||||
|
# panic: file requires newer Go version go1.27 (application built with go1.26)
|
||||||
|
#
|
||||||
|
# An earlier form printed a typecheck error naming the user's own file, which
|
||||||
|
# sends people to debug code that is fine. It cost time in two repos before
|
||||||
|
# anyone recognised it as a host fault rather than a repo fault.
|
||||||
|
#
|
||||||
|
# It is invisible to CI -- the runner image has matching versions -- so it
|
||||||
|
# breaks exactly the local `task check` that AGENT.md requires before a push,
|
||||||
|
# and nothing else. That is why it belongs in doctor: the signal was missing,
|
||||||
|
# not the fix.
|
||||||
|
#
|
||||||
|
# Fails open on anything unparseable. A host with no Go toolchain is a normal
|
||||||
|
# host, not a broken one, and doctor runs in a SessionStart hook everywhere.
|
||||||
|
ver_lt() {
|
||||||
|
# 0 when $1 < $2 on major.minor. Deliberately not sort -V: this is POSIX sh
|
||||||
|
# that also runs on macOS, and the export-data format is minor-versioned, so
|
||||||
|
# patch level does not enter into it.
|
||||||
|
_a=${1%%.*}; _ar=${1#*.}; _am=${_ar%%.*}
|
||||||
|
_b=${2%%.*}; _br=${2#*.}; _bm=${_br%%.*}
|
||||||
|
case "$_a$_am$_b$_bm" in *[!0-9]*) return 1 ;; esac
|
||||||
|
[ "$_a" -lt "$_b" ] && return 0
|
||||||
|
[ "$_a" -gt "$_b" ] && return 1
|
||||||
|
[ "$_am" -lt "$_bm" ]
|
||||||
|
}
|
||||||
|
|
||||||
|
if command -v golangci-lint >/dev/null 2>&1 && command -v go >/dev/null 2>&1; then
|
||||||
|
lint_go=$(golangci-lint --version 2>/dev/null | sed -n 's/.*built with go\([0-9][0-9.]*\).*/\1/p' | head -1)
|
||||||
|
root_go=$(go version 2>/dev/null | sed -n 's/.*go version go\([0-9][0-9.]*\).*/\1/p' | head -1)
|
||||||
|
if [ -n "$lint_go" ] && [ -n "$root_go" ] && ver_lt "$lint_go" "$root_go"; then
|
||||||
|
add FAIL toolchain "golangci-lint built with go$lint_go but GOROOT is go$root_go — lint panics on every Go repo; reinstall it under the current toolchain (infra#375)"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
# --- output -----------------------------------------------------------------
|
# --- output -----------------------------------------------------------------
|
||||||
if [ "$JSON" -eq 1 ]; then
|
if [ "$JSON" -eq 1 ]; then
|
||||||
printf '{"checkout":"%s","expected":%s,"plugin":{"installed":"%s","available":"%s"},"findings":[%s]}\n' \
|
printf '{"checkout":"%s","expected":%s,"plugin":{"installed":"%s","available":"%s"},"findings":[%s]}\n' \
|
||||||
|
|||||||
+51
-2
@@ -17,8 +17,8 @@
|
|||||||
# /plugin install skills@mathias
|
# /plugin install skills@mathias
|
||||||
#
|
#
|
||||||
# That path carries the hooks and MCP server definitions too, which symlinks
|
# That path carries the hooks and MCP server definitions too, which symlinks
|
||||||
# cannot. This script covers the harnesses with no plugin system: Crush, Pi
|
# cannot. This script covers the harnesses with no plugin system: Crush, Pi,
|
||||||
# and OpenCode.
|
# OpenCode, and Antigravity.
|
||||||
#
|
#
|
||||||
# Wiring model — one managed directory, one link per harness:
|
# Wiring model — one managed directory, one link per harness:
|
||||||
#
|
#
|
||||||
@@ -27,6 +27,13 @@
|
|||||||
# ~/.config/crush/skills -> ~/.agents/skills
|
# ~/.config/crush/skills -> ~/.agents/skills
|
||||||
# ~/.config/opencode/skills -> ~/.agents/skills
|
# ~/.config/opencode/skills -> ~/.agents/skills
|
||||||
#
|
#
|
||||||
|
# Antigravity has no symlink-directory discovery — it reads a path-entries
|
||||||
|
# JSON instead (~/.gemini/config/skills.json:
|
||||||
|
# {"entries":[{"path":"..."}]}). One entry points at ~/.agents/skills, so it
|
||||||
|
# sees exactly the same surfaces-filtered, collision-resolved set every other
|
||||||
|
# harness does. Requires jq; skipped with a warning if jq is unavailable —
|
||||||
|
# every other target in this script stays dependency-free on purpose.
|
||||||
|
#
|
||||||
# ~/.agents/skills was itself a link to this checkout until #27. That encoded
|
# ~/.agents/skills was itself a link to this checkout until #27. That encoded
|
||||||
# an assumption nobody had stated — that this checkout is the ONLY source of
|
# an assumption nobody had stated — that this checkout is the ONLY source of
|
||||||
# skills on the host — and on flamingo the only way to install this library
|
# skills on the host — and on flamingo the only way to install this library
|
||||||
@@ -53,7 +60,12 @@ CHECKOUT_DIR="${SKILLS_CHECKOUT_DIR:-$HOME/.local/share/skills}"
|
|||||||
AGENTS_DIR="${AGENTS_SKILLS_DIR:-$HOME/.agents/skills}"
|
AGENTS_DIR="${AGENTS_SKILLS_DIR:-$HOME/.agents/skills}"
|
||||||
CRUSH_DIR="${CRUSH_SKILLS_DIR:-$HOME/.config/crush/skills}"
|
CRUSH_DIR="${CRUSH_SKILLS_DIR:-$HOME/.config/crush/skills}"
|
||||||
OPENCODE_DIR="${OPENCODE_SKILLS_DIR:-$HOME/.config/opencode/skills}"
|
OPENCODE_DIR="${OPENCODE_SKILLS_DIR:-$HOME/.config/opencode/skills}"
|
||||||
|
ANTIGRAVITY_SKILLS_JSON="${ANTIGRAVITY_SKILLS_JSON:-$HOME/.gemini/config/skills.json}"
|
||||||
EXTRA_SOURCES="${SKILLS_EXTRA_SOURCES:-}"
|
EXTRA_SOURCES="${SKILLS_EXTRA_SOURCES:-}"
|
||||||
|
# Overridable so the jq-absent branch is testable on a host that has jq. That
|
||||||
|
# branch is a contract, not a fallback: jq is optional here while every other
|
||||||
|
# target in this script is dependency-free.
|
||||||
|
JQ="${SKILLS_JQ:-jq}"
|
||||||
# Recorded beside the managed directory rather than inside it: a stray file in
|
# Recorded beside the managed directory rather than inside it: a stray file in
|
||||||
# there would look like a skill to every harness reading the dir.
|
# there would look like a skill to every harness reading the dir.
|
||||||
SOURCES_MANIFEST="$(dirname "$AGENTS_DIR")/$(basename "$AGENTS_DIR")-sources"
|
SOURCES_MANIFEST="$(dirname "$AGENTS_DIR")/$(basename "$AGENTS_DIR")-sources"
|
||||||
@@ -213,6 +225,42 @@ link_dir() {
|
|||||||
log "linked $link → $target"
|
log "linked $link → $target"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# wire_antigravity — register $AGENTS_DIR in Antigravity's skills.json.
|
||||||
|
# Merges rather than overwrites: skills.json is a documented user-editable
|
||||||
|
# config (json_configs.md), so a hand-added entry (e.g. a personal skills
|
||||||
|
# dir) must survive a re-run untouched.
|
||||||
|
wire_antigravity() {
|
||||||
|
if ! command -v "$JQ" >/dev/null 2>&1; then
|
||||||
|
log "Antigravity: jq not found — skipping (needed to merge $ANTIGRAVITY_SKILLS_JSON safely)"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
local existing already updated
|
||||||
|
mkdir -p "$(dirname "$ANTIGRAVITY_SKILLS_JSON")"
|
||||||
|
|
||||||
|
if [ -s "$ANTIGRAVITY_SKILLS_JSON" ]; then
|
||||||
|
existing="$(cat "$ANTIGRAVITY_SKILLS_JSON")"
|
||||||
|
else
|
||||||
|
existing='{}'
|
||||||
|
fi
|
||||||
|
|
||||||
|
already="$(printf '%s' "$existing" | "$JQ" --arg p "$AGENTS_DIR" \
|
||||||
|
'[.entries // [] | .[] | select(.path == $p)] | length' 2>/dev/null)" || {
|
||||||
|
log "Antigravity: $ANTIGRAVITY_SKILLS_JSON is not valid JSON — leaving it untouched"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if [ "$already" -gt 0 ]; then
|
||||||
|
log "Antigravity: $AGENTS_DIR already registered in $ANTIGRAVITY_SKILLS_JSON"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
updated="$(printf '%s' "$existing" | "$JQ" --arg p "$AGENTS_DIR" \
|
||||||
|
'.entries = ((.entries // []) + [{path: $p}])')"
|
||||||
|
printf '%s\n' "$updated" > "$ANTIGRAVITY_SKILLS_JSON"
|
||||||
|
log "Antigravity: registered $AGENTS_DIR in $ANTIGRAVITY_SKILLS_JSON"
|
||||||
|
}
|
||||||
|
|
||||||
main() {
|
main() {
|
||||||
ensure_checkout
|
ensure_checkout
|
||||||
|
|
||||||
@@ -224,6 +272,7 @@ main() {
|
|||||||
|
|
||||||
link_dir "$CRUSH_DIR" "$AGENTS_DIR"
|
link_dir "$CRUSH_DIR" "$AGENTS_DIR"
|
||||||
link_dir "$OPENCODE_DIR" "$AGENTS_DIR"
|
link_dir "$OPENCODE_DIR" "$AGENTS_DIR"
|
||||||
|
wire_antigravity
|
||||||
|
|
||||||
log "done — $LINKED_COUNT skill(s) linked from $(sources | wc -l | tr -d ' ') source(s), ref=$REF"
|
log "done — $LINKED_COUNT skill(s) linked from $(sources | wc -l | tr -d ' ') source(s), ref=$REF"
|
||||||
log "Claude Code: install the plugin instead — /plugin marketplace add $REPO_URL"
|
log "Claude Code: install the plugin instead — /plugin marketplace add $REPO_URL"
|
||||||
|
|||||||
+21
-2
@@ -58,8 +58,8 @@ adr: 0004
|
|||||||
title: <the decision, as a sentence — not "database choice">
|
title: <the decision, as a sentence — not "database choice">
|
||||||
status: proposed | accepted | superseded | amended
|
status: proposed | accepted | superseded | amended
|
||||||
date: 2026-08-29
|
date: 2026-08-29
|
||||||
deciders: <who accepted it, by name — or "pending: <name>" while proposed>
|
deciders: <who accepted it, by name — or "pending: <name>" (QUOTED) while proposed>
|
||||||
supersedes: <adr number, or ->
|
supersedes: <adr number, or null>
|
||||||
---
|
---
|
||||||
|
|
||||||
# ADR-0004 — <same title>
|
# ADR-0004 — <same title>
|
||||||
@@ -97,6 +97,25 @@ the condition that fires a revisit. An ADR with no validation section is a prefe
|
|||||||
with a template around it.
|
with a template around it.
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### The frontmatter must actually parse
|
||||||
|
|
||||||
|
It exists to be machine-read, so a block that does not load is worse than no block at
|
||||||
|
all: it drops that ADR silently out of every "what is still open?" query, while looking
|
||||||
|
correct. Two shapes this template used to suggest are invalid YAML, and both were found
|
||||||
|
live in the estate on 2026-09-05 — `openbanking-api` ADR-0001 and `infra` ADR-0019, each
|
||||||
|
copied verbatim from here:
|
||||||
|
|
||||||
|
| Wrong | Why | Right |
|
||||||
|
|---|---|---|
|
||||||
|
| `supersedes: -` | a bare dash is a YAML *sequence entry*, not a string | `supersedes: null` |
|
||||||
|
| `deciders: pending: Mathias` | the second, unquoted colon reads as a nested mapping | `deciders: "pending: Mathias"` |
|
||||||
|
|
||||||
|
Check before committing:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python3 -c "import yaml,sys;yaml.safe_load(open(sys.argv[1]).read().split('---')[1]);print('ok')" docs/adr/0004-*.md
|
||||||
|
```
|
||||||
|
|
||||||
## Preserving a productive tension
|
## Preserving a productive tension
|
||||||
|
|
||||||
Some tradeoffs are not unresolved because nobody thought hard enough. They are
|
Some tradeoffs are not unresolved because nobody thought hard enough. They are
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# `task check` must survive a check that reports "could not verify".
|
||||||
|
#
|
||||||
|
# The regression these exist for: bin/check-mcp-env exits 2 to mean "I could
|
||||||
|
# not run — no jq, or no env-file", which the gate deliberately treats as a
|
||||||
|
# reported NOTE rather than a failure. The Taskfile said exactly that:
|
||||||
|
#
|
||||||
|
# "$DIR/check-mcp-env" "$DIR/.mcp.json" "$HOME/.op-env"
|
||||||
|
# case $? in 0) ;; 2) echo "NOTE: ..." ;; *) fail=1 ;; esac
|
||||||
|
#
|
||||||
|
# and it did not work. Task runs each cmds block under `sh -e`, so a bare
|
||||||
|
# command exiting non-zero aborts the shell *before* `case $?` is ever
|
||||||
|
# reached. The whole gate died with `exit status 2`. Locally the check found
|
||||||
|
# jq and exited 0, so it went green; CI has neither jq nor ~/.op-env, so main
|
||||||
|
# went red on the very commit that added it (skills run #57, 2026-09-07).
|
||||||
|
#
|
||||||
|
# A tolerated exit code that is only tolerated on the host where it never
|
||||||
|
# fires is not tolerated at all. This pins the tolerance itself.
|
||||||
|
#
|
||||||
|
# No mocking: pointing HOME at an empty directory removes ~/.op-env, which is
|
||||||
|
# exactly the condition CI runs under, so check-mcp-env genuinely exits 2.
|
||||||
|
|
||||||
|
set -u
|
||||||
|
|
||||||
|
ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
|
||||||
|
|
||||||
|
pass=0
|
||||||
|
fail=0
|
||||||
|
|
||||||
|
ok() { pass=$((pass + 1)); printf ' ok %s\n' "$1"; }
|
||||||
|
no() { fail=$((fail + 1)); printf ' FAIL %s\n' "$1"; }
|
||||||
|
|
||||||
|
if ! command -v task >/dev/null 2>&1; then
|
||||||
|
printf ' SKIP: task not installed -- cannot exercise the gate\n'
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
TMP=$(mktemp -d)
|
||||||
|
trap 'rm -rf "$TMP"' EXIT
|
||||||
|
|
||||||
|
# Sanity: the precondition must actually hold, or the test proves nothing.
|
||||||
|
# An empty HOME means no ~/.op-env, so check-mcp-env cannot verify anything.
|
||||||
|
"$ROOT/bin/check-mcp-env" "$ROOT/.mcp.json" "$TMP/.op-env" >/dev/null 2>&1
|
||||||
|
rc=$?
|
||||||
|
if [ "$rc" -eq 2 ]; then
|
||||||
|
ok 'precondition: check-mcp-env exits 2 when the env-file is absent'
|
||||||
|
else
|
||||||
|
no "precondition: expected check-mcp-env to exit 2, got $rc"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# The assertion. `task check` must still pass, and must say why it could not
|
||||||
|
# verify -- silence would mean the check was skipped rather than tolerated.
|
||||||
|
out=$(HOME="$TMP" task --dir "$ROOT" check 2>&1)
|
||||||
|
rc=$?
|
||||||
|
|
||||||
|
if [ "$rc" -eq 0 ]; then
|
||||||
|
ok 'task check passes when MCP env wiring cannot be verified'
|
||||||
|
else
|
||||||
|
no "task check exited $rc when check-mcp-env reported unverifiable"
|
||||||
|
printf '%s\n' "$out" | sed 's/^/ | /' | tail -8
|
||||||
|
fi
|
||||||
|
|
||||||
|
if printf '%s' "$out" | grep -q 'unverified'; then
|
||||||
|
ok 'the unverifiable check is reported, not silently skipped'
|
||||||
|
else
|
||||||
|
no 'no NOTE reported -- an unverifiable check must never look like a pass'
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf ' %d passed, %d failed\n' "$pass" "$fail"
|
||||||
|
[ "$fail" -eq 0 ]
|
||||||
@@ -53,5 +53,31 @@ printf '%s' "$tag_job" | grep -qE '^ +needs: *gate' \
|
|||||||
&& ok 'ungated release.yml removed' \
|
&& ok 'ungated release.yml removed' \
|
||||||
|| no 'ungated release.yml removed'
|
|| no 'ungated release.yml removed'
|
||||||
|
|
||||||
|
# skills#46. The manifest-bump commit is created and pushed by the `tag` job
|
||||||
|
# using secrets.GITEA_TOKEN, and a push authenticated with the Actions token
|
||||||
|
# does not trigger workflows -- deliberate platform behaviour to stop CI
|
||||||
|
# recursing. So the ONE commit in this repo that no CI run has ever seen is the
|
||||||
|
# one the release job writes, every release:
|
||||||
|
#
|
||||||
|
# 6cc224f chore(release): plugin.json version 0.19.4 -> 0 runs
|
||||||
|
# f031c2b chore(release): plugin.json version 0.19.5 -> 0 runs
|
||||||
|
#
|
||||||
|
# The koala cluster's CIHeadUncovered{repo=skills} alert had been firing on it
|
||||||
|
# for 85 hours when this was found, and it cannot clear on its own -- only an
|
||||||
|
# unrelated human push moves main's HEAD to a tested commit.
|
||||||
|
#
|
||||||
|
# The commit is not nothing: plugin.json's version is what Claude Code gates
|
||||||
|
# plugin updates on, `task check` asserts it is not behind the latest tag, and
|
||||||
|
# the sed-and-retry logic that writes it already shipped a mismatched manifest
|
||||||
|
# once (v0.10.0 with the manifest reading 0.9.0).
|
||||||
|
#
|
||||||
|
# Anchored past comments: the tag job's prose already SAYS "`task check`
|
||||||
|
# asserts the manifest is not behind the latest tag", and a bare grep matches
|
||||||
|
# that and passes. A grep for a concept matches the rationale, and the more
|
||||||
|
# carefully a thing is documented the more likely that is.
|
||||||
|
printf '%s' "$tag_job" | grep -qE '^[^#]*task check' \
|
||||||
|
&& ok 'the release-bump commit is gated before it is pushed' \
|
||||||
|
|| no 'the release-bump commit is pushed with no gate -- nothing ever tests it (#46)'
|
||||||
|
|
||||||
printf '\nci_gate: %d passed, %d failed\n' "$pass" "$fail"
|
printf '\nci_gate: %d passed, %d failed\n' "$pass" "$fail"
|
||||||
[ "$fail" -eq 0 ]
|
[ "$fail" -eq 0 ]
|
||||||
|
|||||||
@@ -24,6 +24,18 @@ if [ ! -f "$DOCTOR" ]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# The toolchain check (golangci-lint build-Go vs GOROOT) reads the real host
|
||||||
|
# unless we say otherwise, which made every unrelated fixture fail on a machine
|
||||||
|
# whose golangci-lint is genuinely stale. Give the whole suite a matching fake
|
||||||
|
# pair so only the cases that OPT IN exercise that check.
|
||||||
|
STUB_BIN="$WORK/_stub_bin"
|
||||||
|
mkdir -p "$STUB_BIN"
|
||||||
|
printf '#!/bin/sh\nprintf "golangci-lint has version 2.0.0 built with go1.27.0 from abc\\n"\n' > "$STUB_BIN/golangci-lint"
|
||||||
|
printf '#!/bin/sh\ncase "$1" in version) printf "go version go1.27.0 linux/amd64\\n";; *) printf "\\n";; esac\n' > "$STUB_BIN/go"
|
||||||
|
chmod +x "$STUB_BIN/golangci-lint" "$STUB_BIN/go"
|
||||||
|
PATH="$STUB_BIN:$PATH"
|
||||||
|
export PATH
|
||||||
|
|
||||||
cleanup() { rm -rf "$WORK"; }
|
cleanup() { rm -rf "$WORK"; }
|
||||||
trap cleanup EXIT INT TERM
|
trap cleanup EXIT INT TERM
|
||||||
|
|
||||||
@@ -291,5 +303,75 @@ else
|
|||||||
fail=$((fail + 1)); printf ' FAIL --online reports an unreachable remote as its own fault\n rc=%s got: %s\n' "$_rc" "$_out"
|
fail=$((fail + 1)); printf ' FAIL --online reports an unreachable remote as its own fault\n rc=%s got: %s\n' "$_rc" "$_out"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# --- toolchain: golangci-lint must be built with Go >= GOROOT ---------------
|
||||||
|
# Measured on koala 2026-09-08 (infra#375, second recurrence): golangci-lint
|
||||||
|
# 2.11.4 built with go1.26.1 against a go1.27.0 GOROOT PANICS on any Go repo --
|
||||||
|
#
|
||||||
|
# panic: file requires newer Go version go1.27 (application built with go1.26)
|
||||||
|
#
|
||||||
|
# and the earlier form of the same fault printed a typecheck error naming YOUR
|
||||||
|
# file, so the natural response is to debug code that is fine. It cost time in
|
||||||
|
# two repos. CI never sees it (the runner image matches), so it breaks only the
|
||||||
|
# local `task check` that AGENT.md requires before every push.
|
||||||
|
#
|
||||||
|
# The fixtures inject fake `go` and `golangci-lint` on PATH; nothing here reads
|
||||||
|
# the real host toolchain, so the suite still passes on a machine without Go.
|
||||||
|
mk_toolchain() { # $1=fixture $2=lint-build-go $3=goroot-go ("" = absent)
|
||||||
|
_b="$1/bin"; mkdir -p "$_b"
|
||||||
|
if [ -n "$2" ]; then
|
||||||
|
printf '#!/bin/sh\nprintf "golangci-lint has version 2.11.4 built with %s from abc on 2026-01-01\\n"\n' "$2" > "$_b/golangci-lint"
|
||||||
|
chmod +x "$_b/golangci-lint"
|
||||||
|
fi
|
||||||
|
if [ -n "$3" ]; then
|
||||||
|
printf '#!/bin/sh\ncase "$1" in version) printf "go version %s linux/amd64\\n";; *) printf "\\n";; esac\n' "$3" > "$_b/go"
|
||||||
|
chmod +x "$_b/go"
|
||||||
|
fi
|
||||||
|
printf '%s' "$_b"
|
||||||
|
}
|
||||||
|
|
||||||
|
f=$(new_fixture golangci_old); wire_clean "$f"
|
||||||
|
b=$(mk_toolchain "$f" go1.26.1 go1.27.0)
|
||||||
|
_out=$(PATH="$b:$PATH" HOME="$f/home" DOCTOR_CHECKOUT="$f/ck" "$DOCTOR" 2>&1); _rc=$?
|
||||||
|
if [ "$_rc" -ne 0 ] && printf '%s' "$_out" | grep -qi 'golangci'; then
|
||||||
|
pass=$((pass + 1)); printf ' ok golangci-lint older than GOROOT is reported\n'
|
||||||
|
else
|
||||||
|
fail=$((fail + 1)); printf ' FAIL golangci-lint older than GOROOT is reported\n rc=%s got: %s\n' "$_rc" "$_out"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Matching versions are the normal case and must stay silent.
|
||||||
|
f=$(new_fixture golangci_ok); wire_clean "$f"
|
||||||
|
b=$(mk_toolchain "$f" go1.27.0 go1.27.0)
|
||||||
|
_out=$(PATH="$b:$PATH" HOME="$f/home" DOCTOR_CHECKOUT="$f/ck" "$DOCTOR" 2>&1); _rc=$?
|
||||||
|
if [ "$_rc" -eq 0 ]; then
|
||||||
|
pass=$((pass + 1)); printf ' ok golangci-lint matching GOROOT is silent\n'
|
||||||
|
else
|
||||||
|
fail=$((fail + 1)); printf ' FAIL golangci-lint matching GOROOT is silent\n rc=%s got: %s\n' "$_rc" "$_out"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# A NEWER lint than GOROOT is fine -- the constraint is one-directional.
|
||||||
|
f=$(new_fixture golangci_newer); wire_clean "$f"
|
||||||
|
b=$(mk_toolchain "$f" go1.28.0 go1.27.0)
|
||||||
|
_out=$(PATH="$b:$PATH" HOME="$f/home" DOCTOR_CHECKOUT="$f/ck" "$DOCTOR" 2>&1); _rc=$?
|
||||||
|
if [ "$_rc" -eq 0 ]; then
|
||||||
|
pass=$((pass + 1)); printf ' ok golangci-lint newer than GOROOT is not a fault\n'
|
||||||
|
else
|
||||||
|
fail=$((fail + 1)); printf ' FAIL golangci-lint newer than GOROOT is not a fault\n rc=%s got: %s\n' "$_rc" "$_out"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Fail OPEN on anything unparseable. doctor runs in a SessionStart hook on every
|
||||||
|
# machine; a toolchain it cannot read is not a fault it may assert. (A host with
|
||||||
|
# no Go at all takes the same path -- command -v simply misses.)
|
||||||
|
f=$(new_fixture golangci_garbled); wire_clean "$f"
|
||||||
|
_b="$f/bin"; mkdir -p "$_b"
|
||||||
|
printf '#!/bin/sh\nprintf "golangci-lint dev build\\n"\n' > "$_b/golangci-lint"
|
||||||
|
printf '#!/bin/sh\nprintf "go version devel\\n"\n' > "$_b/go"
|
||||||
|
chmod +x "$_b/golangci-lint" "$_b/go"
|
||||||
|
_out=$(PATH="$_b:$PATH" HOME="$f/home" DOCTOR_CHECKOUT="$f/ck" "$DOCTOR" 2>&1); _rc=$?
|
||||||
|
if [ "$_rc" -eq 0 ]; then
|
||||||
|
pass=$((pass + 1)); printf ' ok an unreadable toolchain version is not asserted as a fault\n'
|
||||||
|
else
|
||||||
|
fail=$((fail + 1)); printf ' FAIL an unreadable toolchain version is not asserted as a fault\n rc=%s got: %s\n' "$_rc" "$_out"
|
||||||
|
fi
|
||||||
|
|
||||||
printf '\ndoctor: %d passed, %d failed\n' "$pass" "$fail"
|
printf '\ndoctor: %d passed, %d failed\n' "$pass" "$fail"
|
||||||
[ "$fail" -eq 0 ]
|
[ "$fail" -eq 0 ]
|
||||||
|
|||||||
+91
-1
@@ -17,6 +17,7 @@ SUT="$ROOT/install.sh"
|
|||||||
|
|
||||||
pass=0
|
pass=0
|
||||||
fail=0
|
fail=0
|
||||||
|
skipped=0
|
||||||
WORK=${TMPDIR:-/tmp}/install_test.$$
|
WORK=${TMPDIR:-/tmp}/install_test.$$
|
||||||
|
|
||||||
if [ ! -f "$SUT" ]; then
|
if [ ! -f "$SUT" ]; then
|
||||||
@@ -55,6 +56,7 @@ run_install() {
|
|||||||
AGENTS_SKILLS_DIR="$_f/home/.agents/skills" \
|
AGENTS_SKILLS_DIR="$_f/home/.agents/skills" \
|
||||||
CRUSH_SKILLS_DIR="$_f/home/.config/crush/skills" \
|
CRUSH_SKILLS_DIR="$_f/home/.config/crush/skills" \
|
||||||
OPENCODE_SKILLS_DIR="$_f/home/.config/opencode/skills" \
|
OPENCODE_SKILLS_DIR="$_f/home/.config/opencode/skills" \
|
||||||
|
ANTIGRAVITY_SKILLS_JSON="$_f/home/.gemini/config/skills.json" \
|
||||||
SKILLS_EXTRA_SOURCES="$_extra" \
|
SKILLS_EXTRA_SOURCES="$_extra" \
|
||||||
bash "$SUT" 2>&1)
|
bash "$SUT" 2>&1)
|
||||||
rc=$?
|
rc=$?
|
||||||
@@ -156,6 +158,40 @@ else
|
|||||||
no 'configured sources are recorded for doctor' "$out"
|
no 'configured sources are recorded for doctor' "$out"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# --- Antigravity: jq is optional, so BOTH branches must be tested -----------
|
||||||
|
# These three cases inherited whatever jq the host happened to have. koala has
|
||||||
|
# it, the CI runner does not, so they passed locally and failed in CI — the
|
||||||
|
# classic works-on-my-machine gate. install.sh treats jq as optional by design,
|
||||||
|
# which means the skip is a contract and needs a test of its own.
|
||||||
|
|
||||||
|
# run_install_nojq <fixture> — same as run_install, with the jq lookup pointed
|
||||||
|
# at a binary that does not exist. Deterministic regardless of the host.
|
||||||
|
run_install_nojq() {
|
||||||
|
_f=$1
|
||||||
|
_out=$(HOME="$_f/home" \
|
||||||
|
SKILLS_SKIP_FETCH=1 \
|
||||||
|
SKILLS_CHECKOUT_DIR="$_f/ck" \
|
||||||
|
AGENTS_SKILLS_DIR="$_f/home/.agents/skills" \
|
||||||
|
CRUSH_SKILLS_DIR="$_f/home/.config/crush/skills" \
|
||||||
|
OPENCODE_SKILLS_DIR="$_f/home/.config/opencode/skills" \
|
||||||
|
SKILLS_JQ="definitely-not-jq-$$" \
|
||||||
|
bash "$SUT" 2>&1)
|
||||||
|
rc=$?
|
||||||
|
printf '%s' "$_out"
|
||||||
|
}
|
||||||
|
|
||||||
|
f=$(new_fixture antigravity_nojq)
|
||||||
|
out=$(run_install_nojq "$f")
|
||||||
|
if [ -e "$f/home/.gemini/config/skills.json" ]; then
|
||||||
|
no 'without jq, no skills.json is written' "$out"
|
||||||
|
elif ! printf '%s' "$out" | grep -qi 'jq not found'; then
|
||||||
|
no 'without jq, the skip is reported' "$out"
|
||||||
|
elif [ ! -L "$f/home/.agents/skills/alpha" ]; then
|
||||||
|
no 'without jq, the other harnesses are still wired' "$out"
|
||||||
|
else
|
||||||
|
ok 'without jq, Antigravity is skipped and the rest still wires'
|
||||||
|
fi
|
||||||
|
|
||||||
# --- 8. surfaces: the library link set is not "every skill" -----------------
|
# --- 8. surfaces: the library link set is not "every skill" -----------------
|
||||||
# claude.ai has no shell, no cluster and no checkout, so a chat-only skill
|
# claude.ai has no shell, no cluster and no checkout, so a chat-only skill
|
||||||
# linked into Crush is a skill that can never fire, competing for selection
|
# linked into Crush is a skill that can never fire, competing for selection
|
||||||
@@ -183,5 +219,59 @@ else
|
|||||||
no 'a source with no surfaces convention is linked as-is' "$out"
|
no 'a source with no surfaces convention is linked as-is' "$out"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
printf '\ninstall: %d passed, %d failed\n' "$pass" "$fail"
|
# --- 9-11. Antigravity's merge behaviour, only where jq exists ---------------
|
||||||
|
# These three assert what jq DOES. install.sh treats jq as optional, so on a
|
||||||
|
# host without it there is nothing to assert — and failing there is what turned
|
||||||
|
# main red: they passed on koala (jq present) and failed on the runner (absent).
|
||||||
|
# A skipped case is honest; a case that fails because the host lacks an optional
|
||||||
|
# dependency is a false red, and false reds are how a suite stops being read.
|
||||||
|
if ! command -v jq >/dev/null 2>&1; then
|
||||||
|
skipped=$((skipped + 3))
|
||||||
|
printf ' SKIP jq absent — Antigravity merge behaviour not covered on this host (3 cases)\n'
|
||||||
|
else
|
||||||
|
# skills.json entries must point at $AGENTS_DIR — that carries the
|
||||||
|
# surfaces-filtering, collision handling and multi-source merge. Pointing at
|
||||||
|
# the checkout directly would give Antigravity a different skill set than
|
||||||
|
# every other harness.
|
||||||
|
f=$(new_fixture antigravity)
|
||||||
|
out=$(run_install "$f")
|
||||||
|
AG="$f/home/.gemini/config/skills.json"
|
||||||
|
if [ ! -f "$AG" ]; then
|
||||||
|
no 'Antigravity skills.json is created' "$out"
|
||||||
|
else
|
||||||
|
registered=$(jq -r --arg p "$f/home/.agents/skills" '[.entries[]? | select(.path == $p)] | length' "$AG")
|
||||||
|
if [ "$registered" = "1" ]; then
|
||||||
|
ok 'Antigravity registers the managed agents dir, not the raw checkout'
|
||||||
|
else
|
||||||
|
no 'Antigravity registers the managed agents dir, not the raw checkout' "$(cat "$AG")"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
f=$(new_fixture antigravity_idempotent)
|
||||||
|
run_install "$f" >/dev/null
|
||||||
|
out=$(run_install "$f")
|
||||||
|
AG="$f/home/.gemini/config/skills.json"
|
||||||
|
count=$(jq '.entries | length' "$AG" 2>/dev/null || echo "?")
|
||||||
|
if [ "$count" = "1" ]; then
|
||||||
|
ok 'Antigravity wiring is idempotent (no duplicate entries)'
|
||||||
|
else
|
||||||
|
no 'Antigravity wiring is idempotent (no duplicate entries)' "entries=$count"$'\n'"$out"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# A user may hand-edit skills.json to point at their own personal skills dir
|
||||||
|
# (json_configs.md documents this). Our merge must not clobber it.
|
||||||
|
f=$(new_fixture antigravity_preserve)
|
||||||
|
mkdir -p "$f/home/.gemini/config"
|
||||||
|
printf '{"entries":[{"path":"/Users/mathias/personal-skills"}]}' > "$f/home/.gemini/config/skills.json"
|
||||||
|
out=$(run_install "$f")
|
||||||
|
AG="$f/home/.gemini/config/skills.json"
|
||||||
|
personal=$(jq -r '[.entries[]? | select(.path == "/Users/mathias/personal-skills")] | length' "$AG" 2>/dev/null || echo 0)
|
||||||
|
if [ "$personal" = "1" ]; then
|
||||||
|
ok 'Antigravity wiring preserves pre-existing hand-authored entries'
|
||||||
|
else
|
||||||
|
no 'Antigravity wiring preserves pre-existing hand-authored entries' "$(cat "$AG")"$'\n'"$out"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf '\ninstall: %d passed, %d failed, %d skipped\n' "$pass" "$fail" "$skipped"
|
||||||
[ "$fail" -eq 0 ]
|
[ "$fail" -eq 0 ]
|
||||||
|
|||||||
Executable
+118
@@ -0,0 +1,118 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Tests for bin/check-mcp-env.
|
||||||
|
#
|
||||||
|
# The fault this gate exists for is silent, which is why it needs a gate at
|
||||||
|
# all. An `.mcp.json` server whose Authorization header interpolates a
|
||||||
|
# variable nothing in the session environment defines does not error: the
|
||||||
|
# header goes out empty, the server answers 401, and Claude Code reports
|
||||||
|
# "requires authentication". That is indistinguishable from a revoked or
|
||||||
|
# expired credential, and on 2026-09-07 it cost a full investigation on koala
|
||||||
|
# to establish that the credential was fine and the variable was simply never
|
||||||
|
# set.
|
||||||
|
#
|
||||||
|
# So the property is: every ${VAR} a LIVE server interpolates must be a
|
||||||
|
# variable the session env-file actually defines.
|
||||||
|
#
|
||||||
|
# The `_parked` block is deliberately exempt. Parked servers reference
|
||||||
|
# variables nothing provides on purpose -- that is what parked means -- and a
|
||||||
|
# gate that flagged them would be reporting the intended state as a fault.
|
||||||
|
#
|
||||||
|
# Fixtures drive the checker through argv, so no test touches the real host.
|
||||||
|
|
||||||
|
set -u
|
||||||
|
|
||||||
|
ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
|
||||||
|
CHECK="$ROOT/bin/check-mcp-env"
|
||||||
|
|
||||||
|
pass=0
|
||||||
|
fail=0
|
||||||
|
WORK=${TMPDIR:-/tmp}/mcp_config_test.$$
|
||||||
|
|
||||||
|
if [ ! -f "$CHECK" ]; then
|
||||||
|
printf 'FAIL: %s does not exist\n' "$CHECK"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
cleanup() { rm -rf "$WORK"; }
|
||||||
|
trap cleanup EXIT INT TERM
|
||||||
|
mkdir -p "$WORK"
|
||||||
|
|
||||||
|
if ! command -v jq >/dev/null 2>&1; then
|
||||||
|
printf 'SKIP: jq not installed -- bin/check-mcp-env cannot be exercised\n'
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ok <name> / bad <name> <detail>
|
||||||
|
ok() { pass=$((pass+1)); printf ' ok %s\n' "$1"; }
|
||||||
|
bad() { fail=$((fail+1)); printf ' FAIL %s %s\n' "$1" "${2:-}"; }
|
||||||
|
|
||||||
|
# run <mcp-json> <env-file> -> sets $rc and $out
|
||||||
|
run() {
|
||||||
|
out=$("$CHECK" "$1" "$2" 2>&1); rc=$?
|
||||||
|
}
|
||||||
|
|
||||||
|
ENVFILE="$WORK/op-env"
|
||||||
|
cat > "$ENVFILE" <<'ENVEOF'
|
||||||
|
BRAIN_MCP_TOKEN=op://HomeLab/brain/token
|
||||||
|
DMABE_GITEA_API_TOKEN=op://HomeLab/gitea/token
|
||||||
|
ENVEOF
|
||||||
|
|
||||||
|
# --- a live server whose variable IS defined ---------------------------------
|
||||||
|
cat > "$WORK/good.json" <<'JSONEOF'
|
||||||
|
{"mcpServers":{"brain":{"type":"http","url":"https://x/mcp",
|
||||||
|
"headers":{"Authorization":"Bearer ${BRAIN_MCP_TOKEN}"}}}}
|
||||||
|
JSONEOF
|
||||||
|
run "$WORK/good.json" "$ENVFILE"
|
||||||
|
[ "$rc" -eq 0 ] && ok "defined variable passes" || bad "defined variable passes" "rc=$rc $out"
|
||||||
|
|
||||||
|
# --- a live server whose variable is NOT defined ------------------------------
|
||||||
|
# The exact shape that broke: the server looks correct and fails at runtime.
|
||||||
|
cat > "$WORK/missing.json" <<'JSONEOF'
|
||||||
|
{"mcpServers":{"gitea":{"type":"http","url":"https://x/mcp",
|
||||||
|
"headers":{"Authorization":"Bearer ${GITEA_MCP_STATIC_TOKEN}"}}}}
|
||||||
|
JSONEOF
|
||||||
|
run "$WORK/missing.json" "$ENVFILE"
|
||||||
|
if [ "$rc" -ne 0 ]; then
|
||||||
|
case "$out" in
|
||||||
|
*gitea*GITEA_MCP_STATIC_TOKEN*) ok "undefined variable is flagged, naming server and variable" ;;
|
||||||
|
*) bad "undefined variable is flagged, naming server and variable" "report did not name both: $out" ;;
|
||||||
|
esac
|
||||||
|
else
|
||||||
|
bad "undefined variable is flagged, naming server and variable" "rc=0"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- a server with no headers at all (the `infra` shape) ----------------------
|
||||||
|
# Unauthenticated by design. Nothing to resolve, so nothing to flag.
|
||||||
|
cat > "$WORK/noauth.json" <<'JSONEOF'
|
||||||
|
{"mcpServers":{"infra":{"type":"http","url":"https://x/mcp"}}}
|
||||||
|
JSONEOF
|
||||||
|
run "$WORK/noauth.json" "$ENVFILE"
|
||||||
|
[ "$rc" -eq 0 ] && ok "header-less server is not flagged" || bad "header-less server is not flagged" "rc=$rc $out"
|
||||||
|
|
||||||
|
# --- parked servers are exempt ------------------------------------------------
|
||||||
|
cat > "$WORK/parked.json" <<'JSONEOF'
|
||||||
|
{"mcpServers":{"brain":{"type":"http","url":"https://x/mcp",
|
||||||
|
"headers":{"Authorization":"Bearer ${BRAIN_MCP_TOKEN}"}}},
|
||||||
|
"_parked":{"_reason":"no identity provides these yet",
|
||||||
|
"cad":{"type":"http","url":"https://x/mcp",
|
||||||
|
"headers":{"Authorization":"Bearer ${CAD_MCP_TOKEN}"}}}}
|
||||||
|
JSONEOF
|
||||||
|
run "$WORK/parked.json" "$ENVFILE"
|
||||||
|
if [ "$rc" -eq 0 ]; then
|
||||||
|
ok "parked server is exempt"
|
||||||
|
else
|
||||||
|
bad "parked server is exempt" "rc=$rc $out"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- malformed JSON must not pass silently ------------------------------------
|
||||||
|
# A config the checker cannot read is an unverified config, not a clean one.
|
||||||
|
printf '{"mcpServers":{' > "$WORK/broken.json"
|
||||||
|
run "$WORK/broken.json" "$ENVFILE"
|
||||||
|
[ "$rc" -ne 0 ] && ok "unparseable config does not report success" || bad "unparseable config does not report success" "rc=0"
|
||||||
|
|
||||||
|
# --- a missing env-file is unverifiable, not clean ----------------------------
|
||||||
|
run "$WORK/good.json" "$WORK/no-such-env"
|
||||||
|
[ "$rc" -ne 0 ] && ok "missing env-file does not report success" || bad "missing env-file does not report success" "rc=0"
|
||||||
|
|
||||||
|
printf '\n%s: %d passed, %d failed\n' "$(basename "$0")" "$pass" "$fail"
|
||||||
|
[ "$fail" -eq 0 ]
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Tests for hooks/pre-task-ritual.sh — the UserPromptSubmit hook that surfaces
|
||||||
|
# rule 0 on every prompt (#44).
|
||||||
|
#
|
||||||
|
# WHY THIS EXISTS, AND WHAT IT DELIBERATELY DOES NOT CLAIM
|
||||||
|
# This hook is three lines and echoes a string. It has no bash-isms, so it
|
||||||
|
# cannot fail the way secret-guard appeared to; that risk was overstated when
|
||||||
|
# this test was requested. What it CAN do is stop working silently, and its
|
||||||
|
# whole job is to be visible -- if the text stops appearing, rule 0 stops being
|
||||||
|
# surfaced on every prompt and nothing announces that.
|
||||||
|
#
|
||||||
|
# THE ASSERTION THAT MATTERS IS REGISTRATION.
|
||||||
|
# A hook file that exists but is not in hooks.json does absolutely nothing, and
|
||||||
|
# looks identical on disk to one that works. So this derives the command from
|
||||||
|
# hooks.json rather than assuming one: the registration is the source of truth
|
||||||
|
# for how the hook is invoked, and a test that invokes it its own way is
|
||||||
|
# testing something the harness never runs.
|
||||||
|
#
|
||||||
|
# That distinction is not hypothetical. secret_guard_test.sh piped its hook into
|
||||||
|
# `sh` while hooks.json runs it with `bash`; on Debian /bin/sh is dash, which
|
||||||
|
# has no `set -o pipefail`, and the suite failed 20/20 in CI against a hook that
|
||||||
|
# was fine in production. The test was wrong, not the hook. Deriving the
|
||||||
|
# invocation removes the opportunity for that class of mistake entirely.
|
||||||
|
|
||||||
|
set -u
|
||||||
|
|
||||||
|
ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
|
||||||
|
HOOK="$ROOT/hooks/pre-task-ritual.sh"
|
||||||
|
REG="$ROOT/hooks/hooks.json"
|
||||||
|
|
||||||
|
pass=0
|
||||||
|
fail=0
|
||||||
|
ok() { pass=$((pass + 1)); printf ' ok %s\n' "$1"; }
|
||||||
|
no() { fail=$((fail + 1)); printf ' FAIL %s\n' "$1"; }
|
||||||
|
|
||||||
|
if ! command -v jq >/dev/null 2>&1; then
|
||||||
|
printf ' SKIP: jq not installed -- cannot read the hook registration\n'
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
[ -f "$HOOK" ] || { printf ' FAIL %s does not exist\n' "$HOOK"; exit 1; }
|
||||||
|
ok 'hook file exists'
|
||||||
|
|
||||||
|
[ -f "$REG" ] || { printf ' FAIL %s does not exist\n' "$REG"; exit 1; }
|
||||||
|
|
||||||
|
# 1. Registered, and on the right event. UserPromptSubmit is what makes it fire
|
||||||
|
# on every prompt; registered under any other event it would still "exist"
|
||||||
|
# and still do nothing useful.
|
||||||
|
CMD=$(jq -r '
|
||||||
|
.hooks.UserPromptSubmit[]?.hooks[]?
|
||||||
|
| select(.command | test("pre-task-ritual"))
|
||||||
|
| .command
|
||||||
|
' "$REG" 2>/dev/null | head -1)
|
||||||
|
|
||||||
|
if [ -n "$CMD" ]; then
|
||||||
|
ok 'registered on UserPromptSubmit in hooks.json'
|
||||||
|
else
|
||||||
|
no 'not registered on UserPromptSubmit -- the file would exist and never run'
|
||||||
|
printf ' %d passed, %d failed\n' "$pass" "$fail"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 2. Run it EXACTLY as registered, with the harness variable substituted the way
|
||||||
|
# the harness substitutes it.
|
||||||
|
OUT=$(CLAUDE_PLUGIN_ROOT="$ROOT" sh -c "$CMD" 2>&1)
|
||||||
|
rc=$?
|
||||||
|
|
||||||
|
[ "$rc" -eq 0 ] && ok 'exits 0 as registered' \
|
||||||
|
|| no "exited $rc -- a non-zero UserPromptSubmit hook can block the prompt"
|
||||||
|
|
||||||
|
# 3. The content is the point. A hook that runs and says nothing is the same as
|
||||||
|
# no hook, and that is the failure this pins.
|
||||||
|
if [ -n "$OUT" ]; then
|
||||||
|
ok 'produces output'
|
||||||
|
else
|
||||||
|
no 'produced no output -- rule 0 would silently stop being surfaced'
|
||||||
|
fi
|
||||||
|
|
||||||
|
case "$OUT" in
|
||||||
|
*"Rule 0"*) ok 'names rule 0' ;;
|
||||||
|
*) no "does not name rule 0: '$OUT'" ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# Each of the four steps, individually. Naming rule 0 while having quietly lost
|
||||||
|
# a step is the plausible drift, not losing the whole line.
|
||||||
|
for step in brain_query skill 'failing test' 'success criterion'; do
|
||||||
|
case "$OUT" in
|
||||||
|
*"$step"*) ok "states the '$step' step" ;;
|
||||||
|
*) no "lost the '$step' step from the ritual" ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# 4. The interpreter named in the registration must actually exist, or the hook
|
||||||
|
# fails on that host with a message nobody reads.
|
||||||
|
INTERP=${CMD%% *}
|
||||||
|
if command -v "$INTERP" >/dev/null 2>&1; then
|
||||||
|
ok "registered interpreter '$INTERP' is available"
|
||||||
|
else
|
||||||
|
no "registered interpreter '$INTERP' not found on this host"
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf ' %d passed, %d failed\n' "$pass" "$fail"
|
||||||
|
[ "$fail" -eq 0 ]
|
||||||
@@ -24,7 +24,7 @@ decision_for() {
|
|||||||
_tool=${2:-Bash}
|
_tool=${2:-Bash}
|
||||||
jq -n --arg t "$_tool" --arg c "$_cmd" \
|
jq -n --arg t "$_tool" --arg c "$_cmd" \
|
||||||
'{tool_name: $t, tool_input: {command: $c}}' \
|
'{tool_name: $t, tool_input: {command: $c}}' \
|
||||||
| sh "$GUARD" \
|
| "$GUARD" \
|
||||||
| jq -r '.hookSpecificOutput.permissionDecision // "MISSING"'
|
| jq -r '.hookSpecificOutput.permissionDecision // "MISSING"'
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,6 +42,20 @@ expect() {
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Invoked as a PROGRAM above, never as `sh "$GUARD"`. Claude Code executes the
|
||||||
|
# hook directly, so its `#!/usr/bin/env bash` shebang decides the interpreter;
|
||||||
|
# piping it into `sh` overrides that and tests something production never runs.
|
||||||
|
#
|
||||||
|
# It is not academic. On Debian /bin/sh is dash, which has no `set -o
|
||||||
|
# pipefail`, so the guard died on line 23 before reading a byte of input and
|
||||||
|
# every deny case returned empty -- 20 of 20 assertions failed in CI while
|
||||||
|
# passing on a host where /bin/sh happens to be bash. A security gate that
|
||||||
|
# tests green and cannot start is the worst of both.
|
||||||
|
if [ ! -x "$GUARD" ]; then
|
||||||
|
printf 'FAIL: %s is not executable -- it must run via its own shebang\n' "$GUARD"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
if [ ! -f "$GUARD" ]; then
|
if [ ! -f "$GUARD" ]; then
|
||||||
printf 'FAIL: %s does not exist\n' "$GUARD"
|
printf 'FAIL: %s does not exist\n' "$GUARD"
|
||||||
exit 1
|
exit 1
|
||||||
|
|||||||
Reference in New Issue
Block a user