Initial skill library: 9 skills for delegate briefs

WordPress plugin rebrand/conventions/remote-CLI patterns, Gitea release
workflow, bastille jail provisioning, remote shell quoting safety, server
fleet map, delegate brief writing, and verification discipline -- all
derived from real incidents this session, plus two skills adapted (MIT
license, attributed) from obra/superpowers and andrej-karpathy-skills.
This commit is contained in:
iWP Claudy
2026-08-02 20:08:29 +02:00
parent 9382b1efdc
commit 39a637410c
11 changed files with 882 additions and 1 deletions
+61 -1
View File
@@ -1,3 +1,63 @@
# agent-skills
Reusable skill library for AI coding delegates (kimi, agy, vibe, codex, session Claude) -- self-contained process definitions so delegate briefs can reference a skill instead of re-explaining the process every time.
Reusable skill library for AI coding delegates (session Claude, kimi, agy,
vibe, codex) working on the CloudHost/iWP.es projects. Goal: capture
process knowledge once, so a delegate brief can reference a skill instead
of re-explaining the same process from scratch every time.
## Format
Each skill is a directory under `skills/` containing a `SKILL.md` with
YAML frontmatter (`name`, `description`, optional `license`/`source`)
followed by the actual instructions in markdown. This is the same format
Claude Code's native `Skill` tool consumes directly, and matches the
convention used by other cross-platform skill projects (see Provenance
below) -- Gemini CLI's `activate_skill` tool and some Codex plugin setups
can potentially consume the same format natively, though that hasn't
been separately verified for this repo yet.
**For delegates that don't natively discover skills** (kimi, vibe, and
any codex/agy invocation not specifically configured for plugin
discovery): read the relevant `SKILL.md` yourself before writing the
delegate's brief, and fold its content into the prompt directly rather
than assuming the delegate can fetch it itself.
## Skills in this repo
- `wordpress-plugin-rebrand` -- forking/rebranding an existing WP plugin
under a new brand prefix.
- `wordpress-plugin-conventions` -- baseline WP plugin coding standards.
- `wordpress-cli-remote-execution` -- running wp-cli against a site inside
a remote jail/container.
- `gitea-release-workflow` -- tagging releases on self-hosted Gitea,
including a real release-asset-unreachable gotcha and its workaround.
- `bastille-jail-provisioning` -- FreeBSD jail creation via clone-from-base,
including a real recurring IP-conflict gotcha.
- `remote-shell-quoting-safety` -- the base64-push pattern for landing
`$`/quote-heavy content through nested shell layers without corruption.
- `server-fleet-map` -- which host is for what (dev/corporate/affiliate/
customers) and how to decide where new work belongs.
- `delegate-brief-writing` -- what a self-contained brief for a memoryless
CLI delegate needs to contain.
- `verification-before-completion` -- never relay a delegate's self-report
as fact; how to actually re-verify.
- `karpathy-guidelines` -- general LLM-coding behavioral guidelines
(simplicity, surgical changes, surfacing assumptions).
## Provenance
Some skills here are original (derived directly from real incidents on
this project); some are adapted from external open-source skill
libraries under their original MIT licenses, with attribution kept in
each skill's frontmatter:
- [obra/superpowers](https://github.com/obra/superpowers) (MIT)
- [SuperClaude-Org/SuperClaude](https://github.com/SuperClaude-Org/SuperClaude_Framework) (MIT)
- andrej-karpathy-skills (MIT)
## Adding a new skill
When a real, non-obvious pattern or gotcha comes up more than once,
write it down here rather than re-discovering it next time. Keep each
skill focused on one concern, include the *why* (not just the *what*) so
future edge cases can be judged sensibly, and prefer concrete confirmed
incidents over generic advice.
@@ -0,0 +1,82 @@
---
name: bastille-jail-provisioning
description: Use when creating a new FreeBSD bastille jail (a new site/service on a shared-LAN jail host) via clone-from-known-good-base. Covers the IP-conflict gotcha that has caused real, time-costly incidents twice.
---
# Bastille Jail Provisioning
Pattern for standing up a new jail on a FreeBSD host running Bastille,
by cloning a known-working base jail (already has nginx+php-fpm+packages
configured) rather than building from scratch.
## CRITICAL FIRST STEP: arp-scan before picking an IP
**Confirmed real incident, twice, on two different hosts on the same
shared LAN**: an IP address that looked free by every host-local check
(`bastille list all`, `ifconfig` aliases, a manually-tracked "already
used" list) turned out to already belong to a **different physical
device, or a jail on a different host sharing the same LAN segment**.
Symptom: the new jail's DNS resolution and outbound TCP connections
silently fail or time out with no useful error — this looks exactly like
a jail networking/pf config bug and can cost real time chasing the wrong
theory before the actual cause (an IP conflict) is found.
**Before assigning ANY candidate IP, always:**
```bash
pkg install -y arp-scan # if not already present
arp-scan --interface=vtnet0 <candidate-ip>
```
Zero responses = genuinely free. **Any** response (even from a device
with no recognizable vendor match) means it's taken — pick a different
IP and re-check. Do not rely on ping (misses devices with ICMP
disabled/filtered — a real false-negative that happened once already) or
on a per-host "what's already in use" list (each host only knows about
its own jails, not other physical devices or other hosts' jails on the
same shared subnet).
If you inherit a jail already provisioned on a conflicting IP: stop the
jail, remove the bad alias, update `jail.conf`'s `ip4.addr`, start the
jail again (this re-adds the alias on the new IP), then propagate the IP
change everywhere else it's referenced (DB user grants scoped by IP,
host-level nginx `proxy_pass` target, etc.) — an IP conflict fixed in
`jail.conf` alone but not in the DB grant/nginx config leaves the site
broken even though the jail itself is now healthy.
## Provisioning steps (once the IP is confirmed free)
1. **Clone from a known-good base jail** rather than building from
scratch — inherits working nginx/php-fpm/package configuration:
```bash
bastille clone -a <known-good-base-jail> <new-jail-name> <new-ip>
```
2. **Wipe cloned content, do a fresh install** if this is a brand-new
site (not a migration) — the cloned base's site files are a *template*
for the stack config, not content you want to keep.
3. **Create the database** on the host's local MariaDB, with the grant
scoped to the **jail's own IP**, not the host's IP:
```sql
CREATE DATABASE `dbname` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'dbuser'@'<jail-ip>' IDENTIFIED BY '<random-password>';
GRANT ALL PRIVILEGES ON `dbname`.* TO 'dbuser'@'<jail-ip>';
```
The application's `DB_HOST` config value is the **host's** own LAN IP
(where MariaDB actually listens), NOT the jail's IP — the jail IP is
only used for the GRANT's scope. Confirmed gotcha across multiple
real migrations: mixing these two up is an easy, non-obvious mistake.
4. **Wire the host-level nginx reverse-proxy vhost**, `proxy_pass`ing to
the jail's IP.
5. **Do NOT touch DNS or the reverse-proxy manager's public routing** as
part of automated provisioning — that's a deliberate, manual cutover
step for a human to do once the site is verified working over a
direct IP/Host-header test. Build and verify fully "dark" first.
6. **Smoke test** both directly to the jail IP and via the host nginx
proxy (with the right `Host` header and, if the site checks for HTTPS
via `X-Forwarded-Proto`, that header too) before considering
provisioning done.
## Persist credentials immediately
Write the generated DB password (and any other generated secrets) to a
durable location the moment they're generated — if a later provisioning
step fails, an unrecorded generated password is otherwise lost with no
way to recover it short of resetting it.
+68
View File
@@ -0,0 +1,68 @@
---
name: delegate-brief-writing
description: Use when handing a task off to a CLI-based coding delegate (kimi, agy, vibe, codex, or a fresh subagent) that has no memory of the current conversation. Covers what a self-contained brief needs.
---
# Delegate Brief Writing
A delegate CLI (kimi, agy, vibe, codex) or a fresh subagent has **zero
memory** of anything discussed so far — not the goal, not what's already
been ruled out, not which files matter. Everything it needs has to be in
the prompt itself.
## What a self-contained brief needs
1. **Background**: what is being built and why, in enough detail that
the delegate can make small judgment calls correctly without asking.
2. **Exact scope boundaries**: what's in scope, and just as important,
what's explicitly OUT of scope (a delegate that doesn't know a
boundary exists will happily wander past it).
3. **Confirmed facts, stated as confirmed** — not "probably" or "should
be": if you already checked something (a slug mapping, a config
value, a file's actual current content), say so explicitly and tell
the delegate not to re-derive it. Re-deriving already-known facts
wastes the delegate's budget and can introduce a *different*,
incorrect answer.
4. **Exact commands/patterns to follow**, not just a description of the
goal — if there's an established pattern (see e.g. the
`wordpress-plugin-rebrand` skill), give the exact steps, don't make
the delegate reinvent the process from a one-line goal statement.
5. **Credentials handling**: tell the delegate exactly how to read a
credential (e.g. "read the token fresh from `/path/to/token-file`")
and explicitly forbid printing/writing it anywhere.
6. **What to verify before reporting done**, concretely (lint, a specific
grep, a specific functional test) — see the
`verification-before-completion` skill.
7. **Exact report format expected back**, with a length cap. An
unconstrained delegate report is often much longer than useful.
## Wrapper-agent pattern: make it wait for the real result
If dispatching via an intermediate "wrapper" agent whose job is to
invoke the CLI tool and relay the result: **explicitly instruct it to
run the command synchronously and wait for actual completion before
ending its turn.** Confirmed real failure mode: a wrapper agent
repeatedly backgrounded the CLI process itself and ended its own turn
immediately, producing a stream of "still running, will report back"
notifications that never actually contained a result — because the
wrapper's own turn had already ended, it wasn't actually watching
anything. If this happens, don't keep re-prompting the same wrapper —
find the actual underlying process directly and monitor it yourself.
## Splitting work across multiple parallel delegates
When one deliverable naturally splits into N independent pieces (e.g.
"rebrand these 5 plugins"): do ONE piece yourself (or with one delegate)
first as a pilot, nail down the exact pattern including any gotchas
discovered along the way, THEN write the remaining N-1 briefs
incorporating everything learned from the pilot — including the specific
mistakes that happened during the pilot and how they were fixed, so the
parallel delegates don't repeat them. A brief written *after* the pilot
is meaningfully more reliable than N briefs all written from a cold
start.
## Always re-verify independently afterward
See `verification-before-completion`. A delegate's own final report,
however detailed and confident-sounding, is a claim — re-derive the key
facts yourself before treating the task as actually done.
+98
View File
@@ -0,0 +1,98 @@
---
name: gitea-release-workflow
description: Use when tagging a release for a plugin/package on a self-hosted Gitea instance, especially when the release needs to be downloadable by an external system (an auto-updater, a CI job, etc.).
---
# Gitea Release Workflow
## Creating a release
```bash
curl -s -X POST -H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
-d '{"tag_name": "v1.0.0", "name": "v1.0.0", "body": "<changelog>", "draft": false, "prerelease": false}' \
"$HOST/api/v1/repos/$OWNER/$REPO/releases"
```
Verify it landed:
```bash
curl -s -H "Authorization: token $TOKEN" "$HOST/api/v1/repos/$OWNER/$REPO/releases/latest"
```
Note: on at least one real self-hosted instance, `/releases/latest` 404s
for unauthenticated requests even on a public repo — always send the
token, don't assume public-repo endpoints are auth-free.
## KNOWN GOTCHA: release-asset downloads can be unreachable
**Confirmed live on a real self-hosted Gitea instance**: uploading a zip
as a release asset and then trying to fetch it via the asset's own
`browser_download_url` field returned an **internal address**
(`http://localhost:3000/...`) instead of the instance's real public
hostname — a `ROOT_URL` misconfiguration server-side. This makes the
uploaded asset **completely unreachable from outside the Gitea server
itself**, even though the release object and its metadata (version,
changelog) are fine.
**Symptom**: `update-check`-style API calls that only read release
metadata work fine; the actual **download** step 404s or times out.
**Workaround that reliably works**: don't rely on the release-asset
upload/download mechanism at all for the actual file bytes. Instead use
Gitea's **repo-archive endpoint**, which zips the tagged git tree
on-the-fly and is served from the same working API path pattern as
everything else:
```bash
curl -s -H "Authorization: token $TOKEN" -o output.zip \
"$HOST/api/v1/repos/$OWNER/$REPO/archive/$TAG.zip"
```
This means: the release object still gets created (for its version/
changelog metadata, which downstream consumers do need), but you do
**not** need to upload a zip as a release asset at all — the actual
distributable content is whatever's in the tagged git tree, fetched via
`/archive/{tag}.zip` at request time.
**Before trusting the asset-download path on a NEW Gitea instance**: test
it once (`curl` the `browser_download_url` directly) rather than
assuming it works — if it does work on a given instance, the asset-upload
approach is simpler and fine to use; the archive-endpoint workaround is
only needed on instances that have this specific misconfiguration. Don't
apply the workaround blindly without confirming the actual failure mode
exists on the instance you're using.
## Archive-endpoint folder-naming gotcha
The `/archive/{tag}.zip` endpoint names the zip's top-level folder based
on the repo (exact naming varies by Gitea version — sometimes the repo
name, sometimes lowercased). If the repo's own files live in a
subdirectory rather than at repo root, the resulting zip gets a
**double-nested** top-level folder. Keep single-purpose repos (one
plugin/package per repo) flat at the root to avoid this — see the
`wordpress-plugin-rebrand` skill's flatten-to-root step.
## Forking into a new org
```bash
curl -s -X POST -H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
-d '{"organization": "TargetOrg", "name": "new-repo-name"}' \
"$HOST/api/v1/repos/$SOURCE_OWNER/$SOURCE_REPO/forks"
```
The `name` field renames the fork on creation — you don't have to accept
the source repo's own name. Fork inherits the **source repo's full git
history**; if you're about to do a wholesale rebrand and don't need that
history preserved, a `git init` + single clean commit + `git push --force`
is often cleaner than carrying an unrelated-looking history forward — but
only force-push a repo that's genuinely fresh with nothing else depending
on its prior state.
## Verify a release actually works end-to-end
Don't stop at "the release object exists" — actually fetch the archive
and inspect it:
```bash
curl -s -H "Authorization: token $TOKEN" -o /tmp/verify.zip \
"$HOST/api/v1/repos/$OWNER/$REPO/archive/$TAG.zip"
unzip -l /tmp/verify.zip | head -20
```
Confirm: single non-double-nested top-level folder, expected files
present, no truncation.
+67
View File
@@ -0,0 +1,67 @@
---
name: karpathy-guidelines
description: Behavioral guidelines to reduce common LLM coding mistakes. Use when writing, reviewing, or refactoring code to avoid overcomplication, make surgical changes, surface assumptions, and define verifiable success criteria.
license: MIT
---
# Karpathy Guidelines
Behavioral guidelines to reduce common LLM coding mistakes, derived from [Andrej Karpathy's observations](https://x.com/karpathy/status/2015883857489522876) on LLM coding pitfalls.
**Tradeoff:** These guidelines bias toward caution over speed. For trivial tasks, use judgment.
## 1. Think Before Coding
**Don't assume. Don't hide confusion. Surface tradeoffs.**
Before implementing:
- State your assumptions explicitly. If uncertain, ask.
- If multiple interpretations exist, present them - don't pick silently.
- If a simpler approach exists, say so. Push back when warranted.
- If something is unclear, stop. Name what's confusing. Ask.
## 2. Simplicity First
**Minimum code that solves the problem. Nothing speculative.**
- No features beyond what was asked.
- No abstractions for single-use code.
- No "flexibility" or "configurability" that wasn't requested.
- No error handling for impossible scenarios.
- If you write 200 lines and it could be 50, rewrite it.
Ask yourself: "Would a senior engineer say this is overcomplicated?" If yes, simplify.
## 3. Surgical Changes
**Touch only what you must. Clean up only your own mess.**
When editing existing code:
- Don't "improve" adjacent code, comments, or formatting.
- Don't refactor things that aren't broken.
- Match existing style, even if you'd do it differently.
- If you notice unrelated dead code, mention it - don't delete it.
When your changes create orphans:
- Remove imports/variables/functions that YOUR changes made unused.
- Don't remove pre-existing dead code unless asked.
The test: Every changed line should trace directly to the user's request.
## 4. Goal-Driven Execution
**Define success criteria. Loop until verified.**
Transform tasks into verifiable goals:
- "Add validation" → "Write tests for invalid inputs, then make them pass"
- "Fix the bug" → "Write a test that reproduces it, then make it pass"
- "Refactor X" → "Ensure tests pass before and after"
For multi-step tasks, state a brief plan:
```
1. [Step] → verify: [check]
2. [Step] → verify: [check]
3. [Step] → verify: [check]
```
Strong success criteria let you loop independently. Weak criteria ("make it work") require constant clarification.
@@ -0,0 +1,69 @@
---
name: remote-shell-quoting-safety
description: Use when a command or file content needs to pass through multiple nested shell layers (e.g. local shell -> ssh -> a jail/container exec wrapper -> su -> the target interpreter). Avoids silent corruption of $, quotes, and special characters.
---
# Remote Shell Quoting Safety
## The problem
Content containing `$`, quotes, or other shell-special characters gets
progressively mangled the more shell layers it passes through (local
shell → `ssh` → a container/jail exec wrapper like `bastille cmd`
`su -c` → the actual target interpreter). Each layer's own quoting rules
interact, and by 3+ layers deep it becomes extremely hard to reason about
correctly — and failures are often **silent**: no error, just corrupted
content (e.g. `$_SERVER['KEY']` silently becoming `['KEY']`, `$` and the
variable name simply eaten).
**Confirmed real incident**: a `wp-config.php` snippet containing
`$_SERVER['HTTP_X_FORWARDED_PROTO']` passed through
`ssh → bastille cmd → su -m www -c '...'` came out the other side as
bare `["HTTP_X_FORWARDED_PROTO"]` — no error at the point of corruption,
the resulting fatal PHP error only surfaced two steps later at a
completely different command, making the actual cause much harder to
trace back.
## The fix: base64, not nested quoting
Don't try to escape correctly through N layers. Instead:
1. Write the target file's **complete final content** locally (a real
file, not a shell variable).
2. Base64-encode it locally: `base64 -w0 localfile > /tmp/payload.b64`
(or pipe directly).
3. `cat`/pipe the base64 text through the SSH/exec layers (base64 output
is alphanumeric-only — nothing for any shell layer to misinterpret,
regardless of how many layers it passes through).
4. Decode back to the real file **on the far side, in ONE final step**:
`base64 -d payload.b64 > /real/target/path`.
```bash
B64=$(base64 -w0 local_file.php)
echo "$B64" | ssh user@host "cat > /tmp/upload.b64"
ssh user@host "base64 -d /tmp/upload.b64 > /real/target/path && rm /tmp/upload.b64"
```
For a jail/container layer on top of SSH (e.g. `bastille cmd <jail>`),
land the base64 payload on the **host** filesystem first (which the jail
can usually see via a shared mount or a copy step), then decode from
**inside** the jail in one command — don't try to thread the base64
string itself through the jail-exec wrapper's own quoting too; one
decode step per real filesystem boundary crossed is enough.
## When to reach for this
- Any file content containing `$`, backticks, mixed quotes, or PHP/shell
code being pushed through 2+ nested shell contexts.
- Whenever a "the file exists but does something subtly wrong" bug
appears after a multi-hop deployment step, and the content involves
special characters — check for quoting corruption before looking
elsewhere.
## What NOT to do
Don't respond to a quoting failure by adding more layers of escaping
(`\\\$`, `'"'"'`, etc.) — this usually makes the problem harder to
reason about, not easier, and is exactly the kind of fragile fix that
looks like it works until the next slightly-different payload breaks it
again. Switch to the base64 pattern instead of debugging escape depth.
+43
View File
@@ -0,0 +1,43 @@
---
name: server-fleet-map
description: Use when deciding WHICH host a piece of work belongs on, or when you need a host's connection details. Reference map of the FreeBSD/bastille jail fleet and what each host is actually for.
---
# Server Fleet Map
All hosts reachable over Tailscale (`root@<host>`, SSH key
`/root/.ssh/id_ed25519` unless noted). Each host has a distinct
**purpose** — check this before assuming a new site/service belongs on
whichever host you happen to already be connected to.
| Host | Tailscale IP | Purpose | Notes |
|---|---|---|---|
| **staging** | `100.104.61.54` | Dev/staging — pre-production testing, throwaway experiments | New jail IPs here need the same arp-scan discipline as everywhere else on this shared LAN (see `bastille-jail-provisioning` skill) — this is where that gotcha was first found. |
| **venus** | `100.109.251.127` (LAN `192.168.0.33`) | Corporate — internal business sites/tools, the iWP.es plugin-subscription platform, and other CloudHost-org first-party projects | Bastille jails, combined web+db (local MariaDB, no shared external DB host). New jails typically created via `bastille clone -a <known-good-base>` rather than from scratch. |
| **granja** | `100.98.197.28` | Affiliate network — the travel/tourism site network (menorca.ro, palma.ro, and similar) | No public interface configured directly; sits behind a reverse-proxy manager (NPM) over Tailscale. See `docs/server-granja.md` for the local-curl-simulates-NPM testing trick. |
| **funky** | `100.127.21.100` (LAN `192.168.0.218`) | Customers — sites migrated from the old Linux affiliate fleet, actual paying-customer properties (e.g. news.easycut.es, photomouse.ro) | Combined web+db+valkey, bastille jails. Migration target that absorbed several now-decommissioned Linux hosts (maagar, shoe, proton). |
| **zamolxis** | `100.115.128.41` | Standalone (non-jailed) FreeBSD site host | Not part of the granja/external-fleet jail families — a single site (Newspaper/tagDiv theme, PHP 8.5) running directly on the host, not in a jail. |
| **cabrera** | `100.100.108.19`, SSH port `79` | External Linux affiliate-network host (BTPanel-style) | Part of the older Linux fleet (alongside raptor/maagar/formentor/moonie/spunky/proton/shoe — several since decommissioned/migrated to funky). Hosts easycut.es's main WooCommerce e-commerce site directly (not jailed). |
## How to decide where new work belongs
1. **What kind of thing is it?** A corporate/internal tool → venus. A
customer's production site → funky. A new affiliate-network travel
site → granja. A quick experiment or something not ready for anything
resembling production → staging.
2. **Does it already have an established host from a related sibling
project?** (e.g. another CloudHost-org first-party tool already lives
on venus) — match that, don't scatter related things across hosts
without a reason.
3. **When genuinely unsure, ask** rather than guessing — which host
something lives on affects DNS, backup scope, and who has access to
it later.
## Gitea (separate from any of the above)
`https://devops.cloudhost.es` — not one of the site-hosting fleet, this
is the internal git/CI host. Orgs seen so far: `CloudHost` (main
internal projects + this skills repo), `iWP.es` (the plugin-subscription
product and its forked/rebranded plugins), `InformatiQ` (the security
plugin), `External` (mirrors of third-party repos for internal
reference).
@@ -0,0 +1,65 @@
---
name: verification-before-completion
description: Use before reporting any task as done, especially when the work was done by a delegate/subagent. Never relay a self-report as fact.
license: MIT
source: adapted from obra/superpowers (MIT license), condensed for delegate-brief reuse
---
# Verification Before Completion
**A delegate's "done" is a claim, not a fact.** This applies whether the
delegate is another AI agent, a subagent, or your own past self in an
earlier step. Re-derive the result yourself before reporting completion
to whoever is waiting on it.
## The rule
Never report "done" based solely on:
- A delegate saying it's done.
- A test that wasn't actually re-run by you.
- Code that "should work" based on reading it, when running it was possible.
Instead, for each claim, do the cheapest check that would actually catch
a wrong claim:
| Claim | Real check |
|---|---|
| "The file was created" | `ls`/`cat` it yourself |
| "The syntax is valid" | Actually run the linter yourself, don't trust a self-report of having run it |
| "The tests pass" | Run them yourself, read the actual output |
| "The API returns X" | Make the actual call yourself |
| "The deploy succeeded" | Check the live system, not the deploy log's exit code alone |
| "The old code is gone" | grep for it yourself |
## Why this matters (not just process theater)
Across a real multi-delegate session, every single delegate's first
self-report claimed success. Independent re-verification (re-running the
linter, re-grepping, and — critically — testing the actual live
end-to-end pipeline rather than just checking that files existed) is
what actually confirmed correctness. In an earlier phase of the same
session, a wrapper-agent pattern (spawn an agent whose only job is to
invoke one CLI tool and report back) repeatedly ended its own turn the
instant it *started* a background process, before that process had
produced any real result — producing a loop of confident-sounding "still
running" reports that carried zero information. The fix was to stop
trusting the wrapper's self-reported status and directly monitor the
actual underlying process.
## Escalating verification for higher-stakes changes
- Low-stakes (a text file, a doc change): read it back.
- Medium-stakes (application code): lint + a targeted functional test.
- High-stakes (something that serves real traffic, handles money, or
changes shared/production state): the *actual* end-to-end path a real
user would hit — not a unit test in isolation, the real live pipeline.
If you cannot exercise the real path yourself (e.g. no browser
available for a checkout flow), say so explicitly rather than reporting
success on a partial check.
## What NOT to do
Don't pad out a report with hedging language as a substitute for actually
checking ("this should work", "this is likely correct"). Either verify
it, or clearly state it's unverified and why — don't blur the line
between the two.
@@ -0,0 +1,69 @@
---
name: wordpress-cli-remote-execution
description: Use when running wp-cli commands against a WordPress site living inside a remote jail/container (via ssh + a jail-exec wrapper). Covers the output-prefix stripping gotcha and the correct user/path invocation pattern.
---
# WordPress CLI Remote Execution
## Invocation pattern
```bash
ssh user@host "bastille cmd <jail-name> sh -c 'cd /path/to/site && su -m www -c \"wp <command> --path=/path/to/site\"'"
```
Key points:
- Run wp-cli as the **web server user** (`www`, `www-data`, etc.), not
root — WordPress file ownership assumptions and some plugin behavior
depend on this.
- Always pass `--path=` explicitly rather than relying on `cd` alone
propagating through every nested layer correctly.
## GOTCHA: jail-exec wrappers prefix their own output
A wrapper like `bastille cmd <jail>` prints a `[jailname]:` header line
before the actual command output — this is the wrapper's own framing,
not part of the real output. Any script that captures this output for
further parsing must filter it out:
```bash
OUTPUT=$(ssh user@host "bastille cmd <jail> <command>" 2>&1 | grep -v "^\[<jail>\]:$")
```
Forgetting this is a common, easy-to-miss source of "the command
mysteriously failed to parse" bugs when scripting on top of jail-exec
wrappers.
## GOTCHA: `error_reporting(0)` in bootstrap files can mask real errors
Some CMS/application bootstrap files set `error_reporting(0)` globally
near the very top (often to suppress noisy legacy warnings). If you're
writing a standalone diagnostic/one-off script that `require`s such a
bootstrap file, your own errors after that point are ALSO silently
suppressed — a script can fail with **zero output and just a bad exit
code**, giving no clue why. Fix: explicitly
`error_reporting(E_ALL); ini_set('display_errors', '1');` again
**after** requiring the bootstrap, and add a
`register_shutdown_function` that dumps `error_get_last()` — this turns
a silent failure into an actual, readable error message.
## GOTCHA: glob patterns fail silently under some remote shells
`bastille cmd <jail> grep -rln 'pattern' /some/path/*.php` can fail with
`zsh: no matches found: ...` if the jail's default shell is zsh and the
glob doesn't expand as expected in that invocation context — with no
useful indication that this is a shell-globbing issue rather than "no
files matched." Fix: drop the glob and grep the directory recursively
instead (`grep -rln 'pattern' /some/path/`, no trailing `/*.ext`) — this
avoids the shell needing to expand anything at all.
## When you need to run PHP with full application bootstrap, standalone
If you need to call an application's own PHP functions/classes outside
the normal web request flow (e.g. to directly test a code path), don't
guess at which files to `require` — trace the **actual real bootstrap
sequence** the application's own front controller uses (e.g. its
`index.php`) by reading it, and replicate that exact require order in
your standalone script. Skipping steps because "this part probably isn't
needed" reliably produces a cascade of "undefined function/class" errors
that have to be debugged one require statement at a time — reading the
real bootstrap sequence once up front is faster than that cascade.
@@ -0,0 +1,123 @@
---
name: wordpress-plugin-conventions
description: Use when writing or modifying a WordPress plugin's PHP code. Baseline structural/security conventions to follow without being told each time.
---
# WordPress Plugin Conventions
Baseline conventions for first-party WordPress plugin code. Follow these
without needing to be told in every brief.
## File structure
```
plugin-slug/
plugin-slug.php — main file: header, constants, bootstrap class
includes/
class-<prefix>-*.php — one class per concern, class-based not procedural
admin/ — admin-only UI (settings pages, dashboards)
assets/{css,js}/
languages/ — .pot/.po/.mo if the plugin is translatable
```
## Main file skeleton
```php
<?php
/**
* Plugin Name: ...
* Plugin URI: ...
* Description: ...
* Version: 1.0.0
* Author: ...
* Author URI: ...
* License: GPL v2 or later
* License URI: https://www.gnu.org/licenses/gpl-2.0.html
* Text Domain: plugin-slug
* Requires PHP: 7.4
*/
if (!defined('ABSPATH')) {
exit;
}
define('PREFIX_VERSION', '1.0.0');
define('PREFIX_PATH', plugin_dir_path(__FILE__));
define('PREFIX_URL', plugin_dir_url(__FILE__));
require_once PREFIX_PATH . 'includes/class-prefix-thing.php';
class Prefix_Main {
private static $instance = null;
public static function instance() {
if (null === self::$instance) { self::$instance = new self(); }
return self::$instance;
}
private function __construct() { /* hook registration only */ }
public static function activate() { /* create tables/options/dirs */ }
public static function deactivate() { /* reverse activate() side effects */ }
}
register_activation_hook(__FILE__, ['Prefix_Main', 'activate']);
register_deactivation_hook(__FILE__, ['Prefix_Main', 'deactivate']);
add_action('plugins_loaded', ['Prefix_Main', 'instance']);
```
Every included file starts with `if (!defined('ABSPATH')) exit;` — never
`define('ABSPATH', ...) &&` or any variant, exactly the guard-and-exit
form, so the file can never be requested directly over HTTP.
## Every file must pass `php -l` before you consider a task done
Not optional, not "probably fine" — actually run it, on every changed
file, every time. This is the cheapest possible check and catches a
meaningful fraction of real mistakes (typos, mismatched braces from a
find/replace, etc.) before they ever reach a live site.
## Security baseline
- Nonces on every form/AJAX action: `wp_nonce_field()` /
`check_admin_referer()` / `check_ajax_referer()`.
- Capability checks before any privileged action:
`current_user_can('manage_options')` (or the narrowest capability that
actually applies — don't default to `manage_options` for things a
lower-privileged role should legitimately be able to do).
- Escape on output, every time, using the context-correct function:
`esc_html()`, `esc_attr()`, `esc_url()`, `esc_js()` — never raw-echo
anything that traces back to user input or the database without one of
these.
- Sanitize on input: `sanitize_text_field()`, `absint()`,
`sanitize_email()`, etc. — appropriate to the expected shape of the
data, applied at the point the `$_POST`/`$_GET` value is first read.
- `$wpdb->prepare()` for every query with a variable in it — no string-
interpolated SQL, ever, no exceptions.
## Database tables
If a plugin needs its own tables (not just options), use `dbDelta()`:
```php
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
$charset = $wpdb->get_charset_collate();
$sql = "CREATE TABLE {$wpdb->prefix}prefix_thing (...) {$charset};";
dbDelta($sql);
```
Track a `DB_VERSION` constant + a stored option so future plugin updates
can detect and re-run `dbDelta()` for schema changes.
## PHP 7.4 compatibility
Unless a specific brief says otherwise, target PHP 7.4+ (a large fraction
of real WordPress hosting is still on 7.4). Avoid PHP 8-only syntax
(named arguments, enums, readonly properties, `match`). If a codebase
needs `str_contains`/`str_starts_with`/`str_ends_with` (PHP 8.0+) on an
environment that might run 7.4, guard with `function_exists()`
polyfills rather than assuming they exist.
## Don't build what WordPress core already gives you
Before writing custom code for: cron scheduling (`wp_schedule_event`),
REST endpoints (`register_rest_route`), settings UI
(`register_setting`/`add_settings_field`/`woocommerce_admin_fields` if
WooCommerce context), object caching (`wp_cache_*` functions), HTTP
requests (`wp_remote_get`/`wp_remote_post`, not raw `curl`) — check
whether a core API already does it. Reinventing these is both wasted
effort and a common source of subtle bugs core already solved correctly.
+137
View File
@@ -0,0 +1,137 @@
---
name: wordpress-plugin-rebrand
description: Use when forking and rebranding an existing WordPress plugin under a new name/prefix (e.g. for a white-label or iWP-branded resale product). Covers identifier renaming, repo flattening, updater bundling, and license-field wiring.
---
# WordPress Plugin Rebrand
Process for taking an existing WordPress plugin and rebranding it under a
new name/prefix, without breaking anything or missing a spot. Validated
live across 5 real plugins in one session (WooList, WooBC, WooSol,
SlickWoo, MetAI → iWP-branded forks), each independently re-verified
end-to-end afterward with zero defects found.
## Inputs you need before starting
- Source repo (clone URL + credentials).
- New brand prefix (e.g. `iWP`), new product slug (e.g. `iwp-woolist`) —
**get the exact slug from wherever the catalog/product list already
defines it, never invent your own.**
- The updater/license-client file to bundle, if the target platform has
one (e.g. a shared `IWP_Updater` class), and its exact integration
snippet (api_url, plugin_slug, license_option names).
## Step 1 — Learn the actual existing convention, don't assume
Read the plugin's main file first. Every plugin has its own prefix
scheme — don't assume it matches a previous rebrand. Identify:
- Constant prefix (`PLUGINNAME_...`)
- Class prefix (`PluginName_...`)
- Function prefix (`pluginname_...`)
- Text domain string (usually lowercase-hyphenated, e.g. `pluginname`)
- Whether the plugin lives at the repo root or in a subdirectory
## Step 2 — Flatten to repo root
If the plugin's files live in a subdirectory (not the repo root), move
everything up to root. Reason: many git hosts' repo-archive endpoint
(`/archive/{tag}.zip`-style) names the zip's top-level folder after the
**repo**, so a nested subdirectory inside the repo produces a
double-nested folder in the resulting distributable zip
(`reponame/plugin-subdir/...` instead of `reponame/...`). Flattening
avoids this categorically rather than requiring special-case handling
downstream.
## Step 3 — Rename, in this order
1. **Text-domain / hyphenated contexts first**: every `oldname-` (CSS
classes, script/style handles, asset file paths, admin page slugs) →
`newprefix-oldname-`. A safe global find/replace, catches the vast
majority of hyphenated identifiers correctly in one pass.
2. **Constants**: `OLDNAME_``NEWPREFIX_OLDNAME_`.
3. **Classes**: `OldName_``NewPrefix_OldName_`.
4. **Functions / hooks / underscore contexts**: `oldname_`
`newprefix_oldname_`.
5. **camelCase-after-underscore contexts** (JS localize-object names are
the common case: `oldnameData` used as the 2nd arg to
`wp_localize_script()`) → `newprefix_oldnameData`. **Grep the JS files
for the exact existing spelling before finalizing — don't guess the
casing, the PHP `wp_localize_script()` call must match byte-for-byte
or the JS breaks silently at runtime.**
6. **Remaining bare prose occurrences of the product name** (page titles,
UI labels, doc-comments) → the new display name (e.g. `MetAI`
`iWP MetAI`). Do this LAST, after class renaming, or you'll double-
prefix the class name.
7. **File/directory renames** to match: `class-oldname-admin.php`
`class-newprefix-oldname-admin.php`, etc.
8. Update the plugin header block: `Plugin Name`, `Author`, `Author URI`,
`Plugin URI`, `Text Domain` (always hyphenated, never underscored —
WordPress text domains are always hyphen-form even when everything
else in that context uses underscores).
## Step 4 — Cross-check enqueue calls against actual filenames
Every `wp_enqueue_script()`, `wp_enqueue_style()`, `wp_localize_script()`
handle/path argument must match the ACTUAL renamed filenames on disk
exactly, or the admin UI silently fails to load its JS/CSS with no error
message anywhere obvious. Check this explicitly, don't assume the
renames were consistent.
## Step 5 — Bundle the updater / license client (if applicable)
Copy the shared updater class unchanged into the plugin's `includes/`
directory. Wire it in the main file (near the top, after constants):
```php
require_once <PATH_CONSTANT> . 'includes/class-<updater-file>.php';
add_action( 'plugins_loaded', function () {
new <UpdaterClass>( [
'api_url' => '<the licensing server's API base>',
'plugin_file' => __FILE__,
'plugin_slug' => '<the confirmed catalog slug>',
'version' => <VERSION_CONSTANT>,
'license_option' => '<newprefix>_<name>_license_key',
] );
}, 5 );
```
Then add ONE settings field for the license key (option name matching
`license_option` above) into wherever the plugin already has an admin
settings UI — follow that file's existing code style/pattern for how
other fields are registered/rendered, don't introduce a different
pattern. If the plugin has no settings UI at all, add a minimal one
rather than skipping this step — license-gated updates need somewhere
for the customer to enter the key.
## Step 6 — Verify before pushing
1. `php -l` every `.php` file. Zero tolerance for syntax errors.
2. Case-insensitive grep the whole tree for the bare old name:
`grep -rniE '(^|[^a-z_-])oldname' --include='*.php' --include='*.js' --include='*.css' .`
— every hit must be part of a correctly-prefixed token
(`newprefix-oldname`, `newprefix_oldname`, `NewPrefix_OldName`, or the
new display name). Zero unprefixed exceptions, except possibly an
original-authorship credit comment if one genuinely exists.
3. Sanity-check for double-prefixing bugs (`iWP iWP`, `IWP_IWP`,
`iwp-iwp`, `iwp_iwp` or equivalent for your prefix) — a real mistake
that happens when step 3.6 runs before 3.3, or runs twice.
## Step 7 — Ship
Commit, push (force-push is fine for a freshly-created fork with no
other history depending on it — confirm that's actually the case before
force-pushing anything else). Tag a release. See the
`gitea-release-workflow` skill for the release/verification steps if the
git host is Gitea.
## Common failure modes (all hit at least once during validation)
- **Forgetting the flatten-to-root step** → double-nested zip.
- **JS localize-object name mismatch** (hyphen where an underscore is
required for a valid JS identifier) → admin UI JS silently breaks with
no visible error.
- **Trusting the delegate's self-report instead of re-verifying** — every
single delegate run in the validating session initially reported
success; independent re-verification (re-running `php -l` yourself, a
fresh grep, an actual functional test through the real serving
pipeline) is what actually confirms it, not the report.