Compare commits

...
4 Commits
Author SHA1 Message Date
mathiasandClaude Opus 5 78b9b59eca docs+ci: document the plugin path, bump plugin.json version on release
release / tag (push) Successful in 1s
CI now writes the computed semver into .claude-plugin/plugin.json before
tagging, committed with [skip-release] so it does not re-trigger the tagger.
Claude Code gates plugin updates on that field: without the bump, installed
hosts never see a new tag no matter how many releases go out. `task check`
enforces manifest/tag equality so the two cannot silently diverge.

Docs rewritten around the two delivery paths, and the checkout location is now
stated as load-bearing rather than incidental: SKILLS_CHECKOUT_DIR defaults to
~/.local/share/skills because that is host-local and outside any synced
directory. The previous docs told you to clone into ~/dev, which on this fleet
is an iCloud folder shared by two Macs spanning two path roots — a git working
tree with concurrent writers, and absolute links that are broken on one side by
construction. That advice is what #23 is about; it is now inverted with the
reason attached.

Antigravity and Vibe removed from the docs along with their targets.

Bump-Type: patch

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BeAp5LTscnz2W7Ubt4eJ6m
2026-08-23 14:35:38 +02:00
mathiasandClaude Opus 5 88c54399fe feat(doctor): fail loud on skills drift, wired to SessionStart
Every failure this fleet actually hit was silent. 243 dangling symlinks were
invisible to git because **/.claude/skills/ is in the global gitignore. A
checkout 20 skills behind still resolved, so nothing complained. A loose
secret-hygiene.md loaded in no harness and errored in none. Two machines had
the library missing entirely. Exit status is the signal that was absent.

bin/doctor reports, with a non-zero exit:
  - dangling links, anywhere under the harness dirs
  - links that RESOLVE but land outside the active checkout — the dangerous
    case, because it works while serving stale content
  - skill-count mismatch between a harness and the checkout
  - stray entries where <name>/SKILL.md was expected
  - leftover per-skill Claude symlinks (plugin skills do not override
    same-named user skills, so leftovers mean everything loads twice)
  - divergence between the plugin copy and the copy the other harnesses link to

Wired as a SessionStart hook with --quiet, so a healthy host prints nothing and
a drifting one says so at the top of the next session. That beats a timer:
no launchd/systemd wiring, and it reaches every host that installs the plugin.
27ms on koala, no network, no git fetch.

Two design points found by the tests, both worth keeping:
  - The canonical checkout is whatever ~/.agents/skills resolves to, NOT the
    copy doctor runs from. As a plugin hook $0 lives in the plugin cache, which
    is legitimately a second copy; judging harness links against it flagged
    every one of them as "outside".
  - "cd X/.." resolves ".." lexically against the link path and lands back in
    ~/.agents. Resolve the link first, then take the parent.

tests/doctor_test.sh drives seven fixtures through $HOME and $DOCTOR_CHECKOUT,
so no test touches the real host. Each asserts both the non-zero exit and that
the report names the fault — an exit-code-only assertion would pass while the
message said nothing useful.

Bump-Type: minor

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BeAp5LTscnz2W7Ubt4eJ6m
2026-08-23 14:34:32 +02:00
mathiasandClaude Opus 5 747c6c7f4c feat(plugin): ship the secret-guard and pre-task-ritual hooks + MCP servers
The plugin now carries the two hooks Mathias owns and the brain/gitea/infra
MCP server definitions, not just skills.

Why this matters more than tidiness: both hooks existed ONLY in koala's
~/.claude/, which is host-local and unsynced. flamingo and iguana have been
running with no secret-guard at all — that is the control which blocks the
decrypt-and-print shape behind the 2026-08-05 ntfy-token leak, the op-item-get
shape behind 2026-07-30, and the base64-bypass shape behind 2026-06-11. Its
absence on two of three machines is a security gap, not a config gap. Shipping
it in the plugin means installing the plugin installs the guard.

.mcp.json references tokens as ${BRAIN_MCP_TOKEN} / ${GITEA_MCP_TOKEN} only —
env indirection, never literal values, so the manifest is safe to commit.

