#!/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["claude/CLAUDE.md"]="${HOME}/.claude/CLAUDE.md" 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 `mcp.json` for Cursor. if [[ "${SETUP_EDITOR}" == "cursor" ]]; then STATIC_SYMLINKS["cursor/mcp.json"]="${HOME}/.cursor/mcp.json" 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 cursor | c) echo "cursor" ;; vscode | code | vsc | v) echo "vscode" ;; vscode-insiders | code-insiders | insiders | vsci | i) echo "vscode-insiders" ;; windsurf | wind | surf | w) echo "windsurf" ;; kiro | k) echo "kiro" ;; *) echo "" ;; esac } # 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 } # ============================================================================== # Help # ============================================================================== show_help() { cat << EOF Usage: $(basename "$0") EDITOR COMMAND [OPTIONS] $(basename "$0") config $(basename "$0") shared-extensions [--json] [EDITORS...] Editors: cursor, c Cursor editor kiro, k Kiro editor 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 for editor config files dump-extensions, dump Export installed editor extensions to a lock file. extensions, ext Install editor extensions from a lock file. install Install a specific extension by identifier (e.g., ms-python.python). If no version is specified, installs the latest version. shared-extensions, shared Print extensions present in all specified editors. Defaults to comparing: cursor, vscode Use --json to output a JSON array Options: --latest When used with the extensions command, installs the latest version of each extension instead of the exact version from the lock file. Special Usage: config, conf When used without an editor, creates only static symlinks (CLAUDE.md, dictionaries, etc.) Description: This script manages editor configuration files and extensions. It can create symlinks for settings, keybindings, and snippets, as well as dump extension lock files and install extensions from them. It prefers OpenVSX for Kiro, 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 } # 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 "cursor") echo "${HOME}/Library/Application Support/Cursor/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" ;; "kiro") echo "${HOME}/Library/Application Support/Kiro/User" ;; *) fatal "Invalid editor '${SETUP_EDITOR}' for macOS" ;; esac ;; "Linux") case "${SETUP_EDITOR}" in "cursor") echo "${HOME}/.config/Cursor/User" ;; "vscode") echo "${HOME}/.config/Code/User" ;; "vscode-insiders") echo "${HOME}/.config/Code - Insiders/User" ;; "windsurf") echo "${HOME}/.config/Windsurf/User" ;; "kiro") echo "${HOME}/.config/Kiro/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 "cursor" "cursor" "Cursor.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" _add_editor_paths "kiro" "kiro" "Kiro.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 ! 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)" { echo "# ${SETUP_EDITOR} Extensions" echo "# Generated on ${current_date}" 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: siren EDITOR install 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 # Handle help command. if [[ "$1" == "help" || " $* " == *" --help "* || " $* " == *" -h "* ]]; then show_help exit 0 fi # Normalize first argument. local first_arg first_arg="$(echo "${1}" | tr '[:upper:]' '[:lower:]')" # Handle standalone top-level commands. case "${first_arg}" in config | conf) define_settings do_static_config exit 0 ;; shared-extensions | shared) shift 1 # Remaining arguments are optional editor names do_shared_extensions "$@" exit 0 ;; esac # Set editor from first argument using normalization. SETUP_EDITOR="$(normalize_editor_name "${first_arg}")" if [[ -z "${SETUP_EDITOR}" ]]; then error "Unsupported editor '${first_arg}'" show_help exit 1 fi if [[ "${SETUP_EDITOR}" == "kiro" ]]; then PREFER_OPENVSX="true" fi # Require at least two arguments from this point on (editor and command). if [[ $# -lt 2 ]]; then error "No command specified for editor '${1}'" show_help exit 1 fi # Initialize settings now that SETUP_EDITOR is known. define_settings # Get command and shift arguments. local command="${2}" shift 2 # Parse options. local use_latest="false" local extension_id="" local extra_args=() while [[ $# -gt 0 ]]; do case "$1" in --latest) use_latest="true" shift ;; --force-latest) use_latest="force" shift ;; *) extra_args+=("$1") shift ;; esac done # Handle commands. case "${command}" in "help" | "h") show_help exit 0 ;; "config" | "conf") do_config ;; "dump-extensions" | "dump") do_dump_extensions ;; "extensions" | "ext") do_install_extensions "${use_latest}" ;; "install") if [[ ${#extra_args[@]} -ne 1 ]]; then error "The 'install' command requires exactly one argument: the extension ID." show_help exit 1 fi do_install_extension "${extra_args[0]}" ;; "") error "No command provided" show_help exit 1 ;; *) error "Unknown command '${command}'" show_help exit 1 ;; esac } # Run main function. main "$@"