325 lines
14 KiB
Bash
Executable File
325 lines
14 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
|
|
# The manual this machine hands an agent.
|
|
#
|
|
# skills/ and .agents/skills/panama exist because an agent asked to do anything
|
|
# on a Panama desktop will otherwise infer it from the source and get half of it
|
|
# wrong. That only helps if what the skills say is true -- and a skill is worse
|
|
# than no skill when it is stale, because an agent believes it verbatim and does
|
|
# not check. Prose about design cannot be pinned; the things a skill names can
|
|
# be, so this checks every one of them:
|
|
#
|
|
# 1. The three skills load: SKILL.md with frontmatter whose name is the
|
|
# directory's, and a description, which is the only part of a skill the
|
|
# loader reads before deciding to open it.
|
|
# 2. Every command, path and variable a skill names in backticks resolves.
|
|
# That is a convention on the prose -- name things exactly, in backticks --
|
|
# and it is how they should be written anyway.
|
|
# 3. The delivery works: link-skills is a stage, in the right place, and the
|
|
# personal manifest hands both shared skill homes to the linkdir kind.
|
|
#
|
|
# Sections 1 and 2 report clearly and keep going when a skill is not written
|
|
# yet, so this contract is useful while the skills are still being authored.
|
|
|
|
set -uo pipefail
|
|
|
|
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
|
installer="$repo_dir/install"
|
|
linker="$repo_dir/setup/scripts/link-skills"
|
|
user_linker="$repo_dir/setup/scripts/link-user"
|
|
manifest="$repo_dir/user/manifest"
|
|
|
|
findings=()
|
|
note() { findings+=("$1"); }
|
|
|
|
# Every directory under skills/ ships to every machine. The project-level
|
|
# panama skill stays in this repository and needs no home-directory delivery.
|
|
SKILL_DIRS=()
|
|
for directory in "$repo_dir"/skills/*; do
|
|
[[ -d "$directory" ]] && SKILL_DIRS+=("${directory#"$repo_dir"/}")
|
|
done
|
|
SKILL_DIRS+=(.agents/skills/panama)
|
|
|
|
# The project skill has one agent-neutral source. Claude gets a compatibility
|
|
# symlink, while Codex and other Agent Skills readers use .agents directly.
|
|
[[ -L "$repo_dir/.claude/skills/panama" ]] \
|
|
|| note '.claude/skills/panama is not a compatibility symlink to the agent-neutral source'
|
|
[[ "$(readlink -f "$repo_dir/.claude/skills/panama" 2>/dev/null)" == \
|
|
"$(readlink -f "$repo_dir/.agents/skills/panama" 2>/dev/null)" ]] \
|
|
|| note '.claude and .agents resolve the project panama skill differently'
|
|
|
|
# ── 1. Each skill loads ─────────────────────────────────────────────────────
|
|
|
|
present=()
|
|
for relative in "${SKILL_DIRS[@]}"; do
|
|
directory="$repo_dir/$relative"
|
|
file="$directory/SKILL.md"
|
|
|
|
if [[ ! -d "$directory" ]]; then
|
|
note "$relative does not exist yet, so nothing there can be checked"
|
|
continue
|
|
fi
|
|
if [[ ! -r "$file" ]]; then
|
|
note "$relative has no readable SKILL.md, so the loader ignores it"
|
|
continue
|
|
fi
|
|
present+=("$directory")
|
|
|
|
# Frontmatter is the first --- delimited block, and a skill without one is
|
|
# not a skill: agent loaders skip the directory entirely.
|
|
frontmatter="$(awk 'NR==1 { if ($0 != "---") exit 1; next } $0 == "---" { exit } { print }' "$file")"
|
|
if [[ -z "$frontmatter" ]]; then
|
|
note "$relative/SKILL.md does not open with a --- frontmatter block"
|
|
continue
|
|
fi
|
|
|
|
declared="$(sed -n 's/^name:[[:space:]]*//p' <<<"$frontmatter" | head -1)"
|
|
description="$(sed -n 's/^description:[[:space:]]*//p' <<<"$frontmatter" | head -1)"
|
|
|
|
[[ "$declared" == "$(basename "$directory")" ]] \
|
|
|| note "$relative/SKILL.md declares name '$declared', which is not its directory"
|
|
[[ -n "$description" ]] \
|
|
|| note "$relative/SKILL.md has no description, so nothing ever decides to load it"
|
|
done
|
|
|
|
# ── 2. Every claim resolves ─────────────────────────────────────────────────
|
|
#
|
|
# A skill points rather than duplicates, so almost everything it says is a
|
|
# pointer -- and a pointer is exactly the kind of claim that rots silently. The
|
|
# rule: anything in backticks that looks like a command, a path in this
|
|
# repository, an environment variable or a panama-action verb must exist.
|
|
|
|
if (( ${#present[@]} == 0 )); then
|
|
printf 'skills contract: no skill is written yet; claim checking skipped\n' >&2
|
|
else
|
|
while IFS= read -r claim; do
|
|
[[ -n "$claim" ]] && note "$claim"
|
|
done < <(python3 - "$repo_dir" "${present[@]}" <<'PY'
|
|
import os
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
|
|
repo, directories = sys.argv[1], sys.argv[2:]
|
|
findings = []
|
|
|
|
|
|
def read(path):
|
|
with open(path, encoding="utf-8") as handle:
|
|
return handle.read()
|
|
|
|
|
|
# What bin/panama actually dispatches, read from the dispatcher itself rather
|
|
# than from usage(), which is prose and can drift the same way a skill can.
|
|
subcommands = set(re.findall(r"^\s+([a-z][a-z-]*)\)\s*shift", read(os.path.join(repo, "bin/panama")), re.M))
|
|
|
|
# panama-action's verbs, from the one case statement that dispatches them.
|
|
action = read(os.path.join(repo, "config/dot/quickshell/scripts/panama-action"))
|
|
verbs = set()
|
|
for match in re.finditer(r"^\s+([a-z][a-z0-9|-]*)\)", action.split('case "$action" in', 1)[-1], re.M):
|
|
verbs.update(match.group(1).split("|"))
|
|
|
|
tools = set(os.listdir(os.path.join(repo, "bin")))
|
|
tools |= set(os.listdir(os.path.join(repo, "config/dot/quickshell/scripts")))
|
|
|
|
# Every tracked file, so a path written the way the surrounding sentence reads
|
|
# -- `services/SettingsRoutes.qml`, not the whole path from the root -- still
|
|
# has to resolve to exactly one real file.
|
|
tracked = []
|
|
for root, names, files in os.walk(repo):
|
|
names[:] = [n for n in names if n not in (".git", "__pycache__", "node_modules")]
|
|
for name in files:
|
|
tracked.append(os.path.relpath(os.path.join(root, name), repo))
|
|
|
|
seen_variables = {}
|
|
|
|
|
|
def used_outside_the_skills(name):
|
|
"""An environment variable a skill names has to be one the tree reads."""
|
|
if name not in seen_variables:
|
|
found = subprocess.run(
|
|
["grep", "-rlF", "--exclude-dir=.git", "--", name, repo],
|
|
capture_output=True, text=True,
|
|
).stdout.split()
|
|
seen_variables[name] = any(
|
|
not any(path.startswith(directory) for directory in directories) for path in found
|
|
)
|
|
return seen_variables[name]
|
|
|
|
|
|
def check(token, where):
|
|
token = token.strip()
|
|
if not token:
|
|
return
|
|
words = token.split()
|
|
head = words[0]
|
|
|
|
for variable in re.findall(r"\bPANAMA_[A-Z0-9_]+\b", token):
|
|
if not used_outside_the_skills(variable):
|
|
findings.append(f"{where} names {variable}, which nothing in the tree reads")
|
|
|
|
if head == "panama" and len(words) > 1:
|
|
subcommand = words[1]
|
|
if re.fullmatch(r"[a-z][a-z-]*", subcommand) and subcommand not in subcommands:
|
|
findings.append(f"{where} names `panama {subcommand}`, which the dispatcher does not handle")
|
|
return
|
|
|
|
if head == "panama-action" and len(words) > 1:
|
|
verb = words[1]
|
|
if re.fullmatch(r"[a-z][a-z0-9-]*", verb) and verb not in verbs:
|
|
findings.append(f"{where} names the panama-action verb '{verb}', which it does not dispatch")
|
|
return
|
|
|
|
if re.fullmatch(r"panama-[a-z0-9-]+", head) and head not in tools:
|
|
# An invocation has to be a command. A bare name may be something else
|
|
# Panama calls by that name -- a doctor check group, a systemd unit --
|
|
# and then it only has to be real somewhere in the tree.
|
|
if len(words) > 1:
|
|
findings.append(f"{where} runs `{head}`, which is not in bin/ or the quickshell scripts")
|
|
elif not used_outside_the_skills(head):
|
|
findings.append(f"{where} names '{head}', which appears nowhere else in the tree")
|
|
return
|
|
|
|
# A repository path. Home paths and URLs are runtime, not tracked here, and
|
|
# a glob is a description of several files rather than one claim.
|
|
if "/" in head and not head.startswith(("~", "/", "http", "$")):
|
|
path = re.sub(r"/\*+$", "", head.rstrip("/"))
|
|
if "*" in path or not path:
|
|
return
|
|
if os.path.exists(os.path.join(repo, path)):
|
|
return
|
|
matches = [candidate for candidate in tracked if candidate.endswith("/" + path)]
|
|
if not matches:
|
|
findings.append(f"{where} points at {path}, which is not in the repository")
|
|
elif len(matches) > 1:
|
|
findings.append(f"{where} points at {path}, which is several files; name it from the root")
|
|
|
|
|
|
backticked = re.compile(r"`([^`\n]+)`")
|
|
for directory in directories:
|
|
for root, _, names in os.walk(directory):
|
|
for name in sorted(names):
|
|
if not name.endswith(".md"):
|
|
continue
|
|
path = os.path.join(root, name)
|
|
where = os.path.relpath(path, repo)
|
|
for token in backticked.findall(read(path)):
|
|
check(token, where)
|
|
|
|
print("\n".join(sorted(set(findings))))
|
|
PY
|
|
)
|
|
fi
|
|
|
|
# ── 3. The stage exists and does what it says ───────────────────────────────
|
|
|
|
if [[ ! -x "$linker" ]]; then
|
|
note 'setup/scripts/link-skills is missing or not executable, so no machine gets the skills'
|
|
else
|
|
work="$(mktemp -d)"
|
|
trap 'rm -rf "$work"' EXIT
|
|
|
|
# A checkout and a home of its own. Never the real ones: these are somebody's
|
|
# live agent setup, and a contract that broke them mid-session would be worse
|
|
# than the bug it was looking for.
|
|
checkout="$work/Panama"
|
|
home="$work/home"
|
|
mkdir -p "$checkout/setup/scripts" "$checkout/skills/shipped" "$home/.claude" "$home/.agents"
|
|
cp "$linker" "$checkout/setup/scripts/link-skills"
|
|
printf 'a shipped skill\n' >"$checkout/skills/shipped/SKILL.md"
|
|
|
|
# The machine as it is before this stage ever ran: one whole-directory
|
|
# symlink, which is what link-user used to leave here.
|
|
mkdir -p "$work/personal"
|
|
ln -s "$work/personal" "$home/.claude/skills"
|
|
ln -s "$work/personal" "$home/.agents/skills"
|
|
|
|
run() { HOME="$home" PANAMA_PATH="$checkout" "$checkout/setup/scripts/link-skills" >"$work/log" 2>&1; }
|
|
|
|
if ! run; then
|
|
note "link-skills failed against a throwaway home: $(tail -1 "$work/log")"
|
|
fi
|
|
|
|
[[ -d "$home/.claude/skills" && ! -L "$home/.claude/skills" ]] \
|
|
|| note 'link-skills left ~/.claude/skills a symlink, so nothing else can be linked into it'
|
|
[[ -L "$home/.claude/skills/shipped" ]] \
|
|
|| note 'link-skills did not link each shipped skill as a child of ~/.claude/skills'
|
|
[[ -d "$home/.agents/skills" && ! -L "$home/.agents/skills" ]] \
|
|
|| note 'link-skills left ~/.agents/skills a symlink, so shipped and personal skills cannot coexist'
|
|
[[ -L "$home/.agents/skills/shipped" ]] \
|
|
|| note 'link-skills did not link each shipped skill as a child of ~/.agents/skills'
|
|
grep -q 'Agent skills: 1 linked' "$work/log" \
|
|
|| note 'link-skills does not report how many skills it linked'
|
|
|
|
# A real directory at a shipped skill's name is somebody's work: it moves to
|
|
# config/old rather than being deleted, the same promise the other stages
|
|
# make. A symlink is not, and must not accumulate there.
|
|
for skill_home in "$home/.claude/skills" "$home/.agents/skills"; do
|
|
rm -f "$skill_home/shipped"
|
|
mkdir -p "$skill_home/shipped"
|
|
printf 'installed by hand\n' >"$skill_home/shipped/SKILL.md"
|
|
mkdir -p "$skill_home/untouched"
|
|
done
|
|
|
|
run
|
|
[[ "$(grep -rl 'installed by hand' "$checkout/config/old" 2>/dev/null | wc -l)" == 2 ]] \
|
|
|| note 'link-skills did not preserve real skills from both agent homes'
|
|
for skill_home in "$home/.claude/skills" "$home/.agents/skills"; do
|
|
[[ -d "$skill_home/untouched" ]] \
|
|
|| note "link-skills removed an unshipped skill from $skill_home"
|
|
done
|
|
|
|
before="$(find "$checkout/config/old" | wc -l)"
|
|
run
|
|
after="$(find "$checkout/config/old" | wc -l)"
|
|
[[ "$before" == "$after" ]] \
|
|
|| note 'link-skills backs up its own symlinks, so config/old grows on every upgrade'
|
|
fi
|
|
|
|
# ── The stage runs, in the one order that gives personal skills precedence ──
|
|
|
|
# Checked against every literal per-role stage list. The server list carries
|
|
# no link-skills at all (its three skills operate the desktop), so the demand
|
|
# is: at least one list runs it, and any list that runs it runs it between
|
|
# link-dotfiles and link-user.
|
|
python3 - "$installer" <<'PY' || note 'link-skills is not in STAGES between link-dotfiles and link-user'
|
|
import re, sys
|
|
lines = [l.strip() for l in open(sys.argv[1], encoding="utf-8")
|
|
if l.strip().startswith("STAGES=(") and "upgrade_stages" not in l]
|
|
anywhere = False
|
|
for line in lines:
|
|
stages = re.findall(r"[\w-]+", line)
|
|
if "link-skills" not in stages:
|
|
continue
|
|
anywhere = True
|
|
if not stages.index("link-dotfiles") < stages.index("link-skills") < stages.index("link-user"):
|
|
raise SystemExit(1)
|
|
if not anywhere:
|
|
raise SystemExit(1)
|
|
PY
|
|
|
|
# An upgrade drops stages by name. link-skills must not be one of them --
|
|
# update-command-contract proves that by running the installer; this says why.
|
|
if sed -n '/upgrade_stages=()/,/STAGES=("${upgrade_stages\[@\]}")/p' "$installer" | grep -q 'link-skills'; then
|
|
note 'install --upgrade filters link-skills out, so an existing machine never gets the skills'
|
|
fi
|
|
|
|
# ── The personal half hands the directory over ──────────────────────────────
|
|
|
|
grep -qE '^\s*linkdir\s+agents/skills\s+~/\.claude/skills\s*$' "$manifest" \
|
|
|| note 'the manifest does not use linkdir for ~/.claude/skills, so personal skills would replace the directory'
|
|
grep -qE '^\s*linkdir\s+agents/skills\s+~/\.agents/skills\s*$' "$manifest" \
|
|
|| note 'the manifest does not use linkdir for ~/.agents/skills, so personal skills would replace shipped skills'
|
|
grep -q 'linkdir)' "$user_linker" \
|
|
|| note 'link-user does not implement the linkdir kind the manifest asks for'
|
|
grep -q 'linkdir' "$repo_dir/user/README.md" \
|
|
|| note 'user/README.md does not document the linkdir kind'
|
|
|
|
if (( ${#findings[@]} > 0 )); then
|
|
printf 'skills contract: %d finding(s)\n' "${#findings[@]}" >&2
|
|
printf ' - %s\n' "${findings[@]}" >&2
|
|
exit 1
|
|
fi
|
|
|
|
printf 'skills contract: PASS (%d skill(s) checked)\n' "${#present[@]}"
|