#!/usr/bin/env bash set -euo pipefail # Solana AI Kit Installer # Usage: # curl -fsSL https://aikit.superteam.codes | bash # (aikit.superteam.codes redirects to https://raw.githubusercontent.com/solanabr/ai-kit/main/install.sh) # bash install.sh /path/to/project # bash install.sh --agents /path/to/project # installs into .agents/ instead of .claude/ # bash install.sh --with sendai,jupiter /path/to/project # core skill packs plus these extensions (--with all: every one) # bash install.sh --tier=medium /path/to/project # firewall tier: off, relaxed (default), medium, high # Env: SOLANA_AI_KIT_UPSTREAM overrides the repo cloned (as in update.sh and skills.sh) REPO_URL="${SOLANA_AI_KIT_UPSTREAM:-https://github.com/solanabr/ai-kit.git}" SCRIPT_VERSION="dev" # Parse flags AGENTS_ONLY=false TARGET_ARG="" WITH_SKILLS="" WITH_NEXT=false # Firewall tier. TIER_EXPLICIT separates "the user asked for this one" from the # default: security.json is a protected file, so only an explicit ask re-stamps # an existing one. TIER="relaxed" TIER_EXPLICIT=false TIER_NEXT=false for arg in "$@"; do if [ "$WITH_NEXT" = true ]; then WITH_SKILLS="$WITH_SKILLS,$arg"; WITH_NEXT=false; continue; fi if [ "$TIER_NEXT" = true ]; then TIER="$arg"; TIER_EXPLICIT=true; TIER_NEXT=false; continue; fi case "$arg" in --agents) AGENTS_ONLY=true ;; --with) WITH_NEXT=true ;; --with=*) WITH_SKILLS="$WITH_SKILLS,${arg#--with=}" ;; --tier) TIER_NEXT=true ;; --tier=*) TIER="${arg#--tier=}"; TIER_EXPLICIT=true ;; *) TARGET_ARG="$arg" ;; esac done case "$TIER" in off|relaxed|medium|high) ;; *) printf 'install.sh: unknown --tier "%s" (expected off, relaxed, medium or high)\n' "$TIER" >&2; exit 1 ;; esac TARGET_DIR="${TARGET_ARG:-.}" mkdir -p "$TARGET_DIR" TARGET_DIR="$(cd "$TARGET_DIR" && pwd)" # Set config directory and instruction file based on flag # (--agents targets AGENTS.md readers such as Codex and opencode) if [ "$AGENTS_ONLY" = true ]; then CONFIG_DIR=".agents" INSTR_FILE="AGENTS.md" else CONFIG_DIR=".claude" INSTR_FILE="CLAUDE.md" fi # ── Branding ────────────────────────────────────────────────────────────── # Solana gradient (purple → green), only on interactive truecolor terminals. # NO_COLOR (https://no-color.org) and non-TTY output stay plain. if [ -t 1 ] && [ -z "${NO_COLOR:-}" ] && printf %s "${COLORTERM:-}" | grep -qiE 'truecolor|24bit'; then C1=$'\033[38;2;153;69;255m'; C2=$'\033[38;2;131;98;237m' C3=$'\033[38;2;109;126;220m'; C4=$'\033[38;2;86;155;202m' C5=$'\033[38;2;64;184;184m'; C6=$'\033[38;2;42;212;167m' C7=$'\033[38;2;20;241;149m' CDIM=$'\033[2m'; CRST=$'\033[0m'; CSUB=$'\033[2;38;2;100;100;100m' else C1=""; C2=""; C3=""; C4=""; C5=""; C6=""; C7=""; CDIM=""; CRST=""; CSUB="" fi # Parallel jobs for submodule fetches (network-bound; floor at 8) JOBS="$(getconf _NPROCESSORS_ONLN 2>/dev/null || echo 8)" case "$JOBS" in ''|*[!0-9]*) JOBS=8 ;; esac [ "$JOBS" -ge 8 ] || JOBS=8 print_banner() { printf '%s%s%s\n' "$C1" ' _____ ____ __ ___ _ _____' "$CRST" printf '%s%s%s\n' "$C2" ' / ___// __ \/ / / | / | / / |' "$CRST" printf '%s%s%s\n' "$C3" ' \__ \/ / / / / / /| | / |/ / /| |' "$CRST" printf '%s%s%s\n' "$C4" ' ___/ / /_/ / /___/ ___ |/ /| / ___ |' "$CRST" printf '%s%s%s\n' "$C5" '/____/\____/_____/_/ |_/_/ |_/_/ |_|' "$CRST" printf '%s%s%s\n' "$C6" ' ▄▀█ █ █▄▀ █ ▀█▀' "$CRST" printf '%s%s%s\n' "$C7" ' █▀█ █ █ █ █ █' "$CRST" printf '%s\n\n' "${CSUB} by @SuperteamBR 🇧🇷${CRST}" } # Log helpers — glyph prefixes only; message text stays grep-stable. step() { printf '▸ %s\n' "$*"; } ok() { printf '✓ %s\n' "$*"; } warn() { printf '! %s\n' "$*"; } fail() { printf '✗ %s\n' "$*"; } print_banner TEMP_DIR="$(mktemp -d)" trap 'rm -rf "$TEMP_DIR"' EXIT # Support local source for testing: SOLANA_AI_KIT_LOCAL_SRC=/path/to/repo # (SOLANA_CLAUDE_LOCAL_SRC honored as legacy fallback) LOCAL_SRC="${SOLANA_AI_KIT_LOCAL_SRC:-${SOLANA_CLAUDE_LOCAL_SRC:-}}" if [ -n "$LOCAL_SRC" ] && [ -d "$LOCAL_SRC/.claude" ]; then step "Using local source: $LOCAL_SRC" mkdir -p "$TEMP_DIR/repo" # -R, not -r: -r follows source symlinks, so a pack shipping a self-referential one # fails the copy with ELOOP, and a pack symlinking outside its tree would have the # target's contents copied in. -R keeps links as links. (stripe/ai, which the kit # does not pin, has four such LICENSE loops and is how this was found.) cp -R "$LOCAL_SRC/.claude" "$TEMP_DIR/repo/.claude" cp "$LOCAL_SRC/CLAUDE-solana.md" "$TEMP_DIR/repo/CLAUDE-solana.md" [ -f "$LOCAL_SRC/.mcp.json" ] && cp "$LOCAL_SRC/.mcp.json" "$TEMP_DIR/repo/.mcp.json" [ -f "$LOCAL_SRC/.env.example" ] && cp "$LOCAL_SRC/.env.example" "$TEMP_DIR/repo/.env.example" [ -f "$LOCAL_SRC/.gitmodules" ] && cp "$LOCAL_SRC/.gitmodules" "$TEMP_DIR/repo/.gitmodules" [ -f "$LOCAL_SRC/.claude/VERSION" ] && cp "$LOCAL_SRC/.claude/VERSION" "$TEMP_DIR/repo/.claude/VERSION" [ -f "$LOCAL_SRC/.safe-ai-skill/policy.yaml" ] && cp -R "$LOCAL_SRC/.safe-ai-skill" "$TEMP_DIR/repo/.safe-ai-skill" # CHANGELOG.md stays in the repo — not shipped to user projects else # Resolve latest tagged release; fall back to main (only needed for a network clone) step "Cloning repository..." LATEST_TAG=$(git ls-remote --tags --sort=-v:refname "$REPO_URL" 'refs/tags/v*' 2>/dev/null \ | head -1 | sed 's|.*refs/tags/||; s|\^{}||') BRANCH="${LATEST_TAG:-main}" # No submodules yet: only the skill packs this install keeps are fetched, below. git clone --depth 1 --branch "$BRANCH" "$REPO_URL" "$TEMP_DIR/repo" 2>&1 | tail -1 || true fi # Read version from source [ -f "$TEMP_DIR/repo/.claude/VERSION" ] && SCRIPT_VERSION="$(awk '{print $NF}' "$TEMP_DIR/repo/.claude/VERSION")" # Skill packs: keep the core ext/ packs, plus extensions named with --with or already # installed here (skills/skill-registry.json tiers). select removes the other ext/ # directories, so a clone then fetches only what is left. Runs before the --agents # rewrite below, which would point .gitmodules away from the clone's paths. # Releases without skills.sh have no tiers and vendor every pack. if [ -f "$TEMP_DIR/repo/.claude/bin/skills.sh" ]; then bash "$TEMP_DIR/repo/.claude/bin/skills.sh" select "$TEMP_DIR/repo/.claude" "$TARGET_DIR/$CONFIG_DIR" "$WITH_SKILLS" fi if [ -d "$TEMP_DIR/repo/.git" ] && [ -f "$TEMP_DIR/repo/.gitmodules" ]; then PACK_PATHS=() if [ -f "$TEMP_DIR/repo/.claude/bin/skills.sh" ]; then while IFS= read -r p; do PACK_PATHS+=("$p"); done < <( cd "$TEMP_DIR/repo" && find .claude/skills/ext -mindepth 1 -maxdepth 1 -type d 2>/dev/null | sort ) else PACK_PATHS=(.) fi if [ "${#PACK_PATHS[@]}" -gt 0 ]; then step "Fetching skill packs..." # Parallel + shallow (pins preserved; far faster than serial). Not --recursive: a # pack's own submodules are pinned by that pack's author, not by this kit, and the # install vendors what it fetches, so recursing would copy a third-party tree at an # unrecorded pin into the project (auditor-skill -> trailofbits, CC-BY-SA-4.0; # solana-game -> a second solana-dev at a different commit). The registry records # those nested pins so validate.sh notices when they move; the packs that use one # test for it and fall back when it is absent. if ! git -C "$TEMP_DIR/repo" submodule update -q --init --depth 1 --jobs "$JOBS" -- "${PACK_PATHS[@]}"; then fail "Could not fetch the skill packs from $REPO_URL" exit 1 fi fi fi # The packs about to be vendored must sit at the commits skill-registry.json records. # That record is the only pin the project keeps — the gitfiles go away just below — so a # checkout that drifted from it would deliver a commit nobody here wrote down. A clone is # checked where it was fetched; a local source in place. A source with no gitlinks for # some or all of the packs (a tarball, a mirror, a fork with ext/ as plain files) can # verify only part of them, or none: that is a warning, not a tick, because the gitlink # is the only evidence there is — nothing hashes a pack's content. skills.sh leads a # clean result with ✓ and everything else without one, which is the distinction below. if [ -f "$TEMP_DIR/repo/.claude/bin/skills.sh" ]; then PIN_ROOT="$TEMP_DIR/repo" [ -n "$LOCAL_SRC" ] && PIN_ROOT="$LOCAL_SRC" if PIN_OUT="$(bash "$TEMP_DIR/repo/.claude/bin/skills.sh" pins "$PIN_ROOT" 2>&1)"; then case "$PIN_OUT" in "✓ "*) ok "${PIN_OUT#✓ }" ;; *) warn "$PIN_OUT" ;; esac else printf '%s\n' "$PIN_OUT" fail "A skill pack is not at the commit skill-registry.json records for it" exit 1 fi fi # ext/ skills are vendored copies: drop the fetched checkout's submodule # gitfiles, whose gitdir points into that checkout and would dangle here. if [ -d "$TEMP_DIR/repo/.claude/skills/ext" ]; then find "$TEMP_DIR/repo/.claude/skills/ext" -name .git -type f -exec rm -f {} + fi # …and the surfaces a pack loads into an agent on its own. Stripped here, on the # staging copy, so they never reach the project — and after the pin check above, which # reads the gitlinks of the source rather than this tree. The kit's own ext/ checkout # is never touched: with a local source this tree is a cp -R of it. # shellcheck source=.claude/bin/_pack_strip.sh source "$TEMP_DIR/repo/.claude/bin/_pack_strip.sh" if [ -d "$TEMP_DIR/repo/.claude/skills/ext" ]; then strip_pack_load_surfaces "$TEMP_DIR/repo/.claude/skills/ext"/*/ >/dev/null fi # --agents: the kit ships .claude/ paths in its docs, skills, settings and # .gitmodules. Point them at .agents/ so nothing references a directory this # mode never creates. Left alone: ~/.claude/ (user-global), paths inside the # vendored ext/ repos, bin/ (scripts resolve their own dir), and lines that # already name .agents/ (those are written to handle both modes). # Claude-Code-only files, not installed by --agents. /cleanup turns a fork of the # kit repo into a project (docs/install.md "Using as a GitHub Template"); its paths # describe the kit's own repo, so in an .agents/ project every step of it is false. # Not shipping it beats rewriting it into something plausible but wrong. # The firewall is the same case, for a sharper reason: nothing reads a permission # block under .agents/. Codex reads AGENTS.md plus .agents/skills/ and takes its # hooks from .codex/hooks.json; Cursor, Copilot, Gemini CLI and opencode read # instructions only. A tier record and a generated rule block there would be dead # weight that looks like protection, so --agents installs no firewall at all. AGENTS_SKIP_FILES='commands/cleanup.md commands/firewall.md bin/firewall.sh security.json' agents_paths() { local f for f in "$@"; do [ -f "$f" ] && grep -q '\.claude/' "$f" || continue sed -E '/\.agents\//!{s#(^|[^[:alnum:]_./~-])\.claude/#\1.agents/#g;s#([$][{]CLAUDE_PROJECT_DIR:-[.][}])/\.claude/#\1/.agents/#g;}' \ "$f" > "$f.tmp" && cat "$f.tmp" > "$f" && rm -f "$f.tmp" done } # Claude Code strips HTML comments before the model sees them; Codex and opencode # do not, so the maintainer notes in CLAUDE-solana.md would reach the model as # instructions on every request. Strip them from the AGENTS.md source instead. # Transform once, here: the later cmp/cp both read this file, so a mismatch # would otherwise make every run look like a user edit. strip_md_comments() { awk ' { line = $0; cr = "" if (sub(/\r$/, "", line)) cr = "\r" out = ""; blank = (line ~ /^[ \t]*$/) while (1) { if (inc) { p = index(line, "-->"); if (p == 0) { line = ""; break } line = substr(line, p + 3); inc = 0 } else { p = index(line, "