#!/usr/bin/env bash
set -euo pipefail

# install-skill-pack — Install a curated community skill pack from a single command.
#
# Why this exists (vs bin/add-skill):
#   bin/add-skill works against a generic GitHub repo and scans for SKILL.md files.
#   A *skill pack* is a curated collection with a manifest (skills-pack.json) that
#   declares the pack's identity, version, license, and per-skill metadata. This
#   script reads that manifest and installs the whole pack as one unit — the same
#   distribution surface that the Community Skill Packs README section describes.
#
# Usage:
#   bin/install-skill-pack --list                              List every pack in the community registry (skill-packs.json)
#   bin/install-skill-pack <github-repo>                       Install everything the pack manifest declares
#   bin/install-skill-pack <github-repo> --list                List skills in the pack
#   bin/install-skill-pack <github-repo> <skill> [skill...]    Install only the named skills from the pack
#   bin/install-skill-pack <github-repo> --path skills/sub     Pack lives in a subdirectory (path holds skills-pack.json)
#   bin/install-skill-pack <github-repo> --branch develop      Use a non-default branch
#   bin/install-skill-pack <github-repo> --yes                 Auto-accept HIGH findings (CI/non-interactive)
#   bin/install-skill-pack <github-repo> --force               Install even if security scan finds HIGH issues
#   bin/install-skill-pack <github-repo> --dry-run             Preview without writing
#   bin/install-skill-pack --list --no-secrets                 List only registry packs whose skills declare no secrets_required
#
# Manifest format (skills-pack.json, at pack root or under --path):
#   {
#     "name": "Pack name",
#     "version": "1.0",
#     "description": "One-line summary",
#     "author": "github-handle-or-name",
#     "license": "MIT",                                       (optional)
#     "homepage": "https://...",                              (optional)
#     "skills": [
#       {
#         "slug": "skill-name",
#         "path": "skills/skill-name",                         (defaults to skills/<slug>)
#         "description": "What the skill does",                (optional, falls back to SKILL.md frontmatter)
#         "category": "research|dev|crypto|social|productivity", (optional)
#         "schedule": "0 12 * * *",                            (optional, default 0 12 * * *)
#         "default_enabled": false,                             (optional, default false)
#         "secrets_required": ["VENICE_API_KEY"],               (optional — env vars the skill cannot run without)
#         "secrets_optional": ["VENICE_MODEL"],                 (optional — env vars that tune behaviour but aren't required)
#         "capabilities": ["external_api"]                       (optional, locked taxonomy — see docs/CAPABILITIES.md)
#       }
#     ]
#   }
#
# Fallback (no manifest): scans the pack root for skills/*/SKILL.md and installs
# each one with safe defaults. The pack identity falls back to the repo name.
#
# Capabilities taxonomy (locked — unknown values are rejected at install time):
#   read_only · external_api · writes_external_host · onchain_writes ·
#   agent_messaging · sends_notifications
# See docs/CAPABILITIES.md for the meaning of each value and the rule for
# proposing additions.

ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
SKILLS_DIR="$ROOT_DIR/skills"
SKILLS_JSON="$ROOT_DIR/catalog/skills.json"
SKILLS_LOCK="$ROOT_DIR/skills.lock"
AEON_YML="$ROOT_DIR/aeon.yml"
SCANNER="$ROOT_DIR/scripts/skill-scan.sh"
TRUSTED_FILE="$ROOT_DIR/skills/security/trusted-sources.txt"
REGISTRY_FILE="$ROOT_DIR/catalog/skill-packs.json"
REGISTRY_URL="https://raw.githubusercontent.com/aeonfun/aeon/main/catalog/skill-packs.json"
TMP_DIR=$(mktemp -d)
trap 'rm -rf "$TMP_DIR"' EXIT
# shellcheck source=scripts/lib/skill-install.sh
. "$ROOT_DIR/scripts/lib/skill-install.sh"

# Locked capability taxonomy. Adding a value requires a separate PR that also
# updates docs/CAPABILITIES.md + the skills-pack.json schema reference. Keep
# this list in lockstep with docs/CAPABILITIES.md ("The taxonomy" table) —
# unknown values are rejected at install time pointing back at that file.
ALLOWED_CAPABILITIES=(
  read_only
  external_api
  writes_external_host
  onchain_writes
  agent_messaging
  sends_notifications
)

is_allowed_capability() {
  local needle="$1" cap
  for cap in "${ALLOWED_CAPABILITIES[@]}"; do
    [[ "$cap" == "$needle" ]] && return 0
  done
  return 1
}

