Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b188965161 | ||
|
|
ae66840a03 | ||
|
|
e7e514390b | ||
|
|
087685f2e3 | ||
|
|
83b069afba | ||
|
|
a699ad8cbd | ||
|
|
a42ec18f40 | ||
|
|
46ff1acdfe | ||
|
|
3112b4ce92 | ||
|
|
51a8c25b4d | ||
|
|
d40169f2b7 | ||
|
|
d4f0416dc5 | ||
|
|
abd81f939a | ||
|
|
e42d2ba9cb | ||
|
|
68cebc0bbb | ||
|
|
f50dd6be6a | ||
|
|
1906a774b3 | ||
|
|
38f905e705 | ||
|
|
7db7e0779b | ||
|
|
0be3506799 | ||
|
|
6151b973bc | ||
|
|
6c1b6ce9db | ||
|
|
5c8341c797 | ||
|
|
5ca44c9c6b | ||
|
|
e2a312d05e | ||
|
|
78b9b59eca | ||
|
|
88c54399fe | ||
|
|
747c6c7f4c | ||
|
|
56a947b2a0 | ||
|
|
21be3ea245 |
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "mathias",
|
||||
"description": "Mathias's private plugin marketplace, hosted on gitea.d-ma.be. Self-hosted by design — nothing here transits GitHub.",
|
||||
"owner": {
|
||||
"name": "Mathias",
|
||||
"url": "https://git.d-ma.be/mathias"
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
"name": "skills",
|
||||
"source": ".",
|
||||
"description": "Engineering skills, the pre-task-ritual and secret-guard hooks, and the brain/gitea/infra MCP servers as one versioned unit."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"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.",
|
||||
"version": "0.8.3",
|
||||
"author": {
|
||||
"name": "Mathias",
|
||||
"url": "https://git.d-ma.be/mathias"
|
||||
},
|
||||
"homepage": "https://git.d-ma.be/mathias/skills",
|
||||
"repository": "https://git.d-ma.be/mathias/skills.git"
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
name: CI
|
||||
|
||||
# Two jobs, in order:
|
||||
#
|
||||
# gate — `task check` (structure, frontmatter, manifest/tag parity) plus
|
||||
# `task test` (hooks, doctor, release-skip). Runs on every push and
|
||||
# every PR. Before this existed the suite ran nowhere: CI reported
|
||||
# green having tested nothing (#22).
|
||||
#
|
||||
# tag — auto-tags a push to main with a semver bump derived from the latest
|
||||
# commit footer:
|
||||
# Bump-Type: major → vX.0.0
|
||||
# Bump-Type: minor → v0.Y.0
|
||||
# (default) → v0.0.Z (patch)
|
||||
# Skips when the commit carries the `Skip-Release: true` trailer (the
|
||||
# tagger's own manifest bump) to prevent recursion. Gated on `gate`,
|
||||
# so a red check cannot ship a tag.
|
||||
|
||||
"on":
|
||||
push:
|
||||
branches: ["**"]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
gate:
|
||||
name: Check / Test
|
||||
runs-on: self-hosted
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
# `task check` compares plugin.json's version to `git describe`;
|
||||
# a shallow checkout has no tags and would skip that assertion.
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Verify toolchain
|
||||
run: task --version
|
||||
|
||||
- name: Structure gate
|
||||
run: task check
|
||||
|
||||
- name: Shell test suite
|
||||
run: task test
|
||||
|
||||
tag:
|
||||
name: Tag release
|
||||
needs: gate
|
||||
runs-on: self-hosted
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
steps:
|
||||
- name: checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.GITEA_TOKEN }}
|
||||
|
||||
- name: derive next version
|
||||
id: bump
|
||||
run: |
|
||||
set -euo pipefail
|
||||
last_commit=$(git log -1 --pretty=%B)
|
||||
# Delegated to bin/should-skip-release so the rule is covered by
|
||||
# tests/release_skip_test.sh rather than living untested in YAML.
|
||||
if printf '%s\n' "$last_commit" | sh bin/should-skip-release; then
|
||||
echo "skipping — commit carries the Skip-Release trailer"
|
||||
echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
latest=$(git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0")
|
||||
major=$(echo "$latest" | sed -E 's/^v([0-9]+)\.([0-9]+)\.([0-9]+)$/\1/')
|
||||
minor=$(echo "$latest" | sed -E 's/^v([0-9]+)\.([0-9]+)\.([0-9]+)$/\2/')
|
||||
patch=$(echo "$latest" | sed -E 's/^v([0-9]+)\.([0-9]+)\.([0-9]+)$/\3/')
|
||||
bump=$(printf '%s' "$last_commit" | grep -iE '^Bump-Type:' | head -1 | awk '{print tolower($2)}' || true)
|
||||
case "$bump" in
|
||||
major) major=$((major + 1)); minor=0; patch=0 ;;
|
||||
minor) minor=$((minor + 1)); patch=0 ;;
|
||||
*) patch=$((patch + 1)) ;;
|
||||
esac
|
||||
next="v${major}.${minor}.${patch}"
|
||||
echo "previous=$latest"
|
||||
echo "bump=${bump:-patch}"
|
||||
echo "next=$next"
|
||||
echo "tag=$next" >> "$GITHUB_OUTPUT"
|
||||
echo "skip=false" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: tag + push
|
||||
if: steps.bump.outputs.skip != 'true'
|
||||
env:
|
||||
TAG: ${{ steps.bump.outputs.tag }}
|
||||
GITEA_ACTOR: ${{ gitea.actor }}
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git config user.name "${GITEA_ACTOR}"
|
||||
git config user.email "${GITEA_ACTOR}@noreply.gitea.d-ma.be"
|
||||
|
||||
# Claude Code gates plugin updates on plugin.json's version field:
|
||||
# if it does not move, installed hosts never see the new tag. Bump
|
||||
# it in the same commit train so the tag and the manifest agree —
|
||||
# `task check` enforces that equality on every run.
|
||||
manifest=.claude-plugin/plugin.json
|
||||
version="${TAG#v}"
|
||||
current=$(sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$manifest" | head -1)
|
||||
git tag -a "$TAG" -m "release $TAG"
|
||||
|
||||
if [ "$current" != "$version" ]; then
|
||||
sed -i "s/\"version\"[[:space:]]*:[[:space:]]*\"$current\"/\"version\": \"$version\"/" "$manifest"
|
||||
git add "$manifest"
|
||||
# The trailer below stops this push from re-triggering the tagger.
|
||||
git commit -m "chore(release): plugin.json version $version" -m "Skip-Release: true"
|
||||
# Commit and tag go up in ONE push: the gate job runs `task check`
|
||||
# on the bump commit, and that asserts manifest == latest tag. Two
|
||||
# separate pushes leave a window where it legitimately fails.
|
||||
git push origin HEAD:main "$TAG"
|
||||
echo "bumped $manifest to $version"
|
||||
else
|
||||
git push origin "$TAG"
|
||||
fi
|
||||
echo "pushed $TAG"
|
||||
@@ -1,68 +0,0 @@
|
||||
name: release
|
||||
|
||||
# Auto-tag every push to main with a semver bump derived from the latest
|
||||
# commit footer:
|
||||
# Bump-Type: major → vX.0.0
|
||||
# Bump-Type: minor → v0.Y.0
|
||||
# (default) → v0.0.Z (patch)
|
||||
#
|
||||
# Skips when the latest commit is itself the auto-tagger (commit message
|
||||
# contains "[skip-release]") to prevent recursion. Tag is pushed back
|
||||
# to the repo using the GITEA_TOKEN provided by act_runner.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
tag:
|
||||
runs-on: self-hosted
|
||||
steps:
|
||||
- name: checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.GITEA_TOKEN }}
|
||||
|
||||
- name: derive next version
|
||||
id: bump
|
||||
run: |
|
||||
set -euo pipefail
|
||||
last_commit=$(git log -1 --pretty=%B)
|
||||
if printf '%s' "$last_commit" | grep -qi '\[skip-release\]'; then
|
||||
echo "skipping — commit marked [skip-release]"
|
||||
echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
latest=$(git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0")
|
||||
major=$(echo "$latest" | sed -E 's/^v([0-9]+)\.([0-9]+)\.([0-9]+)$/\1/')
|
||||
minor=$(echo "$latest" | sed -E 's/^v([0-9]+)\.([0-9]+)\.([0-9]+)$/\2/')
|
||||
patch=$(echo "$latest" | sed -E 's/^v([0-9]+)\.([0-9]+)\.([0-9]+)$/\3/')
|
||||
bump=$(printf '%s' "$last_commit" | grep -iE '^Bump-Type:' | head -1 | awk '{print tolower($2)}' || true)
|
||||
case "$bump" in
|
||||
major) major=$((major + 1)); minor=0; patch=0 ;;
|
||||
minor) minor=$((minor + 1)); patch=0 ;;
|
||||
*) patch=$((patch + 1)) ;;
|
||||
esac
|
||||
next="v${major}.${minor}.${patch}"
|
||||
echo "previous=$latest"
|
||||
echo "bump=${bump:-patch}"
|
||||
echo "next=$next"
|
||||
echo "tag=$next" >> "$GITHUB_OUTPUT"
|
||||
echo "skip=false" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: tag + push
|
||||
if: steps.bump.outputs.skip != 'true'
|
||||
env:
|
||||
TAG: ${{ steps.bump.outputs.tag }}
|
||||
GITEA_ACTOR: ${{ gitea.actor }}
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git config user.name "${GITEA_ACTOR}"
|
||||
git config user.email "${GITEA_ACTOR}@noreply.gitea.d-ma.be"
|
||||
git tag -a "$TAG" -m "release $TAG"
|
||||
# gitea-actions checkout uses an x-access-token URL; reuse it.
|
||||
git push origin "$TAG"
|
||||
echo "pushed $TAG"
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"_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.",
|
||||
"mcpServers": {
|
||||
"brain": {
|
||||
"type": "http",
|
||||
"url": "https://koala.tail30472e.ts.net:8443/mcp",
|
||||
"headers": {
|
||||
"Authorization": "Bearer ${BRAIN_MCP_TOKEN}"
|
||||
}
|
||||
},
|
||||
"infra": {
|
||||
"type": "http",
|
||||
"url": "https://koala.tail30472e.ts.net:8444/mcp"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,12 @@
|
||||
# skills
|
||||
|
||||
Engineering skills library — markdown `SKILL.md` files that plug into any
|
||||
project or AI-coding harness. Installed via `task skills:install` in
|
||||
consumer projects, or directly by `install.sh` on hosts without Task.
|
||||
AI-coding harness.
|
||||
|
||||
For **Claude Code** this repo is a plugin, installed from the marketplace it
|
||||
also hosts; that path carries the hooks and MCP servers too. For **Crush, Pi
|
||||
and OpenCode** it is a plain library wired by `install.sh` as one directory
|
||||
symlink per harness. See [`SKILLS_INDEX.md`](./SKILLS_INDEX.md#wiring-into-tools).
|
||||
|
||||
## What is a skill?
|
||||
|
||||
@@ -16,65 +20,73 @@ references.
|
||||
See [`SKILLS_INDEX.md`](./SKILLS_INDEX.md) for the full catalogue with
|
||||
descriptions and "use when" triggers.
|
||||
|
||||
## Install (consumer project)
|
||||
## Install
|
||||
|
||||
The expected pattern: each consumer project's `Taskfile.yml` has a
|
||||
`skills:install` target that delegates here. From a project root:
|
||||
### Claude Code — the plugin
|
||||
|
||||
```bash
|
||||
task skills:install
|
||||
```
|
||||
/plugin marketplace add https://git.d-ma.be/mathias/skills.git
|
||||
/plugin install skills@mathias
|
||||
```
|
||||
|
||||
Bare-shell fallback for hosts without Task installed:
|
||||
The plugin ships skills, the `secret-guard` + `pre-task-ritual` hooks, and the
|
||||
brain/gitea/infra MCP servers as one versioned unit. Skills are namespaced
|
||||
(`skills:tdd`). Updates land when `.claude-plugin/plugin.json`'s `version`
|
||||
moves — CI bumps it on every release.
|
||||
|
||||
### Crush, Pi, OpenCode — `install.sh`
|
||||
|
||||
```bash
|
||||
curl -fsSL https://gitea.d-ma.be/mathias/skills/raw/branch/main/install.sh | bash
|
||||
curl -fsSL https://git.d-ma.be/mathias/skills/raw/branch/main/install.sh | bash
|
||||
```
|
||||
|
||||
Both forms are idempotent and re-runnable. On every run they:
|
||||
Idempotent. It clones or fast-forwards the checkout, then creates **three**
|
||||
symlinks — one directory link per harness, never one per skill:
|
||||
|
||||
1. `git clone` (or `git pull`) this repo into `~/.local/share/skills/` —
|
||||
the **canonical local checkout** on every host.
|
||||
2. Wire per-skill symlinks into the harnesses found on the host (see
|
||||
[Wired harnesses](#wired-harnesses)).
|
||||
| Link | Target |
|
||||
|---|---|
|
||||
| `~/.agents/skills` | `$SKILLS_CHECKOUT_DIR/skills` (Pi reads this path directly) |
|
||||
| `~/.config/crush/skills` | `~/.agents/skills` |
|
||||
| `~/.config/opencode/skills` | `~/.agents/skills` |
|
||||
|
||||
Re-run after `git pull` upstream to pick up new skills or version
|
||||
changes. CI tags every change as a `vX.Y.Z` release (see
|
||||
[Versioning](#versioning)).
|
||||
Adding a skill wires it everywhere with no re-run.
|
||||
|
||||
## Wired harnesses
|
||||
### Where the checkout must live
|
||||
|
||||
| Harness | Target path | Notes |
|
||||
|---|---|---|
|
||||
| **Claude Code (global)** | `~/.claude/skills/<name>` | Visible in every Claude Code session on the host. |
|
||||
| **Claude Code (per-repo)** | `<repo>/.claude/skills/<name>` | Created when `install` is invoked inside a git repo (not the skills repo itself). Gitignored globally via `**/.claude/skills/`. |
|
||||
| **Crush** | `~/.config/crush/skills/<name>` | Charmbracelet Crush — successor to `opencode-ai/opencode` (archived). |
|
||||
| **Antigravity** | `~/.gemini/antigravity/skills/<name>` | Google Antigravity (VS Code extension). Our `SKILL.md` frontmatter (`name` + `description`) is already in the format Antigravity expects, so symlinks suffice — no manifest translation. |
|
||||
`SKILLS_CHECKOUT_DIR` defaults to `~/.local/share/skills`, and that default is
|
||||
load-bearing: it is **host-local, outside any synced or shared directory**.
|
||||
|
||||
### Not wired (and why)
|
||||
|
||||
- **opencode** (`opencode-ai/opencode`): archive notice; succeeded by Crush.
|
||||
Its only "skill"-like surface — Custom Commands at `~/.config/opencode/commands/` —
|
||||
is for user-facing prompt templates, not system-level instructions.
|
||||
Misaligned with how we use skills. Skipped.
|
||||
- **gitea-resident agents** (cobalt-dingo, agentsquad): they consume
|
||||
skills via their containing project's `.claude/skills` directory
|
||||
(populated by the per-repo wirer when run from the host), or via the
|
||||
brain MCP. No special target needed.
|
||||
On this fleet `~/dev` is an iCloud folder shared by flamingo and iguana, and it
|
||||
spans two path roots — `/Users/mathias` on the Macs, `/home/mathias` on koala.
|
||||
A checkout there is a git working tree with two concurrent writers, and any
|
||||
absolute symlink stored there is broken on one side by construction. That is
|
||||
what produced 243 dangling links across 12 repos (#23). Do not "fix" the
|
||||
default to point into `~/dev`.
|
||||
|
||||
### Per-host env-var overrides
|
||||
|
||||
Each target path can be overridden by setting the matching env var before
|
||||
running `install.sh`:
|
||||
|
||||
| Env var | Default |
|
||||
|---|---|
|
||||
| `SKILLS_REPO_URL` | `https://gitea.d-ma.be/mathias/skills.git` |
|
||||
| `SKILLS_REF` | `main` (set to e.g. `v0.1.0` to pin a release) |
|
||||
| `SKILLS_REPO_URL` | `https://git.d-ma.be/mathias/skills.git` |
|
||||
| `SKILLS_REF` | `main` (set to e.g. `v0.4.0` to pin a release) |
|
||||
| `SKILLS_CHECKOUT_DIR` | `$HOME/.local/share/skills` |
|
||||
| `CLAUDE_SKILLS_DIR` | `$HOME/.claude/skills` |
|
||||
| `SKILLS_SKIP_FETCH` | unset (`1` wires an existing checkout without touching HEAD) |
|
||||
| `AGENTS_SKILLS_DIR` | `$HOME/.agents/skills` |
|
||||
| `CRUSH_SKILLS_DIR` | `$HOME/.config/crush/skills` |
|
||||
| `ANTIGRAVITY_SKILLS_DIR` | `$HOME/.gemini/antigravity/skills` |
|
||||
| `OPENCODE_SKILLS_DIR` | `$HOME/.config/opencode/skills` |
|
||||
|
||||
Antigravity and Mistral Vibe targets were removed — unused.
|
||||
|
||||
## Checking a host
|
||||
|
||||
```bash
|
||||
task doctor # or bin/doctor
|
||||
```
|
||||
|
||||
Reports dangling links, links resolving into a foreign checkout, skill-count
|
||||
mismatches, stray entries, and leftover per-skill Claude symlinks. Non-zero
|
||||
exit on drift. It also runs at session start via the plugin, so a drifting
|
||||
host says so rather than waiting to be discovered.
|
||||
|
||||
## Versioning
|
||||
|
||||
|
||||
+61
-23
@@ -19,16 +19,20 @@ This index lists all available engineering skills. Load the full SKILL.md on dem
|
||||
| `gitea-ci` | Gitea CI/CD pipeline with act_runner, buildah, k3s, GitHub mirror | Setting up or debugging CI on this infra |
|
||||
| `debug` | Hypothesis-first debugging discipline | Any test failure or bug whose cause is not immediately obvious |
|
||||
| `feature-spec` | Implementation-level feature spec (tighter than spec-driven-dev) | Scoping one feature inside an already-specced project |
|
||||
| `experiment-spec` | Rigorous experiment spec — falsifiable hypothesis, quantitative acceptance, null-result protocol | Scoping a research/ML phase instead of a feature |
|
||||
| `playwright` | Playwright E2E testing — setup, HTMX patterns, SSE streams, CI wiring | Adding browser tests to any project with a web UI |
|
||||
| `session-retrospective` | Surface non-obvious learnings from a session log | After a coding session ends, before context is lost |
|
||||
| `trainer` | Two-phase brain curation (score candidates, write past quality gate) | Periodic brain pruning and curation |
|
||||
| `dream` | Reflective consolidation pass over an agent's memory directory | Pruning/merging memory files, rebuilding a memory index |
|
||||
| `grill-me` | Structured plan interrogation — Quick Poke, Full Grill, Pre-mortem | Stress-testing a plan before committing; end of Diamond 1; before promoting to pre-prod |
|
||||
| `own-the-task` | Delegation ownership — Intake → Brief → Plan → Execute | A task is handed over open-ended; "take this off my plate"; before a plan exists |
|
||||
| `telos-load` | Load TELOS intention substrate at session start | Starting any koala session; before architectural decisions; CAD pipeline entry |
|
||||
| `secret-hygiene` | Stop credentials rendering into the session transcript; safe rotation chain when one leaks | Before any kubectl/SOPS/token command; after a confirmed leak |
|
||||
| `regulatory-risk-assessment` | Structured risk register for regulated-industry features | Filing a CAD issue (needs Risk: level); features touching payments, auth, external APIs, user data |
|
||||
| `discovery-framing` | Problem-before-solution discovery/framing method | Framing/validating a problem before any solution; opportunity sizing |
|
||||
| `stage-gate-review` | Stage-gate product governance (go/no-go council) | Preparing a Product Council ask; building a go/no-go checklist; gate review |
|
||||
| `web-shot` | Screenshot a running web UI via a koala k3s Playwright job | Visual verification of a running UI when no local browser exists |
|
||||
| `cad-babysit` | Focused human-in-the-loop session working CAD's stuck queue (read-only) | Picking up whatever `cad stuck` flags, with a human present |
|
||||
| `superpowers-brainstorming` | Structured divergent brainstorming | Generating or expanding a set of ideas |
|
||||
| `superpowers-writing-plans` | Writing implementation plans | Planning substantial work before coding |
|
||||
| `superpowers-executing-plans` | Executing a written plan step by step | Running an approved plan to completion |
|
||||
@@ -46,23 +50,55 @@ This index lists all available engineering skills. Load the full SKILL.md on dem
|
||||
|
||||
## Wiring into tools
|
||||
|
||||
This repo **is** the canonical home. Wire every skill into each harness with the
|
||||
**`install.sh`** script (idempotent, per-skill symlinks) — either the one-shot
|
||||
`curl -fsSL https://git.d-ma.be/mathias/skills/raw/branch/main/install.sh | bash`,
|
||||
or, from a clone, `SKILLS_CHECKOUT_DIR="$PWD" bash install.sh`. (The `task
|
||||
install` targets are currently broken — see issue — use `install.sh`.)
|
||||
This repo is the canonical home, and it is delivered two different ways.
|
||||
|
||||
- **Claude Code (global)** — `~/.claude/skills/<name>` → `<repo>/<name>`.
|
||||
- **Claude Code (per-repo)** — `<repo>/.claude/skills/<name>` when `task install`
|
||||
is run inside another git repo (excluded via `~/.config/git/ignore`).
|
||||
- **Crush** — `~/.config/crush/skills/<name>`.
|
||||
- **Antigravity** — `~/.gemini/antigravity/skills/<name>`.
|
||||
- **Mistral Vibe** — `~/.vibe/skills/<name>.md` → `<repo>/<name>/SKILL.md`
|
||||
(flat `.md` layout).
|
||||
### Claude Code — install the plugin
|
||||
|
||||
Individual targets are also runnable: `task install:claude:global`,
|
||||
`install:crush`, `install:antigravity`, `install:vibe`. `task update` pulls the
|
||||
canonical checkout then re-wires.
|
||||
This repo hosts a marketplace containing exactly one plugin: itself. The plugin
|
||||
carries the skills **plus** the `secret-guard` and `pre-task-ritual` hooks and
|
||||
the brain/gitea/infra MCP servers — none of which a symlink can deliver.
|
||||
|
||||
```
|
||||
/plugin marketplace add https://git.d-ma.be/mathias/skills.git
|
||||
/plugin install skills@mathias
|
||||
```
|
||||
|
||||
Skills are namespaced: `tdd` becomes `skills:tdd`. Updates arrive when
|
||||
`.claude-plugin/plugin.json`'s `version` moves, which CI does on every release.
|
||||
Remove any leftover `~/.claude/skills/<name>` symlinks from the pre-plugin era —
|
||||
plugin skills do **not** override same-named user skills, so leftovers make
|
||||
everything load twice.
|
||||
|
||||
### Crush, Pi, OpenCode — run `install.sh`
|
||||
|
||||
These have no plugin system, so they get symlinks — but **one directory link
|
||||
each**, never one per skill:
|
||||
|
||||
```
|
||||
~/.agents/skills -> $SKILLS_CHECKOUT_DIR/skills (Pi reads this directly)
|
||||
~/.config/crush/skills -> ~/.agents/skills
|
||||
~/.config/opencode/skills -> ~/.agents/skills
|
||||
```
|
||||
|
||||
Adding a skill therefore wires it everywhere with no re-run, and a broken link
|
||||
is one broken link instead of 42.
|
||||
|
||||
```
|
||||
curl -fsSL https://git.d-ma.be/mathias/skills/raw/branch/main/install.sh | bash
|
||||
```
|
||||
|
||||
`SKILLS_CHECKOUT_DIR` defaults to `~/.local/share/skills` and **must stay
|
||||
outside `~/dev`**. On this fleet `~/dev` is an iCloud folder shared by both
|
||||
Macs and spans two path roots (`/Users/mathias` vs `/home/mathias` on koala);
|
||||
a checkout there means a git working tree with two concurrent writers, and any
|
||||
absolute link stored there is broken on one side by construction. See #23.
|
||||
|
||||
### Check a host
|
||||
|
||||
`task doctor` (or `bin/doctor`) reports dangling links, links resolving into a
|
||||
foreign checkout, count mismatches, stray entries, and leftover Claude
|
||||
symlinks. It also runs automatically at session start via the plugin, so drift
|
||||
announces itself instead of waiting to be found.
|
||||
|
||||
## Skill Relationships
|
||||
|
||||
@@ -73,14 +109,16 @@ problem-analysis
|
||||
│
|
||||
├── spec-driven-dev (PM-kickoff lifecycle)
|
||||
│ │
|
||||
│ └── feature-spec (per-feature, mid-implementation)
|
||||
│ │
|
||||
│ └── planning
|
||||
│ │
|
||||
│ └── atdd ──→ tdd (foundation)
|
||||
│ ├──→ debug (when tests fail)
|
||||
│ ├──→ clean-code (REFACTOR phase)
|
||||
│ └──→ test-design (review)
|
||||
│ ├── feature-spec (per-feature, mid-implementation)
|
||||
│ │ │
|
||||
│ │ └── planning
|
||||
│ │ │
|
||||
│ │ └── atdd ──→ tdd (foundation)
|
||||
│ │ ├──→ debug (when tests fail)
|
||||
│ │ ├──→ clean-code (REFACTOR phase)
|
||||
│ │ └──→ test-design (review)
|
||||
│ │
|
||||
│ └── experiment-spec (research/ML phase, instead of feature-spec)
|
||||
│
|
||||
└── planning
|
||||
|
||||
|
||||
+94
-154
@@ -2,22 +2,19 @@ version: '3'
|
||||
|
||||
# Skills library Taskfile.
|
||||
#
|
||||
# Primary entry point for project + host installation. Consumer projects
|
||||
# do NOT depend on this file directly — they ship their own
|
||||
# `skills:install` target that delegates here via `install.sh`. This
|
||||
# Taskfile is invoked by maintainers of the skills repo itself, plus by
|
||||
# install.sh during the post-clone wire step.
|
||||
# This repo is two things at once:
|
||||
# 1. A Claude Code PLUGIN (skills + hooks + MCP servers), distributed through
|
||||
# the marketplace this same repo hosts at .claude-plugin/marketplace.json.
|
||||
# 2. A plain skills library for harnesses with no plugin system — Crush, Pi,
|
||||
# OpenCode — wired by install.sh as ONE directory symlink per harness.
|
||||
#
|
||||
# The wiring loops that used to live here were five near-identical copies that
|
||||
# drifted; install.sh owns wiring now and this file delegates to it.
|
||||
|
||||
vars:
|
||||
REPO_URL: 'https://git.d-ma.be/mathias/skills.git'
|
||||
CHECKOUT_DIR: '{{.HOME}}/.local/share/skills'
|
||||
CLAUDE_GLOBAL_DIR: '{{.HOME}}/.claude/skills'
|
||||
CRUSH_DIR: '{{.HOME}}/.config/crush/skills'
|
||||
ANTIGRAVITY_DIR: '{{.HOME}}/.gemini/antigravity/skills'
|
||||
VIBE_DIR: '{{.HOME}}/.vibe/skills'
|
||||
# Anything at the repo root that is NOT a skill directory. Both
|
||||
# Taskfile + install.sh share this exclusion regex.
|
||||
NON_SKILL_ENTRIES: '^(Taskfile.yml|install.sh|README.md|SKILLS_INDEX.md|.gitea|.git$)$'
|
||||
SKILLS_DIR: '{{.TASKFILE_DIR}}/skills'
|
||||
PLUGIN_MANIFEST: '{{.TASKFILE_DIR}}/.claude-plugin/plugin.json'
|
||||
|
||||
tasks:
|
||||
|
||||
@@ -28,134 +25,100 @@ tasks:
|
||||
list:
|
||||
desc: 'Print every skill in this repo'
|
||||
cmds:
|
||||
- 'ls -1 {{.TASKFILE_DIR}} | grep -Ev "^(Taskfile.yml|install.sh|README.md|SKILLS_INDEX.md|.gitea|.git$)$" || true'
|
||||
- 'ls -1 {{.SKILLS_DIR}}'
|
||||
silent: true
|
||||
|
||||
install:
|
||||
desc: 'Wire skills into every supported harness (Claude Code, Crush, Antigravity, Mistral Vibe).'
|
||||
desc: 'Wire THIS checkout into Crush / Pi / OpenCode. Claude Code uses the plugin instead.'
|
||||
cmds:
|
||||
- task: install:claude:global
|
||||
- task: install:claude:repo
|
||||
- task: install:crush
|
||||
- task: install:antigravity
|
||||
- task: install:vibe
|
||||
|
||||
install:claude:global:
|
||||
desc: 'Per-skill symlinks under ~/.claude/skills/<name> — visible in every Claude Code project on this host.'
|
||||
cmds:
|
||||
- mkdir -p {{.CLAUDE_GLOBAL_DIR}}
|
||||
- |
|
||||
for skill in $(ls -1 "{{.TASKFILE_DIR}}" | grep -Ev "{{.NON_SKILL_ENTRIES}}"); do
|
||||
target="{{.TASKFILE_DIR}}/${skill}"
|
||||
link="{{.CLAUDE_GLOBAL_DIR}}/${skill}"
|
||||
if [ -L "$link" ] || [ -e "$link" ]; then
|
||||
current=$(readlink "$link" 2>/dev/null || true)
|
||||
if [ "$current" = "$target" ]; then
|
||||
continue
|
||||
fi
|
||||
rm -rf "$link"
|
||||
fi
|
||||
ln -s "$target" "$link"
|
||||
echo "linked $link → $target"
|
||||
done
|
||||
|
||||
install:claude:repo:
|
||||
desc: 'Per-skill symlinks under $(pwd)/.claude/skills/<name> when invoked inside a git repo.'
|
||||
cmds:
|
||||
- |
|
||||
if ! git rev-parse --show-toplevel >/dev/null 2>&1; then
|
||||
echo "not in a git repo — skipping per-repo wire"; exit 0
|
||||
fi
|
||||
repo=$(git rev-parse --show-toplevel)
|
||||
if [ "$repo" = "{{.TASKFILE_DIR}}" ]; then
|
||||
echo "running from skills repo itself — skipping per-repo wire"; exit 0
|
||||
fi
|
||||
target_dir="$repo/.claude/skills"
|
||||
mkdir -p "$target_dir"
|
||||
for skill in $(ls -1 "{{.TASKFILE_DIR}}" | grep -Ev "{{.NON_SKILL_ENTRIES}}"); do
|
||||
target="{{.TASKFILE_DIR}}/${skill}"
|
||||
link="${target_dir}/${skill}"
|
||||
if [ -L "$link" ] || [ -e "$link" ]; then
|
||||
current=$(readlink "$link" 2>/dev/null || true)
|
||||
if [ "$current" = "$target" ]; then
|
||||
continue
|
||||
fi
|
||||
rm -rf "$link"
|
||||
fi
|
||||
ln -s "$target" "$link"
|
||||
echo "linked $link → $target"
|
||||
done
|
||||
|
||||
install:crush:
|
||||
desc: 'Per-skill symlinks under ~/.config/crush/skills/<name> — visible in every Crush session on this host.'
|
||||
cmds:
|
||||
- mkdir -p {{.CRUSH_DIR}}
|
||||
- |
|
||||
for skill in $(ls -1 "{{.TASKFILE_DIR}}" | grep -Ev "{{.NON_SKILL_ENTRIES}}"); do
|
||||
target="{{.TASKFILE_DIR}}/${skill}"
|
||||
link="{{.CRUSH_DIR}}/${skill}"
|
||||
if [ -L "$link" ] || [ -e "$link" ]; then
|
||||
current=$(readlink "$link" 2>/dev/null || true)
|
||||
if [ "$current" = "$target" ]; then
|
||||
continue
|
||||
fi
|
||||
rm -rf "$link"
|
||||
fi
|
||||
ln -s "$target" "$link"
|
||||
echo "linked $link → $target"
|
||||
done
|
||||
|
||||
install:antigravity:
|
||||
desc: 'Per-skill symlinks under ~/.gemini/antigravity/skills/<name>. SKILL.md frontmatter (name + description) is already antigravity-compatible.'
|
||||
cmds:
|
||||
- mkdir -p {{.ANTIGRAVITY_DIR}}
|
||||
- |
|
||||
for skill in $(ls -1 "{{.TASKFILE_DIR}}" | grep -Ev "{{.NON_SKILL_ENTRIES}}"); do
|
||||
target="{{.TASKFILE_DIR}}/${skill}"
|
||||
link="{{.ANTIGRAVITY_DIR}}/${skill}"
|
||||
if [ -L "$link" ] || [ -e "$link" ]; then
|
||||
current=$(readlink "$link" 2>/dev/null || true)
|
||||
if [ "$current" = "$target" ]; then
|
||||
continue
|
||||
fi
|
||||
rm -rf "$link"
|
||||
fi
|
||||
ln -s "$target" "$link"
|
||||
echo "linked $link → $target"
|
||||
done
|
||||
|
||||
install:vibe:
|
||||
desc: 'Per-skill file symlinks under ~/.vibe/skills/<name>.md → <skill>/SKILL.md for Mistral Vibe (flat .md layout, not a directory).'
|
||||
cmds:
|
||||
- mkdir -p {{.VIBE_DIR}}
|
||||
- |
|
||||
for skill in $(ls -1 "{{.TASKFILE_DIR}}" | grep -Ev "{{.NON_SKILL_ENTRIES}}"); do
|
||||
target="{{.TASKFILE_DIR}}/${skill}/SKILL.md"
|
||||
[ -f "$target" ] || continue
|
||||
link="{{.VIBE_DIR}}/${skill}.md"
|
||||
if [ -L "$link" ] || [ -e "$link" ]; then
|
||||
current=$(readlink "$link" 2>/dev/null || true)
|
||||
if [ "$current" = "$target" ]; then
|
||||
continue
|
||||
fi
|
||||
rm -rf "$link"
|
||||
fi
|
||||
ln -s "$target" "$link"
|
||||
echo "linked $link → $target"
|
||||
done
|
||||
- SKILLS_SKIP_FETCH=1 SKILLS_CHECKOUT_DIR='{{.TASKFILE_DIR}}' bash '{{.TASKFILE_DIR}}/install.sh'
|
||||
|
||||
update:
|
||||
desc: 'git pull the canonical checkout then re-run install.'
|
||||
desc: 'Fast-forward this checkout and re-wire.'
|
||||
cmds:
|
||||
- |
|
||||
if [ ! -d "{{.CHECKOUT_DIR}}/.git" ]; then
|
||||
echo "no canonical checkout at {{.CHECKOUT_DIR}} — run install.sh first"; exit 1
|
||||
fi
|
||||
git -C "{{.CHECKOUT_DIR}}" pull --ff-only
|
||||
- git -C '{{.TASKFILE_DIR}}' pull --ff-only
|
||||
- task: install
|
||||
|
||||
doctor:
|
||||
desc: 'Report drift across every harness on this host. Non-zero exit means something is wrong.'
|
||||
cmds:
|
||||
- '{{.TASKFILE_DIR}}/bin/doctor'
|
||||
|
||||
validate:
|
||||
desc: 'Validate the plugin + marketplace manifests with the Claude Code CLI.'
|
||||
cmds:
|
||||
- claude plugin validate '{{.TASKFILE_DIR}}'
|
||||
|
||||
test:
|
||||
desc: 'Run the shell test suite (hooks + doctor).'
|
||||
cmds:
|
||||
- |
|
||||
fail=0
|
||||
for t in '{{.TASKFILE_DIR}}'/tests/*_test.sh; do
|
||||
[ -f "$t" ] || continue
|
||||
echo "--- $(basename "$t")"
|
||||
sh "$t" || fail=1
|
||||
done
|
||||
exit $fail
|
||||
|
||||
check:
|
||||
desc: 'Structure gate — layout, frontmatter, manifest/tag parity. Runs in CI.'
|
||||
cmds:
|
||||
- |
|
||||
fail=0
|
||||
|
||||
# 1. No stray entries at the repo root. A loose foo.md where
|
||||
# skills/foo/SKILL.md was meant loads in NO harness and errors in
|
||||
# none either — it just silently does nothing. (See #23.)
|
||||
for entry in "{{.TASKFILE_DIR}}"/*; do
|
||||
base=$(basename "$entry")
|
||||
case "$base" in
|
||||
install.sh|Taskfile.yml|README.md|SKILLS_INDEX.md|skills|hooks|bin|tests) continue ;;
|
||||
esac
|
||||
echo "ERROR: unexpected entry at repo root: $base" >&2
|
||||
fail=1
|
||||
done
|
||||
|
||||
# 2. Every skill is a directory with a SKILL.md carrying name + description.
|
||||
count=0
|
||||
for dir in "{{.SKILLS_DIR}}"/*; do
|
||||
name=$(basename "$dir")
|
||||
if [ ! -d "$dir" ]; then
|
||||
echo "ERROR: skills/$name is not a directory" >&2; fail=1; continue
|
||||
fi
|
||||
if [ ! -f "$dir/SKILL.md" ]; then
|
||||
echo "ERROR: skills/$name missing SKILL.md" >&2; fail=1; continue
|
||||
fi
|
||||
head -n 20 "$dir/SKILL.md" | grep -q '^name:' || { echo "ERROR: skills/$name SKILL.md has no 'name:' frontmatter" >&2; fail=1; }
|
||||
head -n 20 "$dir/SKILL.md" | grep -q '^description:' || { echo "ERROR: skills/$name SKILL.md has no 'description:' frontmatter" >&2; fail=1; }
|
||||
count=$((count + 1))
|
||||
done
|
||||
|
||||
# 3. SKILLS_INDEX.md lists exactly the skills that exist. Delegated
|
||||
# to bin/check-index so the rule is covered by
|
||||
# tests/check_index_test.sh rather than living untested here.
|
||||
sh '{{.TASKFILE_DIR}}/bin/check-index' '{{.TASKFILE_DIR}}' || fail=1
|
||||
|
||||
# 4. Plugin manifest version must match the latest tag, or installers
|
||||
# never see the update (Claude Code gates updates on this field).
|
||||
if [ -f '{{.PLUGIN_MANIFEST}}' ]; then
|
||||
manifest_v=$(sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' '{{.PLUGIN_MANIFEST}}' | head -1)
|
||||
tag_v=$(git -C '{{.TASKFILE_DIR}}' describe --tags --abbrev=0 2>/dev/null | sed 's/^v//' || echo '')
|
||||
if [ -n "$tag_v" ] && [ "$manifest_v" != "$tag_v" ]; then
|
||||
echo "ERROR: plugin.json version ($manifest_v) != latest tag ($tag_v)" >&2
|
||||
fail=1
|
||||
fi
|
||||
else
|
||||
echo "ERROR: missing .claude-plugin/plugin.json" >&2; fail=1
|
||||
fi
|
||||
|
||||
if [ $fail -ne 0 ]; then
|
||||
echo "check FAILED" >&2; exit 1
|
||||
fi
|
||||
echo "check OK — $count skills, manifest matches tag"
|
||||
|
||||
release:
|
||||
desc: 'Tag HEAD per Bump-Type in the latest commit footer. Normally CI handles this — run locally only when CI is unavailable.'
|
||||
desc: 'Tag HEAD per Bump-Type in the latest commit footer. Normally CI handles this.'
|
||||
cmds:
|
||||
- |
|
||||
latest=$(git -C "{{.TASKFILE_DIR}}" describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0")
|
||||
@@ -172,26 +135,3 @@ tasks:
|
||||
echo "tagging $next (previous $latest, bump=${bump:-patch})"
|
||||
git -C "{{.TASKFILE_DIR}}" tag -a "$next" -m "release $next"
|
||||
echo "now: git push origin $next"
|
||||
|
||||
check:
|
||||
desc: 'Sanity check — every skill dir has a SKILL.md, no stray files at root.'
|
||||
cmds:
|
||||
- |
|
||||
missing=0
|
||||
for skill in $(ls -1 "{{.TASKFILE_DIR}}" | grep -Ev "^(Taskfile.yml|install.sh|README.md|SKILLS_INDEX.md|.gitea|.git$)$"); do
|
||||
dir="{{.TASKFILE_DIR}}/${skill}"
|
||||
if [ ! -d "$dir" ]; then
|
||||
echo "ERROR: $skill is not a directory" >&2
|
||||
missing=$((missing + 1))
|
||||
continue
|
||||
fi
|
||||
if [ ! -f "$dir/SKILL.md" ]; then
|
||||
echo "ERROR: $dir missing SKILL.md" >&2
|
||||
missing=$((missing + 1))
|
||||
fi
|
||||
done
|
||||
if [ $missing -gt 0 ]; then
|
||||
echo "$missing skill(s) failed structure check" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "all skills have SKILL.md"
|
||||
|
||||
Executable
+42
@@ -0,0 +1,42 @@
|
||||
#!/bin/sh
|
||||
# check-index — asserts SKILLS_INDEX.md lists exactly the skills that exist.
|
||||
#
|
||||
# The index is hand-maintained and drifts silently in both directions: a skill
|
||||
# ships with no row (`dream` and `experiment-spec` did — their rows lived on
|
||||
# branches squash-merged after a directory reorg, so the SKILL.md landed and
|
||||
# the row did not, #15), or a row outlives the skill it names. Neither errors
|
||||
# anywhere; the library just misreports itself to every harness that reads it.
|
||||
#
|
||||
# Usage: check-index [checkout-root] (defaults to this script's repo root)
|
||||
|
||||
set -u
|
||||
|
||||
ROOT=${1:-$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)}
|
||||
INDEX="$ROOT/SKILLS_INDEX.md"
|
||||
fail=0
|
||||
|
||||
if [ ! -f "$INDEX" ]; then
|
||||
echo "ERROR: missing SKILLS_INDEX.md" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# A row is a table line whose first cell is the skill name in backticks.
|
||||
listed=$(sed -n 's/^|[[:space:]]*`\([^`]*\)`.*/\1/p' "$INDEX" | sort -u)
|
||||
|
||||
for dir in "$ROOT"/skills/*/; do
|
||||
name=$(basename "$dir")
|
||||
[ -f "$dir/SKILL.md" ] || continue
|
||||
printf '%s\n' "$listed" | grep -qx "$name" || {
|
||||
echo "ERROR: skills/$name has no row in SKILLS_INDEX.md" >&2
|
||||
fail=1
|
||||
}
|
||||
done
|
||||
|
||||
for name in $listed; do
|
||||
[ -f "$ROOT/skills/$name/SKILL.md" ] || {
|
||||
echo "ERROR: SKILLS_INDEX.md lists '$name', which has no skills/$name/SKILL.md" >&2
|
||||
fail=1
|
||||
}
|
||||
done
|
||||
|
||||
exit $fail
|
||||
Executable
+194
@@ -0,0 +1,194 @@
|
||||
#!/bin/sh
|
||||
# doctor — report skills-library drift on this host.
|
||||
#
|
||||
# Written because every failure this fleet actually hit was SILENT: 243
|
||||
# dangling symlinks hidden from git by the **/.claude/skills/ ignore rule, a
|
||||
# checkout that still resolved but was 20 skills behind, a loose .md that
|
||||
# loaded in no harness, and two machines where the library was simply absent.
|
||||
# None of it errored. Exit status here is the signal that was missing.
|
||||
#
|
||||
# doctor human report, non-zero exit on drift
|
||||
# doctor --quiet print only when something is wrong (SessionStart hook)
|
||||
# doctor --json machine-readable findings
|
||||
#
|
||||
# POSIX sh on purpose: it runs on macOS and Linux, before any toolchain is
|
||||
# guaranteed present, and inside a session-start hook where latency shows.
|
||||
# It does no network I/O and never shells out to git for a fetch.
|
||||
#
|
||||
# Test seams: $HOME drives harness paths, $DOCTOR_CHECKOUT names the checkout.
|
||||
|
||||
set -u
|
||||
|
||||
QUIET=0
|
||||
JSON=0
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--quiet) QUIET=1 ;;
|
||||
--json) JSON=1 ;;
|
||||
-h|--help)
|
||||
sed -n '2,20p' "$0" | sed 's/^# \{0,1\}//'
|
||||
exit 0 ;;
|
||||
*) printf 'doctor: unknown option %s\n' "$arg" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
SELF_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
||||
SELF_CHECKOUT=$(CDPATH= cd -- "$SELF_DIR/.." && pwd)
|
||||
|
||||
# The canonical checkout is whatever ~/.agents/skills resolves to — NOT the
|
||||
# copy doctor happens to be running from. When invoked as a plugin hook, $0
|
||||
# lives in the plugin cache, which is legitimately a second copy: judging the
|
||||
# harness links against it would flag every one of them as "outside".
|
||||
if [ -n "${DOCTOR_CHECKOUT:-}" ]; then
|
||||
CHECKOUT="$DOCTOR_CHECKOUT"
|
||||
elif [ -d "$HOME/.agents/skills" ]; then
|
||||
# Resolve the link FIRST, then take the parent. "cd X/.." resolves ".."
|
||||
# lexically against the link path, which lands back in ~/.agents.
|
||||
_resolved=$(CDPATH= cd -- "$HOME/.agents/skills" 2>/dev/null && pwd -P)
|
||||
CHECKOUT=$(dirname -- "$_resolved")
|
||||
else
|
||||
CHECKOUT="$SELF_CHECKOUT"
|
||||
fi
|
||||
SKILLS_DIR="$CHECKOUT/skills"
|
||||
|
||||
findings=0
|
||||
report=""
|
||||
|
||||
# add <severity> <check> <detail>
|
||||
add() {
|
||||
findings=$((findings + 1))
|
||||
if [ "$JSON" -eq 1 ]; then
|
||||
report="${report}{\"severity\":\"$1\",\"check\":\"$2\",\"detail\":\"$(printf '%s' "$3" | sed 's/"/\\"/g')\"},"
|
||||
else
|
||||
report="${report}$1 $2: $3
|
||||
"
|
||||
fi
|
||||
}
|
||||
|
||||
# count_skills <dir> — directories containing a SKILL.md, one level down.
|
||||
count_skills() {
|
||||
[ -d "$1" ] || { printf '0'; return; }
|
||||
find "$1"/ -mindepth 2 -maxdepth 2 -name SKILL.md 2>/dev/null | wc -l | tr -d ' '
|
||||
}
|
||||
|
||||
# --- the checkout itself ----------------------------------------------------
|
||||
if [ ! -d "$SKILLS_DIR" ]; then
|
||||
add FAIL checkout "no skills/ directory at $CHECKOUT"
|
||||
# Nothing downstream can be judged without it.
|
||||
printf 'FAIL checkout: no skills/ directory at %s\n' "$CHECKOUT" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
EXPECTED=$(count_skills "$SKILLS_DIR")
|
||||
[ "$EXPECTED" -gt 0 ] || add FAIL checkout "skills/ holds no SKILL.md at all"
|
||||
|
||||
# --- stray entries in the checkout -----------------------------------------
|
||||
# A loose foo.md where foo/SKILL.md was meant loads in NO harness and errors
|
||||
# in none either. That is exactly how secret-hygiene.md sat dead for months.
|
||||
for entry in "$SKILLS_DIR"/*; do
|
||||
[ -e "$entry" ] || continue
|
||||
name=$(basename "$entry")
|
||||
if [ ! -d "$entry" ]; then
|
||||
add FAIL stray "$SKILLS_DIR/$name is not a directory — it loads nowhere"
|
||||
elif [ ! -f "$entry/SKILL.md" ]; then
|
||||
add FAIL stray "$SKILLS_DIR/$name has no SKILL.md"
|
||||
fi
|
||||
done
|
||||
|
||||
# --- harness wiring ---------------------------------------------------------
|
||||
# Canonical location first; Pi reads it directly and the others link to it.
|
||||
for dir in \
|
||||
"$HOME/.agents/skills" \
|
||||
"$HOME/.config/crush/skills" \
|
||||
"$HOME/.config/opencode/skills"
|
||||
do
|
||||
[ -L "$dir" ] || [ -d "$dir" ] || continue
|
||||
|
||||
if [ -L "$dir" ] && [ ! -e "$dir" ]; then
|
||||
add FAIL dangling "$dir -> $(readlink "$dir") (target does not exist)"
|
||||
continue
|
||||
fi
|
||||
|
||||
# A real directory where a link belongs holds content nothing manages: it
|
||||
# cannot receive new skills and silently shadows the checkout. Distinguished
|
||||
# from the foreign-checkout case below because the fix differs — move it
|
||||
# aside and re-run install.sh, rather than re-point a link.
|
||||
if [ ! -L "$dir" ] && [ -d "$dir" ]; then
|
||||
add FAIL unmanaged "$dir is a real directory, not a link into the checkout — it holds $(count_skills "$dir") unmanaged skill(s); move it aside and re-run install.sh"
|
||||
continue
|
||||
fi
|
||||
|
||||
# Resolve to a physical path and confirm it lands inside THIS checkout.
|
||||
# A link that resolves to a different checkout is the dangerous case: it
|
||||
# works, so nothing complains, while serving stale content.
|
||||
resolved=$(CDPATH= cd -- "$dir" 2>/dev/null && pwd -P)
|
||||
canonical=$(CDPATH= cd -- "$SKILLS_DIR" 2>/dev/null && pwd -P)
|
||||
if [ -n "$resolved" ] && [ "$resolved" != "$canonical" ]; then
|
||||
add FAIL outside "$dir resolves to $resolved, outside the active checkout $canonical"
|
||||
continue
|
||||
fi
|
||||
|
||||
got=$(count_skills "$dir")
|
||||
if [ "$got" != "$EXPECTED" ]; then
|
||||
add FAIL count "$dir sees $got skills, checkout has $EXPECTED"
|
||||
fi
|
||||
done
|
||||
|
||||
# --- dangling links anywhere under the harness dirs -------------------------
|
||||
for dir in "$HOME/.agents/skills" "$HOME/.config/crush/skills" \
|
||||
"$HOME/.config/opencode/skills" "$HOME/.claude/skills"
|
||||
do
|
||||
[ -d "$dir" ] || continue
|
||||
# Plain glob, not find|while: a while loop on the right of a pipe runs in a
|
||||
# subshell, so add() would increment a counter that dies with it and doctor
|
||||
# would exit 0 having found faults.
|
||||
for link in "$dir"/*; do
|
||||
[ -L "$link" ] || continue
|
||||
[ -e "$link" ] && continue
|
||||
add FAIL dangling "$link -> $(readlink "$link") (target does not exist)"
|
||||
done
|
||||
done
|
||||
|
||||
# --- Claude Code: plugin vs leftover per-skill symlinks ---------------------
|
||||
# Plugin skills do NOT override same-named user skills — both stay visible. So
|
||||
# leftover links from the pre-plugin era mean every skill appears twice.
|
||||
CLAUDE_SKILLS="$HOME/.claude/skills"
|
||||
if [ -d "$CLAUDE_SKILLS" ]; then
|
||||
legacy=$(find "$CLAUDE_SKILLS"/ -maxdepth 1 -type l 2>/dev/null | wc -l | tr -d ' ')
|
||||
if [ "$legacy" -gt 0 ]; then
|
||||
add FAIL legacy "$CLAUDE_SKILLS holds $legacy per-skill symlink(s) from the pre-plugin wiring — remove them or every skill loads twice"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Two delivery mechanisms means two copies that can drift apart: the plugin
|
||||
# (Claude Code) and the checkout the other harnesses link to. Same host,
|
||||
# different skill sets, no error — compare them.
|
||||
# Skipped when DOCTOR_CHECKOUT is set: that scopes doctor to an explicit
|
||||
# target, so comparing it against whatever copy the script lives in is
|
||||
# meaningless rather than informative.
|
||||
if [ -z "${DOCTOR_CHECKOUT:-}" ] && [ -f "$SELF_DIR/../.claude-plugin/plugin.json" ] \
|
||||
&& [ "$SELF_CHECKOUT" != "$CHECKOUT" ]; then
|
||||
plugin_count=$(count_skills "$SELF_CHECKOUT/skills")
|
||||
if [ "$plugin_count" != "$EXPECTED" ]; then
|
||||
add FAIL drift "Claude Code plugin serves $plugin_count skills, the other harnesses see $EXPECTED — the two copies have diverged"
|
||||
fi
|
||||
fi
|
||||
|
||||
INSTALLED="$HOME/.claude/plugins/installed_plugins.json"
|
||||
if [ -f "$INSTALLED" ]; then
|
||||
grep -q '"skills@mathias"' "$INSTALLED" 2>/dev/null \
|
||||
|| add WARN plugin "skills@mathias is not installed for Claude Code — /plugin marketplace add https://git.d-ma.be/mathias/skills.git"
|
||||
fi
|
||||
|
||||
# --- output -----------------------------------------------------------------
|
||||
if [ "$JSON" -eq 1 ]; then
|
||||
printf '{"checkout":"%s","expected":%s,"findings":[%s]}\n' \
|
||||
"$CHECKOUT" "$EXPECTED" "${report%,}"
|
||||
elif [ "$findings" -gt 0 ]; then
|
||||
printf 'skills doctor — %s finding(s), checkout %s\n\n%s\n' \
|
||||
"$findings" "$CHECKOUT" "$report"
|
||||
elif [ "$QUIET" -eq 0 ]; then
|
||||
printf 'skills doctor: clean — %s skills, checkout %s\n' "$EXPECTED" "$CHECKOUT"
|
||||
fi
|
||||
|
||||
[ "$findings" -eq 0 ]
|
||||
Executable
+25
@@ -0,0 +1,25 @@
|
||||
#!/bin/sh
|
||||
# should-skip-release — reads a commit message on stdin. Exits 0 when the
|
||||
# release tagger should skip this commit, 1 when it should tag.
|
||||
#
|
||||
# Skipping is opt-in via a git trailer:
|
||||
#
|
||||
# Skip-Release: true
|
||||
#
|
||||
# It is a TRAILER, matched anchored to its own line, deliberately. The first
|
||||
# version searched the whole message for a bracketed marker as a substring,
|
||||
# and the very commit that introduced the manifest-bump step described that
|
||||
# marker in its body — so the tagger read its own documentation as an
|
||||
# instruction and skipped the release. Success, no tag, no bump, no error
|
||||
# (run 15, 2026-08-23). A marker you cannot mention without triggering is a
|
||||
# footgun, not a mechanism.
|
||||
|
||||
set -u
|
||||
|
||||
msg=$(cat)
|
||||
|
||||
if printf '%s\n' "$msg" | grep -qiE '^[[:space:]]*Skip-Release:[[:space:]]*true[[:space:]]*$'; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
exit 1
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"hooks": {
|
||||
"SessionStart": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "sh \"${CLAUDE_PLUGIN_ROOT}/bin/doctor\" --quiet",
|
||||
"timeout": 5,
|
||||
"statusMessage": "Checking skills wiring..."
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"UserPromptSubmit": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/pre-task-ritual.sh\"",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "Bash",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/secret-guard.sh\"",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
Executable
+3
@@ -0,0 +1,3 @@
|
||||
#!/usr/bin/env bash
|
||||
# Surfaces rule 0 on every prompt so the pre-task ritual is visible in context.
|
||||
echo "Rule 0 (non-negotiable): brain_query → load skill → failing test → state success criterion"
|
||||
Executable
+125
@@ -0,0 +1,125 @@
|
||||
#!/usr/bin/env bash
|
||||
# PreToolUse hook (Bash matcher) — hard-blocks command patterns that reveal
|
||||
# secret values into the transcript, rather than relying on remembering an
|
||||
# existence-only check every time.
|
||||
#
|
||||
# Triggered by two real leaks in one session (2026-07-30):
|
||||
# 1. `op item get <name> --format json` — JSON mode has NO masking, unlike
|
||||
# the human-readable table view (`op item get <name>` alone masks
|
||||
# concealed fields with bullets by default). Printed a live Gitea MCP
|
||||
# token into the transcript.
|
||||
# 2. `env | grep -i gitea` (infra#228, 2026-07-27) — same shape: an
|
||||
# inspection command that dumps values instead of checking existence.
|
||||
#
|
||||
# Policy: block ALL direct reads of secret material via these command
|
||||
# shapes, unconditionally, regardless of flags. Existence/rotation must go
|
||||
# through a wrapper script (e.g. infra's scripts/op-safe.sh, scripts/rotate-
|
||||
# *.sh) that strips values before anything reaches stdout — those scripts
|
||||
# call the same underlying commands internally, but as a subprocess of the
|
||||
# script, never as a literal Bash tool call the model composes and can typo.
|
||||
#
|
||||
# Reads PreToolUse hook JSON on stdin, decides allow/deny for the Bash tool.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
INPUT="$(cat)"
|
||||
TOOL_NAME="$(printf '%s' "$INPUT" | jq -r '.tool_name // empty')"
|
||||
[ "$TOOL_NAME" = "Bash" ] || { printf '%s' '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow"}}'; exit 0; }
|
||||
|
||||
CMD="$(printf '%s' "$INPUT" | jq -r '.tool_input.command // empty')"
|
||||
|
||||
deny() {
|
||||
local reason="$1"
|
||||
jq -n --arg reason "$reason" \
|
||||
'{hookSpecificOutput: {hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: $reason}, systemMessage: $reason}'
|
||||
exit 0
|
||||
}
|
||||
|
||||
# 1Password CLI raw reads — block unconditionally, any flags.
|
||||
if printf '%s' "$CMD" | grep -Eqi '(^|[;&|]|\s)op\s+item\s+get\b'; then
|
||||
deny "Blocked: 'op item get' prints secret values to stdout (JSON mode has no masking at all; even table mode can leak via --fields/--reveal). Use a wrapper that strips .fields[].value before output (e.g. scripts/op-safe.sh exists|meta), or op item edit to WRITE without ever printing the value."
|
||||
fi
|
||||
if printf '%s' "$CMD" | grep -Eqi '(^|[;&|]|\s)op\s+item\s+list\b'; then
|
||||
deny "Blocked: 'op item list' can include concealed field previews. Use a wrapper that filters to titles only (e.g. scripts/op-safe.sh list)."
|
||||
fi
|
||||
if printf '%s' "$CMD" | grep -Eqi '(^|[;&|]|\s)op\s+read\b'; then
|
||||
deny "Blocked: 'op read' prints the raw secret value directly. Consume it inside a subprocess only (op run -- <cmd>, or a script that never echoes/captures it to stdout)."
|
||||
fi
|
||||
if printf '%s' "$CMD" | grep -Eqi '(^|[;&|]|\s)op\s+document\s+get\b'; then
|
||||
deny "Blocked: 'op document get' prints document contents (may contain secrets) to stdout. Use a wrapper script instead."
|
||||
fi
|
||||
|
||||
# Raw env/secret-file dumps (infra#228/#229 root cause).
|
||||
#
|
||||
# `env` is a dump only when nothing but options and VAR=value assignments
|
||||
# follow it. `env -u VAR cmd` and `env FOO=bar cmd` EXEC a command with a
|
||||
# modified environment and print nothing themselves — piping those into grep
|
||||
# filters the command's own output, not the environment (#25). The earlier
|
||||
# pattern matched any `env` token followed anywhere later by a pipe into
|
||||
# grep, which blocked the documented 1Password write path
|
||||
# (`env -u OP_SERVICE_ACCOUNT_TOKEN op item create`) and cost a workaround.
|
||||
# Denying only the "no command word follows" shape also catches `env FOO=bar`
|
||||
# with no command, which the two previous rules let through.
|
||||
if printf '%s' "$CMD" | grep -Eqi '(^|[;&|]|[[:space:]])env([[:space:]]+-[iu][[:space:]]*[A-Za-z_][A-Za-z0-9_]*|[[:space:]]+-[iu]|[[:space:]]+[A-Za-z_][A-Za-z0-9_]*=[^[:space:]]*)*[[:space:]]*($|[|;&])'; then
|
||||
deny "Blocked: this 'env' prints every variable's value, including secrets (no command word follows it, so it dumps rather than execs). Use an existence-only check: [ -n \"\$VAR\" ] && echo set. Note 'env -u VAR cmd' and 'env FOO=bar cmd' are allowed — they run a command."
|
||||
fi
|
||||
if printf '%s' "$CMD" | grep -Eqi '(^|[;&|]|\s)printenv\b'; then
|
||||
deny "Blocked: printenv dumps all env values. Use: [ -n \"\$VAR\" ] && echo set"
|
||||
fi
|
||||
if printf '%s' "$CMD" | grep -Eqi '(^|[;&|]|\s)cat\b[^;&|]*\.env'; then
|
||||
deny "Blocked: cat of a .env file prints secret values. Use: grep -c '=' file.env (count only) or [ -s file.env ] && echo present"
|
||||
fi
|
||||
if printf '%s' "$CMD" | grep -Eqi '(^|[;&|]|\s)cat\b[^;&|]*(\.llmkeys|\.op-env|\.op-agent-write\.env)\b'; then
|
||||
deny "Blocked: cat of a known secrets file prints raw values. Use: grep -o '^[A-Z_]*=' file (key names only, never the value)."
|
||||
fi
|
||||
|
||||
# kubectl secrets — -o yaml/json prints base64 (trivially reversible).
|
||||
if printf '%s' "$CMD" | grep -Eqi '(^|[;&|]|\s)kubectl\b[^;&|]*\bget\s+secrets?\b[^;&|]*(-o|--output)[[:space:]=]*(yaml|json)'; then
|
||||
deny "Blocked: 'kubectl get secret -o yaml/json' prints base64-encoded values (trivially reversible, same as plaintext). Use 'kubectl describe secret <name>' (shows keys + byte lengths only, never values) for existence checks."
|
||||
fi
|
||||
|
||||
# Gitea app.ini — mixes plain config ([queue]/[indexer]/[repository]) with
|
||||
# live secrets ([security].INTERNAL_TOKEN/SECRET_KEY, [oauth2].JWT_SECRET,
|
||||
# [server].LFS_JWT_SECRET) in the SAME file, auto-generated on install and
|
||||
# not git/SOPS-tracked. A context-range grep (-A/-B/-C) doesn't respect
|
||||
# section boundaries and silently pulls a secret line into stdout even when
|
||||
# targeting an unrelated section. Root cause of a real leak, infra sprint
|
||||
# 2026-08-04 (`kubectl exec ... cat app.ini | grep -A15 "^\[queue\]"` pulled
|
||||
# through into [oauth2].JWT_SECRET).
|
||||
# Tight on purpose: requires "cat" immediately followed by a bare path-shaped
|
||||
# token ending in app.ini (no intervening whitespace) so this only fires on
|
||||
# an actual invocation ('cat /data/gitea/conf/app.ini', 'cat app.ini') and
|
||||
# never on prose that merely mentions both words, e.g. a commit message
|
||||
# describing this exact guard (that false-positived once while writing the
|
||||
# fix — commands are matched as raw strings, heredoc bodies included).
|
||||
if printf '%s' "$CMD" | grep -Eq '\bcat[[:space:]]+[A-Za-z0-9_./-]*app\.ini\b'; then
|
||||
deny "Blocked: cat of Gitea's app.ini prints secrets mixed into the same file ([security]/[oauth2]/[server].LFS_JWT_SECRET). Use exact-key checks: grep -c '^KEY = ' app.ini (existence only) — never cat the whole file."
|
||||
fi
|
||||
if printf '%s' "$CMD" | grep -Eq '\bgrep\b[^;&|]*-[A-Za-z]*[ABC][A-Za-z]*[[:space:]]*[0-9][^;&|]*app\.ini\b'; then
|
||||
deny "Blocked: grep -A/-B/-C context flags against app.ini can bleed past section boundaries into [security]/[oauth2]/[server] secrets, even when targeting an unrelated section (this is exactly how the 2026-08-04 leak happened). Use an exact anchor instead: grep '^KEY = ' app.ini, or bound to one section with awk '/^\\[section\\]/,/^\\[/' and confirm the section has no secret keys first."
|
||||
fi
|
||||
|
||||
# sops decrypt output inspected directly — infra sweep-sprint incident,
|
||||
# 2026-08-05: `sops -d ntfy-tokens.enc.yaml > /tmp/plain.yaml` (safe,
|
||||
# redirected to a file) immediately followed by `cat /tmp/plain.yaml | head`
|
||||
# in the SAME command block printed a live ntfy token into the transcript.
|
||||
# Re-encrypting a secret (e.g. switching a file to data-only encryption per
|
||||
# infra#177's pattern) requires decrypting it locally at some point — that
|
||||
# redirect-to-file step alone is fine, the leak is any print command touching
|
||||
# the plaintext afterward. Block the combination unconditionally rather than
|
||||
# trying to track which file a decrypt wrote to across separate tool calls.
|
||||
if printf '%s' "$CMD" | grep -Eq '\bsops\b[^;&|]*(-d\b|--decrypt\b|[[:space:]]decrypt\b)' \
|
||||
&& printf '%s' "$CMD" | grep -Eq '\b(cat|head|tail|less|more|bat|od|xxd)\b'; then
|
||||
deny "Blocked: this command decrypts a sops-encrypted file AND runs a print command (cat/head/tail/etc) in the same invocation — that's exactly the 2026-08-05 ntfy-token leak shape. Decrypt to a temp file (sops -d file > /tmp/x, fine on its own), do whatever transform is needed WITHOUT printing it (e.g. sops -e --encrypted-regex '^(data|stringData)\$' /tmp/x > newfile), then shred -u the temp file. Never cat/head/tail a sops-decrypted file, even to 'just check a few lines'."
|
||||
fi
|
||||
|
||||
# base64 transform of a secret-shaped env var — the documented 2026-06-11
|
||||
# 'defeats op run masking' bypass.
|
||||
if printf '%s' "$CMD" | grep -Eqi '\$\{?[A-Za-z0-9_]*(TOKEN|SECRET|PASSWORD|API_KEY|_KEY|DSN|CREDENTIAL|PAT)[A-Za-z0-9_]*\}?[^;&|]*\|[^;&|]*base64'; then
|
||||
deny "Blocked: piping a secret-shaped variable through base64 to inspect it. base64 is an encoding, not masking — op run's own masking only recognizes the raw value, so this is a known bypass (2026-06-11 incident). Never transform a secret to look at it."
|
||||
fi
|
||||
if printf '%s' "$CMD" | grep -Eqi 'base64[^;&|]*<<<\s*"?\$\{?[A-Za-z0-9_]*(TOKEN|SECRET|PASSWORD|API_KEY|_KEY|DSN|CREDENTIAL|PAT)'; then
|
||||
deny "Blocked: base64 <<< of a secret-shaped variable. Same bypass as above — never transform a secret to inspect it."
|
||||
fi
|
||||
|
||||
jq -n '{hookSpecificOutput: {hookEventName: "PreToolUse", permissionDecision: "allow"}}'
|
||||
+67
-99
@@ -5,25 +5,55 @@
|
||||
# curl -fsSL https://git.d-ma.be/mathias/skills/raw/branch/main/install.sh | bash
|
||||
#
|
||||
# Pinned to a specific tag:
|
||||
# SKILLS_REF=v0.1.0 bash install.sh
|
||||
# SKILLS_REF=v0.4.0 bash install.sh
|
||||
#
|
||||
# Idempotent: re-running pulls the latest changes for the configured ref
|
||||
# (default: main) and re-wires symlinks. Existing correct links are
|
||||
# left alone; mismatched ones are replaced.
|
||||
# (default: main) and re-wires the links.
|
||||
#
|
||||
# Claude Code is NOT wired here. It installs this repo as a plugin from the
|
||||
# marketplace this repo also hosts:
|
||||
#
|
||||
# /plugin marketplace add https://git.d-ma.be/mathias/skills.git
|
||||
# /plugin install skills@mathias
|
||||
#
|
||||
# That path carries the hooks and MCP server definitions too, which symlinks
|
||||
# cannot. This script covers the harnesses with no plugin system: Crush, Pi
|
||||
# and OpenCode.
|
||||
#
|
||||
# Wiring model — one directory link per harness, never one per skill:
|
||||
#
|
||||
# ~/.agents/skills -> $CHECKOUT_DIR/skills (canonical; Pi reads it directly)
|
||||
# ~/.config/crush/skills -> ~/.agents/skills
|
||||
# ~/.config/opencode/skills -> ~/.agents/skills
|
||||
#
|
||||
# Adding a skill to the repo therefore wires it everywhere with no re-run,
|
||||
# and a broken link is one broken link rather than 42.
|
||||
#
|
||||
# CHECKOUT_DIR deliberately defaults OUTSIDE any file-synced or
|
||||
# cross-platform-shared directory. Do not "fix" it to point into ~/dev: on a
|
||||
# fleet whose workspace is shared between macOS (/Users/...) and Linux
|
||||
# (/home/...), an absolute link stored there is broken on one side by
|
||||
# construction. See mathias/skills#23.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
REPO_URL="${SKILLS_REPO_URL:-https://git.d-ma.be/mathias/skills.git}"
|
||||
REF="${SKILLS_REF:-main}"
|
||||
CHECKOUT_DIR="${SKILLS_CHECKOUT_DIR:-$HOME/.local/share/skills}"
|
||||
CLAUDE_GLOBAL_DIR="${CLAUDE_SKILLS_DIR:-$HOME/.claude/skills}"
|
||||
AGENTS_DIR="${AGENTS_SKILLS_DIR:-$HOME/.agents/skills}"
|
||||
CRUSH_DIR="${CRUSH_SKILLS_DIR:-$HOME/.config/crush/skills}"
|
||||
ANTIGRAVITY_DIR="${ANTIGRAVITY_SKILLS_DIR:-$HOME/.gemini/antigravity/skills}"
|
||||
VIBE_SKILLS_DIR="${VIBE_SKILLS_DIR:-$HOME/.vibe/skills}"
|
||||
OPENCODE_DIR="${OPENCODE_SKILLS_DIR:-$HOME/.config/opencode/skills}"
|
||||
|
||||
log() { printf '[skills] %s\n' "$*"; }
|
||||
|
||||
ensure_checkout() {
|
||||
# Wiring an existing working checkout (task install) must not fetch or
|
||||
# move HEAD — that would discard in-progress work.
|
||||
if [ "${SKILLS_SKIP_FETCH:-}" = "1" ]; then
|
||||
[ -d "$CHECKOUT_DIR/skills" ] || { log "no skills/ under $CHECKOUT_DIR"; exit 1; }
|
||||
log "using $CHECKOUT_DIR as-is (SKILLS_SKIP_FETCH=1)"
|
||||
return
|
||||
fi
|
||||
if [ -d "$CHECKOUT_DIR/.git" ]; then
|
||||
log "updating $CHECKOUT_DIR"
|
||||
git -C "$CHECKOUT_DIR" fetch --tags --quiet
|
||||
@@ -40,109 +70,47 @@ ensure_checkout() {
|
||||
fi
|
||||
}
|
||||
|
||||
list_skills() {
|
||||
# Every top-level entry that isn't a known meta file.
|
||||
(cd "$CHECKOUT_DIR" && ls -1) | grep -Ev '^(Taskfile\.yml|install\.sh|README\.md|SKILLS_INDEX\.md|\.gitea|\.git)$' || true
|
||||
count_skills() {
|
||||
find "$CHECKOUT_DIR/skills" -mindepth 2 -maxdepth 2 -name SKILL.md 2>/dev/null | wc -l | tr -d ' '
|
||||
}
|
||||
|
||||
link_skill() {
|
||||
# link_skill <target_dir> <skill_name>
|
||||
local target_dir="$1"
|
||||
local skill="$2"
|
||||
local target="$CHECKOUT_DIR/$skill"
|
||||
local link="$target_dir/$skill"
|
||||
|
||||
if [ -L "$link" ] || [ -e "$link" ]; then
|
||||
local current
|
||||
current=$(readlink "$link" 2>/dev/null || true)
|
||||
if [ "$current" = "$target" ]; then
|
||||
return
|
||||
# link_dir <link_path> <target_path>
|
||||
# Replaces whatever is there — including a directory left behind by the old
|
||||
# per-skill wiring — with a single symlink.
|
||||
link_dir() {
|
||||
local link="$1" target="$2"
|
||||
if [ -L "$link" ]; then
|
||||
[ "$(readlink "$link")" = "$target" ] && return
|
||||
rm -f "$link"
|
||||
elif [ -d "$link" ]; then
|
||||
# Refuse to discard a directory holding anything but symlinks: that
|
||||
# would be someone's real skills, not our old wiring.
|
||||
if find "$link" -mindepth 1 -maxdepth 1 ! -type l | grep -q .; then
|
||||
log "REFUSING to replace $link — it holds non-symlink entries; move them and re-run"
|
||||
return 1
|
||||
fi
|
||||
rm -rf "$link"
|
||||
find "$link" -mindepth 1 -maxdepth 1 -type l -delete
|
||||
rmdir "$link"
|
||||
elif [ -e "$link" ]; then
|
||||
rm -f "$link"
|
||||
fi
|
||||
mkdir -p "$(dirname "$link")"
|
||||
ln -s "$target" "$link"
|
||||
log "linked $link → $target"
|
||||
}
|
||||
|
||||
wire_claude_global() {
|
||||
mkdir -p "$CLAUDE_GLOBAL_DIR"
|
||||
while IFS= read -r skill; do
|
||||
[ -n "$skill" ] || continue
|
||||
link_skill "$CLAUDE_GLOBAL_DIR" "$skill"
|
||||
done < <(list_skills)
|
||||
}
|
||||
|
||||
wire_crush() {
|
||||
# Crush reads skills from ~/.config/crush/skills/<name>/SKILL.md.
|
||||
# Pre-creating the dir is cheap even when Crush isn't installed.
|
||||
mkdir -p "$CRUSH_DIR"
|
||||
while IFS= read -r skill; do
|
||||
[ -n "$skill" ] || continue
|
||||
link_skill "$CRUSH_DIR" "$skill"
|
||||
done < <(list_skills)
|
||||
}
|
||||
|
||||
wire_antigravity() {
|
||||
# Antigravity (Google's VS Code extension) reads global skills from
|
||||
# ~/.gemini/antigravity/skills/<name>/SKILL.md. Our SKILL.md files
|
||||
# already carry the required `name` + `description` YAML frontmatter,
|
||||
# so symlinks are sufficient — no manifest translation step.
|
||||
mkdir -p "$ANTIGRAVITY_DIR"
|
||||
while IFS= read -r skill; do
|
||||
[ -n "$skill" ] || continue
|
||||
link_skill "$ANTIGRAVITY_DIR" "$skill"
|
||||
done < <(list_skills)
|
||||
}
|
||||
|
||||
wire_claude_repo() {
|
||||
# Only wire per-repo when invoked from inside a git repo and it isn't
|
||||
# the skills repo itself.
|
||||
if ! git rev-parse --show-toplevel >/dev/null 2>&1; then
|
||||
return
|
||||
fi
|
||||
local repo
|
||||
repo=$(git rev-parse --show-toplevel)
|
||||
if [ "$repo" = "$CHECKOUT_DIR" ]; then
|
||||
return
|
||||
fi
|
||||
local target_dir="$repo/.claude/skills"
|
||||
mkdir -p "$target_dir"
|
||||
while IFS= read -r skill; do
|
||||
[ -n "$skill" ] || continue
|
||||
link_skill "$target_dir" "$skill"
|
||||
done < <(list_skills)
|
||||
}
|
||||
|
||||
wire_vibe() {
|
||||
# Mistral Vibe reads a flat ~/.vibe/skills/<name>.md — link the SKILL.md
|
||||
# file directly (not the skill directory, as the other harnesses do).
|
||||
mkdir -p "$VIBE_SKILLS_DIR"
|
||||
while IFS= read -r skill; do
|
||||
[ -n "$skill" ] || continue
|
||||
local target="$CHECKOUT_DIR/$skill/SKILL.md"
|
||||
[ -f "$target" ] || continue
|
||||
local link="$VIBE_SKILLS_DIR/$skill.md"
|
||||
if [ -L "$link" ] || [ -e "$link" ]; then
|
||||
local current
|
||||
current=$(readlink "$link" 2>/dev/null || true)
|
||||
if [ "$current" = "$target" ]; then
|
||||
continue
|
||||
fi
|
||||
rm -rf "$link"
|
||||
fi
|
||||
ln -s "$target" "$link"
|
||||
log "linked $link → $target"
|
||||
done < <(list_skills)
|
||||
}
|
||||
|
||||
main() {
|
||||
ensure_checkout
|
||||
wire_claude_global
|
||||
wire_claude_repo
|
||||
wire_crush
|
||||
wire_antigravity
|
||||
wire_vibe
|
||||
log "done — $(list_skills | wc -l | tr -d ' ') skill(s) wired at ref=$REF"
|
||||
|
||||
# Canonical location. Pi reads ~/.agents/skills directly, so it needs no
|
||||
# harness-specific link of its own.
|
||||
link_dir "$AGENTS_DIR" "$CHECKOUT_DIR/skills"
|
||||
|
||||
link_dir "$CRUSH_DIR" "$AGENTS_DIR"
|
||||
link_dir "$OPENCODE_DIR" "$AGENTS_DIR"
|
||||
|
||||
log "done — $(count_skills) skill(s) at ref=$REF, 3 links total"
|
||||
log "Claude Code: install the plugin instead — /plugin marketplace add $REPO_URL"
|
||||
}
|
||||
|
||||
main "$@"
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
---
|
||||
name: cad-babysit
|
||||
description: Focused human-in-the-loop session working CAD's stuck queue (cad-stuck output). Read-only — diagnoses, never dispatches, arms a label, or closes an issue. Use when picking up whatever CAD has run into trouble on, with a human present.
|
||||
---
|
||||
|
||||
# CAD Babysit
|
||||
|
||||
## Overview
|
||||
|
||||
A deliberately narrow session: pick up whatever CAD has run into trouble on, work it with a
|
||||
human present, and stop. This is not an autonomous supervisor — it is a babysitter with a
|
||||
person in the room, and that is the point, not a limitation to route around later.
|
||||
|
||||
**Core principle:** an unattended supervisor reproduces the exact failure it exists to catch.
|
||||
`dispatch` ran dead for eight weeks because it exited 0 and nobody noticed (`dispatch#33`); a
|
||||
long-running watcher process that dies quietly has the same property one level up, and
|
||||
manufactures false confidence while doing it. With a human present, **the human is the
|
||||
liveness check** — that removes the objection entirely and needs no new machinery.
|
||||
|
||||
This skill is the ritual around `cad stuck` (`mathias/cad#14`), not a replacement for it.
|
||||
Without that query it would be a checklist with no input; the query without this skill is
|
||||
data nobody reads carefully. Neither closes the loop alone.
|
||||
|
||||
## When to Use
|
||||
|
||||
- Picking up CAD's stuck queue with a human present and time set aside to work it
|
||||
- After `dispatch` (or any executor) reports a failure and it is not immediately obvious
|
||||
whether it is an infrastructure fault or a real task outcome
|
||||
- Reviewing the "awaiting human" queue (issues CI has verified but nobody has closed)
|
||||
|
||||
**Do not use for:**
|
||||
- Unattended/scheduled runs. This skill assumes a human is present for its entire duration —
|
||||
if nobody is watching, its central safety property (human-as-liveness-check) does not hold.
|
||||
- Dispatching, arming a redispatch label, or closing an issue. This skill only ever diagnoses;
|
||||
see Iron Laws.
|
||||
|
||||
## Iron Laws
|
||||
|
||||
1. **Read-only, always.** No dispatching, no arming labels (including `status:redispatch`), no
|
||||
closing or reopening an issue. A reviewer that can act on its own findings has no
|
||||
independent check — control stays where it already is.
|
||||
2. **Every finding cites a locator.** A run id, commit sha, comment permalink, or a specific
|
||||
log line. A finding without one is an unfalsifiable opinion about a machine — do not emit
|
||||
it, and do not soften this into "probably" language instead of dropping it.
|
||||
3. **Re-derive, never recall.** State lives in Gitea and Actions, not in this session. Start
|
||||
cold every time: run `cad stuck` fresh, do not carry forward a prior session's worklist.
|
||||
Sessions get compacted and lose exactly the cross-run history that makes a stale worklist
|
||||
dangerous to trust.
|
||||
4. **Distinguish "blocked" from "awaiting human".** `cad stuck` already makes this split
|
||||
(`stuck` vs `awaiting_human`); preserve it in every summary. The `awaiting_human` class is
|
||||
the human gate working, not a bottleneck — do not report it as one.
|
||||
5. **Anything that is the operator's call surfaces as `DECISION NEEDED`, never gets decided
|
||||
for them.** See Output Format.
|
||||
|
||||
## Process
|
||||
|
||||
### Phase 1 — Query (re-derive the worklist)
|
||||
|
||||
Run `cad stuck` against the estate (the `dispatch-repos.txt` scope, or whatever repo set the
|
||||
operator names). This is the only input. Do not substitute a memory of a prior run, a brain
|
||||
note, or this issue's own worked examples — all three can be stale within the same day (a
|
||||
taxonomy row's cited live example can stop reproducing between when it was written and when
|
||||
you run the query, because the underlying state genuinely changed; that is the design working,
|
||||
not a bug to paper over).
|
||||
|
||||
Group the output by class exactly as `cad stuck` returns it: `stuck` first, `awaiting_human`
|
||||
second. Pick ONE row to work per pass — this skill is deliberately narrow, not a full-estate
|
||||
sweep.
|
||||
|
||||
### Phase 2 — Read the issue's own comments FIRST
|
||||
|
||||
Before anything else, before touching Actions or pod logs: read every comment on the issue,
|
||||
oldest first. This step exists because it was skipped once and it cost a session — a confident
|
||||
wrong root cause was produced from pod logs while the correct diagnosis sat in the issue's own
|
||||
comment thread from nine days earlier (`cad-atlas#10`). The rule "read issue comments before
|
||||
working" was already written down elsewhere and was not followed; it is written down again
|
||||
here because that is exactly the kind of already-known thing a rushed session skips.
|
||||
|
||||
What to look for: the bot's own report comments (routing decision, ground-truth verdict,
|
||||
attempt count), and any human comment that already contains the diagnosis.
|
||||
|
||||
### Phase 3 — THEN the Actions job logs
|
||||
|
||||
Only after Phase 2 turns up nothing conclusive, read the CI job logs:
|
||||
|
||||
```
|
||||
GET /repos/{owner}/{repo}/actions/runs # find the run, match on .run_number
|
||||
GET /repos/{owner}/{repo}/actions/jobs/{job_id}/logs # returns 200, full text, secrets masked
|
||||
```
|
||||
|
||||
This endpoint works. A prior note claiming it 404s was wrong and cost a diagnosis the day this
|
||||
skill was written — do not rediscover that. Get the run id from `/actions/runs`, not from the
|
||||
`run_number` shown in a comment (they are different fields; match on `.run_number` to find the
|
||||
internal `id` the jobs endpoint needs).
|
||||
|
||||
This is where the two most valuable findings of the day this skill is drawn from lived: an
|
||||
agent issuing the identical refused command five times and stopping (never trying the tools it
|
||||
was actually permitted to use), and a different model, same denial, writing a Go program to
|
||||
route around it. Neither is visible from the issue comments alone — no metric produces either
|
||||
of them. They need a reader of the raw transcript.
|
||||
|
||||
### Phase 4 — Separate infrastructure fault from task outcome
|
||||
|
||||
A run that died before turn 1 says nothing about the issue itself. Before concluding anything
|
||||
about the WORK, check whether the run reached the model at all:
|
||||
|
||||
- Zero turns, a 2-3 record transcript, a "could not start" comment, or a claim label with no
|
||||
attempt comment behind it → infrastructure fault. This is not evidence the issue is hard, ill
|
||||
specified, or that the assigned model is unsuitable — it is evidence something upstream
|
||||
(credit, a key, MCP connectivity, the executor binary) is broken.
|
||||
- A run that reached the model, drove tools, and stopped on a ceiling, a denial loop, or a
|
||||
genuine task failure → a real task outcome, worth diagnosing on its own terms.
|
||||
|
||||
Four claim labels were burned this exact way in one estate sweep and the tracker read them as
|
||||
four handled issues (`dispatch#33`). Get this distinction wrong and everything downstream is
|
||||
about the wrong thing.
|
||||
|
||||
### Phase 5 — Decide, or say `DECISION NEEDED`
|
||||
|
||||
For each row worked, produce one of:
|
||||
|
||||
- **A closed diagnosis** — cause identified, cited to a locator, no operator judgment call
|
||||
required (e.g. "infrastructure fault, credit exhausted, see comment #N — retry is automatic
|
||||
on the next cycle, no action needed").
|
||||
- **A `DECISION NEEDED`** — anything that is genuinely the operator's call: whether to close a
|
||||
verified-but-open issue, whether to raise a ceiling, whether to abandon an issue that keeps
|
||||
hitting the same denial. Never make this call on the operator's behalf.
|
||||
|
||||
This skill's OWN last act, every time, is to stop — not to dispatch, not to redispatch, not to
|
||||
close. Ending a pass is itself the enforcement of Iron Law 1.
|
||||
|
||||
## Output Format
|
||||
|
||||
For a closed diagnosis:
|
||||
|
||||
```
|
||||
DIAGNOSIS: <repo>#<issue> — <category from cad stuck, e.g. ceiling_hit>
|
||||
CAUSE: <one sentence, cites the mechanism>
|
||||
LOCATOR: <url or run id / log line>
|
||||
ACTION: <what happens automatically, or "none — informational">
|
||||
```
|
||||
|
||||
For anything requiring a human call:
|
||||
|
||||
```
|
||||
DECISION NEEDED: <repo>#<issue>
|
||||
QUESTION: <the exact thing only the operator can decide>
|
||||
OPTIONS:
|
||||
1. <option> — <consequence>
|
||||
2. <option> — <consequence>
|
||||
RECOMMENDATION: <your pick, with reasoning — but the operator decides>
|
||||
BLOCKS: <what stays stuck until this is answered, if anything>
|
||||
```
|
||||
|
||||
End every pass with a one-line summary: `N diagnosed, M decisions needed, stopping.`
|
||||
|
||||
## Worked Example
|
||||
|
||||
`cad stuck` returns `mathias/cad-atlas#10 [near_attempt_cap] 4 dispatch attempts recorded, at
|
||||
or above the near-cap threshold (4 of MaxAttempts=5).`
|
||||
|
||||
Phase 2 (issue comments): the issue's own thread already explains the pattern — repeated
|
||||
`status:redispatch` re-arming without the label being cleared after a completed attempt.
|
||||
|
||||
Phase 3: not needed — Phase 2 was conclusive.
|
||||
|
||||
Phase 4: each of the 4 attempts reached the model and ran (not an infrastructure fault) — a
|
||||
real re-fire pattern, not a claim-label artifact.
|
||||
|
||||
Phase 5 output:
|
||||
|
||||
```
|
||||
DECISION NEEDED: mathias/cad-atlas#10
|
||||
QUESTION: one more automatic attempt remains before MaxAttempts=5 gives up permanently. Let
|
||||
it fire, or intervene now (fix the underlying cause, or manually close/redirect the issue)?
|
||||
OPTIONS:
|
||||
1. Let it fire — costs one more headless run; if it fails the same way, dispatch gives up
|
||||
automatically and posts a terminal comment. No action needed until then.
|
||||
2. Intervene now — diagnose why redispatch keeps re-arming (likely: the watcher isn't
|
||||
clearing RedispatchLabel after a serviced run) and fix that before the cap is spent on
|
||||
a symptom rather than the cause.
|
||||
RECOMMENDATION: option 2. The pattern (redispatch not self-clearing after a completed run) is
|
||||
already documented as a known operational hazard (dispatch#54) — spending the last attempt
|
||||
on it again teaches nothing new.
|
||||
BLOCKS: nothing else; this issue's own progress.
|
||||
```
|
||||
|
||||
`1 diagnosed, 1 decision needed, stopping.`
|
||||
|
||||
## Anti-Patterns
|
||||
|
||||
| Anti-Pattern | Why It Fails |
|
||||
|---|---|
|
||||
| Diagnosing from pod/Actions logs before reading issue comments | The exact mistake that cost a session — the answer was already written down nine days earlier. |
|
||||
| Reporting `awaiting_human` rows as a backlog or bottleneck | It is the human gate working. Misreporting it manufactures urgency that isn't there and trains the operator to distrust the queue. |
|
||||
| Treating a zero-turn, could-not-start run as evidence about the issue | It is an infrastructure fault. Four claim labels were burned exactly this way and read as four handled issues. |
|
||||
| Deciding a `DECISION NEEDED` case on the operator's behalf "to save time" | Removes the one property that makes this skill safe to run unattended-adjacent: the human decides, this skill only prepares the decision. |
|
||||
| Applying `status:redispatch`, closing an issue, or adding any label from this skill | Control stays where it is. This skill has no write path and should never be given one. |
|
||||
| Carrying forward a worklist from an earlier session instead of re-running `cad stuck` | State changes between sessions (see the worked note on `brain-gardener#190` in `cad#14`'s own acceptance run — its trigger label was gone hours later). A stale worklist gives false confidence, which is the specific failure mode this whole skill exists to avoid. |
|
||||
|
||||
## Honest Limitation
|
||||
|
||||
This skill encodes the *repeatable* diagnostic sequence: read comments, then logs, separate
|
||||
fault from outcome, verify by exit code, re-derive rather than recall. It reliably catches the
|
||||
taxonomy `cad stuck` already knows about. It will not, by itself, produce a genuinely novel
|
||||
finding the way an unhurried human-plus-agent reading session did the day this skill was
|
||||
written — that came from noticing things (an agent editing the file that authorizes it, an
|
||||
issue body recommending a command the policy forbids) that no checklist anticipates. Expect
|
||||
this skill to be reliable on known failure modes and to miss the next new one. That is an
|
||||
acceptable trade as long as nobody mistakes a clean pass through this skill for "nothing is
|
||||
wrong" — it means "nothing on the known list is wrong."
|
||||
|
||||
## Brain MCP Integration
|
||||
|
||||
**At session start:**
|
||||
- Run `brain_query` for the domain (`cad`, `dispatch`, the specific repo) to check whether a
|
||||
finding this session is about to produce has already been diagnosed and written up. Do not
|
||||
re-diagnose a known pattern from scratch every session.
|
||||
|
||||
**After a genuinely novel finding (not already in the taxonomy or the brain):**
|
||||
- `brain_write` the pattern, generalized beyond the specific issue — the failure mode, not the
|
||||
issue number. Do not write routine, already-known-taxonomy findings; that is what `cad
|
||||
stuck`'s own output already records, durably, in Gitea.
|
||||
|
||||
**Never:**
|
||||
- Write a `DECISION NEEDED` outcome to the brain before the operator has actually decided —
|
||||
that would record a guess as a resolution.
|
||||
|
||||
### Logging
|
||||
|
||||
Call `session_log` once at the end of every phase to record the outcome.
|
||||
|
||||
**At end of each phase:**
|
||||
- `session_log` with `{skill: "cad-babysit", phase: "<phase-name>", final_status: "pass" |
|
||||
"fail" | "skip", message: "<one-line summary>", duration_ms: <wall-clock>, project_root:
|
||||
"<absolute path>"}`
|
||||
|
||||
**Phases for this skill:** query, read-comments, read-actions-logs, classify-fault-vs-outcome,
|
||||
decide
|
||||
|
||||
**Status semantics:**
|
||||
- `pass` — the phase's intended outcome was reached (worklist re-derived; comments read;
|
||||
fault/outcome separated; a diagnosis or `DECISION NEEDED` was produced).
|
||||
- `fail` — the phase could not be completed (e.g. `cad stuck` errored, Gitea was unreachable).
|
||||
- `skip` — phase intentionally skipped (e.g. Phase 3 skipped because Phase 2 was conclusive).
|
||||
|
||||
## Mode 2 Routing Note
|
||||
|
||||
Phases 2-4 (reading comments/logs, classifying fault vs outcome) are judgment-heavy and should
|
||||
stay Mode 1. Phase 1 (running `cad stuck` and grouping its output) is mechanical and a Mode 2
|
||||
routing candidate once Plan 6 ships the routing pod.
|
||||
|
||||
## Cross-References
|
||||
|
||||
- Depends on `mathias/cad#14`'s `cad stuck` query — this skill is the ritual around it, not a
|
||||
replacement.
|
||||
- Load `debug` for the hypothesis-generation discipline if Phase 4's fault/outcome split is
|
||||
itself ambiguous.
|
||||
- Load `secret-hygiene` before reading raw Actions job logs if the repo is client-tagged —
|
||||
logs are secret-masked by Gitea but a pasted excerpt into a summary is not.
|
||||
- `mathias/cad-atlas#11` renders the same `cad stuck` output as a pipeline overlay — read that
|
||||
instead of this skill's text output when a visual "where is everything stuck" view is wanted
|
||||
over working one row in depth.
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: gitea-ci
|
||||
description: End-to-end Gitea CI/CD setup with self-hosted act_runner, quality gate pipeline, buildah image build, k3s deploy, and GitHub mirror. Covers act_runner-specific gotchas that are not documented anywhere.
|
||||
description: End-to-end Gitea CI/CD setup with self-hosted act_runner, quality gate pipeline, shared buildctl image-build script, k3s deploy, and GitHub mirror. Covers act_runner-specific gotchas that are not documented anywhere.
|
||||
---
|
||||
|
||||
# Gitea CI/CD Pipeline Setup
|
||||
@@ -31,7 +31,7 @@ Before writing any YAML, collect the following. Use AskUserQuestion for anything
|
||||
| Container registry | Ask or check existing config | `localhost:5000` on koala, or external |
|
||||
| k3s namespace | k8s/ manifests or ask | Deploy target |
|
||||
| GitHub mirror repo | Ask | Target for the mirror job |
|
||||
| Image name | Ask or derive from repo name | Used in buildah + k3s steps |
|
||||
| Image name | Ask or derive from repo name | Used in buildctl + k3s steps |
|
||||
|
||||
Check the project's `CLAUDE.md` for infra notes first — often this answers most questions without needing to ask.
|
||||
|
||||
@@ -133,35 +133,46 @@ jobs:
|
||||
echo "version-tag=${REF#refs/tags/}" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
# infra#309: do NOT paste a build implementation here. The estate has
|
||||
# exactly one, mathias/infra's ci-templates/build-image.sh, and each
|
||||
# repo carries a byte-identical copy at .gitea/build-image.sh that a
|
||||
# drift gate keeps current.
|
||||
#
|
||||
# Copying the implementation is what made the buildah->buildctl
|
||||
# migration (infra#241) an eleven-repo sprint, and what left this very
|
||||
# skill handing out the broken version for weeks after the app repos
|
||||
# were fixed. Per-repo differences are INPUTS, not edits to the copy.
|
||||
#
|
||||
# To adopt in a new repo:
|
||||
# 1. add the repo to mathias/infra ci-templates/adopters.txt
|
||||
# 2. run `task ci:sync` in mathias/infra -- it writes the copy
|
||||
# 3. commit .gitea/build-image.sh in your repo
|
||||
# 4. call it as below
|
||||
#
|
||||
# Skipping step 1 leaves the copy unmanaged: it works today and falls
|
||||
# silently behind the next time the canonical file changes.
|
||||
#
|
||||
# Inputs: IMAGE (required, usually the workflow-level env), SHA_TAG,
|
||||
# VERSION_TAG, GIT_SHA, IMAGE_SOURCE, BUILD_ARGS, EXTRA_LABELS,
|
||||
# CONTEXT, DOCKERFILE_DIR, DOCKERFILE_NAME, PUSH_LATEST. Read the
|
||||
# canonical file's header for the full list.
|
||||
#
|
||||
# `bash <script>`, not a direct exec: a file written through the Gitea
|
||||
# API lands 0644, and CI that depends on a file mode is a trap.
|
||||
- name: Build and push to local registry
|
||||
run: |
|
||||
REGISTRY="localhost:5000"
|
||||
REF="${REGISTRY}/${{ env.IMAGE }}:${{ steps.meta.outputs.sha-tag }}"
|
||||
buildah build \
|
||||
--label "org.opencontainers.image.revision=${{ github.sha }}" \
|
||||
--label "org.opencontainers.image.source=${{ github.repositoryUrl }}" \
|
||||
-t ${REF} \
|
||||
-t ${REGISTRY}/${{ env.IMAGE }}:latest \
|
||||
.
|
||||
buildah push --tls-verify=false ${REF}
|
||||
buildah push --tls-verify=false ${REGISTRY}/${{ env.IMAGE }}:latest
|
||||
[[ -n "${{ steps.meta.outputs.version-tag }}" ]] && \
|
||||
buildah push --tls-verify=false \
|
||||
${REF} \
|
||||
${REGISTRY}/${{ env.IMAGE }}:${{ steps.meta.outputs.version-tag }} || true
|
||||
echo "✓ Image pushed to ${REF}"
|
||||
env:
|
||||
SHA_TAG: ${{ steps.meta.outputs.sha-tag }}
|
||||
VERSION_TAG: ${{ steps.meta.outputs.version-tag }}
|
||||
GIT_SHA: ${{ github.sha }}
|
||||
IMAGE_SOURCE: ${{ github.repositoryUrl }}
|
||||
run: bash .gitea/build-image.sh
|
||||
|
||||
- name: Smoke test
|
||||
run: |
|
||||
REGISTRY="localhost:5000"
|
||||
REF="${REGISTRY}/${{ env.IMAGE }}:${{ steps.meta.outputs.sha-tag }}"
|
||||
CNAME="smoke-${{ steps.meta.outputs.sha-tag }}"
|
||||
sudo k3s ctr images pull --plain-http ${REF}
|
||||
OUTPUT=$(timeout 5 sudo k3s ctr run --rm ${REF} ${CNAME} /<binary-name> 2>&1 || true)
|
||||
sudo k3s ctr containers delete ${CNAME} 2>/dev/null || true
|
||||
echo "$OUTPUT" | grep -q "<project-name>" \
|
||||
&& echo "✓ Smoke test passed" \
|
||||
|| echo "⚠ Smoke test inconclusive: $OUTPUT"
|
||||
# No smoke test step. The one this skill used to recommend ran
|
||||
# `sudo k3s ctr run` against the host's containerd -- that worked when
|
||||
# the runner was the Arch host, and is impossible now that it is a pod:
|
||||
# no sudo, no ctr, no containerd socket (`sudo: command not found`).
|
||||
# The deploy job's `Verify rollout` + `Confirm pod running new image`
|
||||
# below assert the same property against the real workload.
|
||||
|
||||
# ── 3. Deploy to k3s ────────────────────────────────────────────────────────
|
||||
deploy:
|
||||
@@ -0,0 +1,76 @@
|
||||
---
|
||||
name: secret-hygiene
|
||||
description: Prevent agent sessions from rendering credentials into the transcript, and recover safely when one leaks. Load BEFORE any command that touches credentials - kubectl exec/env/describe, kubectl get secret, whole-file SOPS decryption, token generation or rotation, envFrom inspection, or edits to encrypted config. Also load when a credential has already been exposed and a rotation chain is needed. Triggers - "rotate this token", "decrypt the config", "check the env var", "leaked credential", "did that print a secret", "why is this secret wrong".
|
||||
---
|
||||
|
||||
# Secret hygiene for agent sessions
|
||||
|
||||
Goal: stop the recurring leak pattern where a diagnostic command renders a
|
||||
SOPS-encrypted value or a Kubernetes Secret value into the session
|
||||
transcript, forcing a rotation.
|
||||
|
||||
**Confirmed past leaks** (see `mathias/infra#39` rotation umbrella):
|
||||
- `POSTGRES_PASSWORD` — `kubectl exec ... env` rendered envFrom value
|
||||
- `INFRA_MCP_TOKEN` — `sops -d ingress.enc.yaml` rendered the bearer in the configuration-snippet
|
||||
- `brain_app` postgres DSN — pre-fix ingestion pod log (hyperguild#20)
|
||||
|
||||
Three are pattern, not bug.
|
||||
|
||||
## Rules — never do these
|
||||
|
||||
| Forbidden | Why | Use instead |
|
||||
|---|---|---|
|
||||
| `kubectl exec <pod> -- env` (or `printenv`) | Renders every envFrom + valueFrom.secretKeyRef plaintext | `kubectl describe pod <pod>` — k8s redacts secretKeyRef in describe |
|
||||
| `sops -d <file>` to inspect | Whole-file decrypt dumps everything | `sops --extract '["key"]' <file>` for pinpoint reads |
|
||||
| `kubectl get secret ... -o jsonpath='{.data}'` (whole map) | Returns base64 of every key; trivial to b64-decode the lot | `... -o jsonpath='{.data.SPECIFIC_KEY}'` for the one key needed |
|
||||
| Echoing a generated token to verify length / content | Renders the new token into transcript, defeating the rotation | Verify functionally: `curl ... -H "Authorization: Bearer $(cat /tmp/tokfile)"` — never echo `$NEW_TOK` |
|
||||
| Storing a token in a shell variable then running `echo` / `set` / `env` | Same as above | Pipe through file (mode 600) or stdin to the next consumer |
|
||||
|
||||
## Rules — do these instead
|
||||
|
||||
- **Verify by side-effect, not by value.** If a token rotation should make
|
||||
the old token return 401 and the new one return 200, test those two
|
||||
HTTP responses. Never check `$OLD == $LEAKED` by printing it.
|
||||
- **Pinpoint SOPS reads.** When you need ONE key from a SOPS file, always
|
||||
`sops --extract '["..."]'`. The shape of the key path is safe to log;
|
||||
the value isn't.
|
||||
- **Stash tokens in `/dev/shm/<unique>` mode 600** and pass via filename,
|
||||
not env. `shred -u` after use. Avoid `/tmp` if the system has `/dev/shm`.
|
||||
- **If you must dump a secret to confirm wiring**, immediately rotate it
|
||||
in the same commit train. Treat the dump as "this is now compromised".
|
||||
|
||||
## Rotation chain pattern when a token is already exposed
|
||||
|
||||
If a token was leaked in a prior step of the same session, chain two
|
||||
rotations:
|
||||
|
||||
1. **T1**: replace the leaked value with a new one. The verification step
|
||||
may also leak T1 (acceptable risk).
|
||||
2. **T2**: replace T1 with a fresh value. Run this without any decrypt
|
||||
step that emits to stdout. The new value lives only in the encrypted
|
||||
file + the consumer's secret store (`~/.llmkeys`, the k8s Secret).
|
||||
|
||||
Example T2 path (Python, never decrypts to stdout):
|
||||
|
||||
```python
|
||||
new_t2 = subprocess.check_output(["openssl","rand","-hex","32"]).decode().strip()
|
||||
plaintext = subprocess.check_output(["sops","-d", path]).decode() # in-memory only
|
||||
patched = re.sub(r'Bearer [a-f0-9]+(?=")', f"Bearer {new_t2}", plaintext)
|
||||
# write patched via /dev/shm tmp + sops -e → write back
|
||||
# DO NOT print(patched), DO NOT print(new_t2)
|
||||
```
|
||||
|
||||
After T2 lands via GitOps + Flux, update consumers from the file stash
|
||||
without echoing.
|
||||
|
||||
## Detection ideas (not blocking — telemetry)
|
||||
|
||||
- Add a `bash` pre-hook in Claude harness that refuses obvious patterns
|
||||
(`kubectl exec * env*`, `sops -d *.enc.yaml`). Filed as a follow-up.
|
||||
- Run a periodic grep over `~/.claude/projects/*/sessions/*.jsonl` for
|
||||
high-entropy strings matching `[a-f0-9]{32,}` to catch new leak surface.
|
||||
|
||||
## Reference
|
||||
|
||||
- `gitea.d-ma.be/mathias/infra#39` — umbrella credential rotation issue with confirmed-leak roster.
|
||||
- `gitea.d-ma.be/mathias/infra#44` — POSTGRES_PASSWORD rotation procedure (template for future incidents).
|
||||
@@ -22,18 +22,28 @@ Screenshot a locally-running web UI without installing a browser on the agent's
|
||||
|
||||
## Usage
|
||||
|
||||
`shot.sh` ships beside this SKILL.md, so resolve it from wherever the harness
|
||||
installed the skill rather than from a checkout path — an absolute `~/dev/...`
|
||||
went stale the first time the repo moved and cost a caller three hand-rolled
|
||||
Job attempts before they found the working script (#26).
|
||||
|
||||
```bash
|
||||
# Claude Code installs the plugin; Crush/Pi/OpenCode get ~/.agents/skills.
|
||||
SHOT=$(ls ~/.claude/plugins/marketplaces/*/skills/web-shot/shot.sh \
|
||||
~/.agents/skills/web-shot/shot.sh 2>/dev/null | head -1 || true)
|
||||
[ -n "$SHOT" ] || { echo "web-shot not installed on this host: run install.sh or the plugin install" >&2; exit 1; }
|
||||
|
||||
# auto-detect Tailscale hostname, screenshot port 8099 root
|
||||
~/dev/.skills/web-shot/shot.sh --port 8099
|
||||
"$SHOT" --port 8099
|
||||
|
||||
# explicit URL
|
||||
~/dev/.skills/web-shot/shot.sh --url http://flamingo:5173
|
||||
"$SHOT" --url http://flamingo:5173
|
||||
|
||||
# capture a search state too (types into input[name=q])
|
||||
~/dev/.skills/web-shot/shot.sh --port 8181 --search agentsquad
|
||||
"$SHOT" --port 8181 --search agentsquad
|
||||
|
||||
# different output dir
|
||||
~/dev/.skills/web-shot/shot.sh --port 3000 --out /tmp/shots
|
||||
"$SHOT" --port 3000 --out /tmp/shots
|
||||
```
|
||||
|
||||
## Flags
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user