prune_stale_links_in_target compared "$resolved" == "$canonical_real/"* while length-checking only $resolved. If $canonical_real were ever empty the pattern collapses to == "/"* and matches every absolute path. The failure is precisely inverted, which is what makes it worth fixing rather than noting: is_mosaic_skill_name already `continue`s for names that ARE current mosaic skills, so an empty prefix would delete exactly the FOREIGN symlinks in every target directory and preserve the mosaic ones. On this host that is 4 base installs, including codex's own .system entry. Reported by mos-claude as #1087 after I introduced the same guard in the new legacy-cleanup path in the previous commit and walked past this instance thirty lines away. Same defect class, same file, one function apart. $canonical_real is populated by readlink -f after a mkdir -p, so an empty value requires readlink to fail — unlikely, but the consequence is deleting operator symlinks across every harness, which is not a risk worth carrying for one test.
357 lines
12 KiB
Bash
Executable File
357 lines
12 KiB
Bash
Executable File
#!/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 <<USAGE
|
|
Usage: $(basename "$0") [options]
|
|
|
|
Sync canonical skills into ~/.config/mosaic/skills and link all Mosaic skills into runtime skill directories.
|
|
|
|
Options:
|
|
--link-only Skip git clone/pull and only relink from ~/.config/mosaic/{skills,skills-local}
|
|
--no-link Sync canonical skills but do not update runtime links
|
|
-h, --help Show help
|
|
|
|
Env:
|
|
MOSAIC_HOME Default: ~/.config/mosaic
|
|
MOSAIC_SKILLS_REPO_URL Default: https://git.mosaicstack.dev/mosaic/agent-skills.git
|
|
MOSAIC_SKILLS_REPO_DIR Default: ~/.config/mosaic/sources/agent-skills
|
|
MOSAIC_INSTALL_SKILLS Colon-separated list of skills to link (default: all)
|
|
USAGE
|
|
}
|
|
|
|
while [[ $# -gt 0 ]]; do
|
|
case "$1" in
|
|
--link-only)
|
|
fetch=0
|
|
shift
|
|
;;
|
|
--no-link)
|
|
link_only=1
|
|
shift
|
|
;;
|
|
-h|--help)
|
|
usage
|
|
exit 0
|
|
;;
|
|
*)
|
|
echo "Unknown argument: $1" >&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 <path> — 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
|
|
|
|
# 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 -> <home>/skills
|
|
# pi PI_CODING_AGENT_DIR -> <home>/skills (replaces ~/.pi/agent)
|
|
# codex CODEX_HOME -> <home>/skills
|
|
# opencode XDG_CONFIG_HOME -> <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"
|