#!/usr/bin/env bash # Symlink centralized skills/commands/hooks/context into each agent's config dir. # For non-Nix machines (NixOS boxes use nix/home.nix instead). # Idempotent. Backs up any pre-existing real dir once to .bak. set -euo pipefail REPO="$(cd "$(dirname "$0")/.." && pwd)" # targets: agent config skill roots. Claude Code reads ~/.claude/skills, # Pi reads ~/.agents/skills, opencode auto-loads both dirs, and Codex reads # $CODEX_HOME/skills and nothing else -- ~/.agents/skills is invisible to it, # which is how review sessions ended up reporting an unavailable review-pr # skill. All four consume the same SKILL.md dirs. CLAUDE_SKILLS="$HOME/.claude/skills" AGENTS_SKILLS="$HOME/.agents/skills" CODEX_SKILLS="$HOME/.codex/skills" CLAUDE_CMDS="$HOME/.claude/commands" # commands are Claude-only; Codex ignores OPENCODE_CMDS="${XDG_CONFIG_HOME:-$HOME/.config}/opencode/commands" CLAUDE_HOOKS="$HOME/.claude/hooks" # hooks are Claude-only CLAUDE_SCRIPTS="$HOME/.claude/scripts" # referenced by hook commands in settings.json CLAUDE_HOME="$HOME/.claude" # CLAUDE.md fragments, pulled in via @name.md CLAUDE_RULES="$HOME/.claude/rules" # path-scoped rules CODEX_HOME="$HOME/.codex" # Codex global config root PI_HOME="$HOME/.pi/agent" # Pi global config root OPENCODE_HOME="${XDG_CONFIG_HOME:-$HOME/.config}/opencode" # Which claude-md/machines/.md to bake into Pi/opencode AGENTS.md. MACHINE="${MACHINE:-default}" # Per-FILE links, never a whole-dir link: ~/.claude/hooks and ~/.claude itself hold # machine-local files this repo does not own, and a dir symlink would hide them. # backups go OUTSIDE the skill dirs — a *.bak left inside gets discovered as a # duplicate skill by both Claude Code and Codex. BACKUP="$HOME/.agent-skills-backup" link() { # link local src="$1" dst="$2" if [ -e "$dst" ] && [ ! -L "$dst" ]; then mkdir -p "$BACKUP" mv "$dst" "$BACKUP/$(basename "$(dirname "$dst")")-$(basename "$dst").bak" echo "backed up existing $dst -> $BACKUP/" fi ln -sfn "$src" "$dst" echo "linked $dst -> $src" } gen() { # gen — writes a generated (concatenated) file local dst="$1" shift if [ -e "$dst" ] && ! grep -q "$GEN_MARK" "$dst" 2>/dev/null; then mkdir -p "$BACKUP" mv "$dst" "$BACKUP/$(basename "$(dirname "$dst")")-$(basename "$dst").bak" echo "backed up existing $dst -> $BACKUP/" fi { echo "$GEN_MARK" for f in "$@"; do echo cat "$f" done } >"$dst" echo "wrote $dst (generated from fragments)" } GEN_MARK="" mkdir -p "$CLAUDE_SKILLS" "$AGENTS_SKILLS" "$CODEX_SKILLS" "$CLAUDE_CMDS" "$CLAUDE_HOOKS" "$CLAUDE_SCRIPTS" "$CLAUDE_RULES" "$CODEX_HOME" "$PI_HOME" "$OPENCODE_CMDS" for d in "$REPO"/skills/*/; do name="$(basename "$d")" link "$d" "$CLAUDE_SKILLS/$name" link "$d" "$AGENTS_SKILLS/$name" link "$d" "$CODEX_SKILLS/$name" done for f in "$REPO"/commands/*.md; do [ -e "$f" ] || continue link "$f" "$CLAUDE_CMDS/$(basename "$f")" link "$f" "$OPENCODE_CMDS/$(basename "$f")" done for f in "$REPO"/hooks/*.py "$REPO"/hooks/*.sh; do [ -e "$f" ] || continue link "$f" "$CLAUDE_HOOKS/$(basename "$f")" done # Hook commands in settings.json call these by absolute path, so they have to # exist under ~/.claude/scripts on every machine. for f in "$REPO"/scripts/*; do [ -e "$f" ] || continue link "$f" "$CLAUDE_SCRIPTS/$(basename "$f")" done # code-comments.md is path-scoped and belongs in rules/, not here — linking it # into ~/.claude/ as well would load it unconditionally and defeat the scoping. # opencode-header.md is opencode-only (baked into its generated AGENTS.md). for f in "$REPO"/claude-md/*.md; do [ -e "$f" ] || continue [ "$(basename "$f")" = "code-comments.md" ] && continue [ "$(basename "$f")" = "opencode-header.md" ] && continue link "$f" "$CLAUDE_HOME/$(basename "$f")" done # Per-machine section. entry/CLAUDE.md @imports it unconditionally, so it has # to resolve even on a box with no profile of its own (MACHINE=default). link "$REPO/claude-md/machines/$MACHINE.md" "$CLAUDE_HOME/machine.md" # Path-scoped rules load only when Claude reads a matching file. link "$REPO/claude-md/code-comments.md" "$CLAUDE_RULES/code-comments.md" rm -f "$CLAUDE_HOME/code-comments.md" "$HOME/.agents/AGENTS.md" # Entry files: machine-local sections plus @imports of the shared fragments. # Codex supports @path the same as CLAUDE.md, so neither tool needs a copy. mkdir -p "$CODEX_HOME" link "$REPO/entry/CLAUDE.md" "$CLAUDE_HOME/CLAUDE.md" link "$REPO/entry/codex-AGENTS.md" "$CODEX_HOME/AGENTS.md" rm -f "$CODEX_HOME/RTK.md" # Pi and opencode have no @file imports: their AGENTS.md is generated by # concatenating the fragments (machine profile first). opencode auto-loads # skills from ~/.claude/skills and ~/.agents/skills, so it needs no skill links. gen "$PI_HOME/AGENTS.md" \ "$REPO/claude-md/machines/$MACHINE.md" \ "$REPO/claude-md/operating.md" \ "$REPO/claude-md/writing.md" \ "$REPO/claude-md/code-comments.md" \ "$REPO/claude-md/intercomms.md" \ "$REPO/claude-md/RTK.md" gen "$OPENCODE_HOME/AGENTS.md" \ "$REPO/claude-md/opencode-header.md" \ "$REPO/claude-md/machines/$MACHINE.md" \ "$REPO/claude-md/operating.md" \ "$REPO/claude-md/writing.md" \ "$REPO/claude-md/code-comments.md" \ "$REPO/claude-md/intercomms.md" \ "$REPO/claude-md/RTK.md" # Linked but never enabled: enabling on every machine would spawn one review # session per box for the same week. if [ -d /run/systemd/system ]; then mkdir -p "$HOME/.config/systemd/user" for f in "$REPO"/systemd/*.service "$REPO"/systemd/*.timer; do [ -e "$f" ] || continue link "$f" "$HOME/.config/systemd/user/$(basename "$f")" done fi echo "done. (MACHINE=$MACHINE for generated Pi/opencode AGENTS.md)" echo "hooks still need wiring in ~/.claude/settings.json — see hooks/README.md" echo "timers (one machine only):" echo " systemctl --user daemon-reload" echo " systemctl --user enable --now week-review.timer hourlog.timer"