#!/usr/bin/env bash set -euo pipefail # Link the INSTALLED canonical skills into runtime skill directories. # # The canonical skills ship inside the framework package itself # (packages/mosaic/framework/skills in the monorepo) and are installed into # $MOSAIC_HOME/skills by the framework installer — there is no second # repository to clone. This script only maintains the runtime symlinks. MOSAIC_HOME="${MOSAIC_HOME:-$HOME/.config/mosaic}" MOSAIC_SKILLS_DIR="$MOSAIC_HOME/skills" MOSAIC_LOCAL_SKILLS_DIR="$MOSAIC_HOME/skills-local" # Colon-separated list of skill names to install. When set, only these skills # are linked into runtime skill directories. Empty/unset = link all skills # (the legacy "mosaic sync" full-catalog behavior). MOSAIC_INSTALL_SKILLS="${MOSAIC_INSTALL_SKILLS:-}" usage() { cat <&2 usage >&2 exit 1 ;; esac done mkdir -p "$MOSAIC_HOME" "$MOSAIC_SKILLS_DIR" "$MOSAIC_LOCAL_SKILLS_DIR" if [[ ! -d "$MOSAIC_SKILLS_DIR" ]]; then echo "[mosaic-skills] Canonical skills dir missing: $MOSAIC_SKILLS_DIR" >&2 echo "[mosaic-skills] Canonical skills ship with the framework — reinstall or update the framework package" >&2 exit 1 fi # Skills are linked into the MOSAIC-OWNED harness homes, never a base install. # Paths mirror the config-dir env vars the launcher injects (HARNESS_HOME_ENV in # commands/launch.js): # claude CLAUDE_CONFIG_DIR -> /skills # pi PI_CODING_AGENT_DIR -> /skills (replaces ~/.pi/agent) # codex CODEX_HOME -> /skills # opencode XDG_CONFIG_HOME -> /opencode/skills (XDG adds a level) link_targets=( "$MOSAIC_HOME/.claude/skills" "$MOSAIC_HOME/.codex/skills" "$MOSAIC_HOME/.opencode/opencode/skills" "$MOSAIC_HOME/.pi/skills" ) # Pre-isolation installs planted the same symlink farm directly in the operator's # base installs. Those are now orphaned: the launcher no longer reads them, but # they persist and make a "clean" base install look mosaic-managed. legacy_link_targets=( "$HOME/.claude/skills" "$HOME/.codex/skills" "$HOME/.config/opencode/skills" "$HOME/.pi/agent/skills" ) canonical_real="$(readlink -f "$MOSAIC_SKILLS_DIR")" local_real="$(readlink -f "$MOSAIC_LOCAL_SKILLS_DIR")" # Build an associative array from the colon-separated whitelist for O(1) lookup. # When MOSAIC_INSTALL_SKILLS is empty, all skills are allowed. declare -A _skill_whitelist=() _whitelist_active=0 if [[ -n "$MOSAIC_INSTALL_SKILLS" ]]; then _whitelist_active=1 IFS=':' read -ra _wl_items <<< "$MOSAIC_INSTALL_SKILLS" for _item in "${_wl_items[@]}"; do [[ -n "$_item" ]] && _skill_whitelist["$_item"]=1 done fi is_skill_selected() { local name="$1" if [[ $_whitelist_active -eq 0 ]]; then return 0 fi [[ -n "${_skill_whitelist[$name]:-}" ]] && return 0 return 1 } link_skill_into_target() { local skill_path="$1" local target_dir="$2" local name link_path name="$(basename "$skill_path")" # Do not distribute hidden/system skill directories globally. if [[ "$name" == .* ]]; then return fi # Respect the install whitelist (set during first-run wizard). if ! is_skill_selected "$name"; then return fi link_path="$target_dir/$name" if [[ -L "$link_path" ]]; then local raw_target resolved_target raw_target="$(readlink "$link_path")" resolved_target="$(node -e 'const p=require("node:path"); process.stdout.write(p.resolve(p.dirname(process.argv[1]), process.argv[2]));' "$link_path" "$raw_target")" if [[ "$resolved_target" == "$canonical_real/"* || "$resolved_target" == "$local_real/"* ]]; then ln -sfn "$skill_path" "$link_path" else echo "[mosaic-skills] Preserve foreign runtime symlink: $link_path" fi return fi if [[ -e "$link_path" ]]; then echo "[mosaic-skills] Preserve existing runtime-specific entry: $link_path" return fi ln -s "$skill_path" "$link_path" } is_mosaic_skill_name() { local name="$1" # -d follows symlinks; -L catches broken symlinks that still indicate ownership [[ -d "$MOSAIC_SKILLS_DIR/$name" || -L "$MOSAIC_SKILLS_DIR/$name" ]] && return 0 [[ -d "$MOSAIC_LOCAL_SKILLS_DIR/$name" || -L "$MOSAIC_LOCAL_SKILLS_DIR/$name" ]] && return 0 return 1 } prune_stale_links_in_target() { local target_dir="$1" while IFS= read -r -d '' link_path; do local name resolved name="$(basename "$link_path")" if is_mosaic_skill_name "$name"; then continue fi # -m resolves lexical dangling targets too. If resolution fails, ownership # is unproven and the link must be preserved. resolved="$(readlink -m "$link_path" 2>/dev/null || true)" # $canonical_real must be length-checked BEFORE use as a prefix: if it were # ever empty, "$resolved" == "$canonical_real/"* collapses to == "/"* and # matches every absolute path. Combined with the is_mosaic_skill_name skip # above, that inverts the function precisely — it would delete exactly the # FOREIGN symlinks and keep the mosaic ones. (#1087, reported by mos-claude.) if [[ -n "$resolved" && -n "$canonical_real" && "$resolved" == "$canonical_real/"* ]]; then rm -f "$link_path" echo "[mosaic-skills] Removed stale retired skill link: $link_path" fi done < <(find "$target_dir" -mindepth 1 -maxdepth 1 -type l -print0) } # Remove mosaic-owned symlinks left in a base install by a pre-isolation sync. # # Ownership is proven by RESOLUTION, not by name: only links resolving inside the # canonical or local skills dirs are removed. Anything else — a real directory, a # link elsewhere, an unresolvable link — is left untouched. This mirrors the # refusal in commands/skill.js ("only symlinks pointing inside the Mosaic skills # directory are managed") and preserves e.g. codex's own `.system` dir. # # The directory itself is kept: mosaic-doctor warns when ~/.pi/agent/skills is # missing, and an empty dir is the correct end state, not an absent one. cleanup_legacy_target() { local target_dir="$1" local removed=0 kept=0 [[ -d "$target_dir" ]] || return 0 while IFS= read -r -d '' link_path; do local resolved owned=0 resolved="$(readlink -m "$link_path" 2>/dev/null || true)" # Guard the empty-prefix trap: an unset *_real would make "$resolved" == "/"* # match every absolute path and delete foreign links. if [[ -n "$resolved" ]]; then if [[ -n "$canonical_real" && "$resolved" == "$canonical_real/"* ]]; then owned=1 elif [[ -n "$local_real" && "$resolved" == "$local_real/"* ]]; then owned=1 fi fi if [[ $owned -eq 1 ]]; then rm -f "$link_path" removed=$((removed + 1)) else kept=$((kept + 1)) fi done < <(find "$target_dir" -mindepth 1 -maxdepth 1 -type l -print0) if [[ $removed -gt 0 ]]; then echo "[mosaic-skills] Legacy cleanup: removed $removed mosaic symlink(s) from $target_dir (preserved $kept foreign)" fi } for legacy in "${legacy_link_targets[@]}"; do # Skip anything that is also a current target, so isolation can never # self-destruct if the two lists ever overlap. skip=0 for target in "${link_targets[@]}"; do [[ "$legacy" == "$target" ]] && skip=1 done [[ $skip -eq 1 ]] && continue cleanup_legacy_target "$legacy" done for target in "${link_targets[@]}"; do mkdir -p "$target" # If target already resolves to canonical dir, skip to avoid self-link recursion/corruption. target_real="$(readlink -f "$target" 2>/dev/null || true)" if [[ -n "$target_real" && "$target_real" == "$canonical_real" ]]; then echo "[mosaic-skills] Skip target (already canonical): $target" continue fi prune_stale_links_in_target "$target" while IFS= read -r -d '' skill; do link_skill_into_target "$skill" "$target" done < <(find "$MOSAIC_SKILLS_DIR" -mindepth 1 -maxdepth 1 -type d -print0) if [[ -d "$MOSAIC_LOCAL_SKILLS_DIR" ]]; then while IFS= read -r -d '' skill; do link_skill_into_target "$skill" "$target" done < <(find "$MOSAIC_LOCAL_SKILLS_DIR" -mindepth 1 -maxdepth 1 \( -type d -o -type l \) -print0) fi echo "[mosaic-skills] Linked skills into: $target" done echo "[mosaic-skills] Complete"