diff --git a/.claude/skills/panama/SKILL.md b/.agents/skills/panama/SKILL.md similarity index 97% rename from .claude/skills/panama/SKILL.md rename to .agents/skills/panama/SKILL.md index 904f09d..a9dd014 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 ccc2d1b..d09ea59 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,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 | @@ -186,7 +186,7 @@ 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 keys/ Pinned signing keys, for publishers that ship no fetchable 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/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/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