Phase D2 of plan 2026-08-19: single package, single install, single command. The framework installer already treats skills/** as a shipped, manifest-owned framework subtree, so the folded skills now install into $MOSAIC_HOME/skills with the rest of the framework — no second repository, no separate sync step: - mosaic-sync-skills (bash + powershell): the fetch machinery is gone (clone, pull, dirty-state migration, rsync from sources/agent-skills). The script now only links installed skills into runtime homes. --link-only is a compat no-op; --no-link exits having nothing to do. - catalog.ts: the sources/agent-skills fallback is dead and removed. - install.sh, launch.ts, defaults/README.md, README.md, skills/README.md: references to the second repo rewritten to describe the shipped path. Verified: clean install into a fresh MOSAIC_HOME produces 102 skills with no sources/ directory; the linker then links the selected skills into the four runtime homes with no git involvement.
270 lines
9.0 KiB
Bash
Executable File
270 lines
9.0 KiB
Bash
Executable File
#!/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 <<USAGE
|
|
Usage: $(basename "$0") [options]
|
|
|
|
Link installed skills from ~/.config/mosaic/{skills,skills-local} into runtime
|
|
skill directories. Canonical skills arrive with the framework installer; this
|
|
script never clones or pulls a second repository.
|
|
|
|
Options:
|
|
--link-only Accepted for compatibility; linking is now the whole job
|
|
--no-link Do nothing (kept for compatibility)
|
|
-h, --help Show help
|
|
|
|
Env:
|
|
MOSAIC_HOME Default: ~/.config/mosaic
|
|
MOSAIC_INSTALL_SKILLS Colon-separated list of skills to link (default: all)
|
|
USAGE
|
|
}
|
|
|
|
while [[ $# -gt 0 ]]; do
|
|
case "$1" in
|
|
--link-only)
|
|
# Compat no-op: fetching a skills repo no longer exists.
|
|
shift
|
|
;;
|
|
--no-link)
|
|
echo "[mosaic-skills] Nothing to do (--no-link; canonical skills ship with the installer)"
|
|
exit 0
|
|
;;
|
|
-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 [[ ! -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 -> <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"
|