#!/usr/bin/env bash # Ensure that any command in a pipeline that fails will cause the entire # pipeline to fail. This is critical for API calls piped to jq. set -o pipefail # Require Bash 4+ (associative arrays used) if [[ -z "${BASH_VERSINFO:-}" || "${BASH_VERSINFO[0]}" -lt 4 ]]; then echo "ERROR: Bash 4+ required. On macOS: brew install bash and ensure it is first in PATH." >&2 exit 1 fi # ============================================================================== # Settings # ============================================================================== # Define base globals. SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SETUP_EDITOR="" declare -A STATIC_SYMLINKS=() # When true, try downloading extensions from OpenVSX first, with a fallback to # VS Marketplace. This is forced to true for the Kiro editor. PREFER_OPENVSX="${PREFER_OPENVSX:-false}" # Global cache to track where version information was found # Key: extension name, Value: "openvsx" or "marketplace" declare -A VERSION_SOURCE_CACHE=() # Define settings as a function, allowing them to run after all other functions # have been defined, using them as needed. define_settings() { # List of config files to symlink from current directory. CONFIG_SOURCES=( "keybindings.json" "mcp.json" "settings.json" "snippets" ) # Additional static symlinks to create (source => target). STATIC_SYMLINKS["cspell/vscode-user-dictionary.txt"]="${HOME}/.cspell/vscode-user-dictionary.txt" STATIC_SYMLINKS["harper-ls/dictionary.txt"]="$(harper_config_dir)/dictionary.txt" STATIC_SYMLINKS["harper-ls/file_dictionaries"]="$(harper_config_dir)/file_dictionaries" STATIC_SYMLINKS["harper-ls/ignored_lints"]="$(harper_config_dir)/ignored_lints" # Conditionally add symlinks for Cursor. if [[ "${SETUP_EDITOR}" == "cursor" ]]; then STATIC_SYMLINKS["cursor/mcp.json"]="${HOME}/.cursor/mcp.json" fi } # ============================================================================== # Help # ============================================================================== show_help() { cat << EOF Usage: $(basename "$0") EDITOR COMMAND [OPTIONS] $(basename "$0") COMMAND EDITOR [OPTIONS] $(basename "$0") config $(basename "$0") shared-extensions [--json] [EDITORS...] Editors: antigravity, agy, a Antigravity editor (prefers OpenVSX) cursor, c Cursor editor kiro, k Kiro editor (uses OpenVSX by default) vscode, code, vsc, v Visual Studio Code vscode-insiders, vsci, i Visual Studio Code Insiders windsurf, surf, w Windsurf editor Commands: config, conf Create symlinks. With editor: full editor + static config. Without editor: static-only symlinks. dump-extensions, dump Export installed extensions to lock file for the specified editor. extensions, ext Install extensions from lock file for the specified editor. install Install a specific extension id for the specified editor (e.g. ms-python.python). shared-extensions, shared Print extensions present in all specified editors. Defaults: cursor, vscode. Use --json for JSON. Options: --latest With 'extensions': install latest versions instead of exact lockfile versions. With 'install': install the latest version of the specified extension. --force-latest Force latest behavior where applicable. Notes: - For 'dump', 'extensions', and 'install', the editor may be given as arg 1 or arg 2; both orders are supported. - Kiro prefers OpenVSX, falling back to VS Marketplace. EOF } # ============================================================================== # Functions # ============================================================================== info() { echo "$@" >&2 } debug() { [[ -n "${DEBUG}" ]] && echo "DEBUG: $*" >&2 } warn() { echo "WARN: $*" >&2 } error() { echo "ERROR: $*" >&2 } fatal() { error "$@" >&2 exit 1 } # Check for required dependencies. check_dependencies() { if ! command -v jq > /dev/null 2>&1; then fatal "jq is not installed. Please install it to continue." fi if ! command -v curl > /dev/null 2>&1; then fatal "curl is not installed. Please install it to continue." fi } # Get extensions lockfile path for current editor. # # Returns: Path to extensions lock file via `STDOUT`. get_extensions_lock() { echo "${SCRIPT_DIR}/extensions.${SETUP_EDITOR}.lock" } # Get extensions lockfile path for a specific editor name. # Does not rely on SETUP_EDITOR. extensions_lock_for_editor() { local editor_name="$1" echo "${SCRIPT_DIR}/extensions.${editor_name}.lock" } # Normalize editor name/synonyms to canonical editor id. # # Returns: canonical id via `STDOUT` or empty string if unknown. normalize_editor_name() { local name name="$(echo "${1:-}" | tr '[:upper:]' '[:lower:]')" case "${name}" in antigravity | agy | a) echo "antigravity" ;; cursor | c) echo "cursor" ;; kiro | k) echo "kiro" ;; vscode | code | vsc | v) echo "vscode" ;; vscode-insiders | code-insiders | insiders | vsci | i) echo "vscode-insiders" ;; windsurf | wind | surf | w) echo "windsurf" ;; *) echo "" ;; esac } # Require an editor to be specified for a command. # # Returns: If no editor is specified, exits program with error. require_editor() { local command="$1" if [[ -z "${SETUP_EDITOR}" ]]; then error "No editor specified for command '${command}'" echo >&2 show_help exit 1 fi } # Read a lockfile and output sorted unique extension ids without versions. extensions_ids_from_lock() { local lock_path="$1" grep -Ev '^\s*(#|$)' "${lock_path}" | cut -d@ -f1 | sort -u } # Determine current platform for OpenVSX downloads. # # Returns: Platform string compatible with OpenVSX via `STDOUT`. get_current_platform() { local os_name local arch_name local platform="" # Detect OS case "$(uname -s)" in "Darwin") os_name="darwin" ;; "Linux") # Check if running on Alpine Linux if [[ -f /etc/alpine-release ]]; then os_name="alpine" else os_name="linux" fi ;; "CYGWIN"* | "MINGW"* | "MSYS"*) os_name="win32" ;; *) fatal "Unsupported platform: $(uname -s)" ;; esac # Detect architecture case "$(uname -m)" in "x86_64" | "amd64") arch_name="x64" ;; "arm64" | "aarch64") arch_name="arm64" ;; "armv7l" | "armv6l") arch_name="armhf" ;; "i386" | "i686") arch_name="x86" ;; *) fatal "Unsupported architecture: $(uname -m)" ;; esac platform="${os_name}-${arch_name}" echo "${platform}" } # Determine editor config directory. # # Returns: Editor config directory path via `STDOUT`. editor_config_dir() { case "$(uname -s)" in "Darwin") case "${SETUP_EDITOR}" in "antigravity") echo "${HOME}/Library/Application Support/Antigravity/User" ;; "cursor") echo "${HOME}/Library/Application Support/Cursor/User" ;; "kiro") echo "${HOME}/Library/Application Support/Kiro/User" ;; "vscode") echo "${HOME}/Library/Application Support/Code/User" ;; "vscode-insiders") echo "${HOME}/Library/Application Support/Code - Insiders/User" ;; "windsurf") echo "${HOME}/Library/Application Support/Windsurf/User" ;; *) fatal "Invalid editor '${SETUP_EDITOR}' for macOS" ;; esac ;; "Linux") case "${SETUP_EDITOR}" in "antigravity") echo "${HOME}/.config/Antigravity/User" ;; "cursor") echo "${HOME}/.config/Cursor/User" ;; "kiro") echo "${HOME}/.config/Kiro/User" ;; "vscode") echo "${HOME}/.config/Code/User" ;; "vscode-insiders") echo "${HOME}/.config/Code - Insiders/User" ;; "windsurf") echo "${HOME}/.config/Windsurf/User" ;; *) fatal "Invalid editor '${SETUP_EDITOR}' for Linux" ;; esac ;; *) fatal "Unsupported operating system" ;; esac } # Determine harper-ls config directory. # # Returns: Harper-ls config directory path via `STDOUT`. harper_config_dir() { case "$(uname -s)" in "Darwin") echo "${HOME}/Library/Application Support/harper-ls" ;; "Linux") if [[ -n "${XDG_CONFIG_HOME}" ]]; then echo "${XDG_CONFIG_HOME}/harper-ls" else echo "${HOME}/.config/harper-ls" fi ;; *) fatal "Unsupported operating system" ;; esac } # Cross-platform function to resolve symlinks. # # Returns: Resolved symlink path via `STDOUT`. resolve_symlink() { local path="$1" if command -v realpath > /dev/null 2>&1; then realpath "$path" elif [[ "$(uname -s)" == "Darwin" ]]; then if command -v python > /dev/null 2>&1; then python -c "import os, sys; print(os.path.realpath(sys.argv[1]))" "$path" elif command -v python3 > /dev/null 2>&1; then python3 -c "import os, sys; print(os.path.realpath(sys.argv[1]))" "$path" elif command -v perl > /dev/null 2>&1; then perl -MCwd=realpath -e 'print realpath(shift)' -- "$path" else readlink "$path" fi else readlink -f "$path" fi } # Strip ANSI escape sequences from stdin and write clean text to stdout. # This removes CSI sequences like ESC[...A/K that UIs use for status lines. strip_ansi_sequences() { awk '{ gsub(/\033\[[0-9;?]*[ -\/]*[@-~]/, ""); print }' } # Backup and symlink. backup_and_link() { local source="$1" local target="$2" local real_target local real_source # Create target directory if it doesn't exist. local target_dir target_dir="$(dirname "${target}")" mkdir -p "${target_dir}" # Check if target already exists. if [[ -e "${target}" ]]; then # If it's a symlink, check if it points to the same location. if [[ -L "${target}" ]]; then real_target="$(resolve_symlink "$target")" real_source="$(resolve_symlink "$source")" if [[ "${real_target}" == "${real_source}" ]]; then info "Skipping ${target} - already linked to ${source}" return fi fi info "Backing up existing ${target} to ${target}.bak" mv "${target}" "${target}.bak" fi # Create symlink. info "Creating symlink for ${source} to ${target}" ln -s "${source}" "${target}" } # Private function: Create editor-specific symlinks. symlink_editor_config() { # Create editor config directory if it doesn't exist. local config_dir config_dir="$(editor_config_dir)" mkdir -p "${config_dir}" local path for path in "${CONFIG_SOURCES[@]}"; do backup_and_link "${SCRIPT_DIR}/${path}" "${config_dir}/${path}" done } # Private function: Create static symlinks. symlink_static_config() { local source local target # Create static symlinks to custom locations. for source in "${!STATIC_SYMLINKS[@]}"; do target="${STATIC_SYMLINKS[${source}]}" backup_and_link "${SCRIPT_DIR}/${source}" "${target}" done } # Find the editor CLI command. # # Returns: Editor command path via `STDOUT`. # Helper to add editor paths for both command names and full paths. _add_editor_paths() { local editor_name="$1" local command_name="$2" local app_name="$3" local paths=("${command_name}") if [[ "$(uname -s)" == "Darwin" ]] && [[ -n "${app_name}" ]]; then local app_locations=( "/Applications" "${HOME}/Applications" "/System/Applications" ) local loc for loc in "${app_locations[@]}"; do if [[ -n "${loc}" ]] && [[ -d "${loc}/${app_name}" ]]; then paths+=("${loc}/${app_name}/Contents/Resources/app/bin/${command_name}") fi done fi editor_paths["${editor_name}"]="${paths[*]}" } # Find the editor CLI command. # # Returns: Editor command path via `STDOUT`. find_editor_cmd() { local editor_cmd="" local possible_commands=() local -A editor_paths # Define editor command names and their possible locations. _add_editor_paths "antigravity" "agy" "Antigravity.app" _add_editor_paths "cursor" "cursor" "Cursor.app" _add_editor_paths "kiro" "kiro" "Kiro.app" _add_editor_paths "vscode" "code" "Visual Studio Code.app" _add_editor_paths "vscode-insiders" "code-insiders" "Visual Studio Code - Insiders.app" _add_editor_paths "windsurf" "windsurf" "Windsurf.app" if [[ -z "${editor_paths[${SETUP_EDITOR}]}" ]]; then fatal "Invalid editor '${SETUP_EDITOR}'" fi # Convert string to array of possible commands/paths. read -r -a possible_commands <<< "${editor_paths[${SETUP_EDITOR}]}" # Check for the command in all possible locations. local cmd for cmd in "${possible_commands[@]}"; do if command -v "${cmd}" > /dev/null 2>&1; then editor_cmd="${cmd}" break fi done if [[ -z "${editor_cmd}" ]]; then fatal "${SETUP_EDITOR} command not found" fi echo "${editor_cmd}" } # Validate extension line format. validate_extension_line() { local line="$1" local extension="" local version="" # Check for exactly one `@` symbol. local at_count at_count=$(echo "${line}" | grep -o "@" | wc -l) if [[ ${at_count} -ne 1 ]]; then warn "Invalid format '${line}' - must contain exactly one '@'" return 1 fi # Extract extension and version parts. extension="${line%@*}" version="${line#*@}" # Validate extension part (should be `.`). if [[ ! "${extension}" =~ ^[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+$ ]]; then warn "Invalid extension format '${extension}' - must be" \ "'publisher.extension'" return 1 fi # Validate version is not empty and contains valid characters. if [[ -z "${version}" ]]; then warn "Empty version for extension '${extension}'" return 1 fi if [[ ! "${version}" =~ ^[a-zA-Z0-9._-]+$ ]]; then warn "Invalid version format '${version}' for extension" \ "'${extension}'" return 1 fi # Check for leading/trailing whitespace. if [[ "${line}" != "${line// /}" ]]; then warn "Extension line contains spaces: '${line}'" return 1 fi return 0 } # Global variable to cache installed extensions. _INSTALLED_EXTENSIONS="" # Get installed extensions with versions, using cache if available. # # Returns: List of installed extensions with versions via `STDOUT`. installed_extensions() { local editor_cmd="$1" # Populate the cache if it's not already populated. if [[ -z "${_INSTALLED_EXTENSIONS}" ]]; then _INSTALLED_EXTENSIONS="$( "${editor_cmd}" --list-extensions --show-versions 2> /dev/null )" fi echo "${_INSTALLED_EXTENSIONS}" } # Get the currently installed version of an extension. # # Returns: Version string of installed extension via `STDOUT` (empty if not installed). get_installed_version() { local editor_cmd="$1" local extension="$2" local extension_pattern extension_pattern="$(printf '%s' "${extension}" | sed 's/[[\.*^()$+?{|]/\\&/g')" # Extract version from cached list. installed_extensions "${editor_cmd}" | grep "^${extension_pattern}@" | sed 's/^[^@]*@//' } # Query latest version of an extension. # # Returns: Latest version string via `STDOUT` on success. query_latest_version() { local extension="$1" local extension_source="marketplace" debug "Querying latest version of ${extension}" if [[ "${PREFER_OPENVSX}" == "true" ]]; then extension_source="openvsx" fi case "${extension_source}" in "openvsx") query_openvsx_latest_version "${extension}" || query_marketplace_latest_version "${extension}" || return 1 ;; "marketplace") query_marketplace_latest_version "${extension}" || query_openvsx_latest_version "${extension}" || return 1 ;; esac } # Query extension metadata from OpenVSX registry. # # Returns: JSON metadata via `STDOUT` on success. query_openvsx_metadata() { local extension="$1" local version="$2" local publisher_id="${extension%%.*}" local extension_id="${extension#*.}" local openvsx_api_url="" if [[ "${version}" == "latest" ]]; then openvsx_api_url="https://open-vsx.org/api/${publisher_id}/${extension_id}" else openvsx_api_url="https://open-vsx.org/api/${publisher_id}/${extension_id}/${version}" fi # Query OpenVSX API and return full JSON debug "Querying OpenVSX API for ${extension}@${version}: ${openvsx_api_url}" if curl --silent --compressed --fail "${openvsx_api_url}" 2> /dev/null; then return 0 else debug "OpenVSX API request failed for ${extension}@${version}" return 1 fi } # Query latest version from OpenVSX registry. # # Returns: Latest version string via `STDOUT` on success. query_openvsx_latest_version() { local extension="$1" # Query OpenVSX metadata and extract latest version if query_openvsx_metadata "${extension}" "latest" | jq -r '.version // empty' 2> /dev/null; then # Cache the successful source VERSION_SOURCE_CACHE["${extension}"]="openvsx" return 0 else return 1 fi } # Query extension metadata from VS Marketplace. # # Returns: JSON metadata via `STDOUT` on success. query_marketplace_metadata() { local extension="$1" local metadata_url="https://marketplace.visualstudio.com/_apis/public/gallery/extensionquery" # Use jq to properly construct JSON local request_data request_data=$(jq -n --arg ext "$extension" '{ filters: [{ criteria: [{ filterType: 7, value: $ext }] }], flags: 2 }') # Query the marketplace and return full JSON debug "Querying VS Marketplace API for ${extension}: ${metadata_url}" if curl --silent --compressed --fail -X POST \ -H "Content-Type: application/json" \ -H "Accept: application/json; api-version=7.2-preview.1" \ -d "${request_data}" "${metadata_url}" 2> /dev/null; then return 0 else debug "VS Marketplace API request failed for ${extension}" return 1 fi } # Query latest version from VS Marketplace. # # Returns: Latest version string via `STDOUT` on success. query_marketplace_latest_version() { local extension="$1" # Query marketplace metadata and extract latest version if query_marketplace_metadata "${extension}" | jq -r '.results[0].extensions[0].versions[0].version // empty' 2> /dev/null; then # Cache the successful source VERSION_SOURCE_CACHE["${extension}"]="marketplace" return 0 else return 1 fi } # Find platform-specific version from VS Marketplace metadata. # # Returns: Version and target platform via `STDOUT` as "version:platform". # Helper to find the latest version for a specific platform. _find_latest_platform_version() { local metadata="$1" local platform="$2" echo "${metadata}" | jq -r \ --arg platform "$platform" \ '.results[0].extensions[0].versions[] | select(.targetPlatform == $platform) | .version' | head -n 1 } # Helper to find the latest universal version. _find_latest_universal_version() { local metadata="$1" echo "${metadata}" | jq -r \ '.results[0].extensions[0].versions[] | select(.targetPlatform == null or .targetPlatform == "universal") | .version' | head -n 1 } # Helper to find the overall latest version. _find_overall_latest_version() { local metadata="$1" local version version=$(echo "${metadata}" | jq -r '.results[0].extensions[0].versions[0].version // empty') local platform platform=$(echo "${metadata}" | jq -r '.results[0].extensions[0].versions[0].targetPlatform // "universal"') echo "${version}:${platform}" } # Helper to find a specific version for a given platform. _find_specific_platform_version() { local metadata="$1" local version="$2" local platform="$3" echo "${metadata}" | jq -r \ --arg version "$version" --arg platform "$platform" \ '.results[0].extensions[0].versions[] | select(.version == $version and .targetPlatform == $platform)' } # Helper to find a specific universal version. _find_specific_universal_version() { local metadata="$1" local version="$2" echo "${metadata}" | jq -r \ --arg version "$version" \ '.results[0].extensions[0].versions[] | select(.version == $version and (.targetPlatform == null or .targetPlatform == "universal"))' } # Helper to find a specific version for any platform. _find_specific_version_any_platform() { local metadata="$1" local version="$2" echo "${metadata}" | jq -r \ --arg version "$version" \ '.results[0].extensions[0].versions[] | select(.version == $version)' | head -n 1 } # Find platform-specific version from VS Marketplace metadata. # # Returns: Version and target platform via `STDOUT` as "version:platform". query_marketplace_platform_version() { local extension="$1" local version="$2" local metadata="$3" local current_platform current_platform="$(get_current_platform)" local install_version="" local target_platform="" # If version is "latest", find the latest version for our platform if [[ "${version}" == "latest" ]]; then install_version=$(_find_latest_platform_version "${metadata}" "${current_platform}") target_platform="${current_platform}" # If no platform-specific version, get the latest universal version if [[ -z "${install_version}" ]]; then install_version=$(_find_latest_universal_version "${metadata}") target_platform="universal" fi # If still no version, get the very latest regardless of platform if [[ -z "${install_version}" ]]; then local latest_info latest_info=$(_find_overall_latest_version "${metadata}") install_version="${latest_info%:*}" target_platform="${latest_info#*:}" fi else # Find the specific version entry for our platform and version local version_info version_info=$(_find_specific_platform_version "${metadata}" "${version}" "${current_platform}") # If no platform-specific version found, try universal if [[ -z "${version_info}" || "${version_info}" == "null" ]]; then version_info=$(_find_specific_universal_version "${metadata}" "${version}") fi # If still no specific version found, use the first version with that version number if [[ -z "${version_info}" || "${version_info}" == "null" ]]; then version_info=$(_find_specific_version_any_platform "${metadata}" "${version}") fi install_version="$version" target_platform=$(echo "${version_info}" | jq -r '.targetPlatform // "universal"') fi if [[ -z "${install_version}" || "${install_version}" == "null" ]]; then return 1 fi echo "${install_version}:${target_platform}" } # Install an extension directly using the marketplace. install_extension_direct() { local editor_cmd="$1" local extension="$2" local version="$3" local force_install="$4" local result=0 if [[ "${version}" == "latest" ]]; then if ! "${editor_cmd}" --install-extension "${extension}" --force 2> /dev/null; then warn "Direct install failed for ${extension}" result=1 fi else local install_cmd=("${editor_cmd}" --install-extension "${extension}@${version}") if [[ "${force_install}" == "true" ]]; then install_cmd+=(--force) fi if ! "${install_cmd[@]}" 2> /dev/null; then warn "Direct install failed for ${extension}@${version}" result=1 fi fi return ${result} } # Download extension from OpenVSX registry. # # Returns: Path to downloaded `.vsix` file via `STDOUT` on success. download_from_openvsx() { local extension="$1" local version="$2" local extensions_cache_dir="$3" local publisher_id="${extension%%.*}" local extension_id="${extension#*.}" local install_version="${version}" local metadata="" local download_url="" local vsix_filename="" local vsix_path="" info "Downloading ${extension}@${version} from OpenVSX..." # If version is "latest", query OpenVSX API for latest version if [[ "${version}" == "latest" ]]; then info "Querying OpenVSX for latest version of ${extension}..." if install_version=$(query_openvsx_latest_version "${extension}"); then if [[ -z "${install_version}" ]]; then error "Could not determine latest version from OpenVSX for ${extension}" return 1 fi info "Latest version of ${extension} from OpenVSX is ${install_version}" else error "Failed to query OpenVSX API for ${extension}" return 1 fi fi # Query extension metadata to get download information info "Querying OpenVSX metadata for ${extension}@${install_version}..." if ! metadata=$(query_openvsx_metadata "${extension}" "${install_version}"); then error "Failed to query OpenVSX metadata for ${extension}@${install_version}" return 1 fi # Check if extension has platform-specific downloads local current_platform current_platform="$(get_current_platform)" # Try to get platform-specific download URL first download_url=$(echo "${metadata}" | jq -r ".downloads[\"${current_platform}\"] // empty" 2> /dev/null) # If no platform-specific version, try universal if [[ -z "${download_url}" || "${download_url}" == "null" ]]; then download_url=$(echo "${metadata}" | jq -r '.downloads.universal // .files.download // empty' 2> /dev/null) fi # Fallback to constructing the URL if no downloads object exists if [[ -z "${download_url}" || "${download_url}" == "null" ]]; then warn "No downloads information found, using fallback URL construction" download_url="https://open-vsx.org/api/${publisher_id}/${extension_id}/${install_version}/file/${publisher_id}.${extension_id}-${install_version}.vsix" fi # Extract filename from URL or construct it if [[ "${download_url}" =~ @([^/]+)\.vsix$ ]]; then # Platform-specific filename (e.g., name-version@platform.vsix) vsix_filename="${publisher_id}.${extension_id}-${install_version}@${BASH_REMATCH[1]}.vsix" else # Universal filename vsix_filename="${publisher_id}.${extension_id}-${install_version}.vsix" fi vsix_path="${extensions_cache_dir}/${vsix_filename}" info "Downloading ${extension}@${install_version} from OpenVSX..." info " - Platform: ${current_platform}" info " - OpenVSX URL: ${download_url}" # Create extensions directory if it doesn't exist mkdir -p "${extensions_cache_dir}" if curl --compressed -L -o "${vsix_path}" --fail "${download_url}" 2> /dev/null; then # Verify the download was successful by checking file size if [[ -s "${vsix_path}" ]]; then info "Successfully downloaded from OpenVSX" echo "${vsix_path}" return 0 else error "OpenVSX download failed (empty file)" rm -f "${vsix_path}" return 1 fi else error "OpenVSX download failed" rm -f "${vsix_path}" return 1 fi } # Download extension from VS Marketplace. # # Returns: Path to downloaded `.vsix` file via `STDOUT` on success. download_from_marketplace() { local extension="$1" local version="$2" local extensions_cache_dir="$3" local publisher_id="${extension%%.*}" local extension_id="${extension#*.}" local metadata="" local version_platform="" local install_version="" local target_platform="" local download_url="" local vsix_filename="" local vsix_path="" info "Downloading ${extension}@${version} from VS Marketplace..." # Create extensions directory if it doesn't exist mkdir -p "${extensions_cache_dir}" # Query extension metadata info "Querying VS Marketplace for ${extension}@${version}..." if ! metadata=$(query_marketplace_metadata "${extension}"); then error "Failed to query VS Marketplace API for ${extension}" return 1 fi # Find the appropriate version and platform if ! version_platform=$(query_marketplace_platform_version "${extension}" "${version}" "${metadata}"); then error "Could not determine version and platform for ${extension}@${version}" return 1 fi # Parse the version:platform result install_version="${version_platform%:*}" target_platform="${version_platform#*:}" if [[ "${version}" == "latest" ]]; then info "Latest version of ${extension} from VS Marketplace is ${install_version}" fi # Extract download URL directly from metadata if [[ "${target_platform}" != "universal" && "${target_platform}" != "null" ]]; then download_url=$( echo "${metadata}" | jq -r --arg version "$install_version" --arg platform "$target_platform" \ '.results[0].extensions[0].versions[] | select(.version == $version and .targetPlatform == $platform) | .files[] | select(.assetType == "Microsoft.VisualStudio.Services.VSIXPackage") | .source' \ 2> /dev/null ) vsix_filename="${publisher_id}.${extension_id}-${install_version}@${target_platform}.vsix" else download_url=$( echo "${metadata}" | jq -r --arg version "$install_version" \ '.results[0].extensions[0].versions[] | select(.version == $version and (.targetPlatform == null or .targetPlatform == "universal")) | .files[] | select(.assetType == "Microsoft.VisualStudio.Services.VSIXPackage") | .source' \ 2> /dev/null ) vsix_filename="${publisher_id}.${extension_id}-${install_version}.vsix" fi if [[ -z "${download_url}" || "${download_url}" == "null" ]]; then error "No download URL found in metadata" return 1 fi vsix_path="${extensions_cache_dir}/${vsix_filename}" info "Downloading ${extension}@${install_version} from VS Marketplace..." info " - Platform: $(get_current_platform) (using ${target_platform})" info " - Marketplace URL: ${download_url}" if curl --compressed -L -o "${vsix_path}" --fail "${download_url}" 2> /dev/null; then if [[ -s "${vsix_path}" ]]; then info "Successfully downloaded from VS Marketplace" echo "${vsix_path}" return 0 else error "VS Marketplace download failed (empty file)" rm -f "${vsix_path}" return 1 fi else error "VS Marketplace download failed" rm -f "${vsix_path}" return 1 fi } # Try downloading extension from OpenVSX first, then fallback to official marketplace. # # Returns: Path to downloaded `.vsix` file via `STDOUT` on success. download_extension_vsix() { local extension="$1" local version="$2" local extensions_cache_dir="$3" local extension_source="marketplace" if [[ "${PREFER_OPENVSX}" == "true" ]]; then extension_source="openvsx" fi # Check if we have cached source information for this extension if [[ -n "${VERSION_SOURCE_CACHE[${extension}]:-}" ]]; then extension_source="${VERSION_SOURCE_CACHE[${extension}]}" debug "Using cached source '${extension_source}' for ${extension}" fi case "${extension_source}" in "openvsx") if download_from_openvsx "${extension}" "${version}" "${extensions_cache_dir}"; then return 0 fi # If cached source fails, fall back to marketplace warn "OpenVSX source failed, trying VS Marketplace" download_from_marketplace "${extension}" "${version}" "${extensions_cache_dir}" return $? ;; "marketplace") if download_from_marketplace "${extension}" "${version}" "${extensions_cache_dir}"; then return 0 fi # If cached source fails, fall back to OpenVSX warn "VS Marketplace source failed, trying OpenVSX" download_from_openvsx "${extension}" "${version}" "${extensions_cache_dir}" return $? ;; *) error "Invalid extension source '${extension_source}'" return 1 ;; esac } # Install an extension via downloading `*.vsix` file. install_extension_via_vsix() { local editor_cmd="$1" local extension="$2" local version="$3" local extensions_cache_dir="$4" local result=0 local vsix_path="" vsix_path="$( download_extension_vsix "${extension}" "${version}" "${extensions_cache_dir}" )" if [[ -n "${vsix_path}" ]]; then # Install the extension from the downloaded `*.vsix` file. # Note: Installing from `*.vsix` automatically overwrites existing versions. info "Installing extension from ${vsix_path}" if ! "${editor_cmd}" --install-extension "${vsix_path}" --force; then warn "Failed to install ${extension} from '*.vsix'" result=1 fi # Clean up the `*.vsix` file after installation attempt, only if within cache dir. if [[ "${vsix_path}" == "${extensions_cache_dir}/"* ]]; then rm -f -- "${vsix_path}" fi else warn "Failed to download ${extension}@${version}.vsix" result=1 fi return ${result} } # Install an extension. install_extension() { local editor_cmd="$1" local extension="$2" local version="$3" local use_latest="$4" local force_install="false" local extensions_cache_dir extensions_cache_dir="${SCRIPT_DIR}/cache/extensions" # Check if already installed and get current version local current_version current_version="$(get_installed_version "${editor_cmd}" "${extension}")" local target_version="${version}" local latest_version if [[ "${use_latest}" != "false" ]]; then target_version="latest" fi if [[ -z "${current_version}" ]]; then # Extension not installed. info "Installing ${extension}@${target_version}" elif [[ "${use_latest}" == "force" ]]; then # Force install latest version. info "Extension ${extension} is installed (current: ${current_version})," \ "attempting to force-reinstall latest version" force_install="true" elif [[ "${current_version}" == "${version}" ]]; then # Exact version already installed. info "Extension ${extension}@${version} is already installed, skipping" return 0 elif [[ "${target_version}" != "latest" ]]; then # Wrong version installed, need to force install. info "Extension ${extension} has wrong version installed" \ "(current: ${current_version}, wanted: ${version})," \ "force-installing ${version}" force_install="true" else info "Extension ${extension} is already installed" \ "(current: ${current_version}), skipping" return 0 fi # Try direct installation first; fall back to downloading `*.vsix` if needed. if [ -n "$FORCE_DOWNLOAD_VSX" ] || ! install_extension_direct "${editor_cmd}" "${extension}" \ "${target_version}" "${force_install}"; then local latest_version if [[ "${use_latest}" != "false" ]]; then info "Checking latest version for ${extension}..." latest_version=$(query_latest_version "${extension}") if [[ -n "${latest_version}" ]]; then info " - Latest available version: ${latest_version}" version="${latest_version}" else error "Could not determine latest version for ${extension}" return 1 fi fi if [[ "${current_version}" == "${version}" ]]; then info "Extension ${extension} is already installed" \ "(current: ${current_version}), skipping" return 0 fi warn "Direct installation failed, trying .vsix download method..." install_extension_via_vsix "${editor_cmd}" "${extension}" \ "${version}" "${extensions_cache_dir}" fi # Clean up extensions directory if empty rmdir "${extensions_cache_dir}" 2> /dev/null || true info "Extension ${extension} installed successfully!" return 0 } # ============================================================================== # Command Functions # ============================================================================== # Dump installed extensions to `extensions.lock`. do_dump_extensions() { local editor_cmd editor_cmd="$(find_editor_cmd)" local current_date current_date="$(date)" local extensions_lock extensions_lock="$(get_extensions_lock)" # Capture editor version information local version_output local editor_version="" local editor_commit="" local editor_arch="" if version_output=$("${editor_cmd}" --version 2> /dev/null); then editor_version=$(echo "${version_output}" | sed -n '1p' | tr -d '\r') editor_commit=$(echo "${version_output}" | sed -n '2p' | tr -d '\r') editor_arch=$(echo "${version_output}" | sed -n '3p' | tr -d '\r') fi { echo "# ${SETUP_EDITOR} Extensions" echo "# Generated on ${current_date}" if [[ -n "${editor_version}" && -n "${editor_commit}" && -n "${editor_arch}" ]]; then echo "# Version: ${editor_version} (${editor_commit}, ${editor_arch})" fi echo "${editor_cmd}" --list-extensions --show-versions 2> /dev/null | strip_ansi_sequences | grep -E '^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+@[A-Za-z0-9._-]+$' } > "${extensions_lock}" info "Extensions list dumped to ${extensions_lock}" } # Public function: Create static symlinks only. do_static_config() { symlink_static_config info "Static symlink setup complete!" } # Public function: Create all symlinks (editor-specific + static). do_config() { symlink_editor_config symlink_static_config info "Symlink setup complete!" } # Install extensions from `extensions.lock`. do_install_extensions() { local editor_cmd editor_cmd="$(find_editor_cmd)" local extensions_lock extensions_lock="$(get_extensions_lock)" local use_latest="${1:-false}" if [[ ! -f "${extensions_lock}" ]]; then fatal "${extensions_lock} not found" fi # Warm the installed extensions cache before we start processing the lockfile. installed_extensions "${editor_cmd}" > /dev/null # Process each extension. local line while IFS= read -r line; do if [[ -n "${line}" && ! "${line}" =~ ^[[:space:]]*# ]]; then # Validate extension line format. if ! validate_extension_line "${line}"; then continue fi local extension local version extension="${line%@*}" version="${line#*@}" install_extension "${editor_cmd}" "${extension}" "${version}" \ "${use_latest}" fi done < "${extensions_lock}" } # Install a specific extension by identifier. do_install_extension() { local extension_id="$1" local use_latest="${2:-false}" local editor_cmd editor_cmd="$(find_editor_cmd)" local extension="" local version="" if [[ -z "${extension_id}" ]]; then error "Extension identifier required" info "Usage:" info " siren EDITOR install EXTENSION_ID" info " siren install EDITOR EXTENSION_ID" exit 1 fi # Parse extension ID - can be just extension name or extension@version if [[ "${extension_id}" =~ @ ]]; then # Extension with specific version if ! validate_extension_line "${extension_id}"; then error "Invalid extension format '${extension_id}'" info "Expected format: publisher.extension or publisher.extension@version" exit 1 fi extension="${extension_id%@*}" version="${extension_id#*@}" else # Extension without version - install latest if [[ ! "${extension_id}" =~ ^[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+$ ]]; then error "Invalid extension format '${extension_id}'" info "Expected format: publisher.extension or publisher.extension@version" exit 1 fi extension="${extension_id}" version="latest" fi # Warm the installed extensions cache installed_extensions "${editor_cmd}" > /dev/null if [[ "${version}" == "latest" ]]; then use_latest="force" fi install_extension "${editor_cmd}" "${extension}" "${version}" "${use_latest}" return 0 } # Compute and print the intersection of extensions across editors. do_shared_extensions() { local -a input_editors local -a editors local -A seen local arg local output_json="false" # Parse optional flags (currently only --json) while [[ $# -gt 0 ]]; do case "$1" in --json) output_json="true" shift ;; --) shift break ;; --*) fatal "Unknown option for shared-extensions: '$1'" ;; *) break ;; esac done if [[ $# -eq 0 ]]; then input_editors=("cursor" "vscode") else input_editors=("$@") fi # Normalize and deduplicate editors. for arg in "${input_editors[@]}"; do local norm norm="$(normalize_editor_name "${arg}")" if [[ -z "${norm}" ]]; then fatal "Unknown editor '${arg}'" fi if [[ -z "${seen[${norm}]:-}" ]]; then editors+=("${norm}") seen["${norm}"]=1 fi done if [[ ${#editors[@]} -eq 0 ]]; then fatal "No valid editors specified" fi # Build intersection iteratively. local result="" local idx=0 for arg in "${editors[@]}"; do local lock lock="$(extensions_lock_for_editor "${arg}")" if [[ ! -f "${lock}" ]]; then fatal "Lock file not found for ${arg}: ${lock}" fi local current current="$(extensions_ids_from_lock "${lock}")" if [[ ${idx} -eq 0 ]]; then result="${current}" else # 'comm' requires sorted input; our helper already sorts. result="$(comm -12 <(printf '%s\n' "${result}") \ <(printf '%s\n' "${current}"))" fi idx=$((idx + 1)) done # Print final result. if [[ "${output_json}" == "true" ]]; then # Convert newline-separated list to JSON array using jq safely printf '%s\n' "${result}" | jq -R -s 'split("\n") | map(select(length>0))' else printf '%s\n' "${result}" fi } # ============================================================================== # Main # ============================================================================== main() { check_dependencies # Show help if no arguments are provided. if [[ $# -lt 1 ]]; then show_help exit 1 fi # Set command or SETUP_EDITOR based on first argument. local command="" if [[ $# -ge 1 ]]; then SETUP_EDITOR="$(normalize_editor_name "$1")" if [[ -z "${SETUP_EDITOR}" ]]; then command="$(echo "$1" | tr '[:upper:]' '[:lower:]')" fi shift 1 fi # Set command or SETUP_EDITOR based on second argument. if [[ $# -ge 1 ]]; then if [[ -z "${command}" ]]; then command="$(echo "$1" | tr '[:upper:]' '[:lower:]')" shift 1 else SETUP_EDITOR="$(normalize_editor_name "$1")" if [[ -n "${SETUP_EDITOR}" ]]; then shift 1 fi fi fi # Handle help command. if [[ " $* " == *" --help "* || " $* " == *" -h "* ]]; then command="help" fi # Handle help command early. if [[ "${command}" == "help" ]]; then show_help exit 0 fi # If still no command, exit with error. if [[ -z "${command}" ]]; then error "No command provided" echo >&2 show_help exit 1 fi # Set PREFER_OPENVSX to true for editors that prefer OpenVSX. if [[ "${SETUP_EDITOR}" == "kiro" || "${SETUP_EDITOR}" == "antigravity" ]]; then PREFER_OPENVSX="true" fi define_settings case "${command}" in config | conf) if [[ -n "${SETUP_EDITOR}" ]]; then do_config else do_static_config fi exit 0 ;; dump-extensions | dump) require_editor "dump" do_dump_extensions exit 0 ;; shared-extensions | shared) do_shared_extensions "$@" exit 0 ;; extensions | ext) require_editor "extensions" local use_latest="false" while [[ $# -gt 0 ]]; do case "$1" in --latest) use_latest="true" shift ;; --force-latest) use_latest="force" shift ;; *) break ;; esac done do_install_extensions "${use_latest}" exit 0 ;; install) require_editor "install" local use_latest_i="false" local extension_arg="" while [[ $# -gt 0 ]]; do case "$1" in --latest) use_latest_i="true" shift ;; --force-latest) use_latest_i="force" shift ;; *) if [[ -z "${extension_arg}" ]]; then extension_arg="$1" shift else break fi ;; esac done if [[ -z "${extension_arg}" ]]; then error "The 'install' command requires exactly one argument: the extension ID." show_help exit 1 fi do_install_extension "${extension_arg}" "${use_latest_i}" exit 0 ;; "") error "No command provided" show_help exit 1 ;; *) error "Unknown command '${command}'" show_help exit 1 ;; esac } # Run main function. main "$@"