tests/secret_guard_test.sh pins 14 cases. It asserts on
hookSpecificOutput.permissionDecision, NOT exit status: the guard always exits
0 by contract, so an exit-status assertion would pass vacuously while the guard
silently allowed everything. Each deny case names the incident that produced it.

gsd-* hooks are deliberately NOT migrated — GSD owns and updates those in place.

Bump-Type: minor

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BeAp5LTscnz2W7Ubt4eJ6m
2026-08-23 14:31:04 +02:00
mathiasandClaude Opus 5 56a947b2a0 refactor(plugin)!: ship as a Claude Code plugin, one dir link per harness
Skills move to skills/<name>/SKILL.md and the repo gains both manifests, so
it is now a marketplace hosting exactly one plugin whose root is the repo
root (source: "."). Claude Code installs it from git; no symlinks involved.

Why: install.sh wired the library into every consuming repo as an ABSOLUTE
symlink to $CHECKOUT_DIR. The checkout moved three times, most recently when
local-dev@1966d5a dropped the embedded .skills/ copy, and nothing migrated
the links — 243 dangling links across 12 repos, hidden from git by the
**/.claude/skills/ ignore rule. Worse, ~/dev spans two path roots
(/Users/mathias on the iCloud-shared Macs, /home/mathias on koala), so an
absolute link stored there is broken on one side by construction. Re-running
the installer cannot fix that; only removing the mechanism can.

install.sh keeps the harnesses with no plugin system and drops from 42 links
per harness to one directory link, via the convergent ~/.agents/skills
convention that Pi and OpenCode both read:

  ~/.agents/skills          -> $CHECKOUT_DIR/skills
  ~/.config/crush/skills    -> ~/.agents/skills
  ~/.config/opencode/skills -> ~/.agents/skills

wire_claude_repo is deleted outright rather than gated behind a flag — on a
mixed-root fleet it cannot be made correct. Antigravity and Vibe targets go
too; both unused. CHECKOUT_DIR keeps its host-local default deliberately:
pointing it into ~/dev would put a git working tree inside iCloud with two
concurrent writers.

Taskfile drops five near-identical wiring loops (they had already drifted)
and delegates to install.sh. `task check` gains frontmatter validation, a
stray-root-entry gate, and manifest/tag version parity — the loose
secret-hygiene.md sat unloadable for months because nothing checked layout.

Verified: claude plugin validate . passes clean; a probe skill loaded as
skills:zzprobe through --plugin-dir; task check green at 42 skills.

