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,91 @@
|
|||||||
|
---
|
||||||
|
name: panama
|
||||||
|
description: Use when working on the Panama repository itself — editing anything under config/, setup/, bin/, tests/, or the Quickshell shell and Hyprland Lua it ships. Covers the live-desktop hot-reload rules, the contract-test discipline, the fixture seams, and where the real documentation lives.
|
||||||
|
---
|
||||||
|
|
||||||
|
# panama
|
||||||
|
|
||||||
|
You are editing a running desktop, not a codebase that gets deployed later. Every dotfile in this
|
||||||
|
repository is symlinked into `~/.config`, so a save is live the moment it lands. That single fact
|
||||||
|
drives every rule below.
|
||||||
|
|
||||||
|
Read before large work, in this order:
|
||||||
|
|
||||||
|
- `README.md` — layout, the `panama` command, how installing and updating work
|
||||||
|
- `config/dot/hypr/README.md` — the compositor config is **Lua, not hyprlang**; read "The one
|
||||||
|
thing to know first" and "Never use `hyprctl keyword`" before touching it
|
||||||
|
- `config/dot/quickshell/modules/settings/README.md` — adding a setting, setting ownership,
|
||||||
|
"Things that will bite you", and where state lives; the most load-bearing document here
|
||||||
|
- `docs/settings.md` — generated reference for every settings key (never edit by hand)
|
||||||
|
|
||||||
|
## The live-desktop covenant
|
||||||
|
|
||||||
|
Editing `config/dot/quickshell/**` hot-reloads the live shell on every save.
|
||||||
|
|
||||||
|
1. Every save must leave valid QML. An intermediate broken save is a broken desktop, not a broken
|
||||||
|
build. Convert a component and its body in ONE edit, never two.
|
||||||
|
2. After each batch of saves, check the journal for errors AND "Unable to assign" warnings:
|
||||||
|
`journalctl --user -u panama-quickshell.service --since '-2 minutes' --no-pager`
|
||||||
|
3. Every `.qml` file in `config/dot/quickshell/modules/*` must be registered in that directory's
|
||||||
|
`qmldir` in the same save batch. An unregistered component fails the whole configuration and
|
||||||
|
takes down the bar, dock, and settings together. `tests/quickshell/qmldir-registration-contract`
|
||||||
|
is pure file inspection — run it before the change lands.
|
||||||
|
4. Never `qs kill` from a copied configuration: Quickshell derives the shell ID from config
|
||||||
|
content, so a content-identical harness can share the live shell's ID and kill the desktop.
|
||||||
|
Harnesses use a distinct entry file, `qs -p`, the PID from `qs list --all`, and `kill` that PID.
|
||||||
|
5. Never `hyprctl keyword` — Lua-configured Hyprland refuses it, prints to stdout, and exits 0.
|
||||||
|
`hyprctl eval` also exits 0 on errors. The only proof a write landed is `hyprctl getoption`
|
||||||
|
reading it back. Batch `hyprctl reload` to one per verified change-set, and say when you do it.
|
||||||
|
6. IPC: annotate every parameter and return type or Quickshell silently skips registration; never
|
||||||
|
duplicate an `IpcHandler` target — one silently shadows the other.
|
||||||
|
7. In `config/dot/quickshell/config/PreferenceSchema.qml`, `readAs` describes the compositor's
|
||||||
|
ANSWER shape, not the setting's; a wrong one makes every successful write look rejected.
|
||||||
|
8. No continuously repainting animations (pulse, shimmer, spinners) — they peg the GPU on
|
||||||
|
high-refresh displays.
|
||||||
|
9. `hyprlock.conf`, `hypridle.conf`, `hyprpaper.conf`, `hyprtoolkit.conf` never moved to Lua. Do
|
||||||
|
not "fix" them. Do not install a notification daemon — the shell is one.
|
||||||
|
10. Scripts carry no `.sh` extension (shebang + `chmod +x`), with one deliberate exception:
|
||||||
|
`migrations/*.sh`, which `bin/panama-migrate` globs. No secrets under `user/` — it is
|
||||||
|
world-readable and `tests/setup/user-content-contract` greps for key material.
|
||||||
|
|
||||||
|
## The contract discipline
|
||||||
|
|
||||||
|
~180 executable contracts under `tests/` pin behavior. The rules:
|
||||||
|
|
||||||
|
- `panama contracts <file>` names the contracts that mention a file; run those after touching it.
|
||||||
|
- During a desktop session, run `panama test --safe [pattern]` — it skips everything listed in
|
||||||
|
`tests/desktop-hijacking` (contracts that restart the shell, open real overlays, or write
|
||||||
|
through the live compositor). Run the full `panama test` only with the user's go-ahead, and say
|
||||||
|
so first: it visibly hijacks the desktop.
|
||||||
|
- Contracts run directly too: `tests/setup/interview-contract`.
|
||||||
|
- After changing `PreferenceSchema.qml` or `services/SettingsRoutes.qml`, regenerate:
|
||||||
|
`config/dot/quickshell/scripts/panama-settings-docs` (writes `docs/settings.md`) and
|
||||||
|
`config/dot/quickshell/scripts/panama-settings-commands` (writes the launcher deep links).
|
||||||
|
Both take `--check`; `tests/quickshell/settings-docs-contract` fails when stale.
|
||||||
|
- `README.md` pins the contract count and the `panama` subcommand list
|
||||||
|
(`tests/setup/readme-contract`); adding a contract or subcommand means updating it.
|
||||||
|
|
||||||
|
## Seams for testing
|
||||||
|
|
||||||
|
Helpers are built hermetic: validated inputs, JSON out, secrets on stdin only (never argv).
|
||||||
|
Contracts reach them through env seams — `PANAMA_NETWORK_HELPER`, `PANAMA_FINGERPRINT_FIXTURE`,
|
||||||
|
`PANAMA_EXTRAS_DIR`, and the `PANAMA_*_STATIC_ONLY` family that cuts a contract to its
|
||||||
|
file-inspection half. Prefer exercising a helper through its contract's stubbed PATH over running
|
||||||
|
it against the real system.
|
||||||
|
|
||||||
|
## Safety idioms (contract-enforced)
|
||||||
|
|
||||||
|
- Destructive actions are two-stage: `ConfirmAction` with a unique `actionId`, arbitrated through
|
||||||
|
`ShellState.armedConfirm` — one armed confirm app-wide. Danger tone marks only the confirming
|
||||||
|
press.
|
||||||
|
- Failures render through `ErrorRow`; honest empty states through `NotMeasuredRow` with a
|
||||||
|
`because:`; paragraph-length notes through `SettingsNote`.
|
||||||
|
- Stored user actions (custom shortcuts, gestures, window rules) are DATA — an enum kind and a
|
||||||
|
validated target resolved through whitelist tables in `config/dot/hypr/actions.lua`. Nothing in
|
||||||
|
`~/.config/panama/settings.json` may ever be an executable command; the contracts
|
||||||
|
(`tests/quickshell/settings-idiom-contract`, `tests/quickshell/keybind-rebind-contract`) pin all
|
||||||
|
of this.
|
||||||
|
|
||||||
|
## Root work
|
||||||
|
|
||||||
|
Never bare `sudo` — load the `panama-sudo` skill. Migrations already follow the rule.
|
||||||
@@ -37,6 +37,7 @@ in order, without stopping again:
|
|||||||
| `interview` | Every prompt, before anything is installed. Answers last one run and are never written to a durable path |
|
| `interview` | Every prompt, before anything is installed. Answers last one run and are never written to a durable path |
|
||||||
| `install-packages` | Repos (RPM Fusion, Terra, Hyprland COPR), the package lists in `setup/packages/`, then whichever optional categories were chosen |
|
| `install-packages` | Repos (RPM Fusion, Terra, Hyprland COPR), the package lists in `setup/packages/`, then whichever optional categories were chosen |
|
||||||
| `link-dotfiles` | Symlinks `config/dot/<name>` → `~/.config/<name>`, and seeds the wallpaper, cursor theme and Firefox chrome |
|
| `link-dotfiles` | Symlinks `config/dot/<name>` → `~/.config/<name>`, and seeds the wallpaper, cursor theme and Firefox chrome |
|
||||||
|
| `link-skills` | Links the agent skills in `skills/` into `~/.claude/skills`, one per skill. Every machine gets these; personal ones link after and win a name clash |
|
||||||
| `link-user` | Links the personal content in `user/` — agent instructions, SSH host aliases — but only on a machine that answered yes. See [user/README.md](user/README.md) |
|
| `link-user` | Links the personal content in `user/` — agent instructions, SSH host aliases — but only on a machine that answered yes. See [user/README.md](user/README.md) |
|
||||||
| `change-settings` | Copies `config/copy/` over `/`, applies gsettings, enables user services |
|
| `change-settings` | Copies `config/copy/` over `/`, applies gsettings, enables user services |
|
||||||
| `link-vicinae-scripts` | Publishes the Vicinae script commands |
|
| `link-vicinae-scripts` | Publishes the Vicinae script commands |
|
||||||
@@ -156,6 +157,8 @@ config/
|
|||||||
extensions, linked into ~/.local/share
|
extensions, linked into ~/.local/share
|
||||||
old/ Backups of whatever was replaced (gitignored)
|
old/ Backups of whatever was replaced (gitignored)
|
||||||
wallpapers/ Copied into ~/Pictures/Wallpapers when absent
|
wallpapers/ Copied into ~/Pictures/Wallpapers when absent
|
||||||
|
skills/ Agent skills for operating this desktop, linked into
|
||||||
|
~/.claude/skills
|
||||||
setup/
|
setup/
|
||||||
apps/ Applications built from source, one file each
|
apps/ Applications built from source, one file each
|
||||||
lib/ Shared by more than one stage; the extras catalog reader
|
lib/ Shared by more than one stage; the extras catalog reader
|
||||||
@@ -167,7 +170,7 @@ docs/ Settings reference, and the design specs behind the work
|
|||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
|
|
||||||
177 of them, under `tests/`. Run the lot, or a subset by pattern:
|
179 of them, under `tests/`. Run the lot, or a subset by pattern:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
panama test # everything
|
panama test # everything
|
||||||
@@ -210,6 +213,8 @@ panama sync # review, commit and push your changes to this repo
|
|||||||
panama edit # open it in Neovim
|
panama edit # open it in Neovim
|
||||||
panama doctor # what is actually running, not what was installed
|
panama doctor # what is actually running, not what was installed
|
||||||
panama test # every contract, or a subset by pattern
|
panama test # every contract, or a subset by pattern
|
||||||
|
panama test --safe # the same, minus the ones that take over the desktop
|
||||||
|
panama contracts <file> # which contracts mention a file, and can they be run
|
||||||
panama migrate # apply repairs this machine has not had yet
|
panama migrate # apply repairs this machine has not had yet
|
||||||
panama upgrade # re-run ./install from anywhere, interview and all
|
panama upgrade # re-run ./install from anywhere, interview and all
|
||||||
panama apps # choose applications to install, by category
|
panama apps # choose applications to install, by category
|
||||||
|
|||||||
+184
-13
@@ -5,16 +5,17 @@
|
|||||||
# Author: Gabriel Brown
|
# Author: Gabriel Brown
|
||||||
#
|
#
|
||||||
# Commands:
|
# Commands:
|
||||||
# update Bring this machine up to date: pull, then install --upgrade
|
# update Bring this machine up to date: pull, then install --upgrade
|
||||||
# sync Review, commit & push local changes to this repo
|
# sync Review, commit & push local changes to this repo
|
||||||
# edit Open the Panama repo in Neovim
|
# edit Open the Panama repo in Neovim
|
||||||
# doctor Report what is actually running on this machine
|
# doctor Report what is actually running on this machine
|
||||||
# test Run every contract under tests/
|
# test Run every contract under tests/ (--safe skips the hijacking ones)
|
||||||
# upgrade Re-run the installer from anywhere, interview included
|
# contracts Name the contracts that mention a given file
|
||||||
# migrate Apply repairs this machine has not had yet
|
# upgrade Re-run the installer from anywhere, interview included
|
||||||
# apps Choose applications to install, by category
|
# migrate Apply repairs this machine has not had yet
|
||||||
# app Build and install an application that no repository packages
|
# apps Choose applications to install, by category
|
||||||
# help Show this help
|
# app Build and install an application that no repository packages
|
||||||
|
# help Show this help
|
||||||
#
|
#
|
||||||
# update and sync are deliberately separate verbs. One acts on the machine, the
|
# update and sync are deliberately separate verbs. One acts on the machine, the
|
||||||
# other on the repository, and a single command that guessed between them by
|
# other on the repository, and a single command that guessed between them by
|
||||||
@@ -82,6 +83,11 @@ ${BOLD}Commands:${RESET}
|
|||||||
than what was installed. Takes --summary for one line per check.
|
than what was installed. Takes --summary for one line per check.
|
||||||
${GREEN}test${RESET} Run every contract under tests/. Give it a pattern to run
|
${GREEN}test${RESET} Run every contract under tests/. Give it a pattern to run
|
||||||
a subset: 'panama test dock' runs the ones matching 'dock'.
|
a subset: 'panama test dock' runs the ones matching 'dock'.
|
||||||
|
--safe skips the ones that take over the live desktop; what
|
||||||
|
they are and why is tests/desktop-hijacking.
|
||||||
|
${GREEN}contracts${RESET} Name the contracts that mention a given file, each marked
|
||||||
|
safe or desktop. A heuristic over the text of tests/, so it
|
||||||
|
answers "what should I run" rather than "what covers this".
|
||||||
${GREEN}upgrade${RESET} Re-run ./install from anywhere, interview and all. For a new
|
${GREEN}upgrade${RESET} Re-run ./install from anywhere, interview and all. For a new
|
||||||
machine, or to change an answer you gave. Routine updates are
|
machine, or to change an answer you gave. Routine updates are
|
||||||
'$PROGRAM update', which asks nothing.
|
'$PROGRAM update', which asks nothing.
|
||||||
@@ -106,6 +112,8 @@ ${BOLD}Examples:${RESET}
|
|||||||
$PROGRAM edit
|
$PROGRAM edit
|
||||||
$PROGRAM doctor --summary
|
$PROGRAM doctor --summary
|
||||||
$PROGRAM test dock
|
$PROGRAM test dock
|
||||||
|
$PROGRAM test --safe
|
||||||
|
$PROGRAM contracts config/dot/quickshell/services/Displays.qml
|
||||||
$PROGRAM upgrade
|
$PROGRAM upgrade
|
||||||
$PROGRAM apps
|
$PROGRAM apps
|
||||||
$PROGRAM app
|
$PROGRAM app
|
||||||
@@ -340,6 +348,32 @@ cmd_doctor() {
|
|||||||
exec "$doctor" "$@"
|
exec "$doctor" "$@"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
# The desktop-hijacking ledger
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
#
|
||||||
|
# tests/desktop-hijacking lists, one repo-relative path per line with a '#'
|
||||||
|
# comment saying what it does to the live session, the contracts that drive the
|
||||||
|
# real shell, compositor or machine rather than a harness. Read by `test --safe`
|
||||||
|
# to decide what to skip, and by `contracts` to mark each hit.
|
||||||
|
#
|
||||||
|
# Prints the paths, comments and blank lines stripped. A missing ledger prints
|
||||||
|
# nothing: no ledger means nothing is known to hijack, which is the honest
|
||||||
|
# reading of an absent file and keeps `--safe` from failing on a fresh checkout.
|
||||||
|
DESKTOP_HIJACKING_LEDGER="tests/desktop-hijacking"
|
||||||
|
|
||||||
|
hijacking_entries() {
|
||||||
|
local ledger="$PANAMA_DIR/$DESKTOP_HIJACKING_LEDGER" line
|
||||||
|
[[ -r "$ledger" ]] || return 0
|
||||||
|
while IFS= read -r line || [[ -n "$line" ]]; do
|
||||||
|
line="${line%%#*}"
|
||||||
|
line="${line#"${line%%[![:space:]]*}"}"
|
||||||
|
line="${line%"${line##*[![:space:]]}"}"
|
||||||
|
[[ -n "$line" ]] && printf '%s\n' "$line"
|
||||||
|
done < "$ledger"
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
# ----------------------------------------------------------------------------
|
# ----------------------------------------------------------------------------
|
||||||
# Command: test
|
# Command: test
|
||||||
# ----------------------------------------------------------------------------
|
# ----------------------------------------------------------------------------
|
||||||
@@ -351,9 +385,34 @@ cmd_doctor() {
|
|||||||
# Each runs in its own process and a failure does not stop the rest, because the
|
# Each runs in its own process and a failure does not stop the rest, because the
|
||||||
# useful output is the whole list of what is broken rather than the first thing
|
# useful output is the whole list of what is broken rather than the first thing
|
||||||
# that broke. The exit code is what a caller can act on.
|
# that broke. The exit code is what a caller can act on.
|
||||||
|
#
|
||||||
|
# --safe exists because a fair number of these contracts ARE the desktop: they
|
||||||
|
# open overlays, restart the shell, move your windows. Running the suite while
|
||||||
|
# sitting in front of the machine used to mean losing the session for a few
|
||||||
|
# minutes, so the honest options were "run everything" or "run nothing". --safe
|
||||||
|
# is the third: skip exactly what tests/desktop-hijacking names, and say how
|
||||||
|
# many were skipped, so the gap is stated rather than implied.
|
||||||
cmd_test() {
|
cmd_test() {
|
||||||
local pattern="${1:-}"
|
local pattern="" safe=0 arg
|
||||||
|
# Position-independent, because 'panama test --safe dock' and
|
||||||
|
# 'panama test dock --safe' are the same intent and nobody should have to
|
||||||
|
# remember which one this accepts.
|
||||||
|
for arg in "$@"; do
|
||||||
|
case "$arg" in
|
||||||
|
--safe) safe=1 ;;
|
||||||
|
*) pattern="$arg" ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
local -a suite=()
|
local -a suite=()
|
||||||
|
local -A hijacking=()
|
||||||
|
local skipped=0 entry rel
|
||||||
|
|
||||||
|
if (( safe )); then
|
||||||
|
while IFS= read -r entry; do
|
||||||
|
hijacking["$entry"]=1
|
||||||
|
done < <(hijacking_entries)
|
||||||
|
fi
|
||||||
|
|
||||||
# Executables, plus the Python suites. Those are unittest files rather than
|
# Executables, plus the Python suites. Those are unittest files rather than
|
||||||
# executables, and collecting only what has the executable bit would skip them
|
# executables, and collecting only what has the executable bit would skip them
|
||||||
@@ -361,11 +420,25 @@ cmd_test() {
|
|||||||
# A runner with a blind spot is worse than no runner, because it reports PASS.
|
# A runner with a blind spot is worse than no runner, because it reports PASS.
|
||||||
while IFS= read -r path; do
|
while IFS= read -r path; do
|
||||||
[[ -x "$path" || "$path" == *_test.py ]] || continue
|
[[ -x "$path" || "$path" == *_test.py ]] || continue
|
||||||
[[ -z "$pattern" || "$path" == *"$pattern"* ]] && suite+=("$path")
|
[[ -z "$pattern" || "$path" == *"$pattern"* ]] || continue
|
||||||
|
rel="tests/${path#"$PANAMA_DIR"/tests/}"
|
||||||
|
if (( safe )) && [[ -n "${hijacking[$rel]:-}" ]]; then
|
||||||
|
(( ++skipped ))
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
suite+=("$path")
|
||||||
done < <(find "$PANAMA_DIR/tests" -type f -not -path '*/fixtures/*' -not -path '*__pycache__*' | sort)
|
done < <(find "$PANAMA_DIR/tests" -type f -not -path '*/fixtures/*' -not -path '*__pycache__*' | sort)
|
||||||
|
|
||||||
if (( ${#suite[@]} == 0 )); then
|
if (( ${#suite[@]} == 0 )); then
|
||||||
err "No contracts match '${pattern}'"
|
# "Nothing matched" and "everything that matched was skipped" are different
|
||||||
|
# answers, and reporting the first for the second is how --safe would come
|
||||||
|
# to look like a broken pattern.
|
||||||
|
if (( skipped > 0 )); then
|
||||||
|
err "Every contract matching '${pattern}' is desktop-hijacking; --safe skipped all ${skipped}."
|
||||||
|
printf ' What they do to the session: %s/%s\n' "$PANAMA_DIR" "$DESKTOP_HIJACKING_LEDGER" >&2
|
||||||
|
else
|
||||||
|
err "No contracts match '${pattern}'"
|
||||||
|
fi
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -391,14 +464,111 @@ cmd_test() {
|
|||||||
header "Result"
|
header "Result"
|
||||||
if (( ${#failed[@]} == 0 )); then
|
if (( ${#failed[@]} == 0 )); then
|
||||||
ok "${#suite[@]} contract(s) passed"
|
ok "${#suite[@]} contract(s) passed"
|
||||||
|
if (( safe )); then
|
||||||
|
printf 'Skipped %d desktop-hijacking contract(s) (%s).\n' "$skipped" "$DESKTOP_HIJACKING_LEDGER"
|
||||||
|
fi
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
err "${#failed[@]} of ${#suite[@]} failed:"
|
err "${#failed[@]} of ${#suite[@]} failed:"
|
||||||
printf ' %s\n' "${failed[@]}" >&2
|
printf ' %s\n' "${failed[@]}" >&2
|
||||||
warn "Run one on its own to see why: ${BOLD}${PANAMA_DIR}/tests/<name>${RESET}"
|
warn "Run one on its own to see why: ${BOLD}${PANAMA_DIR}/tests/<name>${RESET}"
|
||||||
|
if (( safe )); then
|
||||||
|
printf 'Skipped %d desktop-hijacking contract(s) (%s).\n' "$skipped" "$DESKTOP_HIJACKING_LEDGER"
|
||||||
|
fi
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
# Command: contracts
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
#
|
||||||
|
# "I changed this file -- what should I run?" There are 177 contracts and no
|
||||||
|
# index, so the honest answers were "all of them" (minutes, and half of them
|
||||||
|
# take the desktop away) or "the ones whose name sounds related" (which is how
|
||||||
|
# a covering contract gets skipped).
|
||||||
|
#
|
||||||
|
# This is a grep, and says so. A contract that names the file, or a
|
||||||
|
# parent-trimmed suffix of it, or just its basename, is a contract worth
|
||||||
|
# running; one that reaches the file through a harness or a generated artifact
|
||||||
|
# is not found, which is why the empty answer says "coverage may be indirect"
|
||||||
|
# rather than "nothing covers this". Naming a file the suite does not mention is
|
||||||
|
# a real answer -- exit 1 so a script can tell the difference -- but it is a
|
||||||
|
# statement about this search, not about the file.
|
||||||
|
#
|
||||||
|
# Each hit is marked from tests/desktop-hijacking, so the output also answers
|
||||||
|
# "and can I run them right now".
|
||||||
|
cmd_contracts() {
|
||||||
|
local target="${1:-}"
|
||||||
|
if [[ -z "$target" ]]; then
|
||||||
|
err "Which file? Usage: ${BOLD}$PROGRAM contracts <file>${RESET}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Absolute, relative to where you are standing, or repo-relative -- all three
|
||||||
|
# are how somebody refers to a file in this tree, and readlink resolves the
|
||||||
|
# symlinked dotfile in ~/.config back into the checkout it points at.
|
||||||
|
local absolute=""
|
||||||
|
if [[ -e "$target" ]]; then
|
||||||
|
absolute="$(readlink -f "$target")"
|
||||||
|
elif [[ -e "$PANAMA_DIR/$target" ]]; then
|
||||||
|
absolute="$(readlink -f "$PANAMA_DIR/$target")"
|
||||||
|
else
|
||||||
|
err "No such file: '$target'"
|
||||||
|
printf 'Give a path, absolute or relative to here or to %s.\n' "$PANAMA_DIR" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
local path
|
||||||
|
case "$absolute" in
|
||||||
|
"$PANAMA_DIR"/*) path="${absolute#"$PANAMA_DIR"/}" ;;
|
||||||
|
*)
|
||||||
|
err "'$target' is outside the Panama repo (${PANAMA_DIR})."
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# The repo-relative path, then each parent trimmed off in turn, ending at the
|
||||||
|
# basename. Contracts refer to their subject every one of these ways: by the
|
||||||
|
# full path from the repo root, by the path from the shell directory, and by
|
||||||
|
# name alone.
|
||||||
|
local -a patterns=()
|
||||||
|
local suffix="$path"
|
||||||
|
while :; do
|
||||||
|
patterns+=(-e "$suffix")
|
||||||
|
[[ "$suffix" == */* ]] || break
|
||||||
|
suffix="${suffix#*/}"
|
||||||
|
done
|
||||||
|
|
||||||
|
local -A hijacking=()
|
||||||
|
local entry
|
||||||
|
while IFS= read -r entry; do
|
||||||
|
hijacking["$entry"]=1
|
||||||
|
done < <(hijacking_entries)
|
||||||
|
|
||||||
|
# The same collection `test` runs, so anything named here is something the
|
||||||
|
# runner would actually execute.
|
||||||
|
local -a hits=()
|
||||||
|
local candidate rel
|
||||||
|
while IFS= read -r candidate; do
|
||||||
|
[[ -x "$candidate" || "$candidate" == *_test.py ]] || continue
|
||||||
|
grep -qF "${patterns[@]}" "$candidate" 2>/dev/null || continue
|
||||||
|
hits+=("tests/${candidate#"$PANAMA_DIR"/tests/}")
|
||||||
|
done < <(find "$PANAMA_DIR/tests" -type f -not -path '*/fixtures/*' -not -path '*__pycache__*' | sort)
|
||||||
|
|
||||||
|
if (( ${#hits[@]} == 0 )); then
|
||||||
|
printf 'No contract mentions %s — coverage may be indirect (a harness or a generated artifact); nothing verified.\n' "$path" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
for rel in "${hits[@]}"; do
|
||||||
|
if [[ -n "${hijacking[$rel]:-}" ]]; then
|
||||||
|
printf '%s [desktop]\n' "$rel"
|
||||||
|
else
|
||||||
|
printf '%s [safe]\n' "$rel"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
# ----------------------------------------------------------------------------
|
# ----------------------------------------------------------------------------
|
||||||
# Command: upgrade
|
# Command: upgrade
|
||||||
# ----------------------------------------------------------------------------
|
# ----------------------------------------------------------------------------
|
||||||
@@ -636,6 +806,7 @@ main() {
|
|||||||
edit) shift; cmd_edit "$@" ;;
|
edit) shift; cmd_edit "$@" ;;
|
||||||
doctor) shift; cmd_doctor "$@" ;;
|
doctor) shift; cmd_doctor "$@" ;;
|
||||||
test) shift; cmd_test "$@" ;;
|
test) shift; cmd_test "$@" ;;
|
||||||
|
contracts) shift; cmd_contracts "$@" ;;
|
||||||
upgrade) shift; cmd_upgrade "$@" ;;
|
upgrade) shift; cmd_upgrade "$@" ;;
|
||||||
migrate) shift; cmd_migrate "$@" ;;
|
migrate) shift; cmd_migrate "$@" ;;
|
||||||
app) shift; cmd_app "$@" ;;
|
app) shift; cmd_app "$@" ;;
|
||||||
|
|||||||
@@ -0,0 +1,206 @@
|
|||||||
|
# Agent integration: the machine carries its own manual for AI hands
|
||||||
|
|
||||||
|
Approved direction (2026-08-25 interview): three Claude-Code skills, delivery approach A
|
||||||
|
(per-skill symlinks under a real `~/.claude/skills`), a ledger-backed `panama test --safe`,
|
||||||
|
and `panama contracts <file>`. Factual content only — Gabriel's personal preferences stay in
|
||||||
|
his own CLAUDE.md and are out of scope. Everything the skills claim is contract-pinned,
|
||||||
|
because an agent believes the manual verbatim.
|
||||||
|
|
||||||
|
## Decisions log
|
||||||
|
|
||||||
|
| Decision | Choice |
|
||||||
|
|---|---|
|
||||||
|
| Audience | Claude Code skills; no AGENTS.md (YAGNI until another tool is in use) |
|
||||||
|
| Skill count | Three: `panama` (develop), `panama-desktop` (operate), `panama-sudo` (privilege) |
|
||||||
|
| Gating | Shipped to every machine, un-gated; personal content stays gated as today |
|
||||||
|
| Dev-skill home | In-repo `.claude/skills/panama/` (project-level, auto-discovered in the checkout) |
|
||||||
|
| Global delivery | Approach A: real `~/.claude/skills` dir + per-skill symlinks; new `linkdir` manifest kind for personal skills |
|
||||||
|
| CLI expansion | Moderate: `panama contracts <file>` + `panama test --safe`; `doctor` already speaks JSON |
|
||||||
|
| Test safety | `tests/desktop-hijacking` ledger + `--safe` flag + honesty contract |
|
||||||
|
| Coverage lookup | Heuristic grep, tier-marked from the ledger, honest when empty |
|
||||||
|
| Skill claims | Pinned by a new `skills-contract` |
|
||||||
|
|
||||||
|
## The three skills
|
||||||
|
|
||||||
|
House format per `user/agents/skills/writing-for-agents/`: frontmatter is `name` +
|
||||||
|
`description` only, H1 matches the name, ~100-column wrap, sibling files via relative links.
|
||||||
|
The rule for all three: **point, don't duplicate**. A skill orients and warns; the facts live
|
||||||
|
in the documents and commands the repo already maintains, so the rot surface is the pointer,
|
||||||
|
not the prose. Skills are written by the orchestrator (not fanned out) — they are the heart
|
||||||
|
of the feature and draw on session knowledge.
|
||||||
|
|
||||||
|
### `.claude/skills/panama/` — developing Panama (project-level)
|
||||||
|
|
||||||
|
Discovered automatically for any session inside the checkout; needs no delivery machinery and
|
||||||
|
works in forks. Description triggers on working in the Panama repo. Contents:
|
||||||
|
|
||||||
|
1. **Orientation** — one paragraph on what Panama is; pointers: `README.md` (layout, the
|
||||||
|
`panama` command), `config/dot/hypr/README.md` (Lua-not-hyprlang, the one thing to know
|
||||||
|
first), `config/dot/quickshell/modules/settings/README.md` (adding a setting, ownership,
|
||||||
|
"things that will bite you", where state lives), `docs/settings.md` (generated reference).
|
||||||
|
2. **The live-desktop covenant** — the distilled never-list, each item one line with its
|
||||||
|
source pointer: every save of `config/dot/quickshell/**` hot-reloads the live shell, so
|
||||||
|
every save must leave valid QML; check the journal for errors AND "Unable to assign" after
|
||||||
|
each batch (command form included); components in `modules/*` require same-save qmldir
|
||||||
|
registration; never `qs kill` from a copied configuration; never `hyprctl keyword` — and
|
||||||
|
`eval` exits 0 on error, so read back with `getoption`; no duplicate IPC targets; annotate
|
||||||
|
every IPC parameter and return type; no continuously-repainting animations; batch
|
||||||
|
compositor reloads and announce them; `readAs` describes the answer, not the setting;
|
||||||
|
don't touch the hyprlang configs that never moved to Lua; no new notification daemon;
|
||||||
|
scripts carry no `.sh` extension except `migrations/*.sh`; no secrets under `user/`.
|
||||||
|
3. **The contract discipline** — contracts live under `tests/`, run via `panama test
|
||||||
|
[pattern]` or directly; run the covering contracts for what you touched
|
||||||
|
(`panama contracts <file>` names them); during a desktop session run
|
||||||
|
`panama test --safe` — the `tests/desktop-hijacking` ledger is the list of what it skips
|
||||||
|
and why; regenerate `docs/settings.md` and the settings commands after schema/route
|
||||||
|
changes (`panama-settings-docs`, `panama-settings-commands`, both `--check`-able); the
|
||||||
|
README pins the contract count and subcommand list.
|
||||||
|
4. **Seams for testing** — fixture env vars (`PANAMA_*_HELPER`, `PANAMA_*_FIXTURE`,
|
||||||
|
`PANAMA_*_STATIC_ONLY`), the harness discipline (distinct semantic entry file, `qs -p`,
|
||||||
|
PID from `qs list --all`, kill only that PID), hermetic-by-construction helpers
|
||||||
|
(validated inputs, JSON out, stdin-only secrets).
|
||||||
|
5. **Safety idioms** — ConfirmAction/ErrorRow/NotMeasuredRow/SettingsNote exist; destructive
|
||||||
|
actions are two-stage; stored actions are data resolved through whitelists, never
|
||||||
|
commands (`settings-idiom-contract`, `keybind-rebind-contract` pin these).
|
||||||
|
|
||||||
|
### `skills/panama-desktop/` — operating the desktop (global)
|
||||||
|
|
||||||
|
For an agent doing *any* task on a Panama machine: how to drive the desktop honestly.
|
||||||
|
Description triggers on desktop interaction (opening settings, checking system health,
|
||||||
|
screenshots, notifications, audio/network/display questions) on a Panama machine. Contents:
|
||||||
|
|
||||||
|
1. `panama doctor` — JSON by default (`schemaVersion` 1), `--summary`, `check <id>`,
|
||||||
|
`--repair <id>`; the 30 check ids by group.
|
||||||
|
2. `panama-action <verb>` as the stable boundary for desktop actions (the verb list), and
|
||||||
|
`qs ipc call <target> <fn>` beneath it for what `panama-action` doesn't cover — pointer
|
||||||
|
to `shell.qml` for the target table rather than duplicating it.
|
||||||
|
3. Settings: open pages via `panama-action settings-page <id>`; change values through the
|
||||||
|
Settings UI or IPC, not by editing `~/.config/panama/settings.json` (the shell's
|
||||||
|
in-memory model wins for keys it has touched); where state lives (the state-paths table
|
||||||
|
pointer).
|
||||||
|
4. `panama-launch --class` for focus-or-launch; `panama test --safe` if asked to verify the
|
||||||
|
desktop; `panama update` for keeping the machine current.
|
||||||
|
5. Privilege: one line — load `panama-sudo` before anything needing root.
|
||||||
|
|
||||||
|
### `skills/panama-sudo/` — privilege etiquette (global)
|
||||||
|
|
||||||
|
Small. Description triggers on any need for root/sudo/pkexec on a Panama machine. Contents:
|
||||||
|
never bare `sudo`/`pkexec`; use `panama-sudo --reason "why" -- cmd args`; `--reason` must be
|
||||||
|
first; the reason appears beside (never instead of) polkitd's own action text; degrades to
|
||||||
|
plain pkexec with no shell running; migrations already follow this rule
|
||||||
|
(`bin/panama-migrate`). Include the one-line trust model so agents phrase reasons honestly:
|
||||||
|
state what the command does, not why it is safe.
|
||||||
|
|
||||||
|
## Delivery: approach A
|
||||||
|
|
||||||
|
### New root `skills/`
|
||||||
|
|
||||||
|
`skills/panama-desktop/SKILL.md`, `skills/panama-sudo/SKILL.md` (+ sibling files if needed).
|
||||||
|
Repo-root `README.md` layout table gains the row (readme-contract pins the layout block).
|
||||||
|
|
||||||
|
### New stage `setup/scripts/link-skills`
|
||||||
|
|
||||||
|
Un-gated. Behavior:
|
||||||
|
|
||||||
|
1. If `~/.claude/skills` is a symlink (today: → `user/agents/skills`), remove it (symlink
|
||||||
|
removal never displaces to `config/old`, matching `link-user`'s rule) and `mkdir -p` a
|
||||||
|
real directory.
|
||||||
|
2. For each child of `$PANAMA_PATH/skills/`, displace any existing real entry at
|
||||||
|
`~/.claude/skills/<name>` to `config/old/` (house `displace` semantics; symlinks just
|
||||||
|
removed) and link `~/.claude/skills/<name>` → `$PANAMA_PATH/skills/<name>`.
|
||||||
|
3. Report `Agent skills: N linked.`
|
||||||
|
|
||||||
|
`install` STAGES gains `link-skills` **after `link-dotfiles`, before `link-user`** (personal
|
||||||
|
must win name collisions by linking last, mirroring Claude Code's personal-over-project
|
||||||
|
precedence), in both the full list and `upgrade_stages`. `tests/setup/update-command-contract`
|
||||||
|
pins STAGE_NAMES — its list gets the new entry as part of this work.
|
||||||
|
|
||||||
|
### `linkdir` manifest kind (link-user)
|
||||||
|
|
||||||
|
`user/manifest` line `link agents/skills ~/.claude/skills` becomes
|
||||||
|
`linkdir agents/skills ~/.claude/skills`: for each child of the source, displace and link it
|
||||||
|
as a direct child of the destination (creating the destination as a real directory if
|
||||||
|
needed). Whole-dir `link` entries (`~/.agents/skills`, CLAUDE.md, rules) are untouched.
|
||||||
|
`user/README.md` documents the new kind. Known behavior, recorded not fixed: a personal
|
||||||
|
skill named like a shipped one shadows it (deliberate — personal wins); a *new* personal
|
||||||
|
skill needs a re-link (`panama update` or `setup/scripts/link-user`) to appear, where the
|
||||||
|
old whole-dir link showed it instantly.
|
||||||
|
|
||||||
|
## CLI additions (`bin/panama`)
|
||||||
|
|
||||||
|
House three-touchpoint rule: header comment, `usage()`, `main()` — plus the README
|
||||||
|
subcommand list (readme-contract cross-checks it).
|
||||||
|
|
||||||
|
### `panama contracts <file>`
|
||||||
|
|
||||||
|
Heuristic: resolve the argument to a repo-relative path; scan `tests/` (excluding
|
||||||
|
`fixtures/`, `__pycache__`) for contracts whose text contains the path, any parent-trimmed
|
||||||
|
suffix of it, or the basename. Output one line per hit: `tests/<name> [safe|desktop]`,
|
||||||
|
tier read from the ledger. No hits → `No contract mentions <path> — coverage may be
|
||||||
|
indirect (a harness or a generated artifact); nothing verified.` and exit 1. No options.
|
||||||
|
|
||||||
|
### `panama test --safe [pattern]`
|
||||||
|
|
||||||
|
`--safe` (position-independent, before or after the pattern): filter out every contract
|
||||||
|
listed in `tests/desktop-hijacking`, run the rest exactly as today, and end the summary with
|
||||||
|
`Skipped N desktop-hijacking contract(s) (tests/desktop-hijacking).` Plain `panama test`
|
||||||
|
is unchanged.
|
||||||
|
|
||||||
|
## The ledger: `tests/desktop-hijacking`
|
||||||
|
|
||||||
|
Package-list style: one repo-relative contract path per line, `#` comments; each entry's
|
||||||
|
comment says what it does to the session (restarts the shell; latches caffeine; opens live
|
||||||
|
overlays; writes every schema key against the real compositor; touches real xdg-mime
|
||||||
|
defaults; …). Seeded from the tier-3 inventory in the exploration (focus-session-restart,
|
||||||
|
focus-session-expiry, settings-window, overview-live-actions, switcher, welcome, cheatsheet,
|
||||||
|
status-events, activity-*, accessibility, calendar-agenda, screen-intelligence,
|
||||||
|
panama-action-ipc, panama-commands, panama-doctor, polkit-reason, signal-glass,
|
||||||
|
settings-write-sweep, settings-system, default-apps-*, gnome-handoff — verified individually
|
||||||
|
during the build, not copied blind).
|
||||||
|
|
||||||
|
## New contracts
|
||||||
|
|
||||||
|
### `tests/setup/skills-contract`
|
||||||
|
|
||||||
|
1. The two shipped skills and `.claude/skills/panama` exist with valid frontmatter (`name`
|
||||||
|
matches the directory, non-empty `description`, nothing else required).
|
||||||
|
2. Claim pinning: every `` `panama <sub>` `` subcommand a skill names exists in
|
||||||
|
`bin/panama`'s dispatcher; every `` `panama-<tool>` `` exists in `bin/` or the
|
||||||
|
quickshell scripts dir; every backticked repo path exists; every `PANAMA_*` env var it
|
||||||
|
names appears somewhere in the tree; every `panama-action` verb it names is in
|
||||||
|
`panama-action`'s dispatch. (Convention this imposes on skill prose: commands, paths and
|
||||||
|
variables are always backticked and exact — which is how they should be written anyway.)
|
||||||
|
3. `link-skills` exists, is in STAGES and `upgrade_stages` ordered after `link-dotfiles`
|
||||||
|
and before `link-user`; the manifest uses `linkdir` for `agents/skills`; `link-user`
|
||||||
|
implements `linkdir`.
|
||||||
|
|
||||||
|
### `tests/setup/desktop-hijacking-contract`
|
||||||
|
|
||||||
|
1. Every ledger entry names an existing contract.
|
||||||
|
2. Honesty sweep: every contract under `tests/` that drives the live shell — matches
|
||||||
|
`qs ipc call` without booting its own `qs -p` harness, or restarts
|
||||||
|
`panama-quickshell.service`, or calls `qs kill` — must be listed; the sweep's heuristics
|
||||||
|
live in this contract so a new hijacking contract cannot stay unlisted.
|
||||||
|
3. `cmd_test` honors the ledger: static check that `--safe` reads the file.
|
||||||
|
|
||||||
|
README test count: +2 (the "N of them" line — orchestrator's, updated at the end).
|
||||||
|
|
||||||
|
## Build plan and ownership
|
||||||
|
|
||||||
|
- **Orchestrator**: this spec; the three skills' content; final seam audit; README count.
|
||||||
|
- **Agent A**: `bin/panama` (`cmd_contracts`, `--safe`), `tests/desktop-hijacking` (verifying
|
||||||
|
each seeded entry by reading the contract), `tests/setup/desktop-hijacking-contract`,
|
||||||
|
README subcommand-list rows.
|
||||||
|
- **Agent B**: `setup/scripts/link-skills`, `linkdir` in `setup/scripts/link-user`,
|
||||||
|
`user/manifest`, `user/README.md`, `install` STAGES + `upgrade_stages`,
|
||||||
|
`tests/setup/skills-contract`, updates to `tests/setup/update-command-contract` and
|
||||||
|
`tests/setup/user-content-contract` where their pins move, README layout row.
|
||||||
|
|
||||||
|
Disjoint by construction; the skills (orchestrator) reference what A and B build, so the
|
||||||
|
skills land last and `skills-contract` runs after both.
|
||||||
|
|
||||||
|
## Out of scope (recorded)
|
||||||
|
|
||||||
|
Generic `AGENTS.md`; a Claude Code plugin; `panama map`/`agent` namespace; JSON output for
|
||||||
|
`panama test`; shipping any of Gabriel's personal preferences; per-contract header
|
||||||
|
annotations (the ledger supersedes); auto-relink on personal-skill creation.
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
# What --upgrade changes, and nothing else:
|
# What --upgrade changes, and nothing else:
|
||||||
#
|
#
|
||||||
# * The interview is skipped, so every PANAMA_* answer is unset and each
|
# * The interview is skipped, so every PANAMA_* answer is unset and each
|
||||||
# stage takes its documented empty-answer path. Five of the seven need no
|
# stage takes its documented empty-answer path. Six of the eight need no
|
||||||
# answer at all; link-user falls back to the decision it recorded.
|
# answer at all; link-user falls back to the decision it recorded.
|
||||||
# * setup-identity and install-hardware are dropped. They exist only to
|
# * setup-identity and install-hardware are dropped. They exist only to
|
||||||
# consume interview answers -- git identity, NVIDIA, Secure Boot, firmware
|
# consume interview answers -- git identity, NVIDIA, Secure Boot, firmware
|
||||||
@@ -166,7 +166,8 @@ gsettings set org.gnome.desktop.session idle-delay 0 2>/dev/null || true
|
|||||||
# which needs both vicinae itself (installed by install-packages) and the
|
# which needs both vicinae itself (installed by install-packages) and the
|
||||||
# theme files it selects among (symlinked into place by link-dotfiles);
|
# theme files it selects among (symlinked into place by link-dotfiles);
|
||||||
# link-user runs before setup-identity so tracked personal content wins over
|
# link-user runs before setup-identity so tracked personal content wins over
|
||||||
# what the interview would otherwise seed; setup-identity needs the gh and
|
# what the interview would otherwise seed, and after link-skills so a personal
|
||||||
|
# skill wins a name collision with a shipped one; setup-identity needs the gh and
|
||||||
# git-all that install-packages provides; and
|
# git-all that install-packages provides; and
|
||||||
# install-hardware is last because MOK enrollment arms a prompt consumed at the
|
# install-hardware is last because MOK enrollment arms a prompt consumed at the
|
||||||
# next boot and a firmware update may ask for a reboot -- a machine that reboots
|
# next boot and a firmware update may ask for a reboot -- a machine that reboots
|
||||||
@@ -222,7 +223,7 @@ if [[ -n "${PANAMA_HOSTNAME:-}" ]]; then
|
|||||||
echo "Hostname set to: $(hostname)"
|
echo "Hostname set to: $(hostname)"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
STAGES=(install-packages link-dotfiles link-user change-settings link-vicinae-scripts setup-identity install-hardware)
|
STAGES=(install-packages link-dotfiles link-skills link-user change-settings link-vicinae-scripts setup-identity install-hardware)
|
||||||
|
|
||||||
# The two an upgrade drops. Both exist only to act on interview answers, and
|
# The two an upgrade drops. Both exist only to act on interview answers, and
|
||||||
# both are first-run decisions: who you are and what hardware this is. Filtered
|
# both are first-run decisions: who you are and what hardware this is. Filtered
|
||||||
|
|||||||
Executable
+78
@@ -0,0 +1,78 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
# Agent skills, shipped to every machine.
|
||||||
|
#
|
||||||
|
# Panama is a desktop that an agent is frequently asked to operate: open the
|
||||||
|
# settings, check what is running, take a screenshot, run something as root. An
|
||||||
|
# agent that has to infer all of that from the source reliably invents half of
|
||||||
|
# it, so the machine carries its own manual and skills/ is where it lives.
|
||||||
|
#
|
||||||
|
# This stage is un-gated, unlike link-user. What it links is documentation of
|
||||||
|
# this repository, not anybody's personal content, so a stranger who clones
|
||||||
|
# Panama wants it for exactly the same reason its author does.
|
||||||
|
#
|
||||||
|
# ~/.claude/skills was a single symlink into user/agents/skills until now, and
|
||||||
|
# a directory cannot be two things at once. So the destination becomes a real
|
||||||
|
# directory and every skill -- shipped here, personal from user/ -- is linked
|
||||||
|
# into it one at a time. link-user runs after this stage on purpose: it links
|
||||||
|
# last, so a personal skill named like a shipped one wins, which is the
|
||||||
|
# precedence Claude Code itself uses.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
log() { echo -e "\033[1;34m[INFO]\033[0m $*"; }
|
||||||
|
|
||||||
|
PANAMA_PATH="${PANAMA_PATH:-$HOME/.local/share/Panama}"
|
||||||
|
SKILLS_DIR="$PANAMA_PATH/skills"
|
||||||
|
PANAMA_OLD="$PANAMA_PATH/config/old"
|
||||||
|
DESTINATION="$HOME/.claude/skills"
|
||||||
|
|
||||||
|
[[ -d "$SKILLS_DIR" ]] || { log "No skills/ in this checkout; nothing to link."; exit 0; }
|
||||||
|
|
||||||
|
mkdir -p "$PANAMA_OLD"
|
||||||
|
|
||||||
|
# Moves whatever is already at a destination out of the way, once -- the same
|
||||||
|
# promise link-dotfiles and link-user make. A symlink is removed rather than
|
||||||
|
# backed up: it is a pointer this installer made, and keeping copies of it
|
||||||
|
# would grow config/old/ by one entry on every upgrade.
|
||||||
|
displace() {
|
||||||
|
local destination="$1" backup
|
||||||
|
if [[ -L "$destination" ]]; then
|
||||||
|
rm -f "$destination"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
[[ -e "$destination" ]] || return 0
|
||||||
|
|
||||||
|
backup="$PANAMA_OLD/skills-$(printf '%s' "${destination#"$HOME"/}" | tr '/' '-')"
|
||||||
|
if [[ -e "$backup" ]]; then
|
||||||
|
backup="$backup.$(date +%s)"
|
||||||
|
fi
|
||||||
|
mv "$destination" "$backup"
|
||||||
|
log "Moved existing $destination to $backup"
|
||||||
|
}
|
||||||
|
|
||||||
|
# The destination itself has to be a real directory before anything can be
|
||||||
|
# linked into it. An old whole-directory symlink is removed; a regular file
|
||||||
|
# somebody left at this path is kept, in config/old/.
|
||||||
|
mkdir -p "$(dirname "$DESTINATION")"
|
||||||
|
if [[ -L "$DESTINATION" ]]; then
|
||||||
|
rm -f "$DESTINATION"
|
||||||
|
log "Removed the old $DESTINATION symlink; skills are linked one by one now"
|
||||||
|
elif [[ -e "$DESTINATION" && ! -d "$DESTINATION" ]]; then
|
||||||
|
displace "$DESTINATION"
|
||||||
|
fi
|
||||||
|
mkdir -p "$DESTINATION"
|
||||||
|
|
||||||
|
linked=0
|
||||||
|
for skill in "$SKILLS_DIR"/*; do
|
||||||
|
[[ -e "$skill" ]] || continue
|
||||||
|
name="$(basename "$skill")"
|
||||||
|
target="$DESTINATION/$name"
|
||||||
|
|
||||||
|
displace "$target"
|
||||||
|
ln -s "$skill" "$target"
|
||||||
|
log "Linked skills/$name → $target"
|
||||||
|
linked=$(( linked + 1 ))
|
||||||
|
done
|
||||||
|
|
||||||
|
log "Agent skills: $linked linked."
|
||||||
@@ -95,6 +95,31 @@ while read -r kind source destination; do
|
|||||||
log "Linked $source → $dst"
|
log "Linked $source → $dst"
|
||||||
linked=$(( linked + 1 ))
|
linked=$(( linked + 1 ))
|
||||||
;;
|
;;
|
||||||
|
linkdir)
|
||||||
|
# A destination that has to hold more than this one source. The
|
||||||
|
# only one today is ~/.claude/skills, which now carries Panama's
|
||||||
|
# own shipped skills as well as these -- so the directory itself
|
||||||
|
# cannot be a symlink, and each child is linked into it instead.
|
||||||
|
#
|
||||||
|
# Whatever link-skills put there is kept; only same-named entries
|
||||||
|
# are displaced, which is how a personal skill deliberately
|
||||||
|
# shadows a shipped one. This stage runs last for that reason.
|
||||||
|
if [[ -L "$dst" ]]; then
|
||||||
|
rm -f "$dst"
|
||||||
|
elif [[ -e "$dst" && ! -d "$dst" ]]; then
|
||||||
|
displace "$dst"
|
||||||
|
fi
|
||||||
|
mkdir -p "$dst"
|
||||||
|
|
||||||
|
for child in "$src"/*; do
|
||||||
|
[[ -e "$child" ]] || continue
|
||||||
|
child_name="$(basename "$child")"
|
||||||
|
displace "$dst/$child_name"
|
||||||
|
ln -s "$child" "$dst/$child_name"
|
||||||
|
log "Linked $source/$child_name → $dst/$child_name"
|
||||||
|
linked=$(( linked + 1 ))
|
||||||
|
done
|
||||||
|
;;
|
||||||
copy)
|
copy)
|
||||||
if [[ -e "$dst" ]]; then
|
if [[ -e "$dst" ]]; then
|
||||||
log "Keeping existing $dst"
|
log "Keeping existing $dst"
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
---
|
||||||
|
name: panama-desktop
|
||||||
|
description: Use when interacting with the desktop on a Panama machine (Hyprland + the Panama Quickshell shell) during any task — opening settings, checking system health, taking screenshots, toggling Do Not Disturb, launching or focusing applications, or diagnosing audio, network, or display questions.
|
||||||
|
---
|
||||||
|
|
||||||
|
# panama-desktop
|
||||||
|
|
||||||
|
This machine runs Panama: Hyprland with a shell that replaces GNOME's bar, dock, notifications,
|
||||||
|
settings, and lock screen. The desktop has a command surface built for exactly what you are about
|
||||||
|
to do — use it instead of poking at processes and config files.
|
||||||
|
|
||||||
|
## Reading the machine
|
||||||
|
|
||||||
|
`panama doctor` prints a JSON health snapshot (`schemaVersion` 1): a `summary` with an overall
|
||||||
|
status and per-check counts, and `checks[]` with `{id, group, title, status, detail, action}`.
|
||||||
|
Groups: `desktop-foundation`, `input-media`, `integrations`, `panama-tools`. Check ids follow
|
||||||
|
`group.name` (`desktop.quickshell`, `input.pipewire`, `integration.kdeconnect`,
|
||||||
|
`panama.updates` — the full list is in any snapshot). Useful forms:
|
||||||
|
|
||||||
|
- `panama doctor --summary` — one human line
|
||||||
|
- `panama doctor check <id>` — a single check, same envelope
|
||||||
|
- `panama doctor --repair <id>` — run a check's repair; exit code is the verdict
|
||||||
|
|
||||||
|
It reports what is actually running, not what was installed — trust it over `ps` archaeology.
|
||||||
|
|
||||||
|
## Driving the desktop
|
||||||
|
|
||||||
|
`panama-action <verb>` (on PATH via `~/.config/quickshell/scripts/panama-action`) is the stable
|
||||||
|
boundary for desktop actions. Verbs: `control-center`, `notifications`, `calendar`, `clipboard`,
|
||||||
|
`overview`, `settings`, `settings-page <id>`, `dock-pin`, `health`, `dnd`, `caffeine`,
|
||||||
|
`night-light`, `focus-start`, `focus-end`, `capture`, `intelligence`, `screenshot`, `microphone`,
|
||||||
|
`gallery`, `restart-shell`.
|
||||||
|
|
||||||
|
Beneath it sits `qs ipc call <target> <function>` — the shell's full IPC surface, one
|
||||||
|
`IpcHandler` per target in `config/dot/quickshell/shell.qml` (status verbs are free to call;
|
||||||
|
anything else changes live state). Prefer `panama-action` when a verb exists.
|
||||||
|
|
||||||
|
Other tools that beat raw commands:
|
||||||
|
|
||||||
|
- `panama-launch --class '<regex>' -- cmd…` — focus the window if it exists, launch otherwise
|
||||||
|
- `panama-sudo --reason "why" -- cmd…` — root work; load the `panama-sudo` skill first
|
||||||
|
- `panama update` — bring the machine current (pull, repairs, unattended stages); asks nothing
|
||||||
|
- `panama test --safe` — verify the desktop's contracts without hijacking the session
|
||||||
|
|
||||||
|
## Settings
|
||||||
|
|
||||||
|
Open a page for the user with `panama-action settings-page <id>` (leaf ids like `displays`,
|
||||||
|
`sound`, `connectivity`; `docs/settings.md` in the repo documents every key and page).
|
||||||
|
|
||||||
|
Change values through the Settings window or the shell's IPC — do NOT hand-edit
|
||||||
|
`~/.config/panama/settings.json` while the shell runs: the shell's writes merge-and-win for any
|
||||||
|
key it has touched, so your edit silently loses. The file is the user's to read and back up, not
|
||||||
|
an API.
|
||||||
|
|
||||||
|
## Where state lives (read, don't write)
|
||||||
|
|
||||||
|
- `~/.config/panama/settings.json` — every setting; also read by the Hyprland Lua config
|
||||||
|
- `$XDG_STATE_HOME/panama/` — generated idle/lock configs, settings backups, migration markers
|
||||||
|
- The repo itself is at `~/.local/share/Panama` (or `$PANAMA_PATH`), and `~/.config` entries are
|
||||||
|
symlinks into it — a change to either is a change to both
|
||||||
|
|
||||||
|
If you find yourself editing files under `~/.config/quickshell` or `~/.config/hypr`, you are
|
||||||
|
editing the Panama repository on a live desktop — stop and load the `panama` skill first.
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
---
|
||||||
|
name: panama-sudo
|
||||||
|
description: Use whenever a command needs root on a Panama machine — any sudo, pkexec, or privileged system change. Panama routes privilege through a wrapper that shows the user your reason beside the password prompt.
|
||||||
|
---
|
||||||
|
|
||||||
|
# panama-sudo
|
||||||
|
|
||||||
|
Never run bare `sudo` or `pkexec` on a Panama machine. Use:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
panama-sudo --reason "why this needs root" -- command args…
|
||||||
|
```
|
||||||
|
|
||||||
|
`--reason` must be the first argument; the `--` separator is optional. The reason is displayed on
|
||||||
|
the desktop's own polkit prompt **beside** — never instead of — polkitd's description of the
|
||||||
|
action, because anything can claim any reason. That is the trust model, and it shapes how you
|
||||||
|
write the reason: state what the command does, not an assurance that it is safe.
|
||||||
|
|
||||||
|
- Good: `--reason "Install the fwupd package so firmware updates can be checked"`
|
||||||
|
- Bad: `--reason "Safe maintenance task"`
|
||||||
|
|
||||||
|
With no `--reason`, no running shell, or no `qs` on PATH, it degrades to plain `pkexec` — so it
|
||||||
|
is always the right call, even in a TTY or a broken session.
|
||||||
|
|
||||||
|
One reason per privileged action. Do not batch unrelated root commands behind a single vague
|
||||||
|
reason; the user approves what the prompt says, and the prompt should say everything.
|
||||||
|
|
||||||
|
House precedent: `bin/panama-migrate` documents the rule — root work goes through
|
||||||
|
`panama-sudo --reason "..."`, never bare sudo — and every shipped migration follows it.
|
||||||
@@ -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)"
|
tmp="$(mktemp -d -t panama-update-contract.XXXXXX)"
|
||||||
trap 'rm -rf "$tmp"' EXIT
|
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)
|
link-vicinae-scripts setup-identity install-hardware)
|
||||||
|
|
||||||
# A PANAMA_PATH that looks enough like the real one for install to run, and
|
# 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 ────────────────────────────────────────
|
# ── 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"
|
grep -qx "$stage" <<<"$ran" || note "install --upgrade did not run $stage"
|
||||||
done
|
done
|
||||||
for stage in setup-identity install-hardware; do
|
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
|
while read -r kind source destination; do
|
||||||
[[ -e "$repo_dir/user/$source" ]] \
|
[[ -e "$repo_dir/user/$source" ]] \
|
||||||
|| note "the manifest points at $source, which is not in user/"
|
|| 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"
|
|| note "the manifest uses an unknown kind: $kind"
|
||||||
[[ "$destination" == '~/'* ]] \
|
[[ "$destination" == '~/'* ]] \
|
||||||
|| note "the manifest destination $destination is not under the home directory"
|
|| 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'
|
cat >"$checkout/user/manifest" <<'FIXTURE'
|
||||||
# a comment, and a blank line follow
|
# a comment, and a blank line follow
|
||||||
|
|
||||||
link agents/AGENTS.md ~/.claude/CLAUDE.md
|
link agents/AGENTS.md ~/.claude/CLAUDE.md
|
||||||
link agents/skills ~/.agents/skills
|
link agents/skills ~/.agents/skills
|
||||||
copy plain.txt ~/.config/plain.txt
|
linkdir agents/skills ~/.claude/skills
|
||||||
link missing.txt ~/.config/missing.txt
|
copy plain.txt ~/.config/plain.txt
|
||||||
|
link missing.txt ~/.config/missing.txt
|
||||||
FIXTURE
|
FIXTURE
|
||||||
|
|
||||||
home="$work/home"
|
home="$work/home"
|
||||||
@@ -101,6 +102,12 @@ PANAMA_USER_CONTENT=no run
|
|||||||
|
|
||||||
# ── 2. Saying yes links, and keeps what was there ───────────────────────────
|
# ── 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
|
PANAMA_USER_CONTENT=yes run
|
||||||
|
|
||||||
[[ -L "$home/.claude/CLAUDE.md" ]] \
|
[[ -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'
|
|| note 'the CLAUDE.md link does not resolve to the tracked file'
|
||||||
[[ -L "$home/.agents/skills" && -f "$home/.agents/skills/example/SKILL.md" ]] \
|
[[ -L "$home/.agents/skills" && -f "$home/.agents/skills/example/SKILL.md" ]] \
|
||||||
|| note 'the skills directory was not linked as a directory'
|
|| 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" ]] \
|
[[ -f "$home/.config/plain.txt" && ! -L "$home/.config/plain.txt" ]] \
|
||||||
|| note 'a copy entry was linked rather than copied'
|
|| note 'a copy entry was linked rather than copied'
|
||||||
|
|
||||||
|
|||||||
+25
-1
@@ -13,13 +13,37 @@ and one file says where each piece goes.
|
|||||||
| Path | Goes to | Why |
|
| Path | Goes to | Why |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `agents/AGENTS.md` | `~/.claude/CLAUDE.md`, `~/.codex/AGENTS.md` | Two tools, two names, one file. These were byte-identical copies before this, waiting to disagree. |
|
| `agents/AGENTS.md` | `~/.claude/CLAUDE.md`, `~/.codex/AGENTS.md` | Two tools, two names, one file. These were byte-identical copies before this, waiting to disagree. |
|
||||||
| `agents/skills/` | `~/.agents/skills`, `~/.claude/skills` | Linked as a directory, so a skill installed on any machine lands in the checkout. |
|
| `agents/skills/` | `~/.agents/skills`, `~/.claude/skills` | A skill installed on any machine lands in the checkout. The Claude path is `linkdir` — see below. |
|
||||||
| `agents/rules/` | `~/.claude/rules` | |
|
| `agents/rules/` | `~/.claude/rules` | |
|
||||||
| `ssh/config` | `~/.ssh/config` | Host aliases only. Keys are per-machine and are never tracked. |
|
| `ssh/config` | `~/.ssh/config` | Host aliases only. Keys are per-machine and are never tracked. |
|
||||||
| `espanso/identity.yml` | `~/.config/espanso/match/identity.yml` | Copied, not linked, because a machine may add its own triggers. |
|
| `espanso/identity.yml` | `~/.config/espanso/match/identity.yml` | Copied, not linked, because a machine may add its own triggers. |
|
||||||
|
|
||||||
`manifest` is the authority; this table is a summary of it.
|
`manifest` is the authority; this table is a summary of it.
|
||||||
|
|
||||||
|
## The three kinds
|
||||||
|
|
||||||
|
`link` and `copy` mean what they say: one symlink, or one copy made only if the
|
||||||
|
destination is empty. `linkdir` is the third, and it exists because one
|
||||||
|
destination is no longer only ours.
|
||||||
|
|
||||||
|
Panama ships its own agent skills now (`skills/`, linked by
|
||||||
|
`setup/scripts/link-skills`), and they go to `~/.claude/skills` — the same
|
||||||
|
directory the personal ones went to as a single symlink. A directory cannot be
|
||||||
|
a symlink to two places, so that entry became `linkdir`: the destination is a
|
||||||
|
real directory, and each child of `user/agents/skills/` is linked into it
|
||||||
|
individually. `~/.agents/skills` is still a whole-directory `link`, because
|
||||||
|
nothing else claims it.
|
||||||
|
|
||||||
|
Two consequences, recorded rather than fixed:
|
||||||
|
|
||||||
|
- **A personal skill named like a shipped one shadows it.** `link-user` runs
|
||||||
|
after `link-skills` and displaces what it finds, so the personal one wins.
|
||||||
|
That is the intent, and it is the precedence Claude Code uses anyway.
|
||||||
|
- **A new personal skill needs a re-link to appear.** The whole-directory link
|
||||||
|
showed a newly created skill instantly; per-child links do not know about a
|
||||||
|
child that did not exist when they were made. Run `panama update` or
|
||||||
|
`setup/scripts/link-user` after adding one.
|
||||||
|
|
||||||
## It is off unless you say yes
|
## It is off unless you say yes
|
||||||
|
|
||||||
The installer asks, naming the destinations, and the default is no. Nothing here
|
The installer asks, naming the destinations, and the default is no. Nothing here
|
||||||
|
|||||||
+13
-5
@@ -7,6 +7,10 @@
|
|||||||
# repository. Use this for anything you want to update once and have
|
# repository. Use this for anything you want to update once and have
|
||||||
# change everywhere -- and for anything a tool writes into, because a
|
# change everywhere -- and for anything a tool writes into, because a
|
||||||
# skill installed on this machine should land in the checkout.
|
# skill installed on this machine should land in the checkout.
|
||||||
|
# linkdir symlink each child of the source into the destination, which is
|
||||||
|
# created as a real directory. Use this where the destination holds
|
||||||
|
# more than this one source -- ~/.claude/skills also carries Panama's
|
||||||
|
# own shipped skills -- so it cannot be a symlink to any single place.
|
||||||
# copy copy once, and never touch it again if the destination exists. Use
|
# copy copy once, and never touch it again if the destination exists. Use
|
||||||
# this for files a machine legitimately diverges on.
|
# this for files a machine legitimately diverges on.
|
||||||
#
|
#
|
||||||
@@ -24,11 +28,15 @@
|
|||||||
link agents/AGENTS.md ~/.claude/CLAUDE.md
|
link agents/AGENTS.md ~/.claude/CLAUDE.md
|
||||||
link agents/AGENTS.md ~/.codex/AGENTS.md
|
link agents/AGENTS.md ~/.codex/AGENTS.md
|
||||||
|
|
||||||
# Skills, at both paths that look for them. Linked as whole directories rather
|
# Skills, at both paths that look for them. Linked so that a skill installed by
|
||||||
# than file by file, so a skill installed by any tool lands in the checkout and
|
# any tool lands in the checkout and `panama update` offers to commit it.
|
||||||
# `panama update` offers to commit it.
|
#
|
||||||
link agents/skills ~/.agents/skills
|
# ~/.agents/skills is this directory and nothing else, so it stays one link.
|
||||||
link agents/skills ~/.claude/skills
|
# ~/.claude/skills also holds the skills Panama itself ships, put there by the
|
||||||
|
# link-skills stage, so it is a real directory with one link per skill -- and
|
||||||
|
# this stage runs after that one, so a personal skill wins a name collision.
|
||||||
|
link agents/skills ~/.agents/skills
|
||||||
|
linkdir agents/skills ~/.claude/skills
|
||||||
|
|
||||||
link agents/rules ~/.claude/rules
|
link agents/rules ~/.claude/rules
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user