#!/usr/bin/env bash # FLABS Skill installer. Downloads a five-file Skill package; never runs their contents. set -euo pipefail usage() { cat <<'USAGE' Usage: bash install.sh [--target agents|claude|codex|gemini|copilot|opencode] [--update] [--migrate-legacy] [--skills-dir DIR] [--link-dir DIR] [--profile-dir DIR] [--actor UUID | --no-actor] Defaults: ~/.agents/skills/flabs; Claude links ~/.claude/skills/flabs to it. DIR names the parent directory, not the flabs directory itself. Existing content is refused unless --update is explicit. Updates keep a backup. Claude may reuse a valid existing flabs package to create its link, without download or update. Wrong links and ordinary directories at the Claude link are never replaced. All six targets use the shared agents directory; only Claude adds a link. Five files and four strict SHA256 entries are checked before any migration or update. Legacy FLABS copies are refused (exit 3) unless --migrate-legacy is explicit; backups remain. --profile-dir confines profile scanning and ignores inherited CODEX_HOME. Windows Bash creates a native directory junction; Unix uses a symbolic link. Actor ID is local UUIDv4 state, not authentication. Default inherits or creates unless disabled. Explicit --actor UUID or --no-actor overrides state; server statistics are disabled by default. Inspect this script before running it. Start a new Agent session after installation. USAGE } fail() { printf 'FLABS: %s\n' "$*" >&2; exit 1; } target=agents update=false migrate_legacy=false actor_option=none actor_explicit_id= profile_dir= profile_explicit=false skills_dir= link_dir= while [ "$#" -gt 0 ]; do case "$1" in --help|-h) usage; exit 0 ;; --target) [ "$#" -ge 2 ] || fail '--target needs a value'; target=$2; shift 2 ;; --skills-dir) [ "$#" -ge 2 ] || fail '--skills-dir needs a value'; skills_dir=$2; [ -n "$skills_dir" ] || fail 'empty --skills-dir'; shift 2 ;; --link-dir) [ "$#" -ge 2 ] || fail '--link-dir needs a value'; link_dir=$2; [ -n "$link_dir" ] || fail 'empty --link-dir'; shift 2 ;; --actor) [ "$actor_option" = none ] && [ "$#" -ge 2 ] || { printf 'FLABS: actor options are unique and --actor requires a UUIDv4 value.\n' >&2; exit 2; } [ "${#2}" -eq 36 ] && [[ "$2" =~ ^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89aAbB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$ ]] || { printf 'FLABS: --actor requires a strict UUIDv4 value.\n' >&2; exit 2; } actor_option=id; actor_explicit_id=$(printf '%s' "$2" | awk '{print tolower($0)}'); shift 2 ;; --no-actor) [ "$actor_option" = none ] || { printf 'FLABS: actor options are unique and mutually exclusive.\n' >&2; exit 2; } actor_option=disabled; shift ;; --update) update=true; shift ;; --migrate-legacy) migrate_legacy=true; shift ;; --profile-dir) [ "$#" -ge 2 ] || fail "--profile-dir needs a value"; profile_dir=$2; [ -n "$profile_dir" ] || fail "empty --profile-dir"; profile_explicit=true; shift 2 ;; *) fail "unknown option: $1" ;; esac done case "$target" in agents|claude|codex|gemini|copilot|opencode) ;; *) fail '--target must be agents, claude, codex, gemini, copilot or opencode' ;; esac for cmd in curl awk mktemp mv rm mkdir rmdir readlink ln dirname printenv cat chmod cp; do command -v "$cmd" >/dev/null || fail "required command missing: $cmd"; done if command -v sha256sum >/dev/null; then hash_file() { sha256sum -- "$1"; } hash_stdin() { sha256sum; } elif command -v shasum >/dev/null; then hash_file() { shasum -a 256 -- "$1"; } hash_stdin() { shasum -a 256; } else fail 'required SHA256 utility missing: sha256sum or shasum'; fi if [ -z "$profile_dir" ]; then [ -n "$HOME" ] || fail 'HOME is required without --profile-dir'; profile_dir="$HOME"; fi [ -d "$profile_dir" ] && [ ! -L "$profile_dir" ] || fail '--profile-dir must name a real existing directory' profile_root=$(cd -P -- "$profile_dir" && pwd -P) [ "$profile_root" != / ] || fail 'the filesystem root cannot be the profile directory' codex_root="$profile_root/.codex" if [ "$profile_explicit" != true ]; then codex_home=$(printenv CODEX_HOME || true) if [ -n "$codex_home" ]; then codex_root=$codex_home; fi fi if [ -z "$skills_dir" ]; then skills_dir="$profile_root/.agents/skills"; fi if [ "$target" = claude ] && [ -z "$link_dir" ]; then link_dir="$profile_root/.claude/skills"; fi assert_profile_ancestors() { [ "$profile_explicit" = true ] || return 0 local ancestor=$1 case "$ancestor" in "$profile_root"|"$profile_root"/*) ;; *) return 0 ;; esac while [ "$ancestor" != "$profile_root" ]; do [ ! -L "$ancestor" ] || fail "profile ancestor is a link: $ancestor; nothing moved" ancestor=$(dirname -- "$ancestor") done } assert_profile_ancestors "$skills_dir" base_url='https://fai.fashion' mkdir -p -- "$skills_dir" skills_root=$(cd -P -- "$skills_dir" && pwd -P) [ "$skills_root" != / ] || fail 'the filesystem root cannot be a skills directory' skill_path="$skills_root/flabs" lock_path="$skills_root/.flabs-install.lock" stage= backup= promoted=false backed_up=false link_created=false committed=false lock_owned=false link_path= link_root= reuse_existing=false inside_skills() { case "$1" in "$skills_root"/*) [ "$1" != "$skills_root" ] ;; *) return 1 ;; esac } move_owned() { inside_skills "$1" && inside_skills "$2" || { printf 'FLABS: refused move outside skills root\n' >&2; return 1; } [ -d "$1" ] && [ ! -L "$1" ] || { printf 'FLABS: move source is not an owned real directory\n' >&2; return 1; } [ ! -e "$2" ] && [ ! -L "$2" ] || { printf 'FLABS: move destination already exists: %s\n' "$2" >&2; return 1; } mv -- "$1" "$2" } is_flabs_skill() { [ -f "$1/SKILL.md" ] && [ ! -L "$1/SKILL.md" ] || return 1 awk ' NR == 1 { if ($0 !~ /^---\r?$/) exit 1; next } NR > 80 { exit 1 } /^---\r?$/ { closed=1; exit } /^name:[[:space:]]*(flabs|"flabs"|\047flabs\047)[[:space:]]*\r?$/ { named=1 } END { if (!closed || !named) exit 1 } ' "$1/SKILL.md" } validate_package() { local dir=$1 filename lines expected actual [ -d "$dir/agents" ] && [ ! -L "$dir/agents" ] || return 1 for filename in SKILL.md README.md LICENSE agents/openai.yaml manifest.sha256; do [ -f "$dir/$filename" ] && [ ! -L "$dir/$filename" ] && [ -s "$dir/$filename" ] || return 1 done is_flabs_skill "$dir" || return 1 lines=$(awk ' { sub(/\r$/, ""); if (length($0) < 67 || substr($0,65,2) != " ") exit 1; hash=substr($0,1,64); name=substr($0,67); if (hash ~ /[^0-9a-fA-F]/ || length(hash) != 64) exit 1; if (name != "SKILL.md" && name != "README.md" && name != "LICENSE" && name != "agents/openai.yaml") exit 1; if (seen[name]++) exit 1; count++; print tolower(hash) " " name; } END { if (count != 4) exit 1 } ' "$dir/manifest.sha256") || return 1 while IFS=' ' read -r expected filename; do actual=$(hash_file "$dir/$filename") || return 1 actual=$(printf '%s' "$actual" | awk '{print $1}') [ "$actual" = "$expected" ] || return 1 done <<< "$lines" } legacy_paths=() legacy_roots=() legacy_backups=() legacy_moved=0 legacy_pending=true is_legacy_path() { local path for path in "${legacy_paths[@]}"; do [ "$path" = "$1" ] && return 0; done return 1 } scan_legacy() { local candidate root legacy_paths=(); legacy_roots=() for candidate in "$profile_root/.claude/skills/flabs" "$codex_root/skills/flabs" "$profile_root/.gemini/skills/flabs" "$profile_root/.copilot/skills/flabs" "$profile_root/.config/opencode/skills/flabs"; do assert_profile_ancestors "$(dirname -- "$candidate")" [ -e "$candidate" ] || [ -L "$candidate" ] || continue if [ -e "$skill_path" ] && [ "$candidate" -ef "$skill_path" ]; then continue; fi [ -d "$candidate" ] && [ ! -L "$candidate" ] || fail "unknown or wrong legacy link/file: $candidate; nothing moved" is_flabs_skill "$candidate" || fail "legacy directory is not name: flabs: $candidate; nothing moved" root=$(cd -P -- "$(dirname -- "$candidate")" && pwd -P) is_legacy_path "$root/flabs" && continue legacy_paths+=("$root/flabs"); legacy_roots+=("$root") done if [ "${#legacy_paths[@]}" -gt 0 ] && [ "$migrate_legacy" != true ]; then printf 'FLABS: legacy copies detected; inspect and explicitly use --migrate-legacy. Nothing moved.\n' >&2 printf '%s\n' "${legacy_paths[@]}" >&2 exit 3 fi } move_legacy() { local from=$1 to=$2 root=$3 case "$from" in "$root"/*) ;; *) return 1 ;; esac case "$to" in "$root"/*) ;; *) return 1 ;; esac [ -d "$from" ] && [ ! -L "$from" ] && is_flabs_skill "$from" || return 1 [ ! -e "$to" ] && [ ! -L "$to" ] || return 1 mv -- "$from" "$to" } archive_legacy() { local i backup_path for ((i=0; i<${#legacy_paths[@]}; i++)); do backup_path=$(mktemp -d "${legacy_roots[i]}/.flabs-legacy-backup.XXXXXXXX") rmdir -- "$backup_path" move_legacy "${legacy_paths[i]}" "$backup_path" "${legacy_roots[i]}" || fail 'legacy move failed; earlier backups will be restored' legacy_backups+=("$backup_path") legacy_moved=$((legacy_moved+1)) done legacy_pending=false } check_existing() { if [ -e "$skill_path" ] || [ -L "$skill_path" ]; then [ -d "$skill_path" ] && [ ! -L "$skill_path" ] || fail 'existing flabs must be a real directory, not a file or link' is_flabs_skill "$skill_path" || fail 'existing SKILL.md does not identify name: flabs; no overwrite' if [ "$update" != true ] && ! { [ "$migrate_legacy" = true ] && [ "${#legacy_paths[@]}" -gt 0 ]; }; then if [ "$target" = claude ] && [ -f "$skill_path/README.md" ] && [ ! -L "$skill_path/README.md" ] && [ -s "$skill_path/README.md" ]; then validate_package "$skill_path" || fail 'existing old or invalid package cannot be reused; inspect and explicitly use --update' reuse_existing=true return fi fail "already exists: $skill_path; use --update only after reviewing it" fi fi } check_link() { if [ -L "$link_path" ]; then local destination destination=$(readlink -- "$link_path") if [ "$destination" != "$skill_path" ]; then [ -e "$skill_path" ] && [ "$link_path" -ef "$skill_path" ] || fail "existing Claude link points elsewhere: $link_path; inspect it manually" fi elif [ -e "$link_path" ]; then if [ "$legacy_pending" = true ] && [ "$migrate_legacy" = true ] && is_legacy_path "$link_path"; then return; fi fail "Claude link path is an ordinary file or directory: $link_path; inspect it manually" fi } create_link() { case "$OSTYPE" in msys*|cygwin*|mingw*) command -v powershell.exe >/dev/null || fail 'Windows Bash requires PowerShell to create a directory junction' command -v cygpath >/dev/null || fail 'Windows Bash requires cygpath' FLABS_INSTALL_LINK=$(cygpath -w "$link_path") \ FLABS_INSTALL_LINK_ROOT=$(cygpath -w "$link_root") \ FLABS_INSTALL_TARGET=$(cygpath -w "$skill_path") \ FLABS_INSTALL_SKILLS_ROOT=$(cygpath -w "$skills_root") \ powershell.exe -NoProfile -NonInteractive -Command ' $ErrorActionPreference = "Stop" $linkAbsolute = [IO.Path]::GetFullPath($env:FLABS_INSTALL_LINK) $targetAbsolute = [IO.Path]::GetFullPath($env:FLABS_INSTALL_TARGET) $linkRootAbsolute = [IO.Path]::GetFullPath($env:FLABS_INSTALL_LINK_ROOT).TrimEnd([IO.Path]::DirectorySeparatorChar) $skillsRootAbsolute = [IO.Path]::GetFullPath($env:FLABS_INSTALL_SKILLS_ROOT).TrimEnd([IO.Path]::DirectorySeparatorChar) if (-not $linkAbsolute.StartsWith($linkRootAbsolute + [IO.Path]::DirectorySeparatorChar, [StringComparison]::OrdinalIgnoreCase) -or [IO.Path]::GetFileName($linkAbsolute) -ne "flabs") { throw "Link path outside its named root" } if (-not $targetAbsolute.StartsWith($skillsRootAbsolute + [IO.Path]::DirectorySeparatorChar, [StringComparison]::OrdinalIgnoreCase) -or [IO.Path]::GetFileName($targetAbsolute) -ne "flabs") { throw "Skill target outside its named root" } if ($null -ne (Get-Item -LiteralPath $linkAbsolute -Force -ErrorAction SilentlyContinue)) { throw "Link destination already exists" } New-Item -ItemType Junction -Path $linkAbsolute -Target $targetAbsolute -ErrorAction Stop | Out-Null $item = Get-Item -LiteralPath $linkAbsolute -Force if ($item.LinkType -ne "Junction" -or -not ([IO.Path]::GetFullPath([string](@($item.Target)[0]))).Equals($targetAbsolute, [StringComparison]::OrdinalIgnoreCase)) { throw "Created junction did not resolve to the expected Skill" } ' ;; *) ln -s -- "$skill_path" "$link_path" ;; esac if [ -L "$link_path" ]; then link_created=true; fi [ -L "$link_path" ] || fail 'created destination is not a real directory link; no success claimed' check_link } actor_uuid_valid() { [ "${#1}" -eq 36 ] && [[ "$1" =~ ^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89aAbB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$ ]]; } actor_kind= actor_id= actor_txn= actor_txn_active=false actor_restore_failed=false actor_old_names=() actor_old_hashes=() actor_new_names=() actor_new_hashes=() assert_actor_files() { local dir=$1 name file [ -d "$dir" ] && [ ! -L "$dir" ] || fail 'actor source must be a real Skill directory' for name in .flabs-actor-id .flabs-actor-disabled .gitignore; do file="$dir/$name" if [ -e "$file" ] || [ -L "$file" ]; then [ -f "$file" ] && [ ! -L "$file" ] || fail 'actor state and ignore files must be regular files, not links or directories' fi done } read_actor_id() { local value [ -f "$1/.flabs-actor-id" ] || return 0 value=$(cat -- "$1/.flabs-actor-id") || fail 'actor state could not be read' while [[ "$value" == *$'\r' || "$value" == *$'\n' ]]; do value=${value%?}; done if actor_uuid_valid "$value"; then printf '%s' "$value" | awk '{print tolower($0)}'; fi } select_actor_state() { local dir value first= sources=() actor_kind=none; actor_id= if [ -d "$skill_path" ] && [ ! -L "$skill_path" ]; then sources+=("$skill_path"); fi for dir in "${legacy_paths[@]}"; do sources+=("$dir"); done for dir in "${sources[@]}"; do assert_actor_files "$dir"; done if [ "$actor_option" = id ]; then actor_kind=id; actor_id=$actor_explicit_id; return; fi if [ "$actor_option" = disabled ]; then actor_kind=disabled; return; fi for dir in "${sources[@]}"; do if [ -f "$dir/.flabs-actor-disabled" ]; then actor_kind=disabled; return; fi done if [ -d "$skill_path" ] && [ ! -L "$skill_path" ]; then value=$(read_actor_id "$skill_path") if [ -n "$value" ]; then actor_kind=id; actor_id=$value; return; fi fi for dir in "${legacy_paths[@]}"; do value=$(read_actor_id "$dir") [ -n "$value" ] || continue if [ -z "$first" ]; then first=$value; elif [ "$first" != "$value" ]; then actor_kind=conflict printf 'FLABS: legacy actor identifiers conflict; no identifier or disabled marker will be created.\n' >&2 return fi done if [ -n "$first" ]; then actor_kind=id; actor_id=$first; return; fi local candidate= case "$OSTYPE" in msys*|cygwin*|mingw*) if command -v powershell.exe >/dev/null; then candidate=$(powershell.exe -NoProfile -NonInteractive -Command '[Guid]::NewGuid().ToString().ToLowerInvariant()' 2>/dev/null) || candidate=; fi ;; esac while [[ "$candidate" == *$'\r' || "$candidate" == *$'\n' ]]; do candidate=${candidate%?}; done if ! actor_uuid_valid "$candidate" && command -v uuidgen >/dev/null; then candidate=$(uuidgen 2>/dev/null) || candidate=; fi while [[ "$candidate" == *$'\r' || "$candidate" == *$'\n' ]]; do candidate=${candidate%?}; done if ! actor_uuid_valid "$candidate" && command -v python3 >/dev/null; then candidate=$(python3 -c 'import uuid; print(uuid.uuid4())' 2>/dev/null) || candidate=; fi while [[ "$candidate" == *$'\r' || "$candidate" == *$'\n' ]]; do candidate=${candidate%?}; done if ! actor_uuid_valid "$candidate" && command -v node >/dev/null; then candidate=$(node -e 'process.stdout.write(require("node:crypto").randomUUID())' 2>/dev/null) || candidate=; fi while [[ "$candidate" == *$'\r' || "$candidate" == *$'\n' ]]; do candidate=${candidate%?}; done if actor_uuid_valid "$candidate"; then actor_id=$(printf '%s' "$candidate" | awk '{print tolower($0)}'); actor_kind=id else actor_kind=none; actor_id= printf 'FLABS: no usable UUIDv4 generator; installation continues without an actor identifier.\n' >&2 fi } write_actor_file() { local dir=$1 name=$2 value=$3 file="$1/$2" [ -d "$dir" ] && [ ! -L "$dir" ] || fail 'actor output must be a real directory' [ ! -e "$file" ] && [ ! -L "$file" ] || fail 'actor output file already exists' case "$name" in .flabs-actor-id|.flabs-actor-disabled|.gitignore) ;; *) fail 'unknown actor output file' ;; esac case "$OSTYPE" in msys*|cygwin*|mingw*) command -v powershell.exe >/dev/null && command -v cygpath >/dev/null || fail 'Windows actor permissions require PowerShell and cygpath' MSYS2_ENV_CONV_EXCL="FLABS_ACTOR_TEXT${MSYS2_ENV_CONV_EXCL:+;$MSYS2_ENV_CONV_EXCL}" \ FLABS_ACTOR_FILE=$(cygpath -w "$file") FLABS_ACTOR_DIRECTORY=$(cygpath -w "$dir") \ FLABS_ACTOR_SKILLS_ROOT=$(cygpath -w "$skills_root") FLABS_ACTOR_TEXT="$value" \ powershell.exe -NoProfile -NonInteractive -Command ' $ErrorActionPreference = "Stop" $file = [IO.Path]::GetFullPath($env:FLABS_ACTOR_FILE) $dir = [IO.Path]::GetFullPath($env:FLABS_ACTOR_DIRECTORY).TrimEnd([IO.Path]::DirectorySeparatorChar) $root = [IO.Path]::GetFullPath($env:FLABS_ACTOR_SKILLS_ROOT).TrimEnd([IO.Path]::DirectorySeparatorChar) if (-not $dir.StartsWith($root + [IO.Path]::DirectorySeparatorChar,[StringComparison]::OrdinalIgnoreCase) -or -not [IO.Path]::GetDirectoryName($file).Equals($dir,[StringComparison]::OrdinalIgnoreCase) -or [IO.Path]::GetFileName($file) -notin @(".flabs-actor-id",".flabs-actor-disabled",".gitignore")) { throw "Actor file path outside named directory" } $item = Get-Item -LiteralPath $dir -Force if (-not $item.PSIsContainer -or ($item.Attributes -band [IO.FileAttributes]::ReparsePoint)) { throw "Actor directory must be a real directory" } if ($null -ne (Get-Item -LiteralPath $file -Force -ErrorAction SilentlyContinue)) { throw "Actor file already exists" } $sid = [Security.Principal.WindowsIdentity]::GetCurrent().User $acl = [Security.AccessControl.FileSecurity]::new() $acl.SetAccessRuleProtection($true,$false); $acl.SetOwner($sid) $acl.AddAccessRule([Security.AccessControl.FileSystemAccessRule]::new($sid,[Security.AccessControl.FileSystemRights]::FullControl,[Security.AccessControl.AccessControlType]::Allow)) $stream = [IO.FileStream]::new($file,[IO.FileMode]::CreateNew,[Security.AccessControl.FileSystemRights]::Write,[IO.FileShare]::None,4096,[IO.FileOptions]::None,$acl) try { $bytes = [Text.UTF8Encoding]::new($false).GetBytes($env:FLABS_ACTOR_TEXT); $stream.Write($bytes,0,$bytes.Length) } finally { $stream.Dispose() } $actual = [IO.File]::GetAccessControl($file) if (-not $actual.AreAccessRulesProtected -or -not $actual.GetOwner([Security.Principal.SecurityIdentifier]).Equals($sid)) { throw "Actor file ACL verification failed" } foreach ($rule in $actual.GetAccessRules($true,$true,[Security.Principal.SecurityIdentifier])) { if ($rule.AccessControlType -eq [Security.AccessControl.AccessControlType]::Allow -and -not $rule.IdentityReference.Equals($sid)) { throw "Actor file ACL allows another identity" } } ' ;; *) (umask 077; set -o noclobber; printf '%s' "$value" > "$file") if [ "$name" = .gitignore ]; then chmod 644 -- "$file"; else chmod 600 -- "$file"; fi ;; esac # Validate against Bash's original bytes, not the native process environment after conversion. local expected_digest actual_digest expected_digest=$(printf '%s' "$value" | hash_stdin | awk '{print $1}') actual_digest=$(hash_file "$file" | awk '{print $1}') [ "$expected_digest" = "$actual_digest" ] || fail 'actor output bytes differ from requested local state' # Windows uses the verified native current-user ACL; Unix uses the explicit chmod modes. } write_actor_state() { local dir=$1 assert_actor_files "$dir" if [ "$actor_kind" = id ]; then write_actor_file "$dir" .flabs-actor-id "$actor_id"$'\n'; fi if [ "$actor_kind" = disabled ]; then write_actor_file "$dir" .flabs-actor-disabled ''; fi write_actor_file "$dir" .gitignore $'/.flabs-actor-id\n/.flabs-actor-disabled\n' } apply_actor_reuse() { local name file digest actor_txn=$(mktemp -d "$skills_root/.flabs-actor-state.XXXXXXXX") mkdir -- "$actor_txn/before" "$actor_txn/new" write_actor_state "$actor_txn/new" assert_actor_files "$skill_path" if [ -f "$skill_path/.gitignore" ]; then cp -- "$skill_path/.gitignore" "$actor_txn/new/.gitignore" for name in /.flabs-actor-id /.flabs-actor-disabled; do if ! awk -v wanted="$name" '{ sub(/\r$/, ""); if ($0 == wanted) found=1 } END { exit !found }' "$actor_txn/new/.gitignore"; then printf '\n%s\n' "$name" >> "$actor_txn/new/.gitignore" fi done fi actor_txn_active=true for name in .flabs-actor-id .flabs-actor-disabled .gitignore; do file="$skill_path/$name" if [ -e "$file" ]; then digest=$(hash_file "$file"); digest=${digest%% *} mv -- "$file" "$actor_txn/before/$name" actor_old_names+=("$name"); actor_old_hashes+=("$digest") fi if [ -f "$actor_txn/new/$name" ]; then digest=$(hash_file "$actor_txn/new/$name"); digest=${digest%% *} [ ! -e "$file" ] && [ ! -L "$file" ] || fail 'actor destination changed before replacement' mv -- "$actor_txn/new/$name" "$file" actor_new_names+=("$name"); actor_new_hashes+=("$digest") fi done } restore_actor_reuse() { local i file digest original [ "$actor_txn_active" = true ] || return 0 if [ ! -d "$skill_path" ] || [ -L "$skill_path" ]; then actor_restore_failed=true; return 1; fi for ((i=${#actor_new_names[@]}-1; i>=0; i--)); do file="$skill_path/${actor_new_names[i]}" if [ -e "$file" ] || [ -L "$file" ]; then if [ ! -f "$file" ] || [ -L "$file" ]; then actor_restore_failed=true; continue; fi digest=$(hash_file "$file"); digest=${digest%% *} if [ "$digest" != "${actor_new_hashes[i]}" ]; then actor_restore_failed=true; continue; fi rm -- "$file" || actor_restore_failed=true fi done for ((i=${#actor_old_names[@]}-1; i>=0; i--)); do file="$skill_path/${actor_old_names[i]}"; original="$actor_txn/before/${actor_old_names[i]}" if [ -e "$file" ] || [ -L "$file" ] || [ ! -f "$original" ] || [ -L "$original" ]; then actor_restore_failed=true; continue; fi digest=$(hash_file "$original"); digest=${digest%% *} if [ "$digest" != "${actor_old_hashes[i]}" ]; then actor_restore_failed=true; continue; fi mv -- "$original" "$file" || actor_restore_failed=true done if [ "$actor_restore_failed" = true ]; then printf 'FLABS: actor state restore failed; original state backup preserved: %s\n' "$actor_txn" >&2; return 1; fi actor_txn_active=false } cleanup_actor_transaction() { [ -n "$actor_txn" ] || return 0 [ "$actor_restore_failed" != true ] || return 0 case "$actor_txn" in "$skills_root"/.flabs-actor-state.*) ;; *) return 1 ;; esac [ -d "$actor_txn" ] && [ ! -L "$actor_txn" ] && [ -d "$actor_txn/before" ] && [ ! -L "$actor_txn/before" ] && [ -d "$actor_txn/new" ] && [ ! -L "$actor_txn/new" ] || return 1 local child file for child in "$actor_txn/before" "$actor_txn/new"; do for file in "$child"/.[!.]* "$child"/..?* "$child"/*; do [ -e "$file" ] || [ -L "$file" ] || continue case "$file" in "$child/.flabs-actor-id"|"$child/.flabs-actor-disabled"|"$child/.gitignore") ;; *) return 1 ;; esac [ -f "$file" ] && [ ! -L "$file" ] || return 1 done done for file in "$actor_txn"/.[!.]* "$actor_txn"/..?* "$actor_txn"/*; do [ -e "$file" ] || [ -L "$file" ] || continue case "$file" in "$actor_txn/before"|"$actor_txn/new") ;; *) return 1 ;; esac done # This run owns the resolved temporary directory under skills_root, never a user Skill root. rm -rf -- "$actor_txn" } cleanup() { local rc=$? trap - EXIT INT TERM set +e if [ "$committed" != true ]; then if [ "$link_created" = true ]; then if [ -L "$link_path" ] && [ "$(readlink -- "$link_path")" = "$skill_path" ]; then rm -- "$link_path" || rc=1 else printf 'FLABS: created link changed; preserved for manual review: %s\n' "$link_path" >&2 rc=1 fi fi if [ "$promoted" = true ]; then if move_owned "$skill_path" "$stage"; then promoted=false; else printf 'FLABS: rollback could not move new installation; preserved: %s\n' "$skill_path" >&2 rc=1 fi fi if [ "$backed_up" = true ]; then if move_owned "$backup" "$skill_path"; then backed_up=false; else printf 'FLABS: restore failed; original backup preserved: %s\n' "$backup" >&2 rc=1 fi fi fi if [ "$committed" != true ]; then restore_actor_reuse || rc=1 for ((i=legacy_moved-1; i>=0; i--)); do if ! move_legacy "${legacy_backups[i]}" "${legacy_paths[i]}" "${legacy_roots[i]}"; then printf 'FLABS: legacy restore failed; backup preserved: %s\n' "${legacy_backups[i]}" >&2; rc=1 fi done fi if [ -n "$stage" ] && [ -e "$stage" ]; then case "$stage" in "$skills_root"/.flabs-stage.*) if [ -d "$stage" ] && [ ! -L "$stage" ]; then rm -rf -- "$stage" || rc=1; else printf 'FLABS: staging path changed; preserved: %s\n' "$stage" >&2; rc=1 fi ;; *) printf 'FLABS: refused staging cleanup outside its named root\n' >&2; rc=1 ;; esac fi cleanup_actor_transaction || { printf 'FLABS: actor transaction cleanup refused; inspect its temporary directory.\n' >&2; rc=1; } if [ "$lock_owned" = true ]; then if [ -d "$lock_path" ] && [ ! -L "$lock_path" ]; then rmdir -- "$lock_path" || rc=1; else printf 'FLABS: install lock changed; preserved: %s\n' "$lock_path" >&2; rc=1 fi fi exit "$rc" } trap cleanup EXIT trap 'exit 130' INT trap 'exit 143' TERM mkdir -- "$lock_path" 2>/dev/null || fail "installation lock exists: $lock_path; inspect any interrupted installation first" lock_owned=true scan_legacy check_existing select_actor_state if [ "$target" = claude ]; then assert_profile_ancestors "$link_dir" mkdir -p -- "$link_dir" link_root=$(cd -P -- "$link_dir" && pwd -P) [ "$link_root" != / ] || fail 'the filesystem root cannot be a link directory' link_path="$link_root/flabs" [ "$link_path" != "$skill_path" ] || fail '--link-dir must differ from --skills-dir' check_link fi if [ "$reuse_existing" = true ]; then check_link apply_actor_reuse if [ ! -L "$link_path" ]; then create_link link_created=true fi committed=true printf 'Existing FLABS Skill reused: %s\n' "$skill_path" printf 'Claude link: %s\n' "$link_path" printf 'No package was downloaded, backed up or updated; its version was not checked. Local actor state was processed.\n' printf 'Open a new Agent session to discover the Skill.\n' exit 0 fi stage=$(mktemp -d "$skills_root/.flabs-stage.XXXXXXXX") mkdir -- "$stage/agents" for filename in SKILL.md README.md LICENSE agents/openai.yaml manifest.sha256; do curl --fail --silent --show-error --location --proto '=http,https' --proto-redir '=http,https' \ --connect-timeout 10 --max-time 60 "$base_url/flabs-skill/$filename" -o "$stage/$filename" [ -s "$stage/$filename" ] || fail "empty downloaded file: $filename" done validate_package "$stage" || fail 'downloaded complete package or manifest failed validation; nothing migrated or upgraded' scan_legacy check_existing if [ "$target" = claude ]; then check_link; fi select_actor_state write_actor_state "$stage" archive_legacy if [ "$target" = claude ]; then check_link; fi if [ -e "$skill_path" ]; then backup=$(mktemp -d "$skills_root/.flabs-backup.XXXXXXXX") rmdir -- "$backup" move_owned "$skill_path" "$backup" backed_up=true fi move_owned "$stage" "$skill_path" promoted=true if [ "$target" = claude ] && [ ! -L "$link_path" ]; then [ ! -e "$link_path" ] || fail 'Claude link destination changed before creation' create_link link_created=true fi validate_package "$skill_path" || fail 'installed complete package validation failed' committed=true for backup_path in "${legacy_backups[@]}"; do printf 'Legacy directory backup retained: %s\n' "$backup_path"; done printf 'FLABS Skill installed: %s\n' "$skill_path" if [ "$backed_up" = true ]; then printf 'Original directory backup retained: %s\n' "$backup"; fi if [ "$target" = claude ]; then printf 'Claude link: %s\n' "$link_path"; fi printf 'Open a new Agent session, then ask for the five most important AI design and production updates from the past 24 hours.\n'