Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
51a8c25b4d | ||
|
|
d40169f2b7 | ||
|
|
d4f0416dc5 | ||
|
|
abd81f939a | ||
|
|
e42d2ba9cb | ||
|
|
68cebc0bbb | ||
|
|
f50dd6be6a | ||
|
|
1906a774b3 | ||
|
|
38f905e705 | ||
|
|
7db7e0779b | ||
|
|
0be3506799 | ||
|
|
6151b973bc | ||
|
|
6c1b6ce9db | ||
|
|
5c8341c797 | ||
|
|
5ca44c9c6b | ||
|
|
e2a312d05e | ||
|
|
78b9b59eca | ||
|
|
88c54399fe | ||
|
|
747c6c7f4c | ||
|
|
56a947b2a0 |
@@ -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.7.0",
|
||||||
|
"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"
|
||||||
|
}
|
||||||
@@ -30,8 +30,10 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
last_commit=$(git log -1 --pretty=%B)
|
last_commit=$(git log -1 --pretty=%B)
|
||||||
if printf '%s' "$last_commit" | grep -qi '\[skip-release\]'; then
|
# Delegated to bin/should-skip-release so the rule is covered by
|
||||||
echo "skipping — commit marked [skip-release]"
|
# 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"
|
echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
@@ -62,6 +64,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"
|
||||||
|
# The trailer below stops this push from re-triggering the tagger.
|
||||||
|
git commit -m "chore(release): plugin.json version $version" -m "Skip-Release: true"
|
||||||
|
git 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"
|
||||||
|
|||||||
@@ -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
|
# 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
|
||||||
|
|
||||||
|
|||||||
+48
-15
@@ -30,6 +30,7 @@ This index lists all available engineering skills. Load the full SKILL.md on dem
|
|||||||
| `discovery-framing` | Problem-before-solution discovery/framing method | Framing/validating a problem before any solution; opportunity sizing |
|
| `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 |
|
| `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 |
|
| `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-brainstorming` | Structured divergent brainstorming | Generating or expanding a set of ideas |
|
||||||
| `superpowers-writing-plans` | Writing implementation plans | Planning substantial work before coding |
|
| `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 |
|
| `superpowers-executing-plans` | Executing a written plan step by step | Running an approved plan to completion |
|
||||||
@@ -47,23 +48,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
@@ -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
+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
+118
@@ -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
@@ -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 "$@"
|
||||||
|
|||||||
@@ -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
|
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
|
# 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 |
|
| Container registry | Ask or check existing config | `localhost:5000` on koala, or external |
|
||||||
| k3s namespace | k8s/ manifests or ask | Deploy target |
|
| k3s namespace | k8s/ manifests or ask | Deploy target |
|
||||||
| GitHub mirror repo | Ask | Target for the mirror job |
|
| 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.
|
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"
|
echo "version-tag=${REF#refs/tags/}" >> "$GITHUB_OUTPUT"
|
||||||
fi
|
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
|
- name: Build and push to local registry
|
||||||
run: |
|
env:
|
||||||
REGISTRY="localhost:5000"
|
SHA_TAG: ${{ steps.meta.outputs.sha-tag }}
|
||||||
REF="${REGISTRY}/${{ env.IMAGE }}:${{ steps.meta.outputs.sha-tag }}"
|
VERSION_TAG: ${{ steps.meta.outputs.version-tag }}
|
||||||
buildah build \
|
GIT_SHA: ${{ github.sha }}
|
||||||
--label "org.opencontainers.image.revision=${{ github.sha }}" \
|
IMAGE_SOURCE: ${{ github.repositoryUrl }}
|
||||||
--label "org.opencontainers.image.source=${{ github.repositoryUrl }}" \
|
run: bash .gitea/build-image.sh
|
||||||
-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}"
|
|
||||||
|
|
||||||
- name: Smoke test
|
# No smoke test step. The one this skill used to recommend ran
|
||||||
run: |
|
# `sudo k3s ctr run` against the host's containerd -- that worked when
|
||||||
REGISTRY="localhost:5000"
|
# the runner was the Arch host, and is impossible now that it is a pod:
|
||||||
REF="${REGISTRY}/${{ env.IMAGE }}:${{ steps.meta.outputs.sha-tag }}"
|
# no sudo, no ctr, no containerd socket (`sudo: command not found`).
|
||||||
CNAME="smoke-${{ steps.meta.outputs.sha-tag }}"
|
# The deploy job's `Verify rollout` + `Confirm pod running new image`
|
||||||
sudo k3s ctr images pull --plain-http ${REF}
|
# below assert the same property against the real workload.
|
||||||
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"
|
|
||||||
|
|
||||||
# ── 3. Deploy to k3s ────────────────────────────────────────────────────────
|
# ── 3. Deploy to k3s ────────────────────────────────────────────────────────
|
||||||
deploy:
|
deploy:
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
#!/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'
|
||||||
|
|
||||||
|
# --- 7b. harness dir is a REAL directory, not a link into the checkout -------
|
||||||
|
# Found on flamingo 2026-08-26: ~/.config/crush/skills held real directories
|
||||||
|
# from a pre-plugin install. The old message read "resolves to
|
||||||
|
# <path>, outside the active checkout" — naming the directory as its own
|
||||||
|
# resolution target, which is true but useless. It must say the dir is not a
|
||||||
|
# link at all, because the fix (move it aside, re-run install.sh) is different
|
||||||
|
# from the fix for a link into a foreign checkout.
|
||||||
|
f=$(new_fixture realdir)
|
||||||
|
mkdir -p "$f/home/.agents" "$f/home/.config/crush/skills/alpha"
|
||||||
|
ln -sfn "$f/ck/skills" "$f/home/.agents/skills"
|
||||||
|
printf -- '---\nname: alpha\ndescription: a\n---\n' > "$f/home/.config/crush/skills/alpha/SKILL.md"
|
||||||
|
assert_fails "$f" 'not a link' 'real directory is reported as unmanaged, not as "outside"'
|
||||||
|
|
||||||
|
# --- 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 ]
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Tests for bin/should-skip-release.
|
||||||
|
#
|
||||||
|
# The regression these exist for: an unanchored substring match let a commit
|
||||||
|
# that merely *described* the skip marker skip its own release.
|
||||||
|
|
||||||
|
set -u
|
||||||
|
|
||||||
|
ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
|
||||||
|
SUT="$ROOT/bin/should-skip-release"
|
||||||
|
|
||||||
|
pass=0
|
||||||
|
fail=0
|
||||||
|
|
||||||
|
if [ ! -f "$SUT" ]; then
|
||||||
|
printf 'FAIL: %s does not exist\n' "$SUT"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# expect <skip|tag> <label> <<message on stdin>>
|
||||||
|
expect() {
|
||||||
|
_want=$1
|
||||||
|
_label=$2
|
||||||
|
_msg=$3
|
||||||
|
printf '%s\n' "$_msg" | sh "$SUT"
|
||||||
|
_rc=$?
|
||||||
|
if [ "$_want" = skip ]; then _wrc=0; else _wrc=1; fi
|
||||||
|
if [ "$_rc" -eq "$_wrc" ]; then
|
||||||
|
pass=$((pass + 1)); printf ' ok %s\n' "$_label"
|
||||||
|
else
|
||||||
|
fail=$((fail + 1)); printf ' FAIL %s (want %s, rc=%s)\n' "$_label" "$_want" "$_rc"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
expect skip 'trailer on its own line' 'chore(release): bump
|
||||||
|
|
||||||
|
Skip-Release: true'
|
||||||
|
|
||||||
|
expect skip 'trailer, case-insensitive' 'chore: x
|
||||||
|
|
||||||
|
skip-release: TRUE'
|
||||||
|
|
||||||
|
expect tag 'ordinary commit' 'feat: add a thing
|
||||||
|
|
||||||
|
Bump-Type: minor'
|
||||||
|
|
||||||
|
# --- the actual regression -------------------------------------------------
|
||||||
|
expect tag 'prose MENTIONING the marker must still tag' 'docs+ci: document the release flow
|
||||||
|
|
||||||
|
CI commits the manifest bump with a Skip-Release: true trailer so it does
|
||||||
|
not re-trigger the tagger. The old [skip-release] marker was a substring
|
||||||
|
match, which is exactly why this commit would have skipped itself.
|
||||||
|
|
||||||
|
Bump-Type: patch'
|
||||||
|
|
||||||
|
expect tag 'legacy bracketed marker no longer skips' 'chore: something [skip-release]'
|
||||||
|
|
||||||
|
expect tag 'empty message' ''
|
||||||
|
|
||||||
|
printf '\nrelease_skip: %d passed, %d failed\n' "$pass" "$fail"
|
||||||
|
[ "$fail" -eq 0 ]
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user