usage() {
  cat <<'EOF'
Usage: bin/install-skill-pack [<github-repo>] [options] [skill-names...]

Install a curated community skill pack from a GitHub repository, or browse the
community registry.

Arguments:
  <github-repo>          owner/repo (e.g. AntFleet/aeon-skills)
                         Omit with --list to browse the community registry.
  [skill-names...]       Optional — limit install to specific slugs from the pack

Options:
  --list                 Without <github-repo>: list every pack in the community
                         registry (skill-packs.json). With <github-repo>: list
                         skills declared inside that pack (no install).
  --path <subdir>        Pack lives in a subdirectory (where skills-pack.json sits)
  --branch <branch>      Use a specific branch or tag (default: main)
  --yes                  Non-interactive — auto-accept HIGH-severity findings
  --force                Install even when security scan reports HIGH findings
  --dry-run              Show what would be installed, write nothing
  --help                 Show this help

Pack manifest:
  The script looks for skills-pack.json at the pack root (or --path). If absent,
  it falls back to scanning skills/*/SKILL.md. See docs/community-skill-packs.md
  for the manifest schema.

Community registry:
  catalog/skill-packs.json is the machine-readable index of known
  packs. Use --list (no repo arg) to enumerate it; the script reads the local
  file when available and falls back to the upstream raw URL otherwise.

Examples:
  bin/install-skill-pack --list
  bin/install-skill-pack AntFleet/aeon-skills --list
  bin/install-skill-pack AntFleet/aeon-skills
  bin/install-skill-pack liquidpadbot/aeon-skill-pack-liquidpad liquidpad-burn-monitor liquidpad-token-alert
  bin/install-skill-pack mnemedb/aeon-skill-pack-mneme --dry-run
EOF
  exit 0
}

# --- Registry listing path -------------------------------------------------
# When the only meaningful argument is --list (no repo, no path/branch/etc),
# print the community registry and exit. This is a strict "no repo + only
# --list flag" check so power flags still error if used incorrectly.
load_registry() {
  if [[ -f "$REGISTRY_FILE" ]]; then
    cat "$REGISTRY_FILE"
    return 0
  fi
  # Local file missing — try the upstream raw URL via curl. If curl is sandbox-
  # blocked the caller can re-run the command from a clone with the file present.
  if curl -sfL "$REGISTRY_URL" 2>/dev/null; then
    return 0
  fi
  return 1
}

list_registry() {
  local no_secrets="${1:-false}"
  if ! command -v jq >/dev/null 2>&1; then
    echo "jq is required to read the registry." >&2
    exit 1
  fi
  local registry_json
  if ! registry_json=$(load_registry); then
    echo "Could not read skill-packs.json (local file missing and fetch from $REGISTRY_URL failed)." >&2
    echo "Pass <github-repo> explicitly if you already know which pack to install." >&2
    exit 1
  fi
  local updated total
  updated=$(echo "$registry_json" | jq -r '.updated // ""')
  total=$(echo "$registry_json" | jq '.packs | length')
  echo ""
  echo "Community skill pack registry${updated:+ (updated $updated)}"
  [[ "$no_secrets" == "true" ]] && echo "Filter: --no-secrets (hiding packs whose skills declare any secrets_required)"
  echo ""
  local shown=0 hidden=0
  for i in $(seq 0 $((total - 1))); do
    local repo desc trust skill_count secrets_count secrets_marker caps_type caps caps_marker
    repo=$(echo "$registry_json" | jq -r ".packs[$i].repo")
    desc=$(echo "$registry_json" | jq -r ".packs[$i].description // \"\"")
    trust=$(echo "$registry_json" | jq -r ".packs[$i].trust_level // \"community\"")
    skill_count=$(echo "$registry_json" | jq ".packs[$i].skills | length // 0")
    # secrets_required at the pack level is the aggregate; missing/null means none.
    secrets_count=$(echo "$registry_json" | jq ".packs[$i].secrets_required | length // 0")
    if [[ "$no_secrets" == "true" ]] && [[ "$secrets_count" != "0" ]]; then
      hidden=$((hidden + 1))
      continue
    fi
    # capabilities at the pack level is the aggregate; missing/null means none
    # declared. Validate each entry against the locked taxonomy so registry
    # drift surfaces in the listing rather than silently leaking through.
    caps_type=$(echo "$registry_json" | jq -r ".packs[$i].capabilities | type")
    caps=""
    if [[ "$caps_type" == "array" ]]; then
      while IFS= read -r cap_line; do
        local cap_kind="${cap_line%% *}"
        local cap_value="${cap_line#* }"
        if [[ "$cap_kind" != "string" ]] || ! is_allowed_capability "$cap_value"; then
          echo "Registry pack '$repo' declares unknown or non-string capability '$cap_value' — see docs/CAPABILITIES.md." >&2
          exit 1
        fi
        caps+="${caps:+,}$cap_value"
      done < <(echo "$registry_json" | jq -r ".packs[$i].capabilities[]? | \"\(type) \(.)\"")
    elif [[ "$caps_type" != "null" ]]; then
      echo "Registry pack '$repo' has capabilities of type $caps_type (expected array) — see docs/CAPABILITIES.md." >&2
      exit 1
    fi
    local trust_badge=" "
    [[ "$trust" == "trusted" ]] && trust_badge="*"
    secrets_marker=""
    [[ "$secrets_count" != "0" ]] && secrets_marker=" [needs ${secrets_count} secret(s)]"
    caps_marker=""
    [[ -n "$caps" ]] && caps_marker=" [caps: $caps]"
    if [[ ${#desc} -gt 80 ]]; then desc="${desc:0:77}..."; fi
    printf "  %s %-44s %2d skills  %s%s%s\n" "$trust_badge" "$repo" "$skill_count" "$desc" "$secrets_marker" "$caps_marker"
    shown=$((shown + 1))
  done
  echo ""
  echo "  * = trusted source (security scan skipped, format check still runs)"
  echo "  [needs N secret(s)] = pack declares N entries in secrets_required — set them in the workflow before first run"
  echo "  [caps: ...] = declared capabilities — see docs/CAPABILITIES.md for the taxonomy"
  echo ""
  if [[ "$no_secrets" == "true" ]]; then
    echo "$shown of $total packs shown ($hidden hidden by --no-secrets). Install with: bin/install-skill-pack <repo>"
  else
    echo "$total packs in registry. Install with: bin/install-skill-pack <repo>"
  fi
  exit 0
}

# Registry-list mode: `bin/install-skill-pack --list` (no repo, optionally with
# --no-secrets) prints the registry. We do a focused detect-and-route here so
# the original single-pack flow below is unchanged. Any unrecognised flag or
# positional argument disqualifies registry mode.
detect_registry_list_mode() {
  local list_seen=false
  REGISTRY_NO_SECRETS=false
  for arg in "$@"; do
    case "$arg" in
      --list) list_seen=true ;;
      --no-secrets) REGISTRY_NO_SECRETS=true ;;
      --help|-h) return 1 ;;
      *)
        # Any non-list/non-no-secrets flag OR any positional argument disqualifies
        # registry mode — the user wants to operate on a specific pack.
        return 1
        ;;
    esac
  done
  [[ "$list_seen" == "true" ]] && return 0 || return 1
}

if detect_registry_list_mode "$@"; then
  list_registry "$REGISTRY_NO_SECRETS"
fi

if [[ $# -lt 1 ]] || [[ "$1" == "--help" ]] || [[ "$1" == "-h" ]]; then
  usage
fi

REPO="$1"; shift

BRANCH="main"
SUBPATH=""
LIST_ONLY=false
DRY_RUN=false
ASSUME_YES=false
FORCE_INSTALL=false
REQUESTED_SLUGS=()

while [[ $# -gt 0 ]]; do
  case "$1" in
    --list) LIST_ONLY=true; shift ;;
    --path) SUBPATH="${2#/}"; SUBPATH="${SUBPATH%/}"; shift 2 ;;
    --branch) BRANCH="$2"; shift 2 ;;
    --yes|-y) ASSUME_YES=true; shift ;;
    --force) FORCE_INSTALL=true; shift ;;
    --dry-run) DRY_RUN=true; shift ;;
    --help|-h) usage ;;
    -*) echo "Unknown option: $1" >&2; exit 2 ;;
    *) REQUESTED_SLUGS+=("$1"); shift ;;
  esac
done

REPO="${REPO#https://github.com/}"
REPO="${REPO%.git}"

if [[ "$REPO" != */* ]]; then
  echo "Repo must be in owner/repo format, got: $REPO" >&2
  exit 2