Refs: #23
Bump-Type: minor

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BeAp5LTscnz2W7Ubt4eJ6m
2026-08-23 14:29:51 +02:00
98 changed files with 876 additions and 309 deletions
+15
View File
@@ -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."
}
]
}
+11
View File
@@ -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.3.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"
}
+17
View File
@@ -62,6 +62,23 @@ jobs:
set -euo pipefail set -euo pipefail
git config user.name "${GITEA_ACTOR}" git config user.name "${GITEA_ACTOR}"
git config user.email "${GITEA_ACTOR}@noreply.gitea.d-ma.be" 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)
if [ "$current" != "$version" ]; then
sed -i "s/\"version\"[[:space:]]*:[[:space:]]*\"$current\"/\"version\": \"$version\"/" "$manifest"
git add "$manifest"
# [skip-release] stops this push from re-triggering the tagger.
git commit -m "chore(release): plugin.json version $version [skip-release]"
git push origin HEAD:main
echo "bumped $manifest to $version"
fi
git tag -a "$TAG" -m "release $TAG" git tag -a "$TAG" -m "release $TAG"
# gitea-actions checkout uses an x-access-token URL; reuse it. # gitea-actions checkout uses an x-access-token URL; reuse it.
git push origin "$TAG" git push origin "$TAG"
+22
View File
@@ -0,0 +1,22 @@
{
"mcpServers": {
"brain": {
"type": "http",
"url": "https://brain-mcp.d-ma.be/mcp",
"headers": {
"Authorization": "Bearer ${BRAIN_MCP_TOKEN}"
}
},
"gitea": {
"type": "http",
"url": "https://git-mcp.d-ma.be/mcp",
"headers": {
"Authorization": "Bearer ${GITEA_MCP_TOKEN}"
}
},
"infra": {
"type": "http",
"url": "https://infra-mcp.d-ma.be/mcp"
}
}
}
+53 -41
View File
@@ -1,8 +1,12 @@
# skills # skills
Engineering skills library — markdown `SKILL.md` files that plug into any Engineering skills library — markdown `SKILL.md` files that plug into any
project or AI-coding harness. Installed via `task skills:install` in AI-coding harness.
consumer projects, or directly by `install.sh` on hosts without Task.
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? ## What is a skill?
@@ -16,65 +20,73 @@ references.
See [`SKILLS_INDEX.md`](./SKILLS_INDEX.md) for the full catalogue with See [`SKILLS_INDEX.md`](./SKILLS_INDEX.md) for the full catalogue with
descriptions and "use when" triggers. descriptions and "use when" triggers.
## Install (consumer project) ## Install
The expected pattern: each consumer project's `Taskfile.yml` has a ### Claude Code — the plugin
`skills:install` target that delegates here. From a project root:
```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 ```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/` — | Link | Target |
the **canonical local checkout** on every host. |---|---|
2. Wire per-skill symlinks into the harnesses found on the host (see | `~/.agents/skills` | `$SKILLS_CHECKOUT_DIR/skills` (Pi reads this path directly) |
[Wired harnesses](#wired-harnesses)). | `~/.config/crush/skills` | `~/.agents/skills` |
| `~/.config/opencode/skills` | `~/.agents/skills` |
Re-run after `git pull` upstream to pick up new skills or version Adding a skill wires it everywhere with no re-run.
changes. CI tags every change as a `vX.Y.Z` release (see
[Versioning](#versioning)).
## Wired harnesses ### Where the checkout must live
| Harness | Target path | Notes | `SKILLS_CHECKOUT_DIR` defaults to `~/.local/share/skills`, and that default is
|---|---|---| load-bearing: it is **host-local, outside any synced or shared directory**.
| **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. |
### Not wired (and why) 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.
- **opencode** (`opencode-ai/opencode`): archive notice; succeeded by Crush. A checkout there is a git working tree with two concurrent writers, and any
Its only "skill"-like surface — Custom Commands at `~/.config/opencode/commands/` — absolute symlink stored there is broken on one side by construction. That is
is for user-facing prompt templates, not system-level instructions. what produced 243 dangling links across 12 repos (#23). Do not "fix" the
Misaligned with how we use skills. Skipped. default to point into `~/dev`.
- **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.
### Per-host env-var overrides ### Per-host env-var overrides
Each target path can be overridden by setting the matching env var before
running `install.sh`:
| Env var | Default | | Env var | Default |
|---|---| |---|---|
| `SKILLS_REPO_URL` | `https://gitea.d-ma.be/mathias/skills.git` | | `SKILLS_REPO_URL` | `https://git.d-ma.be/mathias/skills.git` |
| `SKILLS_REF` | `main` (set to e.g. `v0.1.0` to pin a release) | | `SKILLS_REF` | `main` (set to e.g. `v0.4.0` to pin a release) |
| `SKILLS_CHECKOUT_DIR` | `$HOME/.local/share/skills` | | `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` | | `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 ## Versioning
+47 -15
View File
@@ -47,23 +47,55 @@ This index lists all available engineering skills. Load the full SKILL.md on dem
## Wiring into tools ## Wiring into tools
This repo **is** the canonical home. Wire every skill into each harness with the This repo is the canonical home, and it is delivered two different ways.
**`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`.)
- **Claude Code (global)** — `~/.claude/skills/<name>` → `<repo>/<name>`. ### Claude Code — install the plugin
- **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).
Individual targets are also runnable: `task install:claude:global`, This repo hosts a marketplace containing exactly one plugin: itself. The plugin
`install:crush`, `install:antigravity`, `install:vibe`. `task update` pulls the carries the skills **plus** the `secret-guard` and `pre-task-ritual` hooks and
canonical checkout then re-wires. 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 ## Skill Relationships
+89 -154
View File
@@ -2,22 +2,19 @@ version: '3'
# Skills library Taskfile. # Skills library Taskfile.
# #
# Primary entry point for project + host installation. Consumer projects # This repo is two things at once:
# do NOT depend on this file directly — they ship their own # 1. A Claude Code PLUGIN (skills + hooks + MCP servers), distributed through
# `skills:install` target that delegates here via `install.sh`. This # the marketplace this same repo hosts at .claude-plugin/marketplace.json.
# Taskfile is invoked by maintainers of the skills repo itself, plus by # 2. A plain skills library for harnesses with no plugin system — Crush, Pi,
# install.sh during the post-clone wire step. # 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: vars:
REPO_URL: 'https://git.d-ma.be/mathias/skills.git' REPO_URL: 'https://git.d-ma.be/mathias/skills.git'
CHECKOUT_DIR: '{{.HOME}}/.local/share/skills' SKILLS_DIR: '{{.TASKFILE_DIR}}/skills'
CLAUDE_GLOBAL_DIR: '{{.HOME}}/.claude/skills' PLUGIN_MANIFEST: '{{.TASKFILE_DIR}}/.claude-plugin/plugin.json'
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$)$'
tasks: tasks:
@@ -28,134 +25,95 @@ tasks:
list: list:
desc: 'Print every skill in this repo' desc: 'Print every skill in this repo'
cmds: 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 silent: true
install: 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: cmds:
- task: install:claude:global - SKILLS_SKIP_FETCH=1 SKILLS_CHECKOUT_DIR='{{.TASKFILE_DIR}}' bash '{{.TASKFILE_DIR}}/install.sh'
- 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
update: update:
desc: 'git pull the canonical checkout then re-run install.' desc: 'Fast-forward this checkout and re-wire.'
cmds: cmds:
- | - git -C '{{.TASKFILE_DIR}}' pull --ff-only
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
- task: install - 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. 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: 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: cmds:
- | - |
latest=$(git -C "{{.TASKFILE_DIR}}" describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0") latest=$(git -C "{{.TASKFILE_DIR}}" describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0")
@@ -172,26 +130,3 @@ tasks:
echo "tagging $next (previous $latest, bump=${bump:-patch})" echo "tagging $next (previous $latest, bump=${bump:-patch})"
git -C "{{.TASKFILE_DIR}}" tag -a "$next" -m "release $next" git -C "{{.TASKFILE_DIR}}" tag -a "$next" -m "release $next"
echo "now: git push origin $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
+185
View File
@@ -0,0 +1,185 @@
#!/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
# 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 ]
+39
View File
@@ -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
}
]
}
]
}
}
+3
View File
@@ -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"
+118
View File
@@ -0,0 +1,118 @@
#!/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).
if printf '%s' "$CMD" | grep -Eqi '(^|[;&|]|\s)env(\s|$).*\|.*grep'; then
deny "Blocked: 'env | grep' prints matching values in full. Use an existence-only check: [ -n \"\$VAR\" ] && echo set"
fi
if printf '%s' "$CMD" | grep -Eq '(^|[;&|])[[:space:]]*env[[:space:]]*($|[;&|])'; then
deny "Blocked: bare 'env' prints every variable's value, including secrets. Use an existence-only check: [ -n \"\$VAR\" ] && echo set"
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
View File
@@ -5,25 +5,55 @@
# curl -fsSL https://git.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
# #
# Pinned to a specific tag: # 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 # Idempotent: re-running pulls the latest changes for the configured ref
# (default: main) and re-wires symlinks. Existing correct links are # (default: main) and re-wires the links.
# left alone; mismatched ones are replaced. #
# 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 set -euo pipefail
REPO_URL="${SKILLS_REPO_URL:-https://git.d-ma.be/mathias/skills.git}" REPO_URL="${SKILLS_REPO_URL:-https://git.d-ma.be/mathias/skills.git}"
REF="${SKILLS_REF:-main}" REF="${SKILLS_REF:-main}"
CHECKOUT_DIR="${SKILLS_CHECKOUT_DIR:-$HOME/.local/share/skills}" 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}" CRUSH_DIR="${CRUSH_SKILLS_DIR:-$HOME/.config/crush/skills}"
ANTIGRAVITY_DIR="${ANTIGRAVITY_SKILLS_DIR:-$HOME/.gemini/antigravity/skills}" OPENCODE_DIR="${OPENCODE_SKILLS_DIR:-$HOME/.config/opencode/skills}"
VIBE_SKILLS_DIR="${VIBE_SKILLS_DIR:-$HOME/.vibe/skills}"
log() { printf '[skills] %s\n' "$*"; } log() { printf '[skills] %s\n' "$*"; }
ensure_checkout() { 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 if [ -d "$CHECKOUT_DIR/.git" ]; then
log "updating $CHECKOUT_DIR" log "updating $CHECKOUT_DIR"
git -C "$CHECKOUT_DIR" fetch --tags --quiet git -C "$CHECKOUT_DIR" fetch --tags --quiet
@@ -40,109 +70,47 @@ ensure_checkout() {
fi fi
} }
list_skills() { count_skills() {
# Every top-level entry that isn't a known meta file. find "$CHECKOUT_DIR/skills" -mindepth 2 -maxdepth 2 -name SKILL.md 2>/dev/null | wc -l | tr -d ' '
(cd "$CHECKOUT_DIR" && ls -1) | grep -Ev '^(Taskfile\.yml|install\.sh|README\.md|SKILLS_INDEX\.md|\.gitea|\.git)$' || true
} }
link_skill() { # link_dir <link_path> <target_path>
# link_skill <target_dir> <skill_name> # Replaces whatever is there — including a directory left behind by the old
local target_dir="$1" # per-skill wiring — with a single symlink.
local skill="$2" link_dir() {
local target="$CHECKOUT_DIR/$skill" local link="$1" target="$2"
local link="$target_dir/$skill" if [ -L "$link" ]; then
[ "$(readlink "$link")" = "$target" ] && return
if [ -L "$link" ] || [ -e "$link" ]; then rm -f "$link"
local current elif [ -d "$link" ]; then
current=$(readlink "$link" 2>/dev/null || true) # Refuse to discard a directory holding anything but symlinks: that
if [ "$current" = "$target" ]; then # would be someone's real skills, not our old wiring.
return 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 fi
rm -rf "$link" find "$link" -mindepth 1 -maxdepth 1 -type l -delete
rmdir "$link"
elif [ -e "$link" ]; then
rm -f "$link"
fi fi
mkdir -p "$(dirname "$link")"
ln -s "$target" "$link" ln -s "$target" "$link"
log "linked $link → $target" 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() { main() {
ensure_checkout ensure_checkout
wire_claude_global
wire_claude_repo # Canonical location. Pi reads ~/.agents/skills directly, so it needs no
wire_crush # harness-specific link of its own.
wire_antigravity link_dir "$AGENTS_DIR" "$CHECKOUT_DIR/skills"
wire_vibe
log "done — $(list_skills | wc -l | tr -d ' ') skill(s) wired at ref=$REF" 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 "$@" main "$@"
+124
View File
@@ -0,0 +1,124 @@
#!/bin/sh
# Tests for bin/doctor.
#
# Every case here is a real state this fleet was found in on 2026-08-23, and
# every one of them was SILENT — nothing errored, git status was clean, the
# harness simply ran with the wrong skills or none at all. The point of doctor
# is that none of these can be silent again, so each fixture asserts both a
# non-zero exit AND that the report names the fault.
#
# Fixtures drive doctor through $HOME and $DOCTOR_CHECKOUT, so no test touches
# the real host.
set -u
ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
DOCTOR="$ROOT/bin/doctor"
pass=0
fail=0
WORK=${TMPDIR:-/tmp}/doctor_test.$$
if [ ! -f "$DOCTOR" ]; then
printf 'FAIL: %s does not exist\n' "$DOCTOR"
exit 1
fi
cleanup() { rm -rf "$WORK"; }
trap cleanup EXIT INT TERM
# new_fixture <name> — builds $WORK/<name>/{ck,home} with a 2-skill checkout
# and an empty fake HOME. Echoes the fixture root.
new_fixture() {
_f="$WORK/$1"
mkdir -p "$_f/ck/skills/alpha" "$_f/ck/skills/beta" "$_f/home"
printf -- '---\nname: alpha\ndescription: a\n---\n' > "$_f/ck/skills/alpha/SKILL.md"
printf -- '---\nname: beta\ndescription: b\n---\n' > "$_f/ck/skills/beta/SKILL.md"
printf '%s' "$_f"
}
# wire_clean <fixture> — the correct wiring: one canonical link, harnesses
# pointing at it.
wire_clean() {
mkdir -p "$1/home/.agents" "$1/home/.config/crush" "$1/home/.config/opencode"
ln -sfn "$1/ck/skills" "$1/home/.agents/skills"
ln -sfn "$1/home/.agents/skills" "$1/home/.config/crush/skills"
ln -sfn "$1/home/.agents/skills" "$1/home/.config/opencode/skills"
}
# assert_fails <fixture> <substring> <label>
# Note: doctor must be invoked directly here, not via a helper called in a
# command substitution — $? from inside $( ) belongs to the subshell and the
# exit status, which is the whole point of these assertions, would be lost.
assert_fails() {
_out=$(HOME="$1/home" DOCTOR_CHECKOUT="$1/ck" sh "$DOCTOR" 2>&1); rc=$?
if [ "$rc" -eq 0 ]; then
fail=$((fail + 1))
printf ' FAIL %s\n doctor exited 0; drift went undetected\n' "$3"
elif ! printf '%s' "$_out" | grep -qi -- "$2"; then
fail=$((fail + 1))
printf ' FAIL %s\n exited %s but report never mentions "%s"\n got: %s\n' "$3" "$rc" "$2" "$_out"
else
pass=$((pass + 1))
printf ' ok %s\n' "$3"
fi
}
assert_clean() {
_out=$(HOME="$1/home" DOCTOR_CHECKOUT="$1/ck" sh "$DOCTOR" 2>&1); rc=$?
if [ "$rc" -eq 0 ]; then
pass=$((pass + 1)); printf ' ok %s\n' "$2"
else
fail=$((fail + 1)); printf ' FAIL %s\n exited %s on a healthy fixture\n got: %s\n' "$2" "$rc" "$_out"
fi
}
# --- 1. healthy host stays silent -------------------------------------------
f=$(new_fixture clean); wire_clean "$f"
assert_clean "$f" 'clean fixture exits 0'
# --- 2. dangling link (the 243-link case, gitignored and invisible) ----------
f=$(new_fixture dangling); wire_clean "$f"
ln -sfn "$f/ck/skills-that-moved" "$f/home/.agents/skills"
assert_fails "$f" 'dangling' 'dangling canonical link is reported'
# --- 3. resolves, but to a DIFFERENT checkout (the silent-stale case) --------
# Worse than dangling: it works, so nothing complains, while serving content
# from a checkout that is months behind.
f=$(new_fixture stale)
mkdir -p "$f/other/skills/alpha" "$f/other/skills/beta"
printf -- '---\nname: alpha\ndescription: a\n---\n' > "$f/other/skills/alpha/SKILL.md"
printf -- '---\nname: beta\ndescription: b\n---\n' > "$f/other/skills/beta/SKILL.md"
wire_clean "$f"
ln -sfn "$f/other/skills" "$f/home/.agents/skills"
assert_fails "$f" 'outside' 'link into a foreign checkout is reported'
# --- 4. count mismatch (a harness seeing fewer skills than the checkout) -----
f=$(new_fixture count)
mkdir -p "$f/home/.config/crush/skills/alpha"
printf -- '---\nname: alpha\ndescription: a\n---\n' > "$f/home/.config/crush/skills/alpha/SKILL.md"
mkdir -p "$f/home/.agents"
ln -sfn "$f/ck/skills" "$f/home/.agents/skills"
assert_fails "$f" 'count' 'skill-count mismatch is reported'
# --- 5. stray entry (the loose secret-hygiene.md that loaded nowhere) --------
f=$(new_fixture stray); wire_clean "$f"
printf 'orphan\n' > "$f/ck/skills/loose-note.md"
assert_fails "$f" 'stray' 'stray non-directory entry is reported'
# --- 6. legacy Claude symlinks alongside the plugin (duplicate skills) -------
f=$(new_fixture dupe); wire_clean "$f"
mkdir -p "$f/home/.claude/skills"
ln -sfn "$f/ck/skills/alpha" "$f/home/.claude/skills/alpha"
assert_fails "$f" 'legacy' 'leftover per-skill Claude symlinks are reported'
# --- 7. dangling link inside a harness dir (not the canonical link itself) --
# Exercises the per-entry scan rather than the canonical-link check: this is
# the shape the 243 dead links actually had.
f=$(new_fixture ghost); wire_clean "$f"
mkdir -p "$f/home/.claude/skills"
ln -sfn "$f/ck/skills/vanished" "$f/home/.claude/skills/vanished"
assert_fails "$f" 'dangling' 'dangling link inside a harness dir is reported'
printf '\ndoctor: %d passed, %d failed\n' "$pass" "$fail"
[ "$fail" -eq 0 ]
+86
View File
@@ -0,0 +1,86 @@
#!/bin/sh
# Tests for hooks/secret-guard.sh — the PreToolUse gate that blocks command
# shapes which render secret values into the transcript.
#
# Contract: reads PreToolUse hook JSON on stdin, ALWAYS exits 0, and answers
# in the JSON body as hookSpecificOutput.permissionDecision = allow | deny.
# Asserting on exit status would pass vacuously — assert on the decision.
#
# Every deny case below is a shape that caused a real leak; the comment names
# the incident so nobody "simplifies" the pattern away later.
set -u
ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
GUARD="$ROOT/hooks/secret-guard.sh"
pass=0
fail=0
# decision_for <command-string> [tool_name]
# Emits the guard's permissionDecision for a synthetic Bash tool call.
decision_for() {
_cmd=$1
_tool=${2:-Bash}
jq -n --arg t "$_tool" --arg c "$_cmd" \
'{tool_name: $t, tool_input: {command: $c}}' \
| sh "$GUARD" \
| jq -r '.hookSpecificOutput.permissionDecision // "MISSING"'
}
expect() {
_want=$1
_cmd=$2
_label=$3
_got=$(decision_for "$_cmd")
if [ "$_got" = "$_want" ]; then
pass=$((pass + 1))
printf ' ok %s\n' "$_label"
else
fail=$((fail + 1))
printf ' FAIL %s\n want=%s got=%s\n cmd=%s\n' "$_label" "$_want" "$_got" "$_cmd"
fi
}
if [ ! -f "$GUARD" ]; then
printf 'FAIL: %s does not exist\n' "$GUARD"
exit 1
fi
command -v jq >/dev/null 2>&1 || { printf 'SKIP: jq not installed\n'; exit 0; }
# --- deny: decrypt-and-print in one invocation (2026-08-05 ntfy-token leak) ---
expect deny 'sops -d secrets.enc.yaml | tail -20' 'sops decrypt piped to tail'
expect deny 'sops -d ingress.enc.yaml > /tmp/x; cat /tmp/x' 'sops decrypt then cat'
# --- deny: 1Password raw reads (2026-07-30) ---
expect deny 'op item get "gitea mcp" --format json' 'op item get'
expect deny 'op read "op://vault/item/field"' 'op read'
# --- deny: env dumps (infra#228, 2026-07-27) ---
expect deny 'env | grep -i gitea' 'env piped to grep'
expect deny 'env' 'bare env'
expect deny 'printenv' 'printenv'
# --- deny: kubernetes secret material ---
expect deny 'kubectl get secret brain-app -o yaml' 'kubectl get secret -o yaml'
# --- deny: base64 as a masking bypass (2026-06-11) ---
expect deny 'echo "$GITEA_MCP_TOKEN" | base64' 'token piped through base64'
# --- allow: ordinary commands must not be blocked ---
expect allow 'ls -la /tmp' 'plain ls'
expect allow 'git status --porcelain' 'git status'
expect allow 'kubectl describe secret brain-app' 'kubectl describe secret (values redacted by k8s)'
expect allow '[ -n "$GITEA_MCP_TOKEN" ] && echo set' 'existence-only check'
# --- allow: the guard only judges Bash ---
_got=$(decision_for 'env' 'Read')
if [ "$_got" = "allow" ]; then
pass=$((pass + 1)); printf ' ok non-Bash tool passes through\n'
else
fail=$((fail + 1)); printf ' FAIL non-Bash tool passes through\n want=allow got=%s\n' "$_got"
fi
printf '\nsecret_guard: %d passed, %d failed\n' "$pass" "$fail"
[ "$fail" -eq 0 ]