PHP was the one language with real code in two shapes here and no skill: a Composer library held at PHPStan level max, and a plain PHP-FPM application behind nginx with no framework. The rules come from reading both rather than from general PHP advice, so each one names the failure it prevents. The two halves differ enough to be separate sections. A library requires PSR interfaces and ships no implementation; an application's rules are about escaping, keeping the document root unwritable, atomic writes and sessions. Everything in §1-§8 applies to both. Three findings are worth the space they take because they were each paid for once already: `json_encode` with JSON_UNESCAPED_SLASHES into an inline <script> is an XSS hole, a web user that cannot write its own cache fails silently on every request, and inside the dev container the host's own `php` bridge shadows the real interpreter so every vendor/bin tool dies naming distrobox. docs(python): cover MCP servers, Red cogs and HA components, and correct three rules the owner's own code contradicts The skill described a toolchain and was silent on the four things most of this Python actually is. Three of its rules are also wrong in context, and the code is right: getLogger(__name__) puts a cog outside the red.* tree where Red's log controls apply; TaskGroup's cancel-on-first-error is the wrong default for fan-out over N providers, where the real pattern is a semaphore plus gather(return_exceptions=True); and the gate runs locally and through pre-commit, not through CI, which never runs under this account. Also: ruff ALL-minus-documented-ignores is the preferred shape rather than the explicit select list, lockfiles split by artifact type, and bare `python` and bare `pip` are different interpreters on this host. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
14 KiB
name, description
| name | description |
|---|---|
| cli-tooling | Use this skill whenever invoking a command-line tool, choosing between a CLI and an ad-hoc script, writing a standalone utility script, or orienting on unfamiliar work before starting. Covers discovering a CLI's real interface instead of guessing flags, non-interactive and machine-readable invocation, checking an API's payload rather than its exit status, keeping credentials out of argv, eventually-consistent systems that briefly report the old state, script placement and symlinking, and care around destructive or outward-facing commands — including additive changes that silently take over a name already serving traffic. |
CLI & Tooling — Discovery, Invocation, and Script Placement
How to use command-line tools correctly, and where utility scripts belong.
Companion to coding-dev.md, which covers writing the code itself.
1. Discovery Over Guessing
Important
Read the tool's own help before using it. Never guess subcommands or flags.
<tool> --help <tool> <subcommand> --help
A tool's built-in help is authoritative and current; memory and training data are neither. Flags get renamed, subcommands get restructured, and a confidently-invented flag either errors out or — far worse — silently means something else.
This applies to listing capabilities, too. When asked what a tool can do, run its help and report what it actually says. Do not produce a speculative command list; a plausible- looking invented flag is indistinguishable from a real one to whoever reads it next.
Walk the whole help tree
Multi-level CLIs hide most of their surface one level down. <tool> --help shows the
subcommand list; the flags that matter usually live in <tool> <subcommand> --help.
Don't reinvent a tool that exists
When a dedicated CLI already provides the functionality, use it rather than writing an
ad-hoc script, a one-off curl pipeline, or a bespoke API client. The CLI already handles
authentication, retries, pagination, error formatting, and the edge cases you have not
thought of yet.
Before writing a script to do something, check whether:
- a dedicated CLI for that system is already installed,
- the tool you are already using has a subcommand for it,
- the project's own
scripts/directory already contains it.
2. Non-Interactive Invocation
Anything run from a script, a CI job, or an agent must not depend on a human being present.
- Pass required targets explicitly. A CLI that manages multiple objects will, when no
target is given, either prompt interactively or fail — both of which break automation.
Always pass
--server <id>,--profile <name>,--target <x>rather than relying on a default or a prompt. - Use the tool's own blocking flag (
--wait,--follow,--no-detach) when you need an operation to finish before the next step. Do not build a polling loop around a command that already knows how to wait — and never write a shelluntil/whilewait loop (seecode-languages/bash.md). - Prefer a
--dry-runrehearsal for anything that deploys, releases, or deletes, when the tool offers one. - Set
--yes/--non-interactivedeliberately and only where the action is genuinely safe — not as a reflex to stop a tool from asking.
3. Machine-Readable Output
When output feeds another program — or an agent's context — ask the tool for a structured format rather than parsing its human-facing table.
tool list -o json # full fidelity
tool list -o compact # minimal tokens, good for agent context
tool list -o csv # structured columns for a spreadsheet or script
- Never scrape a table meant for humans. Column widths, truncation, colour codes, and alignment all change without notice; a structured format is a contract.
- Check the payload, not just the exit status. Plenty of HTTP APIs answer
200 OKwith{"success": false, "errors": [...]}in the body, andcurlexits0because the request worked. A script that trusts the exit code reports a clean success and writes nothing. Assert on the field the API uses for this, and printerrors[]when it trips:if [[ "$(jq -r '.success' <<<"$response")" != true ]]; then jq -r '.errors[]? | " - \(.message)"' <<<"$response" >&2 exit 1 fi - Strip colour explicitly (
--color never,NO_COLOR=1) when capturing output, or escape sequences end up in your parsed values. - Keep in mind that a "compact" or summary format is the right default for feeding an agent,
while
jsonis right for a script that needs every field.
Warning
Never pipe build, lint, or test output through a filter to make it shorter. That is a different situation and the rule there is the opposite — see
coding-dev.md§2. Structured output flags are for data;grepon diagnostics hides warnings.
4. Orient Before Starting
Before beginning work in an unfamiliar project or one you have not touched recently, spend a moment establishing what has already happened. Cheap, and it prevents the most expensive failure mode: rebuilding something that already exists.
In rough order of value:
- The project's own instructions —
AGENTS.md,CLAUDE.md,.rules,CONTRIBUTING.md,README.md. Read them before touching code. - Recent history —
git log --oneline -20, andgit status --shortto see what is already in flight so you do not clobber it. - Prior work records — session/conversation history tooling, issue trackers, or notes files, scoped to the project directory.
- The code itself —
grepfor the symbol before trusting any document that says it is missing. - Whether the thing is already running — before editing a live app's config or starting a
second copy. Ask the supervisor (
systemctl --user is-active <unit>) and, for a bare process,pgrep -ax <name>— never barepgrep -f <name>, which matches your own shell and reads as a false positive. Seecode-languages/bash.md.
Caution
Task documents are leads, not facts. A
TODO.mdentry can be stale, duplicated, or simply wrong — including when it sounds confident and especially when it sounds familiar. Verify against the code before acting on one. A singlegrepis cheaper than rediscovering that the work shipped last week.
5. Where Scripts Live
Important
Never write a standalone script directly into a
PATHdirectory (~/.local/bin,/usr/local/bin, or similar). Write it into a dedicated scripts directory that is version-controlled, then symlink it intoPATH.
# write the script to its real home
$EDITOR /path/to/scripts/my-tool.sh
chmod +x /path/to/scripts/my-tool.sh
# then expose it
ln -s /path/to/scripts/my-tool.sh ~/.local/bin/my-tool
Why: one versioned source of truth. A script living directly in PATH is invisible to
version control, easy to clobber on a reinstall, impossible to diff, and indistinguishable
from a package-manager-owned binary sitting beside it. The symlink makes the provenance
obvious — ls -l shows exactly where the real file is.
The same applies within a project: scripts belong in scripts/, referenced by the build
system, not scattered at the repository root.
6. Destructive and Outward-Facing Commands
- Look at the target before deleting or overwriting it. Inspect it first — the cost of a
wrong
rmor a wrong overwrite is unbounded and the check is one command. - Read-only first. When exploring an unfamiliar tool, use its inspection subcommands
(
status,list,show,get) before any mutating one. - Mutating commands that rewrite shared configuration — syncing settings across machines, purging caches or state, patching installed applications — should be confirmed before running, even when they are reversible in principle.
- Never use real data as scratch space. Real accounts, real configuration directories, live services, and the user's actual files are not disposable test fixtures. Use temporary directories and disposable targets.
- Anything that sends, publishes, deploys, or purchases is outward-facing: confirm first, and treat approval as applying to that one action rather than the category.
Adding something can take something over
Caution
A change that deletes nothing can still break a live service. The dangerous operations are not only the ones named
delete. Adding a routing rule, a proxy entry, a redirect, a DNS override or a higher-priority config can silently intercept a name that is already serving traffic — while the thing it shadows still looks perfectly healthy in every listing, because it was never touched.That is the worst shape a failure can take: the old config is still there, still correct, still pointing where it should, and the service is dead anyway. Nothing in the inventory says what happened.
Before claiming a name, ask what currently answers to it — and refuse by default when something does:
# Only an unrouteable placeholder, or an entry this tool made, is safe to assume is free.
if [[ "$(current_target "$host")" != "$PLACEHOLDER" && "$(owner "$host")" != "$MARKER" ]]; then
[[ "$FORCE" == true ]] || die "$host already serves $(current_target "$host") — pass --force"
fi
Two things make that check possible:
- Mark what you create. A comment field, a tag, a description prefix — anything that
distinguishes your entries from ones a human made. It turns "is this mine to replace?"
into a lookup rather than a guess, and it is what lets the matching
--deleterefuse to remove something it did not create. - Make
--forcethe only way past, and name in the refusal what is there now. A tool that silently overwrites is one nobody can safely run against production.
Tip
Test such a tool against a name you expect it to reject, not only against a free one. The rejection path is where the damage would have been, and it is the half a happy-path test never exercises.
7. Credentials Belong on stdin, Not in argv
Warning
argvis world-readable. Any user on the box can runpsand read the full command line of every process. A token passed as-H "Authorization: Bearer $TOKEN",--password, or--tokenis therefore exposed for as long as the call runs — and lands in shell history and process accounting too.
Most CLIs that accept a secret as a flag also accept it somewhere safer. Use that instead:
# WRONG — the token is visible in `ps` for the life of the request
curl -H "Authorization: Bearer $TOKEN" "$url"
# RIGHT — curl reads its options from stdin; argv holds nothing sensitive
printf 'header = "Authorization: Bearer %s"\n' "$TOKEN" | curl --config - "$url"
The equivalent elsewhere: --password-stdin (docker login, many package tools), -u with a
.netrc, --token-file, or simply an environment variable the tool reads by name. Reach for
the env var when nothing better exists — it is readable through /proc/<pid>/environ but only
by the owner and root, which is a meaningfully smaller exposure than argv.
See code-languages/bash.md §8 for the surrounding rules on
where secrets are stored and why they are never echoed.
8. Eventually-Consistent Systems Lie Briefly
Edge networks, DNS, CDNs, object stores, container registries and clustered schedulers all acknowledge a write before it is visible everywhere. A read taken immediately after a successful write is not evidence of anything.
The failure mode is believing the tool is broken and "fixing" it:
- A changed rule still returns the old value for a while — which reads as "my write was ignored".
- A brand-new name returns an error for a while — a 5xx, an NXDOMAIN, a 404 — which reads much worse than it is.
So: take the write's own success response as the result, and treat the first few reads as unreliable. When a verification read matters, say how long propagation takes in the tool's own help or header comment, so the next person does not rediscover it at 2am. State it as a number — "~30s for a rule, ~40s for a new name" — because "eventually" tells nobody when to start worrying.
Do not wrap this in a polling loop (see
code-languages/bash.md §3); take one sample per
invocation and let the caller run it again.
Warning
Checking too early can make the delay much worse. A failed lookup is itself cached. Query a DNS name before it exists and the resolver stores the
NXDOMAINfor the SOA's negative TTL — commonly 300-1800s — so the name stays invisible to you long after it is live for everyone else. Polling during propagation is what creates this, which is a second reason not to loop.When a check does come back negative, confirm where the negative lives before concluding anything: query the authoritative source directly and compare. The answer is often "the record is fine, my resolver is lying", and flushing the local cache will not help if the stale entry is on an upstream resolver you do not control.
dig +short @1.1.1.1 "$name" # authoritative-ish: is it actually published? dig +short "$name" # what my resolver believes curl --resolve "$name:443:<ip>" "https://$name/" # bypass DNS entirely to test the service
9. Environment Constraints
- Entering a container or changing shells resets the working directory. Chain
cdinside the same command string rather than relying on it persisting:container-run <name> -- bash -c "cd /path/to/project && make" - Verify what actually exists rather than assuming from documentation. Container names,
tool versions, and installed toolchains drift. A one-command check (
<tool> list,<tool> --version,command -v <tool>) costs nothing and prevents a confident invocation of something that was removed months ago. - Check for an optional dependency before the step that needs it, and fail loudly if it is missing. A packaging step that silently skips when its tool is absent ships an incomplete release.
- Don't saturate the machine. A full build consuming every core makes the workstation
unusable. When something latency-sensitive is running, reduce the job count and
nicethe work — and say that you did.