fi

REPO_NAME="${REPO#*/}"
PACK_LABEL="$REPO_NAME"

echo "Fetching $REPO ($BRANCH)..."

REPO_ROOT=$(skill_fetch_repo "$REPO" "$BRANCH" "$TMP_DIR") || exit 1
# skill_fetch_repo may have resolved a different ref (default-branch fallback);
# record what was actually fetched so skills.lock provenance stays honest.
if [[ -f "$TMP_DIR/.skill-fetch-branch" ]]; then
  BRANCH=$(cat "$TMP_DIR/.skill-fetch-branch")
fi

PACK_DIR="$REPO_ROOT"
if [[ -n "$SUBPATH" ]]; then
  PACK_DIR="$REPO_ROOT/$SUBPATH"
  if [[ ! -d "$PACK_DIR" ]]; then
    echo "--path $SUBPATH not found inside $REPO" >&2
    exit 1
  fi
fi

# Reset declarations (no associative arrays — they require Bash 4+ which macOS lacks).
# SKILL_SECRETS_REQ / SKILL_SECRETS_OPT hold space-separated env-var names per skill
# (parallel arrays — same index as SLUGS). Empty string means none declared.
# SKILL_CAPS holds the per-skill capabilities as a space-separated string
# (parallel array, same index as SLUGS). Empty string means none declared.
SLUGS=()
SKILL_PATHS=()
SKILL_DESCS=()
SKILL_CATS=()
SKILL_SCHEDS=()
SKILL_DEFAULT_ENABLED=()
SKILL_SECRETS_REQ=()
SKILL_SECRETS_OPT=()
SKILL_CAPS=()
MANIFEST_FOUND=false
MANIFEST_PATH="$PACK_DIR/skills-pack.json"

