diff --git a/.claude/skills/panama/SKILL.md b/.agents/skills/panama/SKILL.md similarity index 98% rename from .claude/skills/panama/SKILL.md rename to .agents/skills/panama/SKILL.md index 12eba00..9dc5c1a 100644 --- a/.claude/skills/panama/SKILL.md +++ b/.agents/skills/panama/SKILL.md @@ -9,7 +9,7 @@ You are editing a running desktop, not a codebase that gets deployed later. Ever 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: +Before large work, read these in 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 @@ -62,7 +62,8 @@ Every executable contract under `tests/` is classified in `tests/contracts.manif `PANAMA_TEST_TIMEOUT_SECONDS` value. Failures print the contract's captured stdout and stderr. Successful stdout stays quiet. Successful stderr is surfaced as a warning. - Contracts run directly too: `tests/setup/interview-contract`. -- After changing `PreferenceSchema.qml` or `services/SettingsRoutes.qml`, regenerate: +- After changing `PreferenceSchema.qml` or + `config/dot/quickshell/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. diff --git a/.claude/skills/panama b/.claude/skills/panama new file mode 120000 index 0000000..2f6234f --- /dev/null +++ b/.claude/skills/panama @@ -0,0 +1 @@ +../../.agents/skills/panama \ No newline at end of file diff --git a/README.md b/README.md index 17237ce..0b23afd 100644 --- a/README.md +++ b/README.md @@ -102,7 +102,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 | | `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/` → `~/.config/`, 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-skills` | Links the agent skills in `skills/` into `~/.agents/skills` and `~/.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) | | `change-settings` | Copies `config/copy/` over `/`, applies gsettings, enables user services | | `link-vicinae-scripts` | Publishes the Vicinae script commands | @@ -235,10 +235,13 @@ server/ The server role: compose services (one directory per service), the nightly image updater, and its units. See server/README.md skills/ Agent skills for operating this desktop, linked into - ~/.claude/skills + ~/.agents/skills and ~/.claude/skills setup/ apps/ Applications built from source, one file each - lib/ Shared by more than one stage; the extras catalog reader + keys/ Pinned signing keys, for publishers that ship no fetchable + one; setup/keys/README.md records where each came from + lib/ Shared by more than one stage; the extras catalog reader, + the machine role, the verified ChatGPT repository packages/ One package per line; extras/ holds the optional categories scripts/ Run in order by ./install tests/ Contracts. See below @@ -247,7 +250,7 @@ docs/ Settings reference, and the design specs behind the work ## Tests -188 of them, under `tests/`. `tests/contracts.manifest` classifies every +189 of them, under `tests/`. `tests/contracts.manifest` classifies every contract by the capabilities it needs. Run the hermetic set, or grant a specific external capability when automation needs it: diff --git a/bin/panama b/bin/panama index 6db8338..5fb9e0c 100755 --- a/bin/panama +++ b/bin/panama @@ -134,7 +134,6 @@ ${BOLD}Examples:${RESET} $PROGRAM upgrade $PROGRAM apps $PROGRAM app - $PROGRAM app chatgpt-desktop EOF } @@ -451,11 +450,16 @@ CONTRACT_CAPABILITIES=(hermetic live-host live-compositor live-desktop network p contract_paths() { local candidate + # The manifest is kept in byte order, so both the discovery sort and the + # comparison below have to be byte order too. A UTF-8 collation folds the + # punctuation away -- `calendar_agenda_bridge_test.py` sorts before + # `calendar-agenda-helper-contract` under en_US and after it under C -- and + # a gate that passes or fails on the machine's LANG is not a gate. while IFS= read -r candidate; do [[ -x "$candidate" || "$candidate" == *_test.py ]] || continue printf 'tests/%s\n' "${candidate#"$PANAMA_DIR/tests/"}" done < <(find "$PANAMA_DIR/tests" -type f \ - -not -path '*/fixtures/*' -not -path '*__pycache__*' | sort) + -not -path '*/fixtures/*' -not -path '*__pycache__*' | LC_ALL=C sort) } contract_manifest_entries() { @@ -478,6 +482,8 @@ validate_contract_manifest() { require_contract_manifest || return 1 local manifest="$PANAMA_DIR/$CONTRACT_MANIFEST" + # Byte order, for the same reason contract_paths sorts in it. + local LC_ALL=C local line capabilities path extra previous_comment="" previous_was_comment=0 local previous_path="" capability discovered local -a capability_list=() findings=() diff --git a/config/bash/aliases b/config/bash/aliases index 2f10d2c..440e795 100644 --- a/config/bash/aliases +++ b/config/bash/aliases @@ -22,6 +22,7 @@ alias avante='nvim -c "lua vim.defer_fn(function()require(\"avante.api\").zen_mo alias nlc='nvim leetcode.nvim' alias clauded='claude --allow-dangerously-skip-permissions' alias claudedr='claude --allow-dangerously-skip-permissions --resume' +alias klaude='CLAUDE_CONFIG_DIR="$HOME/.klaude" claude' alias commit-repo='git add -A && git commit -m "Update stuff" && git push' # Docker Shortcuts diff --git a/config/dot/quickshell/config/PreferenceSchema.qml b/config/dot/quickshell/config/PreferenceSchema.qml index 085ea3f..afb9ddd 100644 --- a/config/dot/quickshell/config/PreferenceSchema.qml +++ b/config/dot/quickshell/config/PreferenceSchema.qml @@ -1805,7 +1805,7 @@ Singleton { "org.mozilla.thunderbird_esr", "com.slack.Slack", "app.bluebubbles.BlueBubbles", "rustdesk", "io.podman_desktop.PodmanDesktop", "com.anthropic.Claude", - "codex-desktop", "md.obsidian.Obsidian", + "chatgpt", "md.obsidian.Obsidian", "com.obsproject.Studio", "steam" ] }, diff --git a/migrations/1787804505.sh b/migrations/1787804505.sh new file mode 100755 index 0000000..7711f0e --- /dev/null +++ b/migrations/1787804505.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash + +# replace the community ChatGPT Desktop build with the official OpenAI package +# +# `panama app chatgpt-desktop` used to build a community wrapper (codex-desktop) +# from the upstream macOS disk image, complete with a local rebuild daemon. +# OpenAI ships an official Linux RPM now, and the installer takes that instead; +# this repairs machines still carrying the community build. The official +# package goes on before the community one comes off, so a failure part-way +# leaves the machine with an app, never without one. +# +# Rules, because the runner cannot enforce them: +# +# * Safe to run twice. The marker records success, not intent. +# * Tolerant of the repair already being correct -- the user may have fixed +# it by hand, or a later ./install may have put it back. +# * Root work goes through `panama-sudo --reason "..."`, never bare sudo, +# so the password prompt names the repair. +# * Exit non-zero to be retried at the next login. Exit zero only when the +# machine is genuinely in the state this describes. + +set -euo pipefail + +PANAMA_PATH="${PANAMA_PATH:-$HOME/.local/share/Panama}" + +# Machines that never had the community build are already correct. The official +# app is the installer's job, not this one's. +rpm -q codex-desktop >/dev/null 2>&1 || exit 0 + +# The same verified repository the installer establishes: the pinned signing +# key, then a repository that names it, so dnf checks OpenAI's signature before +# root installs anything. See setup/lib/chatgpt-package. +# shellcheck source=../setup/lib/chatgpt-package +source "$PANAMA_PATH/setup/lib/chatgpt-package" + +sudo_cmd=(sudo) +if [[ -t 0 && -x "$PANAMA_PATH/bin/panama-sudo" ]]; then + sudo_cmd=( + "$PANAMA_PATH/bin/panama-sudo" --reason + "Replacing the community-built ChatGPT Desktop (codex-desktop) with the official OpenAI package" + -- + ) +fi + +# The official package first, so the machine is never left without one. +if ! rpm -q chatgpt >/dev/null 2>&1; then + chatgpt_install_repository "${sudo_cmd[@]}" + "${sudo_cmd[@]}" dnf install -y chatgpt +fi + +# The community package's updater is a user unit; stop it before dnf removes +# the unit file out from under it. Removal also takes the app in /opt, both +# binaries, and the polkit policy the local rebuilds needed. +systemctl --user disable --now codex-update-manager.service 2>/dev/null || true +"${sudo_cmd[@]}" dnf remove -y codex-desktop +systemctl --user daemon-reload 2>/dev/null || true + +# The rebuild state the updater kept; the official package needs none of it. +rm -rf "${XDG_CACHE_HOME:-$HOME/.cache}/codex-update-manager" \ + "${XDG_CACHE_HOME:-$HOME/.cache}/codex-runtimes" \ + "${XDG_CONFIG_HOME:-$HOME/.config}/codex-update-manager" \ + "${XDG_STATE_HOME:-$HOME/.local/state}/codex-update-manager" + +echo "Replaced the community codex-desktop build with the official chatgpt package." diff --git a/setup/apps/chatgpt-desktop b/setup/apps/chatgpt-desktop deleted file mode 100644 index c640613..0000000 --- a/setup/apps/chatgpt-desktop +++ /dev/null @@ -1,19 +0,0 @@ -# ChatGPT Desktop. -# -# OpenAI ships macOS and Windows only. This is a community wrapper that converts -# the upstream macOS disk image into a Linux Electron app and packages it as an -# RPM, so the installed result is again something dnf owns. -# -# Same exception, same reason: there is no packaged form to prefer. Nothing is -# pinned; `bootstrap-native` fetches the current upstream image each time and -# fails loudly when it cannot. - -description="ChatGPT Desktop, built into a Fedora RPM" -repo="https://github.com/ilysenko/codex-desktop-linux.git" - -# bootstrap-native installs build dependencies, builds, packages, and installs -# the newest artifact -- so unlike the Claude build there is no separate install -# step to do here. -build() { - make bootstrap-native -} diff --git a/setup/keys/README.md b/setup/keys/README.md new file mode 100644 index 0000000..336abbf --- /dev/null +++ b/setup/keys/README.md @@ -0,0 +1,38 @@ +# Pinned signing keys + +A key lands here when a publisher signs what Panama installs but does not +publish the key, or its fingerprint, anywhere an install could fetch and check +them first. Pinning the key is what lets `dnf` verify a download before root +ever sees it. + +Nothing here is a secret. These are public keys, and the reason to track them +is that a *changed* one should be a merge request somebody reads, not a silent +change of who is trusted. + +## `RPM-GPG-KEY-chatgpt` + +| | | +| --- | --- | +| Fingerprint | `3BFA0E4AE8B8CC16A2D9BA684A3B4A566C4660E4` | +| User ID | `Codex Linux Repository` | +| Signs | the `chatgpt` package and the repository metadata at `https://persistent.oaistatic.com/codex-app-prod/linux/rpm/$basearch` | +| Used by | `setup/lib/chatgpt-package` | + +Captured on 2026-08-27 from a machine where the official package had been +installed, at `/etc/pki/rpm-gpg/RPM-GPG-KEY-chatgpt`, where the package's own +root scriptlet writes it. It is the key that signed both the installed +`chatgpt` package and the live `repodata/repomd.xml.asc`. + +Be honest about what that is worth: OpenAI's documented instructions +() are to download an RPM and +install it, and they publish no key URL and no fingerprint to compare against. +So this is trust established on first use and then held, not trust verified +against the publisher. Held is the part that matters -- from here every machine +checks the same fingerprint, and a swapped download fails instead of installing. + +To re-derive the fingerprint from the file: + +```bash +gpg --show-keys --with-colons setup/keys/RPM-GPG-KEY-chatgpt \ + | awk -F: '$1 == "fpr" { print $10; exit }' +``` diff --git a/setup/keys/RPM-GPG-KEY-chatgpt b/setup/keys/RPM-GPG-KEY-chatgpt new file mode 100644 index 0000000..cc1c7cc --- /dev/null +++ b/setup/keys/RPM-GPG-KEY-chatgpt @@ -0,0 +1,28 @@ +-----BEGIN PGP PUBLIC KEY BLOCK----- + +mQINBGpypFUBEACi1Vvzq9pIpA6lj7chbqELuxJtVuzUzxrasa6ZU0yF4yhq7jf8 +3YkJRHwbezBKeQyzJ5lkX0EhXS8aXxUhMAm3PFpAlwcInfKzmV7atJwvaxIw6Rmd +GYe9fBWKjTN/SmPIjtyxrTznZY97+TfD1AeGZpLaJ8fsnhrC+HkiN2TACiTocgpe +hFiP0OWK7mWZeTWnY2scpIYXP1Ro7nQv4KacmY4JacTQ7m/HM0Qej/3olhuEv2Cw +lMVWw57/oHhmTllfLDQOogFQyIVqaaR98y/Eu6cAabSfcsqAAZ2A8vfHYD27z28J +vLO2PZEJd5ThlnX4Zqv0eIpZdBj//8Sl/MSqTshFZ1NDsRoqwdqw284X5MpnOJ4k +4Sc2Se8tJxt/nCeibH3dJ504Fb1X/mnOqhCAQ6pVJz4RB5HRlFPSkxVPyag1v1m/ +7T4vie+OR4eqFQNz6mudrOoMmeVIfyL5fbe4cOr4fk/FyvEE2xMgkFatPqXn7vM9 +og+zremPCfwRAFpBPyX74VowFY7llcdaj/w8K5T8PzM14Hb3E4ZKizMluKmTvTq9 +WE1/eSQJLLQqXD5VmtmdUaC/VyE/1ZlIxcA1LWqvEQ327UXREvX/nHsrkKrl956W +jzkiHFUTsD1NJ0dMfs+csOt8Furb5jZj+HsMmCm9jLdfz5b/4WKLPbvxIwARAQAB +tBZDb2RleCBMaW51eCBSZXBvc2l0b3J5iQJRBBMBCgA7FiEEO/oOSui4zBai2bpo +SjtKVmxGYOQFAmpypFUCGwMFCwkIBwICIgIGFQoJCAsCBBYCAwECHgcCF4AACgkQ +SjtKVmxGYORlCQ/9FyikZo8HQcJBP9E/oXVPds/fQnIFB2qJR2z3DrfYEonNt/ev +SAySkPPq4/mEOjaI0pFlDDGSaps+FTcJFgoVRTasBIF7JJivvjW9ap8iWEbhhVLe +IrFLbMLpUcTRntUx7R4fVMJ/1/cGn+NWZmNwS9ORorzSyCH0IAgCw1Xc3ZrjuMbF +VjdToMC1TiXXCEmlYpQakmQ3Ay1cH0FHC2BBNn1MNVkJdPhpZIZCdhaMPHfYFpyo +pg8wFvZ5iIcvlbMgyuy8CPJVRWUcYy2dOhEOGnYJnXRPkE3E1hf8YOHNzRlduH89 +6lT9qcEK2+fpLfrVGoc4zscLZ+Ey+Ko6iQRdVE1j67+wNR3hX8ukue574v1N/xxu +i575jumSE19lEj1sH4+P4gFHOtTbF0JhKKzLctbga0IAwTPKhnt3qzj1U5Yj/MZS +uEVjrLhdRauOuFBXUclgyVf2w/lE85UUOdlcollsYA6Huq7xDamqf8SslZQGre3E +I+lhpqJR1cOwDMUzzcl40uTyhrxXXd/bk4QSlhZbwHR25Pnt+ZMtWavlQWS0eDEV +8djuXAURCmx5WOqAFB/TJe1mn5EvyWg4VFzrY/NVNOpzgY5+Xp7J28z7f637r712 +Eu9j4imVcdPigwS+jf/0f81i2o9b82Y26TN8+EtDLCY841MJ1lrjDrX/dno= +=Y+3h +-----END PGP PUBLIC KEY BLOCK----- diff --git a/setup/lib/chatgpt-package b/setup/lib/chatgpt-package new file mode 100644 index 0000000..f608c79 --- /dev/null +++ b/setup/lib/chatgpt-package @@ -0,0 +1,97 @@ +# Installing OpenAI's ChatGPT Desktop without trusting the download. Sourced, +# not run. +# +# OpenAI signs both its packages and its repository metadata, with one key, and +# publishes neither that key nor its fingerprint anywhere a first install could +# fetch them. The documented instructions are "download this RPM and install +# it" -- and the RPM's own root scriptlet is what writes the repository file and +# drops the key into /etc/pki/rpm-gpg. Following them means handing an +# unverified download to root and letting it decide afterwards what to trust, +# which is the one thing this repository will not do with a network response. +# +# So the key is pinned here instead. setup/keys/ carries a copy and records +# where it came from; this verifies that copy's fingerprint, installs it, and +# writes the repository itself with gpgcheck on. dnf then checks the metadata +# signature and the package signature against that key before anything runs as +# root, and every later upgrade goes through the same repository and the same +# key. +# +# Two callers, which is why this is a library: install-packages, for a machine +# being built, and the migration that replaces the community codex-desktop +# build on machines that predate the official package. + +# The key that signs the packages and the repository metadata. Pinned, so a +# substituted key is a failure here rather than a silent change of publisher. +CHATGPT_KEY_FINGERPRINT="3BFA0E4AE8B8CC16A2D9BA684A3B4A566C4660E4" + +# `$basearch` stays literal: dnf expands it, and this is the same base URL the +# package's own scriptlet configures. +CHATGPT_REPO_BASEURL="https://persistent.oaistatic.com/codex-app-prod/linux/rpm/\$basearch" +CHATGPT_REPO_FILE="/etc/yum.repos.d/chatgpt.repo" +CHATGPT_KEY_FILE="/etc/pki/rpm-gpg/RPM-GPG-KEY-chatgpt" + +chatgpt_pinned_key() { + printf '%s/setup/keys/RPM-GPG-KEY-chatgpt' "${PANAMA_PATH:-$HOME/.local/share/Panama}" +} + +# The fingerprint of the pinned copy. Nonzero when it cannot be read at all, +# which the caller reports differently from a key that reads but is the wrong +# one. +chatgpt_pinned_fingerprint() { + local key + key="$(chatgpt_pinned_key)" + [[ -r "$key" ]] || return 1 + gpg --show-keys --with-colons "$key" 2>/dev/null \ + | awk -F: '$1 == "fpr" { print $10; exit }' +} + +# Fails without touching anything when the pinned key is missing, unreadable, +# or not the key this repository says it is. Everything below assumes it passed. +chatgpt_verify_pinned_key() { + local found + if ! command -v gpg >/dev/null 2>&1; then + printf 'gpg is missing, so the pinned ChatGPT signing key cannot be verified.\n' >&2 + return 1 + fi + if ! found="$(chatgpt_pinned_fingerprint)"; then + printf 'The pinned ChatGPT signing key is missing: %s\n' "$(chatgpt_pinned_key)" >&2 + return 1 + fi + if [[ "$found" != "$CHATGPT_KEY_FINGERPRINT" ]]; then + printf 'The pinned ChatGPT signing key is %s, not the expected %s.\n' \ + "${found:-unreadable}" "$CHATGPT_KEY_FINGERPRINT" >&2 + return 1 + fi +} + +# Installs the verified key and the repository that names it, so the install +# after this one is a signature check rather than an act of faith. +# +# Takes the command that gets root, because the two callers ask for it +# differently: plain `sudo` from the installer, which authenticated once at the +# top of the run, and `panama-sudo --reason ...` from a migration, whose prompt +# has to say which repair it is for. +chatgpt_install_repository() { + local -a sudo_cmd=("$@") + (( ${#sudo_cmd[@]} > 0 )) || sudo_cmd=(sudo) + + chatgpt_verify_pinned_key || return 1 + + "${sudo_cmd[@]}" install -D -m 0644 "$(chatgpt_pinned_key)" "$CHATGPT_KEY_FILE" || return 1 + "${sudo_cmd[@]}" rpmkeys --import "$CHATGPT_KEY_FILE" || return 1 + + # Written here rather than left to the package's scriptlet, because the + # point of it is to exist -- with gpgcheck on and this key named -- before + # the first install rather than after it. Same base URL and same key the + # scriptlet writes, so it finds nothing to change later. + printf '%s\n' \ + '[openai-chatgpt]' \ + 'name=ChatGPT' \ + "baseurl=$CHATGPT_REPO_BASEURL" \ + 'enabled=1' \ + 'type=rpm-md' \ + 'gpgcheck=1' \ + 'repo_gpgcheck=1' \ + "gpgkey=file://$CHATGPT_KEY_FILE" \ + | "${sudo_cmd[@]}" tee "$CHATGPT_REPO_FILE" >/dev/null || return 1 +} diff --git a/setup/scripts/install-packages b/setup/scripts/install-packages index aecfa99..304a7c7 100755 --- a/setup/scripts/install-packages +++ b/setup/scripts/install-packages @@ -89,6 +89,11 @@ source "$PANAMA_PATH/setup/lib/extras-catalog" source "$PANAMA_PATH/setup/lib/machine-role" ROLE="$(panama_role)" +# Establishing the verified ChatGPT repository, shared with the migration that +# replaces the community build, so neither can install it a less careful way. +# shellcheck source=../lib/chatgpt-package +source "$PANAMA_PATH/setup/lib/chatgpt-package" + # One list, installed the way every list is installed: --skip-unavailable so a # single rotted name cannot cost the transaction, then report_missing so a # skipped name is a warning somebody reads. @@ -1299,6 +1304,28 @@ if ! install_claude_desktop_if_trusted; then softly_failed+=("Claude Desktop") fi +# ChatGPT Desktop: OpenAI ships an official Linux RPM now. Panama used to build +# a community wrapper from the macOS disk image -- it was `panama app +# chatgpt-desktop` -- because no packaged form existed; that build froze often +# and carried its own local rebuild daemon. The official package is strictly +# better: it comes from a repository, so it upgrades with every other package +# from then on. +# +# The repository and its signing key are established first, from the copy +# pinned in setup/keys/, so dnf verifies the metadata and the package before +# either reaches root. Upstream's own instructions do not allow that -- see +# setup/lib/chatgpt-package for why they are not followed here. +if rpm -q chatgpt >/dev/null 2>&1; then + log "ChatGPT Desktop already installed" +elif ! chatgpt_install_repository sudo; then + log "Could not establish the verified ChatGPT repository; skipping" + softly_failed+=("ChatGPT Desktop") +else + log "Installing ChatGPT Desktop..." + sudo dnf install -y chatgpt > /dev/null \ + || { log "ChatGPT Desktop install failed; skipping"; softly_failed+=("ChatGPT Desktop"); } +fi + # The RPM ships rustdesk.service already enabled, which is what provides # unattended access; Panama deliberately does not start it a second time. install_rustdesk || true diff --git a/setup/scripts/link-skills b/setup/scripts/link-skills index f65f23c..8736ede 100755 --- a/setup/scripts/link-skills +++ b/setup/scripts/link-skills @@ -11,12 +11,11 @@ # 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. +# ~/.agents/skills and ~/.claude/skills may each start as a single symlink into +# user/agents/skills, but a directory cannot point at personal and shipped +# skills at once. Both destinations become real directories with one link per +# skill. link-user runs after this stage on purpose, so a personal skill named +# like a shipped one wins in every agent runtime. set -euo pipefail @@ -25,7 +24,7 @@ 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" +DESTINATIONS=("$HOME/.agents/skills" "$HOME/.claude/skills") [[ -d "$SKILLS_DIR" ]] || { log "No skills/ in this checkout; nothing to link."; exit 0; } @@ -51,27 +50,30 @@ displace() { 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" +# Each destination 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 the path is kept in config/old/. +for destination in "${DESTINATIONS[@]}"; do + 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" +done 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" + for destination in "${DESTINATIONS[@]}"; do + target="$destination/$name" + displace "$target" + ln -s "$skill" "$target" + log "Linked skills/$name → $target" + done linked=$(( linked + 1 )) done diff --git a/tests/contracts.manifest b/tests/contracts.manifest index 97cfda7..b2c752f 100644 --- a/tests/contracts.manifest +++ b/tests/contracts.manifest @@ -247,6 +247,7 @@ hermetic tests/server/containers-shape-contract hermetic tests/server/panama-server-contract hermetic tests/setup/apps-contract hermetic tests/setup/boot-contract +hermetic tests/setup/chatgpt-package-contract hermetic tests/setup/contract-manifest-contract hermetic tests/setup/crash-watch-contract hermetic tests/setup/desktop-first-contract diff --git a/tests/setup/apps-contract b/tests/setup/apps-contract index 3159513..ddfc3de 100755 --- a/tests/setup/apps-contract +++ b/tests/setup/apps-contract @@ -32,8 +32,6 @@ panama="$repo_dir/bin/panama" findings=() note() { findings+=("$1"); } -[[ -d "$apps_dir" ]] || { printf 'apps contract: no %s\n' "$apps_dir" >&2; exit 1; } - shopt -s nullglob definitions=("$apps_dir"/*) diff --git a/tests/setup/chatgpt-package-contract b/tests/setup/chatgpt-package-contract new file mode 100755 index 0000000..8ad9dbd --- /dev/null +++ b/tests/setup/chatgpt-package-contract @@ -0,0 +1,195 @@ +#!/usr/bin/env bash + +# The one download that gets to run as root, and how it earns that. +# +# OpenAI publishes no signing key and no fingerprint that a first install could +# fetch and compare against: the documented instructions are to download an RPM +# and install it, and that RPM's own root scriptlet is what decides afterwards +# which repository and which key the machine will trust. Panama pins the key +# instead -- setup/keys/ carries it, setup/lib/chatgpt-package verifies the copy +# and writes the repository -- so dnf checks a signature before root sees a byte +# of it. +# +# What must hold: +# +# 1. The pinned key is the key the library says it is. Everything else here +# is worthless if this drifts, and a changed key must be a failing test +# somebody reads rather than a quiet change of publisher. +# 2. A pinned key that is missing, unreadable, or simply not that key stops +# the install and leaves the machine untouched. Failing closed is the +# whole point; falling back to installing anyway would be worse than +# never having checked. +# 3. What it writes actually enforces the check: gpgcheck and repo_gpgcheck +# on, and the gpgkey pointing at the key it just installed. +# 4. Both callers go through it, and neither hands root a downloaded RPM. +# The installer and the codex-desktop migration install `chatgpt` by name +# from that repository, which is what makes the signature mandatory. +# +# Hermetic: the key file is read locally, root is a stub that records what it +# was asked to do, and the destinations are redirected into a temporary +# directory. Nothing here contacts OpenAI or touches /etc. + +set -uo pipefail + +repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +library="$repo_dir/setup/lib/chatgpt-package" +installer="$repo_dir/setup/scripts/install-packages" +migration="$repo_dir/migrations/1787804505.sh" +pinned_key="$repo_dir/setup/keys/RPM-GPG-KEY-chatgpt" + +findings=() +note() { findings+=("$1"); } + +[[ -r "$library" ]] || { + printf 'chatgpt package contract: %s is missing\n' "$library" >&2 + exit 1 +} + +command -v gpg >/dev/null 2>&1 || { + printf 'chatgpt package contract: gpg is required to read the pinned key\n' >&2 + exit 1 +} + +work="$(mktemp -d)" +trap 'rm -rf "$work"' EXIT + +# Root, as a recording stub. It logs the command and then runs it for real, +# which is safe because every destination below is redirected into $work. +stub="$work/bin" +mkdir -p "$stub" +cat >"$stub/sudo" <<'STUB' +#!/usr/bin/env bash +printf '%s\n' "$*" >>"$SUDO_RECORD" +exec "$@" +STUB +cat >"$stub/rpmkeys" <<'STUB' +#!/usr/bin/env bash +printf '%s\n' "$*" >>"$RPMKEYS_RECORD" +STUB +chmod +x "$stub/sudo" "$stub/rpmkeys" +export PATH="$stub:$PATH" + +# ── 1. The pinned key is the pinned key ───────────────────────────────────── + +if [[ ! -r "$pinned_key" ]]; then + note 'setup/keys/RPM-GPG-KEY-chatgpt is missing, so nothing can be verified' +else + declared="$(grep -oP '(?<=^CHATGPT_KEY_FINGERPRINT=")[0-9A-F]+' "$library" | head -1)" + actual="$(gpg --show-keys --with-colons "$pinned_key" 2>/dev/null \ + | awk -F: '$1 == "fpr" { print $10; exit }')" + [[ -n "$declared" ]] \ + || note 'the library pins no fingerprint, so any key file would be accepted' + [[ -n "$actual" ]] \ + || note 'the pinned key file does not parse as a public key' + [[ "$declared" == "$actual" ]] \ + || note "the pinned key is $actual but the library expects $declared" + grep -q 'RPM-GPG-KEY-chatgpt' "$repo_dir/setup/keys/README.md" 2>/dev/null \ + || note 'setup/keys/README.md does not record where the pinned key came from' + grep -q "$actual" "$repo_dir/setup/keys/README.md" 2>/dev/null \ + || note 'setup/keys/README.md records a fingerprint other than the key it ships' +fi + +# One attempt, against redirected destinations and a recording root. Every +# variable the library exposes is set here rather than in the caller's shell, +# so a case cannot leak into the next one. +attempt() { + local dir="$1" panama_path="$2" fingerprint="${3:-}" + mkdir -p "$dir" + ( + export SUDO_RECORD="$dir/sudo.log" RPMKEYS_RECORD="$dir/rpmkeys.log" + : >"$SUDO_RECORD" + : >"$RPMKEYS_RECORD" + PANAMA_PATH="$panama_path" + # shellcheck source=/dev/null + source "$library" + CHATGPT_KEY_FILE="$dir/pki/RPM-GPG-KEY-chatgpt" + CHATGPT_REPO_FILE="$dir/repos/chatgpt.repo" + mkdir -p "$dir/repos" + [[ -z "$fingerprint" ]] || CHATGPT_KEY_FINGERPRINT="$fingerprint" + chatgpt_install_repository sudo + ) >"$dir/out" 2>&1 +} + +# ── 2. It fails closed ────────────────────────────────────────────────────── + +# A checkout with no pinned key at all. +empty="$work/no-key" +mkdir -p "$empty/checkout/setup/keys" +attempt "$empty" "$empty/checkout" \ + && note 'a missing pinned key still established the repository' +[[ ! -e "$empty/repos/chatgpt.repo" ]] \ + || note 'a missing pinned key still wrote a repository file' +grep -qi 'missing' "$empty/out" \ + || note 'a missing pinned key does not say so' + +# A key file that is not a key. +garbage="$work/garbage-key" +mkdir -p "$garbage/checkout/setup/keys" +printf 'not a key\n' >"$garbage/checkout/setup/keys/RPM-GPG-KEY-chatgpt" +attempt "$garbage" "$garbage/checkout" \ + && note 'an unreadable pinned key still established the repository' +[[ ! -e "$garbage/repos/chatgpt.repo" ]] \ + || note 'an unreadable pinned key still wrote a repository file' + +# The real key, against a fingerprint that is not its own -- the shape a +# substituted publisher would take. +wrong="$work/wrong-fingerprint" +attempt "$wrong" "$repo_dir" '0000000000000000000000000000000000000000' \ + && note 'a key that does not match the pinned fingerprint was accepted' +[[ ! -e "$wrong/repos/chatgpt.repo" ]] \ + || note 'a fingerprint mismatch still wrote a repository file' +[[ ! -s "$wrong/rpmkeys.log" ]] \ + || note 'a fingerprint mismatch still imported the key into the rpm keyring' + +# ── 3. What it writes enforces the check ──────────────────────────────────── + +good="$work/verified" +if ! attempt "$good" "$repo_dir"; then + note "the pinned key was rejected: $(cat "$good/out")" +else + repo_file="$good/repos/chatgpt.repo" + key_file="$good/pki/RPM-GPG-KEY-chatgpt" + + cmp -s "$key_file" "$pinned_key" \ + || note 'the installed key is not the pinned key' + grep -q 'import' "$good/rpmkeys.log" \ + || note 'the verified key was never imported, so dnf has nothing to check against' + + grep -qx 'gpgcheck=1' "$repo_file" \ + || note 'the repository does not set gpgcheck=1, so package signatures go unchecked' + grep -qx 'repo_gpgcheck=1' "$repo_file" \ + || note 'the repository does not set repo_gpgcheck=1, so the metadata goes unchecked' + grep -qx "gpgkey=file://$key_file" "$repo_file" \ + || note 'the repository does not point gpgkey at the key that was just installed' + grep -q 'baseurl=https://' "$repo_file" \ + || note 'the repository has no https base URL' +fi + +# ── 4. Both callers go through it ─────────────────────────────────────────── + +for caller in "$installer" "$migration"; do + name="${caller#"$repo_dir"/}" + [[ -r "$caller" ]] || { note "$name is missing"; continue; } + + grep -q 'setup/lib/chatgpt-package' "$caller" \ + || note "$name does not source the verified install library" + grep -q 'chatgpt_install_repository' "$caller" \ + || note "$name does not establish the verified repository before installing" + grep -qE 'dnf install -y chatgpt\b' "$caller" \ + || note "$name does not install chatgpt by name from that repository" + + # The shape this contract exists to keep out: fetch an RPM, hand it to + # root, and let its scriptlet decide what the machine trusts afterwards. + grep -qE 'curl.*chatgpt.*\.rpm' "$caller" \ + && note "$name downloads a ChatGPT RPM instead of installing it from the verified repository" + grep -qE 'dnf install[^|]*\$\{?chatgpt_rpm' "$caller" \ + && note "$name installs a downloaded ChatGPT RPM as root" +done + +if (( ${#findings[@]} > 0 )); then + printf 'chatgpt package contract: %d finding(s)\n' "${#findings[@]}" >&2 + printf ' - %s\n' "${findings[@]}" >&2 + exit 1 +fi + +printf 'chatgpt package contract: PASS\n' diff --git a/tests/setup/contract-manifest-contract b/tests/setup/contract-manifest-contract index eb26af0..a4a5755 100755 --- a/tests/setup/contract-manifest-contract +++ b/tests/setup/contract-manifest-contract @@ -11,16 +11,22 @@ manifest="$repo_dir/tests/contracts.manifest" discover_contracts() { discovered_contracts=() + # Byte order, exactly as the runner discovers them. A UTF-8 collation folds + # the punctuation away and reorders the pairs that differ only by `-` and + # `_`, so a manifest correct here would be wrong on a machine with a + # different LANG. while IFS= read -r path; do [[ -x "$path" || "$path" == *_test.py ]] || continue discovered_contracts+=("tests/${path#"$repo_dir/tests/"}") done < <(find "$repo_dir/tests" -type f \ - -not -path '*/fixtures/*' -not -path '*__pycache__*' | sort) + -not -path '*/fixtures/*' -not -path '*__pycache__*' | LC_ALL=C sort) } validate_manifest() { local candidate="$1" local -n expected_contracts="$2" + # Byte order, for the same reason discover_contracts sorts in it. + local LC_ALL=C local line capabilities path extra previous_comment="" previous_was_comment=0 local -a capability_list=() local -A manifest_paths=() capability_counts=() diff --git a/tests/setup/package-provenance-contract b/tests/setup/package-provenance-contract index c63fc2c..2284101 100755 --- a/tests/setup/package-provenance-contract +++ b/tests/setup/package-provenance-contract @@ -336,8 +336,9 @@ expect_failure load_installer_provenance "$parser_fixture" installer_fixture="$test_tmp/installer-fixture" mkdir -p "$installer_fixture/setup/lib" "$installer_fixture/setup/provenance/keys" \ "$installer_fixture/setup/scripts" -cp "$repo_dir/setup/lib/artifact-provenance" "$repo_dir/setup/lib/extras-catalog" \ - "$repo_dir/setup/lib/machine-role" "$installer_fixture/setup/lib/" +cp "$repo_dir/setup/lib/artifact-provenance" "$repo_dir/setup/lib/chatgpt-package" \ + "$repo_dir/setup/lib/extras-catalog" "$repo_dir/setup/lib/machine-role" \ + "$installer_fixture/setup/lib/" cp "$config" "$installer_fixture/setup/provenance/installers.conf" cp "$repo_dir"/setup/provenance/keys/*.asc "$installer_fixture/setup/provenance/keys/" sed '/^# --- The server path/,$d' "$repo_dir/setup/scripts/install-packages" \ diff --git a/tests/setup/skills-contract b/tests/setup/skills-contract index 390460f..01f7659 100755 --- a/tests/setup/skills-contract +++ b/tests/setup/skills-contract @@ -2,7 +2,7 @@ # The manual this machine hands an agent. # -# skills/ and .claude/skills/panama exist because an agent asked to do anything +# skills/ and .agents/skills/panama exist because an agent asked to do anything # on a Panama desktop will otherwise infer it from the source and get half of it # wrong. That only helps if what the skills say is true -- and a skill is worse # than no skill when it is stale, because an agent believes it verbatim and does @@ -16,7 +16,7 @@ # 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. +# personal manifest hands both shared skill homes to the linkdir kind. # # Sections 1 and 2 report clearly and keep going when a skill is not written # yet, so this contract is useful while the skills are still being authored. @@ -32,9 +32,21 @@ 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) +# Every directory under skills/ ships to every machine. The project-level +# panama skill stays in this repository and needs no home-directory delivery. +SKILL_DIRS=() +for directory in "$repo_dir"/skills/*; do + [[ -d "$directory" ]] && SKILL_DIRS+=("${directory#"$repo_dir"/}") +done +SKILL_DIRS+=(.agents/skills/panama) + +# The project skill has one agent-neutral source. Claude gets a compatibility +# symlink, while Codex and other Agent Skills readers use .agents directly. +[[ -L "$repo_dir/.claude/skills/panama" ]] \ + || note '.claude/skills/panama is not a compatibility symlink to the agent-neutral source' +[[ "$(readlink -f "$repo_dir/.claude/skills/panama" 2>/dev/null)" == \ + "$(readlink -f "$repo_dir/.agents/skills/panama" 2>/dev/null)" ]] \ + || note '.claude and .agents resolve the project panama skill differently' # ── 1. Each skill loads ───────────────────────────────────────────────────── @@ -54,7 +66,7 @@ for relative in "${SKILL_DIRS[@]}"; do present+=("$directory") # Frontmatter is the first --- delimited block, and a skill without one is - # not a skill: Claude Code skips the directory entirely. + # not a skill: agent loaders skip the directory entirely. frontmatter="$(awk 'NR==1 { if ($0 != "---") exit 1; next } $0 == "---" { exit } { print }' "$file")" if [[ -z "$frontmatter" ]]; then note "$relative/SKILL.md does not open with a --- frontmatter block" @@ -207,12 +219,12 @@ 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. + # A checkout and a home of its own. Never the real ones: these are somebody's + # live agent setup, and a contract that broke them mid-session would be worse + # than the bug it was looking for. checkout="$work/Panama" home="$work/home" - mkdir -p "$checkout/setup/scripts" "$checkout/skills/shipped" "$home/.claude" + mkdir -p "$checkout/setup/scripts" "$checkout/skills/shipped" "$home/.claude" "$home/.agents" cp "$linker" "$checkout/setup/scripts/link-skills" printf 'a shipped skill\n' >"$checkout/skills/shipped/SKILL.md" @@ -220,6 +232,7 @@ else # symlink, which is what link-user used to leave here. mkdir -p "$work/personal" ln -s "$work/personal" "$home/.claude/skills" + ln -s "$work/personal" "$home/.agents/skills" run() { HOME="$home" PANAMA_PATH="$checkout" "$checkout/setup/scripts/link-skills" >"$work/log" 2>&1; } @@ -231,22 +244,30 @@ else || note 'link-skills left ~/.claude/skills a symlink, so nothing else can be linked into it' [[ -L "$home/.claude/skills/shipped" ]] \ || note 'link-skills did not link each shipped skill as a child of ~/.claude/skills' + [[ -d "$home/.agents/skills" && ! -L "$home/.agents/skills" ]] \ + || note 'link-skills left ~/.agents/skills a symlink, so shipped and personal skills cannot coexist' + [[ -L "$home/.agents/skills/shipped" ]] \ + || note 'link-skills did not link each shipped skill as a child of ~/.agents/skills' grep -q 'Agent skills: 1 linked' "$work/log" \ || note 'link-skills does not report how many skills it linked' # A real directory at a shipped skill's name is somebody's work: it moves to # config/old rather than being deleted, the same promise the other stages # make. A symlink is not, and must not accumulate there. - 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" + for skill_home in "$home/.claude/skills" "$home/.agents/skills"; do + rm -f "$skill_home/shipped" + mkdir -p "$skill_home/shipped" + printf 'installed by hand\n' >"$skill_home/shipped/SKILL.md" + mkdir -p "$skill_home/untouched" + done run - grep -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' + [[ "$(grep -rl 'installed by hand' "$checkout/config/old" 2>/dev/null | wc -l)" == 2 ]] \ + || note 'link-skills did not preserve real skills from both agent homes' + for skill_home in "$home/.claude/skills" "$home/.agents/skills"; do + [[ -d "$skill_home/untouched" ]] \ + || note "link-skills removed an unshipped skill from $skill_home" + done before="$(find "$checkout/config/old" | wc -l)" run @@ -287,8 +308,8 @@ fi 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 -qE '^\s*linkdir\s+agents/skills\s+~/\.agents/skills\s*$' "$manifest" \ + || note 'the manifest does not use linkdir for ~/.agents/skills, so personal skills would replace shipped skills' grep -q 'linkdir)' "$user_linker" \ || note 'link-user does not implement the linkdir kind the manifest asks for' grep -q 'linkdir' "$repo_dir/user/README.md" \ diff --git a/tests/setup/user-content-contract b/tests/setup/user-content-contract index f4c5e53..2c4eb5e 100755 --- a/tests/setup/user-content-contract +++ b/tests/setup/user-content-contract @@ -73,7 +73,7 @@ cat >"$checkout/user/manifest" <<'FIXTURE' # a comment, and a blank line follow link agents/AGENTS.md ~/.claude/CLAUDE.md -link agents/skills ~/.agents/skills +linkdir agents/skills ~/.agents/skills linkdir agents/skills ~/.claude/skills copy plain.txt ~/.config/plain.txt link missing.txt ~/.config/missing.txt @@ -102,11 +102,12 @@ PANAMA_USER_CONTENT=no run # ── 2. Saying yes links, and keeps what was there ─────────────────────────── -# ~/.claude/skills is shared now: link-skills has already made it a real -# directory and linked Panama's own skills into it. A linkdir entry has to land -# beside those rather than replace the directory holding them. -mkdir -p "$home/.claude/skills" +# Both skill homes are shared now: link-skills has already made them real +# directories and linked Panama's own skills into each. Personal linkdir +# entries land beside those rather than replacing either directory. +mkdir -p "$home/.claude/skills" "$home/.agents/skills" ln -s "$checkout/skills/shipped" "$home/.claude/skills/shipped" +ln -s "$checkout/skills/shipped" "$home/.agents/skills/shipped" PANAMA_USER_CONTENT=yes run @@ -114,8 +115,12 @@ PANAMA_USER_CONTENT=yes run || note 'CLAUDE.md was not replaced with a symlink into the checkout' [[ "$(cat "$home/.claude/CLAUDE.md")" == "tracked instructions" ]] \ || note 'the CLAUDE.md link does not resolve to the tracked file' -[[ -L "$home/.agents/skills" && -f "$home/.agents/skills/example/SKILL.md" ]] \ - || note 'the skills directory was not linked as a directory' +[[ -d "$home/.agents/skills" && ! -L "$home/.agents/skills" ]] \ + || note 'a linkdir entry replaced ~/.agents/skills with a symlink' +[[ -L "$home/.agents/skills/example" && -f "$home/.agents/skills/example/SKILL.md" ]] \ + || note 'a linkdir entry did not publish the personal skill for agent-neutral readers' +[[ -L "$home/.agents/skills/shipped" ]] \ + || note 'a linkdir entry removed the shipped skill from ~/.agents/skills' [[ -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" ]] \ diff --git a/user/README.md b/user/README.md index 06804a8..ad0e582 100644 --- a/user/README.md +++ b/user/README.md @@ -13,7 +13,7 @@ and one file says where each piece goes. | 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/skills/` | `~/.agents/skills`, `~/.claude/skills` | A skill installed on any machine lands in the checkout. The Claude path is `linkdir` — see below. | +| `agents/skills/` | `~/.agents/skills`, `~/.claude/skills` | Personal skills live once in the checkout. Both destinations use `linkdir` so Panama's shipped skills can live beside them. | | `agents/rules/` | `~/.claude/rules` | | | `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. | @@ -27,22 +27,22 @@ 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. +`setup/scripts/link-skills`), and they go to both skill homes. A directory +cannot be a symlink to two places, so both personal entries use `linkdir`: each +destination is a real directory, and each child of `user/agents/skills/` is +linked into it individually. 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. + after `link-skills` and displaces what it finds, so the personal one wins in + both skill homes. - **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. + child that did not exist when they were made. Put new shared skills in + `user/agents/skills/`, then run `panama update` or `setup/scripts/link-user`. + A tool that installs directly into a home skill directory creates a + machine-local skill until it is moved into the checkout. ## It is off unless you say yes diff --git a/user/agents/skills/ticket/SKILL.md b/user/agents/skills/ticket/SKILL.md index af1e7f2..930d1c1 100644 --- a/user/agents/skills/ticket/SKILL.md +++ b/user/agents/skills/ticket/SKILL.md @@ -1,6 +1,6 @@ --- name: ticket -description: End-to-end Jira ticket workflow — fetch a ticket into .claude/docs/epics/, write a plan, implement it with clean commits, run pre-mr-review to convergence, and write the MR doc. Use when the user gives you a Jira ticket key (e.g. KACP-11111) to work, or asks to plan/implement/wrap up a ticket. +description: End-to-end Jira ticket workflow — fetch a ticket into .claude/docs/epics/, write a plan, implement it with clean commits, drive the pre-mr-review audit to convergence (the user runs it themselves from their work Claude account), and write the MR doc. Use when the user gives you a Jira ticket key (e.g. KACP-11111) to work, or asks to plan/implement/wrap up a ticket. disable-model-invocation: true --- @@ -372,7 +372,7 @@ place from the start. - Proceed to implementing the existing `plan.md` as-is (they reviewed and approved it) - Resume implementation (some plan steps are already checked off / some commits already exist on the ticket branch — pick up from the first unchecked step) - - Run `pre-mr-review` now (implementation looks done, just need the audit + MR doc) + - Move to the pre-mr-review stage (implementation looks done, just need the audit + MR doc; the user runs the audit from their work account, see Phase 2 step 7) Route to **Phase 1** or **Phase 2** accordingly. - **Both `plan.md` and `mr.md` exist** — this ticket looks finished. Tell the user `mr.md` already exists at its path and ask whether they want you to refresh it @@ -620,8 +620,8 @@ Entered only when the user has confirmed (per Phase 0) that the plan is approved Order matters and is not just convenience. `pre-mr-review`'s output is a readiness verdict that gets pasted verbatim into `mr.md`. Anything that runs after it invalidates that verdict by construction, and you end up rerunning it and rewriting - the handoff. Review first, fix, commit, and only then run `pre-mr-review` over the - final tree. + the handoff. Review first, fix, commit, and only then have the user + run `pre-mr-review` over the final tree. - **Skip this step for genuinely trivial changes**: a copy tweak, a styling fix, a one line correction with no logic in it. Run it whenever the change adds or @@ -671,11 +671,25 @@ Entered only when the user has confirmed (per Phase 0) that the plan is approved in the current repo). If it doesn't, stop here, tell the user implementation and local verification are done but this repo has no `pre-mr-review` skill to run, and let them decide how to proceed. -7. Invoke the `pre-mr-review` skill. Read its verdict. - - **Ready to Open MR**: continue to step 8. - - **Almost Ready / Not Ready Yet**: fix what it flagged (each meaningful fix as its - own commit), then invoke `pre-mr-review` again. Repeat until the verdict is Ready - to Open MR. Don't write `mr.md` before that verdict is reached. +7. **Do NOT invoke the `pre-mr-review` skill yourself.** The org asks that this + review run from the developer's separate work Claude account, so the audit on + record must come from there, not from this session. Instead: + 1. Tell the user the branch is ready for its pre-mr-review and ask them to run + `/pre-mr-review` from their work account, then let you know when it has + finished. Stop and wait, this is a hard gate. + 2. When they say it ran, read the audit and handoff files it wrote under + `.claude/audits/pre-mr/` (the context script from the repo's `pre-mr-review` + skill prints the exact paths). Check the `last_reviewed_head` in the audit + header matches the current HEAD; if the branch moved after their run, say so + and ask them to rerun before acting on a stale audit. + 3. Read the verdict. + - **Ready to Open MR**: continue to step 8. + - **Almost Ready / Not Ready Yet**: fix what it flagged (each meaningful fix + as its own commit), then ask the user to rerun the review from the work + account and wait again. Repeat until the verdict is Ready to Open MR. Don't + write `mr.md` before that verdict is reached. + Findings that need no code change (mentions, disclosures) get addressed in + `mr.md`'s Additional Notes or the Jira fields as usual. 8. Read `~/.agents/skills/ticket/templates/mr.md` — this is the org's MR template, copied into this skill so it still works even though the original `.claude/docs/mr/template.md` no longer exists in the command-center repo. `mr.md` @@ -707,9 +721,9 @@ Entered only when the user has confirmed (per Phase 0) that the plan is approved the audit's own words, not a retelling of them. Concretely: - Never hand-write a handoff section, and never edit one in place. If it is - wrong, thin, or stale, fix the handoff file by rerunning `/pre-mr-review`, - then re-paste. - - On EVERY rerun, replace the whole existing handoff block with the whole + wrong, thin, or stale, ask the user to rerun `/pre-mr-review` from their work + account to regenerate the handoff file, then re-paste. + - On EVERY rerun of theirs, replace the whole existing handoff block with the whole regenerated one. Do not patch the copy sitting in `mr.md` to match the new head — that is how the two silently diverge, and the version the reviewer reads stops being the version the audit actually produced. diff --git a/user/agents/skills/writing-for-agents/SKILL-MECHANICS.md b/user/agents/skills/writing-for-agents/SKILL-MECHANICS.md index bd6a262..3c42fe8 100644 --- a/user/agents/skills/writing-for-agents/SKILL-MECHANICS.md +++ b/user/agents/skills/writing-for-agents/SKILL-MECHANICS.md @@ -16,7 +16,9 @@ ln -s ../../.agents/skills/ ~/.claude/skills/ Writing a second copy into `~/.claude/skills/` gives you two files that drift, and the drift is silent because each harness only ever reads its own. One home, one symlink per harness that needs it. -A skill that only makes sense inside one repo belongs in that repo, at `/.claude/skills/`. +A skill that only makes sense inside one repo lives once at +`/.agents/skills//`. Point Claude Code at that source with a +`/.claude/skills/` symlink. ## Frontmatter diff --git a/user/manifest b/user/manifest index 278f42c..97201b5 100644 --- a/user/manifest +++ b/user/manifest @@ -28,14 +28,13 @@ link agents/AGENTS.md ~/.claude/CLAUDE.md link agents/AGENTS.md ~/.codex/AGENTS.md -# Skills, at both paths that look for them. Linked so that a skill installed by -# any tool lands in the checkout and `panama update` offers to commit it. +# Skills, at both paths that look for them. Each tracked skill is linked from +# this checkout so edits stay shared and `panama update` offers to commit them. # -# ~/.agents/skills is this directory and nothing else, so it stays one link. -# ~/.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 +# Both destinations also hold skills Panama itself ships, put there by the +# link-skills stage, so both are real directories with one link per skill. This +# stage runs after that one, so a personal skill wins a name collision. +linkdir agents/skills ~/.agents/skills linkdir agents/skills ~/.claude/skills link agents/rules ~/.claude/rules