Files
.vscode.d/siren
Jim Myhrberg e2b2a221ca feat(ext): expand workspace recommended extensions to extensions shared between Cursor and VSCode
This is done via the help of a new `./siren shared` command, and the `.vscode/extensions.json` file.
2025-09-13 06:20:38 +01:00

1465 lines
42 KiB
Bash
Executable File

#!/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 `<publisher>.<extension>`).
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 "$@"