Files
.vscode.d/siren

1307 lines
38 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
# ==============================================================================
# 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"
}
# ==============================================================================
# Help
# ==============================================================================
show_help() {
cat << EOF
Usage: $(basename "$0") EDITOR COMMAND [OPTIONS]
$(basename "$0") config
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.
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.
EOF
}
# ==============================================================================
# Functions
# ==============================================================================
info() {
echo "$1" >&2
}
debug() {
[[ -n "${DEBUG}" ]] && echo "DEBUG: $1" >&2
}
warn() {
echo "WARN: $1" >&2
}
error() {
echo "ERROR: $1" >&2
}
fatal() {
error "$1" >&2
exit 1
}
# 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"
;;
*)
os_name="linux" # Default fallback
;;
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"
;;
*)
arch_name="x64" # Default fallback
;;
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
# Use `printf` to safely pass the path to Python.
python -c "import os, sys; print(os.path.realpath(sys.argv[1]))" "$path"
else
readlink -f "$path"
fi
}
# 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}"
for path in "${CONFIG_SOURCES[@]}"; do
backup_and_link "${SCRIPT_DIR}/${path}" "${config_dir}/${path}"
done
}
# Private function: Create static symlinks.
symlink_static_config() {
# 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`.
find_editor_cmd() {
local editor_cmd=""
local possible_commands=()
case "${SETUP_EDITOR}" in
"cursor")
# Set possible Cursor CLI command locations.
possible_commands=(
"cursor"
"/Applications/Cursor.app/Contents/Resources/app/bin/cursor"
"${HOME}/Applications/Cursor.app/Contents/Resources/app/bin/cursor"
)
;;
"vscode")
# Set possible VSCode CLI command locations.
possible_commands=(
"code"
"/Applications/Visual Studio Code.app/Contents/Resources/app/bin/code"
"${HOME}/Applications/Visual Studio Code.app/Contents/Resources/app/bin/code"
)
;;
"vscode-insiders")
# Set possible VSCode Insiders CLI command locations.
possible_commands=(
"code-insiders"
"/Applications/Visual Studio Code - Insiders.app/Contents/Resources/app/bin/code"
"${HOME}/Applications/Visual Studio Code - Insiders.app/Contents/Resources/app/bin/code"
)
;;
"windsurf")
# Set possible Windsurf CLI command locations.
possible_commands=(
"windsurf"
"/Applications/Windsurf.app/Contents/Resources/app/bin/windsurf"
"${HOME}/Applications/Windsurf.app/Contents/Resources/app/bin/windsurf"
)
;;
"kiro")
# Set possible Kiro CLI command locations.
possible_commands=(
"kiro"
"/Applications/Kiro.app/Contents/Resources/app/bin/kiro"
"${HOME}/Applications/Kiro.app/Contents/Resources/app/bin/kiro"
)
;;
*)
fatal "Invalid editor '${SETUP_EDITOR}'"
;;
esac
# Check for the command in all possible locations.
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"
# Check for jq availability
if ! command -v jq > /dev/null 2>&1; then
return 1
fi
# 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"
# Check for jq availability
if ! command -v jq > /dev/null 2>&1; then
return 1
fi
# 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"
# Check for jq availability
if ! command -v jq > /dev/null 2>&1; then
return 1
fi
# 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".
query_marketplace_platform_version() {
local extension="$1"
local version="$2"
local metadata="$3" # JSON metadata from query_marketplace_metadata
local current_platform
current_platform="$(get_current_platform)"
local install_version=""
local target_platform=""
# Check for jq availability
if ! command -v jq > /dev/null 2>&1; then
return 1
fi
# If version is "latest", find the latest version for our platform
if [[ "${version}" == "latest" ]]; then
# First try to find a version for our specific platform
install_version=$(
echo "${metadata}" | jq -r --arg platform "$current_platform" \
'.results[0].extensions[0].versions[] | select(.targetPlatform == $platform) | .version' \
2> /dev/null | head -1
)
target_platform="$current_platform"
# If no platform-specific version, get the latest universal version
if [[ -z "${install_version}" || "${install_version}" == "null" ]]; then
install_version=$(
echo "${metadata}" | jq -r \
'.results[0].extensions[0].versions[] | select(.targetPlatform == null or .targetPlatform == "universal") | .version' \
2> /dev/null | head -1
)
target_platform="universal"
fi
# If still no version, get the very latest regardless of platform
if [[ -z "${install_version}" || "${install_version}" == "null" ]]; then
install_version=$(
echo "${metadata}" | jq -r '.results[0].extensions[0].versions[0].version // empty' \
2> /dev/null
)
target_platform=$(
echo "${metadata}" | jq -r '.results[0].extensions[0].versions[0].targetPlatform // "universal"' \
2> /dev/null
)
fi
else
# Find the specific version entry for our platform and version
local version_info
version_info=$(
echo "${metadata}" | jq -r --arg version "$version" --arg platform "$current_platform" \
'.results[0].extensions[0].versions[] | select(.version == $version and .targetPlatform == $platform)' \
2> /dev/null
)
# If no platform-specific version found, try universal
if [[ -z "${version_info}" || "${version_info}" == "null" ]]; then
version_info=$(
echo "${metadata}" | jq -r --arg version "$version" \
'.results[0].extensions[0].versions[] | select(.version == $version and (.targetPlatform == null or .targetPlatform == "universal"))' \
2> /dev/null
)
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=$(
echo "${metadata}" | jq -r --arg version "$version" \
'.results[0].extensions[0].versions[] | select(.version == $version)' \
2> /dev/null | head -1
)
fi
install_version="$version"
target_platform=$(echo "${version_info}" | jq -r '.targetPlatform // "universal"' 2> /dev/null)
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..."
# Check for jq availability
if ! command -v jq > /dev/null 2>&1; then
error "jq is required to parse OpenVSX API response"
return 1
fi
# 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..."
# Check for jq availability
if ! command -v jq > /dev/null 2>&1; then
error "jq is required to parse VS Marketplace API response"
return 1
fi
# 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
local 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.
rm -f "${vsix_path}"
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}")"
if [[ "${use_latest}" != "false" ]]; then
if [[ -n "${current_version}" && "${use_latest}" != "force" ]]; then
echo "Extension ${extension} is already installed" \
"(current: ${current_version}), skipping"
return 0
fi
info "Checking latest version for ${extension}..."
local latest_version
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 [[ -z "${current_version}" ]]; then
# Extension not installed.
info "Installing ${extension}@${version}"
elif [[ "${current_version}" == "${version}" ]]; then
# Exact version already installed.
info "Extension ${extension}@${version} is already installed, skipping"
return 0
else
# 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"
fi
# For Cursor we need to download and install from `*.vsix` file, as
# installation via ID fails with a signature verification error.
if [[ "${SETUP_EDITOR}" == "cursor" ]]; then
install_extension_via_vsix "${editor_cmd}" "${extension}" "${version}" \
"${extensions_cache_dir}"
return 0
fi
if ! install_extension_direct "${editor_cmd}" "${extension}" \
"${version}" "${force_install}"; then
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
} > "${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.
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
}
# ==============================================================================
# Main
# ==============================================================================
main() {
if [[ $# -lt 1 ]]; then
error "No editor specified"
show_help
exit 1
fi
if [[ "$1" == "help" || "$1" == "--help" || "$1" == "-h" ]]; then
show_help
exit 0
fi
# Check if first argument is config/conf (standalone mode).
local first_arg
first_arg="$(echo "${1}" | tr '[:upper:]' '[:lower:]')"
if [[ "${first_arg}" == "config" || "${first_arg}" == "conf" ]]; then
define_settings
do_static_config
exit 0
fi
if [[ $# -lt 2 ]]; then
error "No command specified"
show_help
exit 1
fi
# Set editor from first argument.
editor="${first_arg}"
case "${editor}" in
"vscode" | "code" | "vsc" | "v")
SETUP_EDITOR="vscode"
;;
"vscode-insiders" | "code-insiders" | "insiders" | "vsci" | "i")
SETUP_EDITOR="vscode-insiders"
;;
"cursor" | "c")
SETUP_EDITOR="cursor"
;;
"windsurf" | "wind" | "surf" | "w")
SETUP_EDITOR="windsurf"
;;
"kiro" | "k")
SETUP_EDITOR="kiro"
PREFER_OPENVSX="true"
;;
*)
error "Unsupported editor '${editor}'"
info "Supported editors: cursor, kiro, vscode (vsc), vscode-insiders (vsci), windsurf (wind)"
exit 1
;;
esac
# Define settings after `SETUP_EDITOR` is set.
define_settings
# Get command from second argument.
local command="${2}"
shift 2
# Default values for options.
local use_latest="false"
local extension_id=""
# Handle command-specific options.
case "${command}" in
"install" | "inst")
# Handle install command specially since it requires an extension argument
if [[ $# -lt 1 ]]; then
error "Extension identifier required for install command"
info "Usage: siren EDITOR install EXTENSION_ID"
info "Example: siren cursor install ms-python.python"
exit 1
fi
extension_id="$1"
shift
# For install command, reject any additional options
if [[ $# -gt 0 ]]; then
error "Unknown option '$1' for install command"
info "Usage: siren EDITOR install EXTENSION_ID"
exit 1
fi
;;
"extensions" | "ext")
# Parse additional options for other commands
while [[ $# -gt 0 ]]; do
case "$1" in
--latest)
if [[ "${use_latest}" != "force" ]]; then
use_latest="true"
fi
shift
;;
--force-latest)
use_latest="force"
shift
;;
*)
error "Unknown option '$1'"
show_help
exit 1
;;
esac
done
;;
esac
# Handle commands.
case "${command}" in
"config" | "conf")
do_config
;;
"dump-extensions" | "dump")
do_dump_extensions
;;
"extensions" | "ext")
do_install_extensions "${use_latest}"
;;
"install")
do_install_extension "${extension_id}"
;;
"")
error "No command provided"
show_help
exit 1
;;
*)
error "Unknown command '${command}'"
show_help
exit 1
;;
esac
}
# Run main function.
main "$@"