#!/usr/bin/env bash set -euo pipefail MOSAIC_HOME="${MOSAIC_HOME:-$HOME/.config/mosaic}" SKILLS_REPO_URL="${MOSAIC_SKILLS_REPO_URL:-https://git.mosaicstack.dev/mosaic/agent-skills.git}" SKILLS_REPO_DIR="${MOSAIC_SKILLS_REPO_DIR:-$MOSAIC_HOME/sources/agent-skills}" 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:-}" fetch=1 link_only=0 usage() { cat <&2 usage >&2 exit 1 ;; esac done mkdir -p "$MOSAIC_HOME" "$MOSAIC_SKILLS_DIR" "$MOSAIC_LOCAL_SKILLS_DIR" if [[ $fetch -eq 1 ]]; then if [[ -d "$SKILLS_REPO_DIR/.git" ]]; then echo "[mosaic-skills] Updating skills source: $SKILLS_REPO_DIR" # ── Detect dirty state ────────────────────────────────────────────── dirty="" dirty="$(git -C "$SKILLS_REPO_DIR" status --porcelain 2>/dev/null || true)" if [[ -n "$dirty" ]]; then # ── Auto-migrate customized skills to skills-local/ ───────────── # Instead of stash/pop (fragile, merge conflicts), we: # 1. Identify which skill dirs contain user edits # 2. Copy those full skill dirs into skills-local/ (preserving edits) # 3. Reset the repo clean so pull always succeeds # 4. skills-local/ takes precedence during linking, so edits win SOURCE_SKILLS_SUBDIR="$SKILLS_REPO_DIR/skills" migrated=() while IFS= read -r line; do # porcelain format: XY — extract the file path file="${line:3}" # Only migrate files under skills/ subdir in the repo if [[ "$file" == skills/* ]]; then # Extract the skill directory name (first path component after skills/) skill_name="${file#skills/}" skill_name="${skill_name%%/*}" # Skip if already migrated this skill in this run local_skill_dir="$MOSAIC_LOCAL_SKILLS_DIR/$skill_name" if [[ -d "$local_skill_dir" ]]; then continue fi # Skip if skill_name is empty or hidden if [[ -z "$skill_name" || "$skill_name" == .* ]]; then continue fi # Copy the skill (with user's edits) from repo working tree to skills-local/ if [[ -d "$SOURCE_SKILLS_SUBDIR/$skill_name" ]]; then cp -R "$SOURCE_SKILLS_SUBDIR/$skill_name" "$local_skill_dir" migrated+=("$skill_name") fi fi done <<< "$dirty" if [[ ${#migrated[@]} -gt 0 ]]; then echo "[mosaic-skills] Migrated ${#migrated[@]} customized skill(s) to skills-local/:" for s in "${migrated[@]}"; do echo " → $MOSAIC_LOCAL_SKILLS_DIR/$s" done echo "[mosaic-skills] Your edits are preserved there and take precedence over canonical." fi # Reset repo to clean state so pull always works echo "[mosaic-skills] Resetting source repo to clean state..." git -C "$SKILLS_REPO_DIR" checkout . 2>/dev/null || true git -C "$SKILLS_REPO_DIR" clean -fd 2>/dev/null || true fi if ! git -C "$SKILLS_REPO_DIR" pull --rebase 2>/dev/null; then echo "[mosaic-skills] WARN: pull failed — continuing with existing checkout" >&2 git -C "$SKILLS_REPO_DIR" rebase --abort 2>/dev/null || true fi else echo "[mosaic-skills] Cloning skills source to: $SKILLS_REPO_DIR" mkdir -p "$(dirname "$SKILLS_REPO_DIR")" git clone "$SKILLS_REPO_URL" "$SKILLS_REPO_DIR" fi SOURCE_SKILLS_DIR="$SKILLS_REPO_DIR/skills" if [[ ! -d "$SOURCE_SKILLS_DIR" ]]; then echo "[mosaic-skills] Missing source skills dir: $SOURCE_SKILLS_DIR" >&2 exit 1 fi if command -v rsync >/dev/null 2>&1; then rsync -a --delete "$SOURCE_SKILLS_DIR/" "$MOSAIC_SKILLS_DIR/" else rm -rf "$MOSAIC_SKILLS_DIR"/* cp -R "$SOURCE_SKILLS_DIR"/* "$MOSAIC_SKILLS_DIR"/ fi fi if [[ ! -d "$MOSAIC_SKILLS_DIR" ]]; then echo "[mosaic-skills] Canonical skills dir missing: $MOSAIC_SKILLS_DIR" >&2 exit 1 fi if [[ $link_only -eq 1 ]]; then echo "[mosaic-skills] Canonical sync completed (link update skipped)" exit 0 fi link_targets=( "$HOME/.claude/skills" "$HOME/.codex/skills" "$HOME/.config/opencode/skills" "$HOME/.pi/agent/skills" ) canonical_real="$(readlink -f "$MOSAIC_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 ln -sfn "$skill_path" "$link_path" 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 resolved="$(readlink -f "$link_path" 2>/dev/null || true)" if [[ -z "$resolved" ]]; then rm -f "$link_path" echo "[mosaic-skills] Removed stale broken skill link: $link_path" continue fi if [[ "$resolved" == "$MOSAIC_HOME/"* ]]; 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) } 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"