The machine now carries its own manual for AI hands
Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
This commit is contained in:
@@ -0,0 +1,99 @@
|
||||
# Contracts that take over the desktop you are sitting in front of.
|
||||
#
|
||||
# Most of the suite is hermetic: stub commands on PATH, a throwaway HOME, or a
|
||||
# Quickshell harness booted with `qs -p` under its own semantic entry file. Those
|
||||
# are safe to run mid-session and are not listed here.
|
||||
#
|
||||
# The ones below are not. Each drives the LIVE shell, compositor or machine --
|
||||
# it opens overlays over whatever you were doing, restarts the running shell,
|
||||
# moves your windows, changes your monitor, or rewrites a real default. Run
|
||||
# during a working session they are, at best, a series of surprises; at worst
|
||||
# they leave state behind when interrupted.
|
||||
#
|
||||
# `panama test --safe` skips exactly this list, which is why every entry's
|
||||
# comment says what it does to the session rather than what it proves.
|
||||
# tests/setup/desktop-hijacking-contract checks each path exists and sweeps
|
||||
# tests/ for hijacking contracts that are missing from here.
|
||||
#
|
||||
# One repo-relative path per line.
|
||||
|
||||
# Pushes a fixture privacy state into the running shell and opens the activity
|
||||
# panel over the desktop.
|
||||
tests/quickshell/activity-indicator-contract
|
||||
|
||||
# Pushes fixture privacy states and Signal Glass events into the running shell,
|
||||
# and dismisses the live capsule.
|
||||
tests/quickshell/activity-state-contract
|
||||
|
||||
# Opens the live agenda popover and the notification centre, and starts a real
|
||||
# focus session on the running shell.
|
||||
tests/quickshell/calendar-agenda-contract
|
||||
|
||||
# Opens and closes the cheatsheet overlay on the running shell.
|
||||
tests/quickshell/cheatsheet-contract
|
||||
|
||||
# Changes the real default browser through the default-apps helper -- xdg-mime
|
||||
# and xdg-settings defaults on this machine -- and puts it back.
|
||||
tests/quickshell/default-apps-roles-contract
|
||||
|
||||
# Changes the real monitor's mode, position, scale and rotation through the live
|
||||
# compositor, restoring the display it started from.
|
||||
tests/quickshell/displays-contract
|
||||
|
||||
# Starts, pauses, reveals and ends a real focus session, and opens Mission
|
||||
# Control on the running shell.
|
||||
tests/quickshell/focus-session-contract
|
||||
|
||||
# Restarts panama-quickshell.service (or `qs kill`s the shell when unsupervised)
|
||||
# with a focus session in flight; an interrupted run leaves caffeine latched on.
|
||||
tests/quickshell/focus-session-expiry
|
||||
|
||||
# Restarts panama-quickshell.service (or `qs kill`s the shell when unsupervised)
|
||||
# mid-session to prove a paused focus session survives it.
|
||||
tests/quickshell/focus-session-restart
|
||||
|
||||
# Spawns a real kitty window, moves it between your workspaces and into the
|
||||
# scratchpad, and opens the overview.
|
||||
tests/quickshell/overview-live-actions
|
||||
|
||||
# Opens the overview on the running shell and types a search into it.
|
||||
tests/quickshell/overview-search-contract
|
||||
|
||||
# Toggles the live caffeine inhibitor and Night Light on the running shell.
|
||||
tests/quickshell/panama-action-ipc-contract
|
||||
|
||||
# Opens and closes the live overview.
|
||||
tests/quickshell/scratchpad-shelf-contract
|
||||
|
||||
# Opens the live screen-intelligence and capture overlays and runs an analysis
|
||||
# through them.
|
||||
tests/quickshell/screen-intelligence-contract
|
||||
|
||||
# Flips real compositor policy -- gaps, blur, inactive opacity, keyboard layout
|
||||
# -- on the live compositor and restores it.
|
||||
tests/quickshell/settings-hyprland-write-contract
|
||||
|
||||
# Applies compositor policy through the production write path against the live
|
||||
# compositor, and writes preferences to the real settings store (no isolated
|
||||
# config home).
|
||||
tests/quickshell/settings-system-contract
|
||||
|
||||
# Opens the real Settings window, routes it between pages, and closes it through
|
||||
# the compositor.
|
||||
tests/quickshell/settings-window-contract
|
||||
|
||||
# Flips every compositor-backed setting to a value it does not hold, one at a
|
||||
# time, on the live compositor.
|
||||
tests/quickshell/settings-write-sweep-contract
|
||||
|
||||
# Publishes a critical fixture event into the live Signal Glass capsule.
|
||||
tests/quickshell/signal-glass-contract
|
||||
|
||||
# Publishes fixture events into the live capsule and toggles Do Not Disturb.
|
||||
tests/quickshell/status-events-contract
|
||||
|
||||
# Drives the live window switcher and commits a switch, which moves your focus.
|
||||
tests/quickshell/switcher-contract
|
||||
|
||||
# Opens and closes the welcome overlay on the running shell.
|
||||
tests/quickshell/welcome-contract
|
||||
Executable
+184
@@ -0,0 +1,184 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# The desktop-hijacking ledger is complete, and `panama test --safe` obeys it.
|
||||
#
|
||||
# `panama test --safe` exists so the suite can be run from inside the session it
|
||||
# tests. That promise is only as good as tests/desktop-hijacking: a contract
|
||||
# that takes over the live shell and is not listed there is run by --safe, and
|
||||
# the desktop goes away in the middle of somebody's work -- with the command
|
||||
# line having just claimed it would not.
|
||||
#
|
||||
# A hand-kept list decays, so it is not trusted on its own. This sweeps tests/
|
||||
# for the shapes a hijacking contract has and fails on any that are missing from
|
||||
# the ledger. The heuristics live here, in the thing that runs, so a new
|
||||
# hijacking contract cannot stay unlisted quietly:
|
||||
#
|
||||
# * it calls `qs ipc call` without booting its own `qs -p` harness, so the
|
||||
# instance answering is the shell you are looking at;
|
||||
# * it restarts panama-quickshell.service;
|
||||
# * it calls a bare `qs kill`, which kills that same shell.
|
||||
#
|
||||
# The sweep is one direction only. The ledger is deliberately larger than what
|
||||
# these three shapes find -- a contract that rotates the real monitor or
|
||||
# rewrites a real xdg-mime default hijacks the session just as thoroughly and
|
||||
# looks like nothing in particular from the outside -- so an entry the sweep
|
||||
# does not reach is not a finding.
|
||||
#
|
||||
# Nothing here runs a listed contract. The one live check drives `--safe` with a
|
||||
# pattern that matches only ledger entries, so the run selects them, skips them
|
||||
# all, and executes nothing.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
ledger="$repo_dir/tests/desktop-hijacking"
|
||||
panama="$repo_dir/bin/panama"
|
||||
|
||||
findings=()
|
||||
note() { findings+=("$1"); }
|
||||
|
||||
[[ -r "$ledger" ]] || { printf 'desktop hijacking contract: %s is missing\n' "$ledger" >&2; exit 1; }
|
||||
|
||||
# ── The entries ──────────────────────────────────────────────────────────────
|
||||
#
|
||||
# Every line names a contract that exists AND that `panama test` would collect.
|
||||
# A listed path the runner never picks up (no executable bit, not a *_test.py)
|
||||
# is skipped by --safe in name only, which reads as protection and is not.
|
||||
|
||||
entries=()
|
||||
commented=()
|
||||
pending_comment=0
|
||||
|
||||
while IFS= read -r line || [[ -n "$line" ]]; do
|
||||
case "$line" in
|
||||
'#'*) pending_comment=1; continue ;;
|
||||
''|[[:space:]]*'') ;;
|
||||
esac
|
||||
trimmed="${line%%#*}"
|
||||
trimmed="${trimmed#"${trimmed%%[![:space:]]*}"}"
|
||||
trimmed="${trimmed%"${trimmed##*[![:space:]]}"}"
|
||||
if [[ -z "$trimmed" ]]; then
|
||||
[[ -z "$line" ]] && pending_comment=0
|
||||
continue
|
||||
fi
|
||||
entries+=("$trimmed")
|
||||
commented+=("$pending_comment")
|
||||
pending_comment=0
|
||||
done < "$ledger"
|
||||
|
||||
(( ${#entries[@]} > 0 )) || note 'the ledger lists no contracts at all'
|
||||
|
||||
for index in "${!entries[@]}"; do
|
||||
entry="${entries[$index]}"
|
||||
path="$repo_dir/$entry"
|
||||
|
||||
[[ "$entry" == tests/* ]] \
|
||||
|| note "\"$entry\" is not a repo-relative path under tests/"
|
||||
|
||||
if [[ ! -e "$path" ]]; then
|
||||
note "the ledger lists $entry, which does not exist"
|
||||
continue
|
||||
fi
|
||||
|
||||
[[ -x "$path" || "$entry" == *_test.py ]] \
|
||||
|| note "$entry is listed but 'panama test' would never collect it, so skipping it protects nothing"
|
||||
|
||||
# The ledger's whole job is saying what a contract does to the session. An
|
||||
# entry with no comment is a path somebody has to go and read.
|
||||
(( commented[index] )) \
|
||||
|| note "$entry is listed with no comment saying what it does to the live session"
|
||||
done
|
||||
|
||||
duplicates="$(printf '%s\n' "${entries[@]}" | sort | uniq -d)"
|
||||
[[ -z "$duplicates" ]] || note "the ledger lists these twice: ${duplicates//$'\n'/, }"
|
||||
|
||||
# ── The honesty sweep ────────────────────────────────────────────────────────
|
||||
#
|
||||
# Whole-line comments are dropped first: contracts discuss `qs ipc call` in
|
||||
# their headers, and a header is not a call. What survives is matched only at a
|
||||
# command position -- start of line, or after a pipe, semicolon, &&, (, or ! --
|
||||
# so the same words quoted inside a grep pattern or a failure message do not
|
||||
# count as driving anything.
|
||||
|
||||
command_position='(^|[|;&({!]|\$\()[[:space:]]*'
|
||||
|
||||
is_listed() {
|
||||
local candidate="$1" listed
|
||||
for listed in "${entries[@]}"; do
|
||||
[[ "$listed" == "$candidate" ]] && return 0
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
while IFS= read -r file; do
|
||||
[[ -x "$file" || "$file" == *_test.py ]] || continue
|
||||
|
||||
code="$(grep -v '^[[:space:]]*#' "$file")"
|
||||
rel="tests/${file#"$repo_dir"/tests/}"
|
||||
reason=""
|
||||
|
||||
if grep -qE "${command_position}qs[[:space:]]+ipc[[:space:]]+call" <<<"$code"; then
|
||||
# Its own harness means its own Quickshell instance: `qs -p <entry>`
|
||||
# addresses that root, not the shell running the desktop.
|
||||
grep -qE "${command_position}[A-Za-z_]*[[:space:]]*=?[[:space:]]*.*qs[[:space:]]+-p" <<<"$code" \
|
||||
|| reason='calls `qs ipc call` without booting its own `qs -p` harness'
|
||||
fi
|
||||
|
||||
if [[ -z "$reason" ]] && grep -qE "${command_position}systemctl.*restart.*panama-quickshell\.service" <<<"$code"; then
|
||||
reason='restarts panama-quickshell.service'
|
||||
fi
|
||||
|
||||
if [[ -z "$reason" ]] && grep -qE "${command_position}qs[[:space:]]+kill" <<<"$code"; then
|
||||
reason='calls a bare `qs kill`, which stops the live shell'
|
||||
fi
|
||||
|
||||
[[ -n "$reason" ]] || continue
|
||||
is_listed "$rel" \
|
||||
|| note "$rel $reason, but is not in tests/desktop-hijacking"
|
||||
done < <(find "$repo_dir/tests" -type f -not -path '*/fixtures/*' -not -path '*__pycache__*' | sort)
|
||||
|
||||
# ── --safe actually reads it ─────────────────────────────────────────────────
|
||||
#
|
||||
# Static first, because the summary line is the only thing telling a reader that
|
||||
# anything was left out, and a --safe run that silently skips is worse than one
|
||||
# that does not skip at all.
|
||||
|
||||
if [[ ! -r "$panama" ]]; then
|
||||
note 'bin/panama is missing'
|
||||
elif ! test_body="$(sed -n '/^cmd_test()/,/^}/p' "$panama")" || [[ -z "$test_body" ]]; then
|
||||
note 'cmd_test could not be found in bin/panama'
|
||||
else
|
||||
grep -q -- '--safe' <<<"$test_body" \
|
||||
|| note 'cmd_test does not handle --safe'
|
||||
grep -qE 'DESKTOP_HIJACKING_LEDGER|desktop-hijacking' <<<"$test_body" \
|
||||
|| note 'cmd_test never consults the desktop-hijacking ledger, so --safe skips nothing'
|
||||
grep -qF 'desktop-hijacking contract(s)' <<<"$test_body" \
|
||||
|| note '--safe no longer reports how many contracts it skipped'
|
||||
fi
|
||||
|
||||
grep -qF 'tests/desktop-hijacking' "$panama" \
|
||||
|| note 'bin/panama never names tests/desktop-hijacking'
|
||||
|
||||
# Then for real. The pattern is the first ledger entry with its 'tests/' prefix
|
||||
# removed, which cmd_test matches against the full path -- so it selects that
|
||||
# one contract, --safe removes it, and nothing is left to run. A --safe that
|
||||
# ignored the ledger would run it instead, which is the failure this catches.
|
||||
if (( ${#entries[@]} > 0 )) && [[ -x "$panama" ]]; then
|
||||
probe="${entries[0]#tests/}"
|
||||
output="$("$panama" test --safe "$probe" 2>&1)"
|
||||
status=$?
|
||||
if (( status == 0 )); then
|
||||
note "'panama test --safe $probe' ran a ledger-listed contract instead of skipping it"
|
||||
elif ! grep -qF 'desktop-hijacking' <<<"$output"; then
|
||||
note "'panama test --safe $probe' refused without mentioning the ledger: $output"
|
||||
fi
|
||||
fi
|
||||
|
||||
if (( ${#findings[@]} > 0 )); then
|
||||
mapfile -t findings < <(printf '%s\n' "${findings[@]}" | sort -u)
|
||||
printf 'desktop hijacking contract: %d finding(s)\n' "${#findings[@]}" >&2
|
||||
printf ' - %s\n' "${findings[@]}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
printf 'desktop hijacking contract: PASS (%d contracts listed; the sweep found none unlisted)\n' "${#entries[@]}"
|
||||
Executable
+294
@@ -0,0 +1,294 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# The manual this machine hands an agent.
|
||||
#
|
||||
# skills/ and .claude/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 ~/.claude/skills over 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"); }
|
||||
|
||||
# The three, and where each is delivered from. The two under skills/ are shipped
|
||||
# to every machine; the third is project-level and needs no delivery at all.
|
||||
SKILL_DIRS=(skills/panama-desktop skills/panama-sudo .claude/skills/panama)
|
||||
|
||||
# ── 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: Claude Code skips 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: ~/.claude/skills on
|
||||
# this machine is somebody's live agent setup, and a contract that broke it
|
||||
# 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"
|
||||
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"
|
||||
|
||||
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'
|
||||
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.
|
||||
rm "$home/.claude/skills/shipped"
|
||||
mkdir -p "$home/.claude/skills/shipped"
|
||||
printf 'installed by hand\n' >"$home/.claude/skills/shipped/SKILL.md"
|
||||
mkdir -p "$home/.claude/skills/untouched"
|
||||
|
||||
run
|
||||
grep -rq 'installed by hand' "$checkout/config/old" 2>/dev/null \
|
||||
|| note 'link-skills destroyed a real skill instead of moving it to config/old'
|
||||
[[ -d "$home/.claude/skills/untouched" ]] \
|
||||
|| note 'link-skills removed a skill it does not ship'
|
||||
|
||||
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 ──
|
||||
|
||||
python3 - "$installer" <<'PY' || note 'link-skills is not in STAGES between link-dotfiles and link-user'
|
||||
import re, sys
|
||||
line = next(l for l in open(sys.argv[1], encoding="utf-8") if l.startswith("STAGES="))
|
||||
stages = re.findall(r"[\w-]+", line)
|
||||
for name in ("link-dotfiles", "link-skills", "link-user"):
|
||||
if name not in stages:
|
||||
raise SystemExit(1)
|
||||
if not stages.index("link-dotfiles") < stages.index("link-skills") < stages.index("link-user"):
|
||||
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*link\s+agents/skills\s+~/\.agents/skills\s*$' "$manifest" \
|
||||
|| note '~/.agents/skills is no longer a whole-directory link, and nothing else claims that path'
|
||||
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[@]}"
|
||||
@@ -36,7 +36,7 @@ note() { findings+=("$1"); }
|
||||
tmp="$(mktemp -d -t panama-update-contract.XXXXXX)"
|
||||
trap 'rm -rf "$tmp"' EXIT
|
||||
|
||||
STAGE_NAMES=(install-packages link-dotfiles link-user change-settings
|
||||
STAGE_NAMES=(install-packages link-dotfiles link-skills link-user change-settings
|
||||
link-vicinae-scripts setup-identity install-hardware)
|
||||
|
||||
# A PANAMA_PATH that looks enough like the real one for install to run, and
|
||||
@@ -136,7 +136,7 @@ fi
|
||||
|
||||
# ── 2. Exactly the answer-free stages ────────────────────────────────────────
|
||||
|
||||
for stage in install-packages link-dotfiles link-user change-settings link-vicinae-scripts; do
|
||||
for stage in install-packages link-dotfiles link-skills link-user change-settings link-vicinae-scripts; do
|
||||
grep -qx "$stage" <<<"$ran" || note "install --upgrade did not run $stage"
|
||||
done
|
||||
for stage in setup-identity install-hardware; do
|
||||
|
||||
@@ -49,7 +49,7 @@ done < <(find "$repo_dir/user" -type f \( -name 'id_*' -o -name '*.pem' -o -name
|
||||
while read -r kind source destination; do
|
||||
[[ -e "$repo_dir/user/$source" ]] \
|
||||
|| note "the manifest points at $source, which is not in user/"
|
||||
[[ "$kind" == link || "$kind" == copy ]] \
|
||||
[[ "$kind" == link || "$kind" == linkdir || "$kind" == copy ]] \
|
||||
|| note "the manifest uses an unknown kind: $kind"
|
||||
[[ "$destination" == '~/'* ]] \
|
||||
|| note "the manifest destination $destination is not under the home directory"
|
||||
@@ -72,10 +72,11 @@ printf 'copied once\n' >"$checkout/user/plain.txt"
|
||||
cat >"$checkout/user/manifest" <<'FIXTURE'
|
||||
# a comment, and a blank line follow
|
||||
|
||||
link agents/AGENTS.md ~/.claude/CLAUDE.md
|
||||
link agents/skills ~/.agents/skills
|
||||
copy plain.txt ~/.config/plain.txt
|
||||
link missing.txt ~/.config/missing.txt
|
||||
link agents/AGENTS.md ~/.claude/CLAUDE.md
|
||||
link agents/skills ~/.agents/skills
|
||||
linkdir agents/skills ~/.claude/skills
|
||||
copy plain.txt ~/.config/plain.txt
|
||||
link missing.txt ~/.config/missing.txt
|
||||
FIXTURE
|
||||
|
||||
home="$work/home"
|
||||
@@ -101,6 +102,12 @@ PANAMA_USER_CONTENT=no run
|
||||
|
||||
# ── 2. Saying yes links, and keeps what was there ───────────────────────────
|
||||
|
||||
# ~/.claude/skills is shared now: link-skills has already made it a real
|
||||
# directory and linked Panama's own skills into it. A linkdir entry has to land
|
||||
# beside those rather than replace the directory holding them.
|
||||
mkdir -p "$home/.claude/skills"
|
||||
ln -s "$checkout/skills/shipped" "$home/.claude/skills/shipped"
|
||||
|
||||
PANAMA_USER_CONTENT=yes run
|
||||
|
||||
[[ -L "$home/.claude/CLAUDE.md" ]] \
|
||||
@@ -109,6 +116,12 @@ PANAMA_USER_CONTENT=yes run
|
||||
|| note 'the CLAUDE.md link does not resolve to the tracked file'
|
||||
[[ -L "$home/.agents/skills" && -f "$home/.agents/skills/example/SKILL.md" ]] \
|
||||
|| note 'the skills directory was not linked as a directory'
|
||||
[[ -d "$home/.claude/skills" && ! -L "$home/.claude/skills" ]] \
|
||||
|| note 'a linkdir entry replaced its destination directory with a symlink'
|
||||
[[ -L "$home/.claude/skills/example" && -f "$home/.claude/skills/example/SKILL.md" ]] \
|
||||
|| note 'a linkdir entry did not link each child into the destination'
|
||||
[[ -L "$home/.claude/skills/shipped" ]] \
|
||||
|| note 'a linkdir entry removed what another stage had linked into the destination'
|
||||
[[ -f "$home/.config/plain.txt" && ! -L "$home/.config/plain.txt" ]] \
|
||||
|| note 'a copy entry was linked rather than copied'
|
||||
|
||||
|
||||
Reference in New Issue
Block a user