if [[ -f "$MANIFEST_PATH" ]]; then
  MANIFEST_FOUND=true
  echo "Manifest: skills-pack.json"

  if ! jq -e . "$MANIFEST_PATH" >/dev/null 2>&1; then
    echo "skills-pack.json is not valid JSON" >&2
    exit 1
  fi

  manifest_name=$(jq -r '.name // ""' "$MANIFEST_PATH")
  manifest_version=$(jq -r '.version // ""' "$MANIFEST_PATH")
  manifest_desc=$(jq -r '.description // ""' "$MANIFEST_PATH")
  manifest_author=$(jq -r '.author // ""' "$MANIFEST_PATH")
  manifest_license=$(jq -r '.license // ""' "$MANIFEST_PATH")
  [[ -n "$manifest_name" ]] && PACK_LABEL="$manifest_name"

  echo "  pack:       ${manifest_name:-$REPO_NAME}${manifest_version:+ v$manifest_version}"
  [[ -n "$manifest_author" ]] && echo "  author:     $manifest_author"
  [[ -n "$manifest_license" ]] && echo "  license:    $manifest_license"
  [[ -n "$manifest_desc" ]] && echo "  about:      $manifest_desc"

  skill_count=$(jq '.skills | length' "$MANIFEST_PATH" 2>/dev/null || echo 0)
  if [[ "$skill_count" == "0" ]] || [[ -z "$skill_count" ]]; then
    echo "Manifest has no skills." >&2
    exit 1
  fi

  seen_slugs=""
  for i in $(seq 0 $((skill_count - 1))); do
    slug=$(jq -r ".skills[$i].slug // empty" "$MANIFEST_PATH")
    if [[ -z "$slug" ]]; then
      echo "Manifest skill #$i has no slug — aborting." >&2
      exit 1
    fi
    # Reject slugs that would escape skills/ — defensive even though add-skill validates too.
    if [[ "$slug" =~ [^a-zA-Z0-9_-] ]] || [[ "$slug" == "." ]] || [[ "$slug" == ".." ]]; then
      echo "Invalid slug in manifest: $slug" >&2
      exit 1
    fi
    # Reject duplicate slugs — a second declaration would silently overwrite the
    # first skill's install, leaving the author confused about which config won.
    # Space-delimited string check (no associative arrays — macOS bash 3 compat).
    case " $seen_slugs " in
      *" $slug "*) echo "Duplicate slug in manifest: '$slug' is declared more than once — aborting." >&2; exit 1 ;;
    esac
    seen_slugs="$seen_slugs $slug"
    rel_path=$(jq -r ".skills[$i].path // empty" "$MANIFEST_PATH")
    [[ -z "$rel_path" ]] && rel_path="skills/$slug"
    rel_path="${rel_path#/}"
    rel_path="${rel_path%/}"
    # `path` is the skill DIRECTORY. Pack authors routinely point it at the file
    # instead ("skills/foo/SKILL.md") — we'd then look for SKILL.md/SKILL.md and
    # skip every skill in the pack with a "missing ..." line that reads like the
    # file is absent when it's right there. Accept the file form and use its
    # parent; the whole directory is copied either way, so this is the same
    # install, not a laxer one.
    if [[ "$rel_path" == */SKILL.md ]]; then
      rel_path=$(dirname "$rel_path")
    elif [[ "$rel_path" == "SKILL.md" ]]; then
      # A bare root SKILL.md has no directory to copy — taking the parent here
      # would vacuum the whole repo (LICENSE, README, everything) into skills/.
      echo "Manifest skill '$slug' sets path to a root-level SKILL.md." >&2
      echo "Give each skill its own directory (skills/$slug/SKILL.md) and set path to that directory." >&2
      exit 1
    fi
    # Reject path traversal.
    if [[ "$rel_path" == *".."* ]]; then
      echo "Manifest path may not contain '..': $rel_path" >&2
      exit 1
    fi
    desc=$(jq -r ".skills[$i].description // \"\"" "$MANIFEST_PATH")
    cat=$(jq -r ".skills[$i].category // \"\"" "$MANIFEST_PATH")
    sched=$(jq -r ".skills[$i].schedule // \"0 12 * * *\"" "$MANIFEST_PATH")
    default_enabled=$(jq -r ".skills[$i].default_enabled // false" "$MANIFEST_PATH")
    # secrets_required / secrets_optional are optional string arrays. Render
    # them as space-separated env-var names (the per-skill loop splits on " ").
    secrets_req=$(jq -r ".skills[$i].secrets_required // [] | join(\" \")" "$MANIFEST_PATH")
    secrets_opt=$(jq -r ".skills[$i].secrets_optional // [] | join(\" \")" "$MANIFEST_PATH")
    # capabilities — optional string array. Iterate each element via jq so
    # element boundaries survive (word-splitting on a joined string would
    # mishandle empty / whitespace-bearing entries). Type-check the field
    # and each element so non-array shapes and non-string entries surface our
    # taxonomy pointer instead of an opaque jq error under `set -e`.
    caps_type=$(jq -r ".skills[$i].capabilities | type" "$MANIFEST_PATH")
    if [[ "$caps_type" != "null" ]] && [[ "$caps_type" != "array" ]]; then
      echo "Manifest skill '$slug' has capabilities of type $caps_type (expected array)." >&2
      echo "See docs/CAPABILITIES.md for the taxonomy and field shape." >&2
      exit 1
    fi
    caps=""
    if [[ "$caps_type" == "array" ]]; then
      # Emit one line per element prefixed with its jq type ("string foo" /
      # "number 7" / "boolean true" / ...). Reading line-by-line preserves
      # element boundaries even if a manifest tries to sneak whitespace in.
      while IFS= read -r cap_line; do
        cap_kind="${cap_line%% *}"
        cap_value="${cap_line#* }"
        if [[ "$cap_kind" != "string" ]]; then
          echo "Manifest skill '$slug' has a non-string entry in capabilities (got $cap_kind)." >&2
          echo "Each value must be a string from the locked taxonomy — see docs/CAPABILITIES.md." >&2
          exit 1
        fi
        if ! is_allowed_capability "$cap_value"; then
          echo "Manifest skill '$slug' declares unknown capability '$cap_value'." >&2
          echo "Allowed values: ${ALLOWED_CAPABILITIES[*]}." >&2
          echo "See docs/CAPABILITIES.md for the taxonomy and how to propose additions." >&2
          exit 1
        fi
        caps+="${caps:+ }$cap_value"
      done < <(jq -r ".skills[$i].capabilities[]? | \"\(type) \(.)\"" "$MANIFEST_PATH")
    fi
    SLUGS+=("$slug")
    SKILL_PATHS+=("$rel_path")
    SKILL_DESCS+=("$desc")
    SKILL_CATS+=("$cat")
    SKILL_SCHEDS+=("$sched")
    SKILL_DEFAULT_ENABLED+=("$default_enabled")
    SKILL_SECRETS_REQ+=("$secrets_req")
    SKILL_SECRETS_OPT+=("$secrets_opt")
    SKILL_CAPS+=("$caps")
  done
