#!/usr/bin/env bash set -euo pipefail # ============================================================================ # mallard — Install Script # Personal Claude Code toolkit — skills, commands, and setup scripts. # # Usage: # curl -fsSL https://raw.githubusercontent.com/educlopez/mallard/main/install.sh | bash # # Pin a specific version: # MALLARD_VERSION=v0.2.0 curl -fsSL https://raw.githubusercontent.com/educlopez/mallard/main/install.sh | bash # # Override install directory: # MALLARD_INSTALL_DIR=/usr/local/bin curl -fsSL ... | bash # ============================================================================ GITHUB_OWNER="educlopez" GITHUB_REPO="mallard" BINARY_NAME="mallard" # Try to resolve a GitHub token for private-repo downloads. # Order: explicit GITHUB_TOKEN env, GH_TOKEN env, or `gh auth token` if available. resolve_token() { if [ -n "${GITHUB_TOKEN:-}" ]; then printf '%s' "$GITHUB_TOKEN" return fi if [ -n "${GH_TOKEN:-}" ]; then printf '%s' "$GH_TOKEN" return fi if command -v gh >/dev/null 2>&1; then gh auth token 2>/dev/null || true fi } GH_AUTH_TOKEN="$(resolve_token)" # curl with auth header when we have a token (private repo support). curl_auth() { if [ -n "$GH_AUTH_TOKEN" ]; then curl -H "Authorization: Bearer $GH_AUTH_TOKEN" "$@" else curl "$@" fi } # ============================================================================ # Color support # ============================================================================ setup_colors() { if [ -t 1 ] && [ "${TERM:-}" != "dumb" ]; then RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' BLUE='\033[0;34m' CYAN='\033[0;36m' BOLD='\033[1m' DIM='\033[2m' NC='\033[0m' else RED='' GREEN='' YELLOW='' BLUE='' CYAN='' BOLD='' DIM='' NC='' fi } info() { printf '%b[info]%b %s\n' "$BLUE" "$NC" "$*"; } success() { printf '%b[ok]%b %s\n' "$GREEN" "$NC" "$*"; } warn() { printf '%b[warn]%b %s\n' "$YELLOW" "$NC" "$*"; } error() { printf '%b[error]%b %s\n' "$RED" "$NC" "$*" >&2; } fatal() { error "$@"; exit 1; } step() { printf '\n%b%b==>%b %b%s%b\n' "$CYAN" "$BOLD" "$NC" "$BOLD" "$*" "$NC"; } # ============================================================================ # Platform detection # ============================================================================ detect_platform() { local uname_os uname_arch uname_os="$(uname -s)" uname_arch="$(uname -m)" case "$uname_os" in Darwin) OS="darwin"; OS_LABEL="macOS" ;; Linux) OS="linux"; OS_LABEL="Linux" ;; *) fatal "Unsupported OS: $uname_os. Only macOS and Linux are supported." ;; esac case "$uname_arch" in x86_64|amd64) ARCH="amd64" ;; arm64|aarch64) ARCH="arm64" ;; *) fatal "Unsupported architecture: $uname_arch. Only amd64 and arm64 are supported." ;; esac success "Platform: ${OS_LABEL} (${OS}/${ARCH})" } # ============================================================================ # Prerequisites # ============================================================================ check_prerequisites() { local missing=() command -v curl >/dev/null 2>&1 || missing+=("curl") command -v tar >/dev/null 2>&1 || missing+=("tar") if [ ${#missing[@]} -gt 0 ]; then fatal "Missing required tools: ${missing[*]}. Please install them and try again." fi } # ============================================================================ # Version resolution # ============================================================================ resolve_version() { if [ -n "${MALLARD_VERSION:-}" ]; then VERSION_TAG="$MALLARD_VERSION" # Allow user to pass either "v0.2.0" or "0.2.0" case "$VERSION_TAG" in v*) ;; *) VERSION_TAG="v${VERSION_TAG}" ;; esac info "Using pinned version: ${VERSION_TAG}" else info "Fetching latest release from GitHub..." local url="https://api.github.com/repos/${GITHUB_OWNER}/${GITHUB_REPO}/releases/latest" local response http_code body response="$(curl_auth -sL -w '\n%{http_code}' "$url")" \ || fatal "Failed to query GitHub API" http_code="$(printf '%s\n' "$response" | tail -n 1)" body="$(printf '%s\n' "$response" | sed '$d')" if [ "$http_code" = "404" ] && [ -z "$GH_AUTH_TOKEN" ]; then fatal "GitHub API returned 404 — mallard is private. Run \`gh auth login\` first, or export GITHUB_TOKEN before piping to bash." fi if [ "$http_code" != "200" ]; then fatal "GitHub API returned HTTP $http_code. Rate limited? Try again or pin MALLARD_VERSION." fi # Extract tag_name — works without jq VERSION_TAG="$(printf '%s' "$body" \ | sed -n 's/.*"tag_name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' \ | head -n 1)" if [ -z "$VERSION_TAG" ]; then fatal "Could not determine latest version from GitHub API response." fi success "Latest version: ${VERSION_TAG}" fi # Strip leading 'v' for archive naming (goreleaser drops it). VERSION_NUMBER="${VERSION_TAG#v}" } # ============================================================================ # Download + verify # ============================================================================ download_and_install() { local archive_name download_url checksums_url tmpdir archive_name="${BINARY_NAME}_${VERSION_NUMBER}_${OS}_${ARCH}.tar.gz" download_url="https://github.com/${GITHUB_OWNER}/${GITHUB_REPO}/releases/download/${VERSION_TAG}/${archive_name}" checksums_url="https://github.com/${GITHUB_OWNER}/${GITHUB_REPO}/releases/download/${VERSION_TAG}/checksums.txt" tmpdir="$(mktemp -d)" # shellcheck disable=SC2064 trap "rm -rf '$tmpdir'" EXIT info "Downloading ${archive_name}..." if ! curl_auth -sfL -o "${tmpdir}/${archive_name}" "$download_url"; then if [ -z "$GH_AUTH_TOKEN" ]; then fatal "Failed to download (likely auth — mallard is private):\n ${download_url}\n\nRun \`gh auth login\` first, or export GITHUB_TOKEN before retrying." fi fatal "Failed to download:\n ${download_url}\n\nDoes a release exist for ${VERSION_TAG} on ${OS}/${ARCH}?" fi # Sanity: reject suspiciously small files (404 HTML, etc.) local file_size file_size="$(wc -c < "${tmpdir}/${archive_name}" | tr -d '[:space:]')" if [ "$file_size" -lt 1000 ]; then fatal "Downloaded file is suspiciously small (${file_size} bytes). Archive may not exist for this platform." fi success "Downloaded ${archive_name} (${file_size} bytes)" # Verify checksum — fail closed. info "Verifying checksum..." if ! curl_auth -sfL -o "${tmpdir}/checksums.txt" "$checksums_url"; then fatal "Could not download checksums.txt from:\n ${checksums_url}" fi local expected_checksum expected_checksum="$(grep " ${archive_name}\$" "${tmpdir}/checksums.txt" 2>/dev/null | awk '{print $1}' || true)" if [ -z "$expected_checksum" ]; then # Fallback: match without anchor (handles slight format variations) expected_checksum="$(grep "${archive_name}" "${tmpdir}/checksums.txt" 2>/dev/null | awk '{print $1}' | head -n 1 || true)" fi if [ -z "$expected_checksum" ]; then fatal "Archive '${archive_name}' not found in checksums.txt. Refusing to install unverified binary." fi local actual_checksum if command -v sha256sum >/dev/null 2>&1; then actual_checksum="$(sha256sum "${tmpdir}/${archive_name}" | awk '{print $1}')" elif command -v shasum >/dev/null 2>&1; then actual_checksum="$(shasum -a 256 "${tmpdir}/${archive_name}" | awk '{print $1}')" else fatal "No sha256sum or shasum tool found. Cannot verify checksum." fi if [ "$actual_checksum" != "$expected_checksum" ]; then fatal "Checksum mismatch!\n Expected: ${expected_checksum}\n Got: ${actual_checksum}" fi success "Checksum verified (sha256)" # Optional cosign signature verification — requires cosign on PATH. # If absent, we continue with sha256-only verification (does not break existing installs). if command -v cosign >/dev/null 2>&1; then info "cosign found — verifying checksums.txt signature..." local sig_url pem_url sig_url="https://github.com/${GITHUB_OWNER}/${GITHUB_REPO}/releases/download/${VERSION_TAG}/checksums.txt.sig" pem_url="https://github.com/${GITHUB_OWNER}/${GITHUB_REPO}/releases/download/${VERSION_TAG}/checksums.txt.pem" if curl_auth -sfL -o "${tmpdir}/checksums.txt.sig" "$sig_url" && \ curl_auth -sfL -o "${tmpdir}/checksums.txt.pem" "$pem_url"; then if cosign verify-blob \ --certificate="${tmpdir}/checksums.txt.pem" \ --signature="${tmpdir}/checksums.txt.sig" \ --certificate-identity-regexp="^https://github.com/${GITHUB_OWNER}/${GITHUB_REPO}/\.github/workflows/release\.yml@.+\$" \ --certificate-oidc-issuer="https://token.actions.githubusercontent.com" \ "${tmpdir}/checksums.txt" 2>/dev/null; then success "cosign signature verified" else warn "cosign verification failed — checksums.txt signature invalid. Aborting." fatal "cosign verify-blob failed for checksums.txt. Release may be tampered." fi else # Releases published before signing was added ship no .sig/.pem assets; # fail open so those installs keep working (sha256 already verified above). warn "Could not download cosign signature/certificate (release may predate signing) — continuing with sha256-only verification." fi else info "cosign not found — skipping keyless signature check (sha256 verification still applied)." fi # Extract info "Extracting ${BINARY_NAME}..." tar -xzf "${tmpdir}/${archive_name}" -C "$tmpdir" \ || fatal "Failed to extract archive" if [ ! -f "${tmpdir}/${BINARY_NAME}" ]; then fatal "Binary '${BINARY_NAME}' not found in archive" fi # Install destination local install_dir="${MALLARD_INSTALL_DIR:-${HOME}/.local/bin}" mkdir -p "$install_dir" info "Installing to ${install_dir}/${BINARY_NAME}..." if ! install -m 755 "${tmpdir}/${BINARY_NAME}" "${install_dir}/${BINARY_NAME}" 2>/dev/null; then if command -v sudo >/dev/null 2>&1; then warn "Permission denied. Retrying with sudo..." sudo install -m 755 "${tmpdir}/${BINARY_NAME}" "${install_dir}/${BINARY_NAME}" else fatal "Cannot write to ${install_dir}. Set MALLARD_INSTALL_DIR to a writable directory." fi fi INSTALL_DIR="$install_dir" success "Installed ${BINARY_NAME} to ${install_dir}/${BINARY_NAME}" } # ============================================================================ # Next steps # ============================================================================ print_next_steps() { echo "" printf '%b%bInstallation complete!%b\n' "$GREEN" "$BOLD" "$NC" echo "" # Warn if install dir is not in PATH case ":$PATH:" in *":${INSTALL_DIR}:"*) ;; *) warn "${INSTALL_DIR} is not in your PATH." printf ' Add this to your shell profile (~/.bashrc, ~/.zshrc, ...):\n' printf ' %bexport PATH="$PATH:%s"%b\n\n' "$DIM" "$INSTALL_DIR" "$NC" ;; esac printf '%bNext steps:%b\n' "$BOLD" "$NC" printf ' %b1.%b %bmallard update%b install Claude/Codex/OpenCode skills + commands\n' "$CYAN" "$NC" "$BOLD" "$NC" printf ' %b2.%b %bmallard doctor%b verify installation\n' "$CYAN" "$NC" "$BOLD" "$NC" printf ' %b3.%b %bmallard%b launch interactive TUI\n' "$CYAN" "$NC" "$BOLD" "$NC" echo "" printf '%bDocs: https://github.com/%s/%s%b\n' "$DIM" "$GITHUB_OWNER" "$GITHUB_REPO" "$NC" echo "" } # ============================================================================ # Main # ============================================================================ main() { setup_colors step "Detecting platform" detect_platform check_prerequisites step "Resolving version" resolve_version step "Installing ${BINARY_NAME} ${VERSION_TAG}" download_and_install print_next_steps } main "$@"