#!/usr/bin/env bash set -euo pipefail # ─── Mosaic Framework Installer ────────────────────────────────────────────── # # Installs/upgrades the framework DATA to ~/.config/mosaic/. # No executables are placed on PATH — the mosaic npm CLI is the only binary. # # Called by tools/install.sh (the unified installer). Can also be run directly. # # Environment: # MOSAIC_HOME — target directory (default: ~/.config/mosaic) # MOSAIC_INSTALL_MODE — prompt|keep|overwrite (default: prompt) # MOSAIC_ALLOW_MISSING_SEQUENTIAL_THINKING — 1 to bypass MCP check # MOSAIC_SKIP_SKILLS_SYNC — 1 to skip skill sync # ────────────────────────────────────────────────────────────────────────────── SOURCE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" TARGET_DIR="${MOSAIC_HOME:-$HOME/.config/mosaic}" INSTALL_MODE="${MOSAIC_INSTALL_MODE:-prompt}" # Shared framework path-ownership manifest reader (#791). Parity with # packages/mosaic/src/framework/manifest.ts — both consume framework-manifest.txt. # Sourcing does not run its CLI dispatch (guarded by BASH_SOURCE==$0). # shellcheck source=tools/_lib/manifest.sh source "$SOURCE_DIR/tools/_lib/manifest.sh" # Which paths a keep-mode upgrade may touch is no longer a hand-maintained # denylist. It is derived from the shared framework-manifest.txt (#791): the # updater only ever creates/overwrites framework-owned paths and only prunes a # retired framework file inside a shipped framework subtree. Everything else — # every operator file, and every path the manifest never anticipated — is # operator-owned by default (fail-safe) and is never written or deleted. See # sync_framework_keep() below and packages/mosaic/src/framework/manifest.ts. # Framework-owned contract files: re-copied from defaults/ on every upgrade (the # user must not edit them; a divergent copy is backed up once before overwrite). # USER_SEEDED files are written once on first install, then owned by the user. # Both lists are APPEND-FRIENDLY — add a new shipped framework file here and to the # matching list in packages/mosaic/src/config/file-adapter.ts. FRAMEWORK_OWNED=("CONSTITUTION.md" "AGENTS.md" "STANDARDS.md") USER_SEEDED=("TOOLS.md") # Current framework schema version — bump this when the layout changes. # The migration system uses this to run upgrade steps. FRAMEWORK_VERSION=3 # ─── colours ────────────────────────────────────────────────────────────────── if [[ -t 1 ]]; then GREEN='\033[0;32m' YELLOW='\033[0;33m' RED='\033[0;31m' CYAN='\033[0;36m' BOLD='\033[1m' RESET='\033[0m' else GREEN='' YELLOW='' RED='' CYAN='' BOLD='' RESET='' fi ok() { echo -e " ${GREEN}✓${RESET} $1"; } warn() { echo -e " ${YELLOW}⚠${RESET} $1" >&2; } fail() { echo -e " ${RED}✗${RESET} $1" >&2; } step() { echo -e "\n${BOLD}$1${RESET}"; } # ─── snapshot / restore (crash safety for upgrades) ────────────────────────── SNAPSHOT_DIR="" make_snapshot() { is_existing_install || return 0 SNAPSHOT_DIR="$(mktemp -d "${TMPDIR:-/tmp}/mosaic-snapshot-XXXXXX")" cp -a "$TARGET_DIR/." "$SNAPSHOT_DIR/" 2>/dev/null || true } restore_snapshot() { [[ -n "$SNAPSHOT_DIR" && -d "$SNAPSHOT_DIR" ]] || return 0 fail "Install interrupted/failed — restoring previous state from snapshot" rm -rf "$TARGET_DIR"; mkdir -p "$TARGET_DIR" cp -a "$SNAPSHOT_DIR/." "$TARGET_DIR/" 2>/dev/null || true } cleanup_snapshot() { [[ -n "$SNAPSHOT_DIR" && -d "$SNAPSHOT_DIR" ]] && rm -rf "$SNAPSHOT_DIR"; SNAPSHOT_DIR=""; } # Reconcile contract files after sync: framework-owned overwrite (backup-once), # user-seeded seed-if-absent. reconcile_framework_files() { local defaults="$TARGET_DIR/defaults" f [[ -d "$defaults" ]] || return 0 for f in "${FRAMEWORK_OWNED[@]}"; do [[ -f "$defaults/$f" ]] || continue # Already current — skip to avoid mtime churn. if [[ -f "$TARGET_DIR/$f" ]] && cmp -s "$TARGET_DIR/$f" "$defaults/$f"; then continue fi if [[ -f "$TARGET_DIR/$f" && ! -f "$TARGET_DIR/${f}.pre-constitution.bak" ]]; then cp "$TARGET_DIR/$f" "$TARGET_DIR/${f}.pre-constitution.bak" warn "$f is now framework-owned and was updated; your previous copy is saved as ${f}.pre-constitution.bak — re-apply intended changes as a .local overlay or policy/ file (see CONSTITUTION.md / constitution/LAYER-MODEL.md)." fi cp "$defaults/$f" "$TARGET_DIR/$f" done for f in "${USER_SEEDED[@]}"; do [[ -f "$defaults/$f" ]] || continue if [[ ! -f "$TARGET_DIR/$f" ]]; then cp "$defaults/$f" "$TARGET_DIR/$f" ok "Seeded $f from defaults" fi done } # ─── helpers ────────────────────────────────────────────────────────────────── is_existing_install() { [[ -d "$TARGET_DIR" ]] || return 1 [[ -f "$TARGET_DIR/AGENTS.md" || -f "$TARGET_DIR/SOUL.md" ]] } installed_framework_version() { local vf="$TARGET_DIR/.framework-version" if [[ -f "$vf" ]]; then cat "$vf" 2>/dev/null || echo "0" else # No version file = legacy install (version 0 or 1) if [[ -d "$TARGET_DIR/bin" ]]; then echo "1" # Has bin/ → pre-migration legacy else echo "0" # Fresh or unknown fi fi } write_framework_version() { echo "$FRAMEWORK_VERSION" > "$TARGET_DIR/.framework-version" } select_install_mode() { case "$INSTALL_MODE" in keep|overwrite|prompt) ;; *) fail "Invalid MOSAIC_INSTALL_MODE='$INSTALL_MODE'. Use: prompt, keep, overwrite." exit 1 ;; esac if ! is_existing_install; then INSTALL_MODE="overwrite" return fi case "$INSTALL_MODE" in keep|overwrite) ;; prompt) if [[ -t 0 ]]; then echo "" echo "Existing Mosaic install detected at: $TARGET_DIR" echo " 1) keep Update framework, preserve local files (SOUL.md, USER.md, etc.)" echo " 2) overwrite Replace everything" echo " 3) cancel Abort" printf "Selection [1/2/3] (default: 1): " read -r selection case "${selection:-1}" in 1|k|K|keep) INSTALL_MODE="keep" ;; 2|o|O|overwrite) INSTALL_MODE="overwrite" ;; *) fail "Install cancelled."; exit 1 ;; esac else INSTALL_MODE="keep" fi ;; esac } sync_framework() { local source_real target_real source_real="$(cd "$SOURCE_DIR" && pwd -P)" target_real="$(mkdir -p "$TARGET_DIR" && cd "$TARGET_DIR" && pwd -P)" if [[ "$source_real" == "$target_real" ]]; then warn "Source and target are the same directory; skipping file sync." return fi if [[ "$INSTALL_MODE" == "keep" ]]; then # The `mosaic update` path. Manifest-driven, never-deleting-outside-framework: # operator config is structurally protected (#791). No rsync --delete here. manifest_load sync_framework_keep return fi # overwrite mode — a full replace, chosen only for a fresh install or when the # operator explicitly asks to replace everything. No operator state to protect. sync_framework_overwrite } # Keep-mode sync: create/refresh framework-owned files and prune only retired # framework files inside shipped framework subtrees. Operator-owned and unknown # paths (fail-safe default) are never written and never deleted — the #791 HARD # GATE. Single code path (no rsync) so it is byte-for-byte parity-testable. sync_framework_keep() { local src="$SOURCE_DIR" dst="$TARGET_DIR" abs rel root # 1) Overlay copy — every framework-owned source file, refreshed only when its # bytes changed (no mtime churn on unchanged files, never on operator files). while IFS= read -r -d '' abs; do rel="${abs#"$src"/}" case "$rel" in .git|.git/*|.framework-version|*.pre-constitution.bak) continue ;; esac manifest_is_framework "$rel" || continue if [[ -f "$dst/$rel" ]] && cmp -s "$abs" "$dst/$rel"; then continue; fi [[ "$rel" == */* ]] && mkdir -p "$dst/${rel%/*}" cp "$abs" "$dst/$rel" done < <(find "$src" -type f -print0) # 2) Scoped prune — within each shipped framework subtree root, remove # framework-owned target files the current source no longer ships. Operator # carve-outs (e.g. tools/_lib/credentials.json) resolve to operator and are # skipped; unknown paths resolve to operator too — both are unreachable here. while IFS= read -r root; do [[ -n "$root" && -d "$dst/$root" ]] || continue while IFS= read -r -d '' abs; do rel="${abs#"$dst"/}" case "$rel" in *.pre-constitution.bak) continue ;; esac [[ -f "$src/$rel" ]] && continue # still shipped manifest_is_framework "$rel" || continue rm -f "$abs" done < <(find "$dst/$root" -type f -print0) # Drop framework dirs left empty by the prune (never touches a dir that still # holds an operator file — those are never emptied). find "$dst/$root" -type d -empty -delete 2>/dev/null || true done < <(manifest_subtree_roots) } # Overwrite-mode sync: full replace. Only reached for a fresh install or an # explicit operator "replace everything" choice, so nothing is preserved. sync_framework_overwrite() { if command -v rsync >/dev/null 2>&1; then rsync -a --delete \ --exclude ".git" --exclude ".framework-version" --exclude "*.pre-constitution.bak" \ "$SOURCE_DIR/" "$TARGET_DIR/" return fi find "$TARGET_DIR" -mindepth 1 -maxdepth 1 \ ! -name ".git" ! -name ".framework-version" ! -name "*.pre-constitution.bak" \ -exec rm -rf {} + cp -R "$SOURCE_DIR"/. "$TARGET_DIR"/ rm -rf "$TARGET_DIR/.git" } # ═══════════════════════════════════════════════════════════════════════════════ # Migrations — run sequentially from the installed version to FRAMEWORK_VERSION # ═══════════════════════════════════════════════════════════════════════════════ run_migrations() { local from_version from_version="$(installed_framework_version)" if [[ "$from_version" -ge "$FRAMEWORK_VERSION" ]]; then return # Already current fi step "Running migrations (v${from_version} → v${FRAMEWORK_VERSION})" # ── Migration: v0/v1 → v2 ───────────────────────────────────────────────── # Remove bin/ directory — all executables now live in the npm CLI. # Scripts that were in bin/ are now in tools/_scripts/. if [[ "$from_version" -lt 2 ]]; then if [[ -d "$TARGET_DIR/bin" ]]; then ok "Removing legacy bin/ directory (executables now in npm CLI)" rm -rf "$TARGET_DIR/bin" fi # Remove old mosaic PATH entry from shell profiles for profile in "$HOME/.bashrc" "$HOME/.zshrc" "$HOME/.profile"; do if [[ -f "$profile" ]] && grep -qF "$TARGET_DIR/bin" "$profile"; then # Remove the PATH line and the comment above it sed -i.mosaic-migration-bak \ -e "\|# Mosaic agent framework|d" \ -e "\|$TARGET_DIR/bin|d" \ "$profile" ok "Cleaned up old PATH entry from $(basename "$profile")" rm -f "${profile}.mosaic-migration-bak" fi done # Remove stale rails/ symlink if [[ -L "$TARGET_DIR/rails" ]]; then rm -f "$TARGET_DIR/rails" fi fi # ── Migration: v2 → v3 (Constitution split) ─────────────────────────────── # CONSTITUTION.md / AGENTS.md / STANDARDS.md become framework-owned (overwritten # on upgrade). reconcile_framework_files() has already run before this point: it # backed up any user-edited copy to .pre-constitution.bak and installed the # new framework version. Nothing further to do here — the advisory was emitted at # reconcile time. (STANDARDS.local.md composition lands with the overlay composer.) if [[ "$from_version" -lt 3 ]]; then ok "Migrated to the Constitution layout (framework-owned CONSTITUTION/AGENTS/STANDARDS)" fi } # ═══════════════════════════════════════════════════════════════════════════════ # Main # ═══════════════════════════════════════════════════════════════════════════════ step "Installing Mosaic framework" mkdir -p "$TARGET_DIR" select_install_mode if [[ "$INSTALL_MODE" == "keep" ]]; then ok "Install mode: keep local files (SOUL.md, USER.md, TOOLS.md, memory/)" else ok "Install mode: overwrite" fi # Snapshot before any destructive file operation; restore on interrupt/failure. make_snapshot trap 'restore_snapshot' ERR INT TERM sync_framework # Ensure persistent directories exist mkdir -p "$TARGET_DIR/memory" mkdir -p "$TARGET_DIR/credentials" # Reconcile contract files from defaults/ into the framework root: framework-owned # files (CONSTITUTION/AGENTS/STANDARDS) are overwritten every upgrade (a divergent # copy is backed up once); user-seeded files (TOOLS) are written on first install only. # # This list must match the framework-contract whitelist in # packages/mosaic/src/config/file-adapter.ts (FileConfigAdapter.syncFramework). # SOUL.md and USER.md are intentionally NOT seeded here — they are generated # by `mosaic init` from templates with user-supplied values. reconcile_framework_files # Ensure tool scripts are executable find "$TARGET_DIR/tools" -name "*.sh" -exec chmod +x {} + 2>/dev/null || true find "$TARGET_DIR/tools/_scripts" -type f -exec chmod +x {} + 2>/dev/null || true ok "Framework synced to $TARGET_DIR" # Run migrations before post-install (migrations may remove old bin/ etc.) run_migrations # File-system phase complete and consistent — clear the restore trap. trap - ERR INT TERM cleanup_snapshot # Testability / minimal-install hook: stop after the file-system phase, before any # environment-touching post-install steps (runtime linking, MCP setup, skills, doctor). if [[ "${MOSAIC_SYNC_ONLY:-0}" == "1" ]]; then write_framework_version ok "Sync-only mode: file phase complete" exit 0 fi step "Post-install tasks" SCRIPTS="$TARGET_DIR/tools/_scripts" if [[ -x "$SCRIPTS/mosaic-link-runtime-assets" ]]; then if "$SCRIPTS/mosaic-link-runtime-assets" >/dev/null 2>&1; then ok "Runtime assets linked" else warn "Runtime asset linking failed (non-fatal)" fi fi if [[ -x "$SCRIPTS/mosaic-ensure-sequential-thinking" ]]; then if "$SCRIPTS/mosaic-ensure-sequential-thinking" >/dev/null 2>&1; then ok "sequential-thinking MCP configured" else if [[ "${MOSAIC_ALLOW_MISSING_SEQUENTIAL_THINKING:-0}" == "1" ]]; then warn "sequential-thinking MCP setup bypassed (MOSAIC_ALLOW_MISSING_SEQUENTIAL_THINKING=1)" else fail "sequential-thinking MCP setup failed (hard requirement)." exit 1 fi fi fi if [[ -x "$SCRIPTS/mosaic-ensure-excalidraw" ]]; then "$SCRIPTS/mosaic-ensure-excalidraw" >/dev/null 2>&1 && ok "excalidraw MCP configured" || warn "excalidraw MCP setup failed (non-fatal)" fi if [[ "${MOSAIC_SKIP_SKILLS_SYNC:-0}" != "1" ]] && [[ -x "$SCRIPTS/mosaic-sync-skills" ]]; then "$SCRIPTS/mosaic-sync-skills" >/dev/null 2>&1 && ok "Skills synced" || warn "Skills sync failed (non-fatal)" fi if [[ -x "$SCRIPTS/mosaic-migrate-local-skills" ]]; then "$SCRIPTS/mosaic-migrate-local-skills" --apply >/dev/null 2>&1 && ok "Local skills migrated" || warn "Local skill migration failed (non-fatal)" fi if [[ -x "$SCRIPTS/mosaic-doctor" ]]; then "$SCRIPTS/mosaic-doctor" >/dev/null 2>&1 && ok "Health audit passed" || warn "Health audit reported issues — run 'mosaic doctor' for details" fi # Write version stamp AFTER everything succeeds write_framework_version # ── Summary ────────────────────────────────────────────────── echo "" echo -e "${GREEN}${BOLD} Mosaic framework installed.${RESET}" echo "" if [[ ! -f "$TARGET_DIR/SOUL.md" ]]; then echo -e " Run ${CYAN}mosaic init${RESET} to set up your agent identity." echo "" fi