else
  echo "No skills-pack.json — falling back to scanning skills/ in $REPO"
  scan_root="$PACK_DIR/skills"
  if [[ ! -d "$scan_root" ]]; then
    echo "Neither skills-pack.json nor skills/ directory found in pack." >&2
    exit 1
  fi
  while IFS= read -r skill_file; do
    sd=$(dirname "$skill_file")
    slug=$(basename "$sd")
    rel_path="skills/$slug"
    SLUGS+=("$slug")
    SKILL_PATHS+=("$rel_path")
    SKILL_DESCS+=("")
    SKILL_CATS+=("")
    SKILL_SCHEDS+=("0 12 * * *")
    SKILL_DEFAULT_ENABLED+=("false")
    SKILL_SECRETS_REQ+=("")
    SKILL_SECRETS_OPT+=("")
    SKILL_CAPS+=("")
  done < <(find "$scan_root" -mindepth 2 -maxdepth 2 -name SKILL.md -type f 2>/dev/null | sort)
fi

if [[ ${#SLUGS[@]} -eq 0 ]]; then
  echo "No skills found in pack." >&2
  exit 1
fi

# Filter by --list / requested-slug subset.
if [[ "$LIST_ONLY" == "true" ]]; then
  echo ""
  echo "Skills in pack '$PACK_LABEL':"
  echo ""
  for i in "${!SLUGS[@]}"; do
    slug="${SLUGS[$i]}"
    desc="${SKILL_DESCS[$i]:-}"
    if [[ -z "$desc" ]]; then
      skill_md="$PACK_DIR/${SKILL_PATHS[$i]}/SKILL.md"
      if [[ -f "$skill_md" ]]; then
        desc=$(skill_fm "$skill_md" description)
      fi
    fi
    if [[ ${#desc} -gt 80 ]]; then desc="${desc:0:77}..."; fi
    local_marker=""
    [[ -d "$SKILLS_DIR/$slug" ]] && local_marker=" (installed)"
    printf "  %-28s %s%s\n" "$slug" "$desc" "$local_marker"
  done
  echo ""
  echo "${#SLUGS[@]} skills in pack. Re-run without --list to install."
  exit 0
fi

# Restrict to requested slugs if specified.
if [[ ${#REQUESTED_SLUGS[@]} -gt 0 ]]; then
  FILTERED_SLUGS=()
  FILTERED_PATHS=()
  FILTERED_DESCS=()
  FILTERED_CATS=()
  FILTERED_SCHEDS=()
  FILTERED_DEFAULT=()
  FILTERED_SECRETS_REQ=()
  FILTERED_SECRETS_OPT=()
  FILTERED_CAPS=()
  for want in "${REQUESTED_SLUGS[@]}"; do
    found=false
    for i in "${!SLUGS[@]}"; do
      if [[ "${SLUGS[$i]}" == "$want" ]]; then
        FILTERED_SLUGS+=("${SLUGS[$i]}")
        FILTERED_PATHS+=("${SKILL_PATHS[$i]}")
        FILTERED_DESCS+=("${SKILL_DESCS[$i]}")
        FILTERED_CATS+=("${SKILL_CATS[$i]}")
        FILTERED_SCHEDS+=("${SKILL_SCHEDS[$i]}")
        FILTERED_DEFAULT+=("${SKILL_DEFAULT_ENABLED[$i]}")
        FILTERED_SECRETS_REQ+=("${SKILL_SECRETS_REQ[$i]}")
        FILTERED_SECRETS_OPT+=("${SKILL_SECRETS_OPT[$i]}")
        FILTERED_CAPS+=("${SKILL_CAPS[$i]}")
        found=true
        break
      fi
    done
    if [[ "$found" == "false" ]]; then
      echo "Requested skill '$want' is not in this pack — aborting." >&2
      exit 1
    fi
  done
  SLUGS=("${FILTERED_SLUGS[@]}")
  SKILL_PATHS=("${FILTERED_PATHS[@]}")
  SKILL_DESCS=("${FILTERED_DESCS[@]}")
  SKILL_CATS=("${FILTERED_CATS[@]}")
  SKILL_SCHEDS=("${FILTERED_SCHEDS[@]}")
  SKILL_DEFAULT_ENABLED=("${FILTERED_DEFAULT[@]}")
  SKILL_SECRETS_REQ=("${FILTERED_SECRETS_REQ[@]}")
  SKILL_SECRETS_OPT=("${FILTERED_SECRETS_OPT[@]}")
  SKILL_CAPS=("${FILTERED_CAPS[@]}")
fi

# Check trusted-sources for the pack repo.
TRUSTED_SOURCE=false
if skill_is_trusted "$REPO" "$TRUSTED_FILE"; then
  TRUSTED_SOURCE=true
  echo "Source $REPO is trusted — skipping deep security scan."
fi

echo ""
if [[ "$DRY_RUN" == "true" ]]; then
  echo "DRY RUN — would install ${#SLUGS[@]} skill(s) from $PACK_LABEL:"
else
  echo "Installing ${#SLUGS[@]} skill(s) from $PACK_LABEL..."
fi
echo ""

INSTALLED=0
SKIPPED=0
FAILED=0

# Run scanner and prompt on HIGH findings.
# Returns: 0 = clean or accepted, 1 = blocked.
scan_and_prompt() {
  local skill="$1"
  local skill_md="$2"
  if [[ "$TRUSTED_SOURCE" == "true" ]] || [[ ! -x "$SCANNER" ]]; then
    return 0
  fi
  local scan_output
  if scan_output=$("$SCANNER" "$skill_md" 2>&1); then
    echo "  ✓ security scan passed: $skill"
    return 0
  fi
  echo "  ⚠ HIGH-severity findings on $skill:"
  echo "$scan_output" | sed 's/^/      /'
  if [[ "$FORCE_INSTALL" == "true" ]]; then
    echo "  → installing anyway (--force)"
    return 0
  fi
  if [[ "$ASSUME_YES" == "true" ]]; then
    echo "  → installing anyway (--yes)"
    return 0
  fi
  # Interactive prompt — only available on a TTY.
  if [[ ! -t 0 ]]; then
    echo "  ✗ stdin is not a TTY and --yes/--force not passed — blocking install of $skill"
    return 1
  fi
  printf "  Install %s despite HIGH findings? [y/N]: " "$skill"
  local answer
  read -r answer
  case "$answer" in
    y|Y|yes|YES) return 0 ;;
    *) return 1 ;;
  esac
}

# Pre-flight: ensure scanner is available when needed.
if [[ "$TRUSTED_SOURCE" == "false" ]] && [[ ! -x "$SCANNER" ]]; then
  echo "Warning: security scanner not found at $SCANNER — skipping per-skill scans."
fi

# Surface required/optional secrets declared by the manifest. Loud warning, no
# gate — the operator may install dry-run, or wire the secret afterward before
# the first scheduled run. Called after scan_and_prompt clears, before file copy.
#
# Names that don't look like POSIX env-var identifiers ([A-Za-z_][A-Za-z0-9_]*)
# are flagged as malformed instead of being passed to `${!var}` — indirect
# expansion of `FOO-BAR` or `1FOO` triggers a bad-substitution error that
# `set -u` turns into a script abort, and a manifest is untrusted input.
warn_missing_secrets() {
  local skill="$1"
  local req_list="$2"   # space-separated env var names, may be empty
  local opt_list="$3"   # space-separated env var names, may be empty
  if [[ -n "$req_list" ]]; then
    local missing=() malformed=()
    for var in $req_list; do
      if ! [[ "$var" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]]; then
        malformed+=("$var")
        continue
      fi
      # Indirect expansion — empty means unset OR set-to-empty (both block the skill).
      # Safe now: identifier was validated above.
      if [[ -z "${!var:-}" ]]; then
        missing+=("$var")
      fi
    done
    if [[ ${#missing[@]} -gt 0 ]]; then
      echo "  ⚠ secrets_required missing for $skill: ${missing[*]}"
      echo "      set these in \`secrets:\` of your workflow before the first scheduled run"
    fi
    if [[ ${#malformed[@]} -gt 0 ]]; then
      echo "  ⚠ secrets_required for $skill includes malformed env-var names (skipped): ${malformed[*]}"
      echo "      pack maintainer: each entry must match [A-Za-z_][A-Za-z0-9_]*"
    fi
  fi
  if [[ -n "$opt_list" ]]; then
    echo "  · secrets_optional for $skill (tunes behaviour, not required): $opt_list"
  fi
}

# skills.json mutation needs jq — same dependency as add-skill.
if ! command -v jq >/dev/null 2>&1; then
  echo "jq is required (used to mutate skills.json and skills.lock)." >&2
  exit 1
fi

record_provenance() {
  local slug="$1"
  local source_path="$2"
  local commit_sha
  # -X GET is required: `gh api` switches to POST as soon as a -f field is
  # present, and POST /repos/{o}/{r}/commits is not an endpoint — it 404s, and
  # gh prints the error body on STDOUT, so the old form captured
  # '{"message":"Not Found",...}unknown' and wrote that into skills.lock as the
  # commit_sha of every installed skill. -f sha= pins the log to the branch we
  # actually fetched instead of the repo default.
  # $source_path is relative to the PACK dir; the API wants it relative to the
  # repo root, so a --path pack has to carry that prefix or every lookup misses
  # and the whole pack records commit_sha "unknown".
  local api_path="${SUBPATH:+$SUBPATH/}$source_path/SKILL.md"
  commit_sha=$(gh api -X GET "repos/$REPO/commits" \
    -f path="$api_path" -f sha="$BRANCH" -f per_page=1 \
    --jq '.[0].sha' 2>/dev/null || true)
  # Anything that isn't a 40-char hex object name is a failure, not a sha.
  [[ "$commit_sha" =~ ^[0-9a-f]{40}$ ]] || commit_sha="unknown"
  local entry
  entry=$(jq -n \
    --arg name "$slug" \
    --arg repo "$REPO" \
    --arg path "$source_path/SKILL.md" \
    --arg branch "$BRANCH" \
    --arg sha "$commit_sha" \
    --arg at "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" \
    --arg pack "$PACK_LABEL" \
    '{skill_name: $name, source_repo: $repo, source_path: $path, branch: $branch, commit_sha: $sha, imported_at: $at, pack: $pack}')
  skill_lock_upsert "$SKILLS_LOCK" "$entry"
  echo "    -> skills.lock updated (sha: ${commit_sha:0:7})"
}

update_skills_json() {
  local slug="$1"
  local desc="$2"
  local category="$3"
  # NB: schedule is intentionally not written here — it's per-deployment config
  # that lives in aeon.yml, not in the canonical catalog (matches generate-skills-json).
  [[ ! -f "$SKILLS_JSON" ]] && return 0
  local sha7="unknown"
  if [[ -f "$SKILLS_LOCK" ]]; then
    sha7=$(jq -r --arg name "$slug" '[.[] | select(.skill_name == $name)][0].commit_sha // "unknown" | .[0:7]' "$SKILLS_LOCK" 2>/dev/null || echo "unknown")
  fi
  local today
  today=$(date -u '+%Y-%m-%d')
  local install_cmd="bin/install-skill-pack $REPO $slug"
  local entry
  entry=$(jq -n \
    --arg slug "$slug" \
    --arg name "$slug" \
    --arg description "$desc" \
    --arg category "${category:-research}" \
    --arg sha "$sha7" \
    --arg updated "$today" \
    --arg install "$install_cmd" \
    --arg source_repo "$REPO" \
    --arg pack "$PACK_LABEL" \
    '{slug: $slug, name: $name, description: $description, category: $category, var: "", files: 0, sha: $sha, updated: $updated, install: $install, source_repo: $source_repo, pack: $pack}')
  jq --argjson entry "$entry" \
    '.skills = ([.skills[] | select(.slug != $entry.slug)] + [$entry]) | .total = (.skills | length) | .generated = (now | strftime("%Y-%m-%dT%H:%M:%SZ"))' \
    "$SKILLS_JSON" > "${SKILLS_JSON}.tmp" && mv "${SKILLS_JSON}.tmp" "$SKILLS_JSON"
  echo "    -> skills.json updated"
}

add_to_aeon_yml() {
  local slug="$1" schedule="$2" default_enabled="$3" enabled_val="false"
  [[ "$default_enabled" == "true" ]] && enabled_val="true"
  skill_add_to_aeon_yml "$AEON_YML" "$slug" "$enabled_val" "$schedule"
}

for i in "${!SLUGS[@]}"; do
  slug="${SLUGS[$i]}"
  rel_path="${SKILL_PATHS[$i]}"
  desc="${SKILL_DESCS[$i]}"
  category="${SKILL_CATS[$i]}"
  schedule="${SKILL_SCHEDS[$i]}"
  default_enabled="${SKILL_DEFAULT_ENABLED[$i]}"

  src_dir="$PACK_DIR/$rel_path"
  if [[ ! -d "$src_dir" ]] || [[ ! -f "$src_dir/SKILL.md" ]]; then
    echo "  skip: '$slug' (missing $rel_path/SKILL.md in pack)"
    FAILED=$((FAILED + 1))
    continue
  fi

  # Pull description from frontmatter if manifest didn't provide one.
  if [[ -z "$desc" ]]; then
    desc=$(skill_fm "$src_dir/SKILL.md" description)
  fi

  if ! scan_and_prompt "$slug" "$src_dir/SKILL.md"; then
    echo "  ✗ blocked: $slug"
    FAILED=$((FAILED + 1))
    continue
  fi

  warn_missing_secrets "$slug" "${SKILL_SECRETS_REQ[$i]}" "${SKILL_SECRETS_OPT[$i]}"

  # Surface declared capabilities (already validated upstream against the locked
  # taxonomy). Informational only — no gate, no prompt.
  if [[ -n "${SKILL_CAPS[$i]}" ]]; then
    echo "  · capabilities for $slug: ${SKILL_CAPS[$i]}"
  fi

  dest="$SKILLS_DIR/$slug"

  if [[ "$DRY_RUN" == "true" ]]; then
    if [[ -d "$dest" ]]; then
      echo "  would update: $slug"
    else
      echo "  would install: $slug"
    fi
    INSTALLED=$((INSTALLED + 1))
    continue
  fi

  if [[ -d "$dest" ]]; then
    echo "  update: $slug (overwriting existing copy)"
    rm -rf "$dest"
  else
    echo "  install: $slug"
  fi

  cp -r "$src_dir" "$dest"
  INSTALLED=$((INSTALLED + 1))

  record_provenance "$slug" "$rel_path"
  update_skills_json "$slug" "$desc" "$category"
  add_to_aeon_yml "$slug" "$schedule" "$default_enabled"
done

echo ""
echo "==============================="
if [[ "$DRY_RUN" == "true" ]]; then
  echo "Dry run complete: $INSTALLED would install, $FAILED skipped"
else
  echo "Done: $INSTALLED installed, $FAILED skipped/failed"
fi

if [[ "$DRY_RUN" == "false" ]] && [[ $INSTALLED -gt 0 ]]; then
  # Deterministically regenerate the catalog so it can never go stale on the
  # caller. The incremental update_skills_json above is a fast path; this is the
  # source of truth. Critically it refreshes packs.json — generate-packs-json
  # reads skills.lock (written above) and routes the new skills into the
  # synthetic "installed" pack the dashboard surfaces. Leaving this to a caller's
  # follow-up command is exactly how an install lands skills.json-but-not-
  # packs.json and the skill never appears. Best-effort: warn, don't abort.
  if [[ -x "$ROOT_DIR/bin/generate-skills-json" ]]; then
    "$ROOT_DIR/bin/generate-skills-json" >/dev/null 2>&1 \
      && echo "  catalog: skills.json regenerated" \
      || echo "  warning: skills.json regen failed — run bin/generate-skills-json"
  fi
  if [[ -x "$ROOT_DIR/bin/generate-packs-json" ]] && command -v python3 >/dev/null 2>&1; then
    "$ROOT_DIR/bin/generate-packs-json" >/dev/null 2>&1 \
      && echo "  catalog: packs.json regenerated (installed pack)" \
      || echo "  warning: packs.json regen failed — run bin/generate-packs-json"
  fi

  echo ""
  echo "Enable skills in aeon.yml to schedule them, or run manually:"
  echo "  Read skills/<name>/SKILL.md and execute its steps."
  echo ""
  echo "Provenance recorded in skills.lock under pack: $PACK_LABEL"
fi
