mirror of
https://github.com/jimeh/.vscode.d.git
synced 2026-02-19 11:26:39 +00:00
Skills are directories (containing SKILL.md) rather than flat files, so this adds dedicated _add_skill_symlinks() and _cleanup_stale_skills() functions mirroring the existing command symlink pattern. Only symlinks pointing into our source tree are managed; skills from other sources (e.g. ~/.agents/skills/) are left untouched. Also adds the frontend-design-systems skill for systematic visual design consistency rules. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1673 lines
48 KiB
Bash
Executable File
1673 lines
48 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["cspell/vscode-user-dictionary.txt"]="${HOME}/.cspell/vscode-user-dictionary.txt"
|
|
STATIC_SYMLINKS["harper-ls/dictionary.txt"]="$(harper_config_dir)/dictionary.txt"
|
|
STATIC_SYMLINKS["harper-ls/file_dictionaries"]="$(harper_config_dir)/file_dictionaries"
|
|
STATIC_SYMLINKS["harper-ls/ignored_lints"]="$(harper_config_dir)/ignored_lints"
|
|
|
|
# Conditionally add symlinks for Claude (only if CLI is installed).
|
|
if command -v claude &>/dev/null; then
|
|
STATIC_SYMLINKS["claude/CLAUDE.md"]="${HOME}/.claude/CLAUDE.md"
|
|
STATIC_SYMLINKS["claude/settings.json"]="${HOME}/.claude/settings.json"
|
|
STATIC_SYMLINKS["claude/statusline.sh"]="${HOME}/.claude/statusline.sh"
|
|
_add_command_symlinks "claude" "${HOME}/.claude"
|
|
_add_skill_symlinks "claude" "${HOME}/.claude"
|
|
fi
|
|
|
|
# Conditionally add symlinks for Cursor.
|
|
if [[ "${SETUP_EDITOR}" == "cursor" ]]; then
|
|
STATIC_SYMLINKS["cursor/mcp.json"]="${HOME}/.cursor/mcp.json"
|
|
_add_command_symlinks "cursor" "${HOME}/.cursor"
|
|
fi
|
|
}
|
|
|
|
# ==============================================================================
|
|
# Help
|
|
# ==============================================================================
|
|
|
|
show_help() {
|
|
cat << EOF
|
|
Usage:
|
|
$(basename "$0") EDITOR COMMAND [OPTIONS]
|
|
$(basename "$0") COMMAND EDITOR [OPTIONS]
|
|
$(basename "$0") config
|
|
$(basename "$0") shared-extensions [--json] [EDITORS...]
|
|
|
|
Editors:
|
|
antigravity, agy, a Antigravity editor (prefers OpenVSX)
|
|
cursor, c Cursor editor
|
|
kiro, k Kiro editor (uses OpenVSX by default)
|
|
vscode, code, vsc, v Visual Studio Code
|
|
vscode-insiders, vsci, i Visual Studio Code Insiders
|
|
windsurf, surf, w Windsurf editor
|
|
|
|
Commands:
|
|
config, conf Create symlinks.
|
|
With editor: full editor + static config.
|
|
Without editor: static-only symlinks.
|
|
dump-extensions, dump Export installed extensions to lock file
|
|
for the specified editor.
|
|
extensions, ext Install extensions from lock file for the
|
|
specified editor.
|
|
install Install a specific extension id for the
|
|
specified editor (e.g. ms-python.python).
|
|
shared-extensions, shared
|
|
Print extensions present in all specified editors.
|
|
Defaults: cursor, vscode. Use --json for JSON.
|
|
|
|
Options:
|
|
--latest With 'extensions': install latest versions instead
|
|
of exact lockfile versions. With 'install': install
|
|
the latest version of the specified extension.
|
|
--force-latest Force latest behavior where applicable.
|
|
|
|
Notes:
|
|
- For 'dump', 'extensions', and 'install', the editor may be given as
|
|
arg 1 or arg 2; both orders are supported.
|
|
- Kiro prefers OpenVSX, falling back to VS Marketplace.
|
|
EOF
|
|
}
|
|
|
|
# ==============================================================================
|
|
# Functions
|
|
# ==============================================================================
|
|
|
|
info() {
|
|
echo "$@" >&2
|
|
}
|
|
|
|
debug() {
|
|
[[ -n "${DEBUG}" ]] && echo "DEBUG: $*" >&2
|
|
}
|
|
|
|
warn() {
|
|
echo "WARN: $*" >&2
|
|
}
|
|
|
|
error() {
|
|
echo "ERROR: $*" >&2
|
|
}
|
|
|
|
fatal() {
|
|
error "$@" >&2
|
|
exit 1
|
|
}
|
|
|
|
# Check for required dependencies.
|
|
check_dependencies() {
|
|
if ! command -v jq > /dev/null 2>&1; then
|
|
fatal "jq is not installed. Please install it to continue."
|
|
fi
|
|
|
|
if ! command -v curl > /dev/null 2>&1; then
|
|
fatal "curl is not installed. Please install it to continue."
|
|
fi
|
|
}
|
|
|
|
# Get extensions lockfile path for current editor.
|
|
#
|
|
# Returns: Path to extensions lock file via `STDOUT`.
|
|
get_extensions_lock() {
|
|
echo "${SCRIPT_DIR}/extensions.${SETUP_EDITOR}.lock"
|
|
}
|
|
|
|
# Get extensions lockfile path for a specific editor name.
|
|
# Does not rely on SETUP_EDITOR.
|
|
extensions_lock_for_editor() {
|
|
local editor_name="$1"
|
|
echo "${SCRIPT_DIR}/extensions.${editor_name}.lock"
|
|
}
|
|
|
|
# Normalize editor name/synonyms to canonical editor id.
|
|
#
|
|
# Returns: canonical id via `STDOUT` or empty string if unknown.
|
|
normalize_editor_name() {
|
|
local name
|
|
name="$(echo "${1:-}" | tr '[:upper:]' '[:lower:]')"
|
|
case "${name}" in
|
|
antigravity | agy | a)
|
|
echo "antigravity"
|
|
;;
|
|
cursor | c)
|
|
echo "cursor"
|
|
;;
|
|
kiro | k)
|
|
echo "kiro"
|
|
;;
|
|
vscode | code | vsc | v)
|
|
echo "vscode"
|
|
;;
|
|
vscode-insiders | code-insiders | insiders | vsci | i)
|
|
echo "vscode-insiders"
|
|
;;
|
|
windsurf | wind | surf | w)
|
|
echo "windsurf"
|
|
;;
|
|
*)
|
|
echo ""
|
|
;;
|
|
esac
|
|
}
|
|
|
|
# Require an editor to be specified for a command.
|
|
#
|
|
# Returns: If no editor is specified, exits program with error.
|
|
require_editor() {
|
|
local command="$1"
|
|
|
|
if [[ -z "${SETUP_EDITOR}" ]]; then
|
|
error "No editor specified for command '${command}'"
|
|
echo >&2
|
|
show_help
|
|
exit 1
|
|
fi
|
|
}
|
|
|
|
# Read a lockfile and output sorted unique extension ids without versions.
|
|
extensions_ids_from_lock() {
|
|
local lock_path="$1"
|
|
grep -Ev '^\s*(#|$)' "${lock_path}" |
|
|
cut -d@ -f1 |
|
|
sort -u
|
|
}
|
|
|
|
# Determine current platform for OpenVSX downloads.
|
|
#
|
|
# Returns: Platform string compatible with OpenVSX via `STDOUT`.
|
|
get_current_platform() {
|
|
local os_name
|
|
local arch_name
|
|
local platform=""
|
|
|
|
# Detect OS
|
|
case "$(uname -s)" in
|
|
"Darwin")
|
|
os_name="darwin"
|
|
;;
|
|
"Linux")
|
|
# Check if running on Alpine Linux
|
|
if [[ -f /etc/alpine-release ]]; then
|
|
os_name="alpine"
|
|
else
|
|
os_name="linux"
|
|
fi
|
|
;;
|
|
"CYGWIN"* | "MINGW"* | "MSYS"*)
|
|
os_name="win32"
|
|
;;
|
|
*)
|
|
fatal "Unsupported platform: $(uname -s)"
|
|
;;
|
|
esac
|
|
|
|
# Detect architecture
|
|
case "$(uname -m)" in
|
|
"x86_64" | "amd64")
|
|
arch_name="x64"
|
|
;;
|
|
"arm64" | "aarch64")
|
|
arch_name="arm64"
|
|
;;
|
|
"armv7l" | "armv6l")
|
|
arch_name="armhf"
|
|
;;
|
|
"i386" | "i686")
|
|
arch_name="x86"
|
|
;;
|
|
*)
|
|
fatal "Unsupported architecture: $(uname -m)"
|
|
;;
|
|
esac
|
|
|
|
platform="${os_name}-${arch_name}"
|
|
echo "${platform}"
|
|
}
|
|
|
|
# Determine editor config directory.
|
|
#
|
|
# Returns: Editor config directory path via `STDOUT`.
|
|
editor_config_dir() {
|
|
case "$(uname -s)" in
|
|
"Darwin")
|
|
case "${SETUP_EDITOR}" in
|
|
"antigravity")
|
|
echo "${HOME}/Library/Application Support/Antigravity/User"
|
|
;;
|
|
"cursor")
|
|
echo "${HOME}/Library/Application Support/Cursor/User"
|
|
;;
|
|
"kiro")
|
|
echo "${HOME}/Library/Application Support/Kiro/User"
|
|
;;
|
|
"vscode")
|
|
echo "${HOME}/Library/Application Support/Code/User"
|
|
;;
|
|
"vscode-insiders")
|
|
echo "${HOME}/Library/Application Support/Code - Insiders/User"
|
|
;;
|
|
"windsurf")
|
|
echo "${HOME}/Library/Application Support/Windsurf/User"
|
|
;;
|
|
*)
|
|
fatal "Invalid editor '${SETUP_EDITOR}' for macOS"
|
|
;;
|
|
esac
|
|
;;
|
|
"Linux")
|
|
case "${SETUP_EDITOR}" in
|
|
"antigravity")
|
|
echo "${HOME}/.config/Antigravity/User"
|
|
;;
|
|
"cursor")
|
|
echo "${HOME}/.config/Cursor/User"
|
|
;;
|
|
"kiro")
|
|
echo "${HOME}/.config/Kiro/User"
|
|
;;
|
|
"vscode")
|
|
echo "${HOME}/.config/Code/User"
|
|
;;
|
|
"vscode-insiders")
|
|
echo "${HOME}/.config/Code - Insiders/User"
|
|
;;
|
|
"windsurf")
|
|
echo "${HOME}/.config/Windsurf/User"
|
|
;;
|
|
*)
|
|
fatal "Invalid editor '${SETUP_EDITOR}' for Linux"
|
|
;;
|
|
esac
|
|
;;
|
|
*)
|
|
fatal "Unsupported operating system"
|
|
;;
|
|
esac
|
|
}
|
|
|
|
# Determine harper-ls config directory.
|
|
#
|
|
# Returns: Harper-ls config directory path via `STDOUT`.
|
|
harper_config_dir() {
|
|
case "$(uname -s)" in
|
|
"Darwin")
|
|
echo "${HOME}/Library/Application Support/harper-ls"
|
|
;;
|
|
"Linux")
|
|
if [[ -n "${XDG_CONFIG_HOME}" ]]; then
|
|
echo "${XDG_CONFIG_HOME}/harper-ls"
|
|
else
|
|
echo "${HOME}/.config/harper-ls"
|
|
fi
|
|
;;
|
|
*)
|
|
fatal "Unsupported operating system"
|
|
;;
|
|
esac
|
|
}
|
|
|
|
# Cross-platform function to resolve symlinks.
|
|
#
|
|
# Returns: Resolved symlink path via `STDOUT`.
|
|
resolve_symlink() {
|
|
local path="$1"
|
|
if command -v realpath > /dev/null 2>&1; then
|
|
realpath "$path"
|
|
elif [[ "$(uname -s)" == "Darwin" ]]; then
|
|
if command -v python > /dev/null 2>&1; then
|
|
python -c "import os, sys; print(os.path.realpath(sys.argv[1]))" "$path"
|
|
elif command -v python3 > /dev/null 2>&1; then
|
|
python3 -c "import os, sys; print(os.path.realpath(sys.argv[1]))" "$path"
|
|
elif command -v perl > /dev/null 2>&1; then
|
|
perl -MCwd=realpath -e 'print realpath(shift)' -- "$path"
|
|
else
|
|
readlink "$path"
|
|
fi
|
|
else
|
|
readlink -f "$path"
|
|
fi
|
|
}
|
|
|
|
# Strip ANSI escape sequences from stdin and write clean text to stdout.
|
|
# This removes CSI sequences like ESC[...A/K that UIs use for status lines.
|
|
strip_ansi_sequences() {
|
|
awk '{ gsub(/\033\[[0-9;?]*[ -\/]*[@-~]/, ""); print }'
|
|
}
|
|
|
|
# Backup and symlink.
|
|
backup_and_link() {
|
|
local source="$1"
|
|
local target="$2"
|
|
local real_target
|
|
local real_source
|
|
|
|
# Create target directory if it doesn't exist.
|
|
local target_dir
|
|
target_dir="$(dirname "${target}")"
|
|
mkdir -p "${target_dir}"
|
|
|
|
# Check if target already exists.
|
|
if [[ -e "${target}" ]]; then
|
|
# If it's a symlink, check if it points to the same location.
|
|
if [[ -L "${target}" ]]; then
|
|
real_target="$(resolve_symlink "$target")"
|
|
real_source="$(resolve_symlink "$source")"
|
|
if [[ "${real_target}" == "${real_source}" ]]; then
|
|
info "Skipping ${target} - already linked to ${source}"
|
|
return
|
|
fi
|
|
fi
|
|
|
|
info "Backing up existing ${target} to ${target}.bak"
|
|
mv "${target}" "${target}.bak"
|
|
fi
|
|
|
|
# Create symlink.
|
|
info "Creating symlink for ${source} to ${target}"
|
|
ln -s "${source}" "${target}"
|
|
}
|
|
|
|
# Private function: Create editor-specific symlinks.
|
|
symlink_editor_config() {
|
|
# Create editor config directory if it doesn't exist.
|
|
local config_dir
|
|
config_dir="$(editor_config_dir)"
|
|
|
|
mkdir -p "${config_dir}"
|
|
local path
|
|
for path in "${CONFIG_SOURCES[@]}"; do
|
|
backup_and_link "${SCRIPT_DIR}/${path}" "${config_dir}/${path}"
|
|
done
|
|
}
|
|
|
|
# Private function: Create static symlinks.
|
|
symlink_static_config() {
|
|
local source
|
|
local target
|
|
|
|
# Create static symlinks to custom locations.
|
|
for source in "${!STATIC_SYMLINKS[@]}"; do
|
|
target="${STATIC_SYMLINKS[${source}]}"
|
|
backup_and_link "${SCRIPT_DIR}/${source}" "${target}"
|
|
done
|
|
}
|
|
|
|
# Add symlinks for all markdown files in a commands directory.
|
|
# Args: $1 = source subdir (e.g., "claude"), $2 = target dir (e.g., ~/.claude)
|
|
_add_command_symlinks() {
|
|
local source_subdir="$1"
|
|
local target_base="$2"
|
|
local cmd_file
|
|
|
|
for cmd_file in "${SCRIPT_DIR}/${source_subdir}/commands/"*.md; do
|
|
if [[ -f "${cmd_file}" ]]; then
|
|
local filename
|
|
filename="$(basename "${cmd_file}")"
|
|
STATIC_SYMLINKS["${source_subdir}/commands/${filename}"]="${target_base}/commands/${filename}"
|
|
fi
|
|
done
|
|
}
|
|
|
|
# Add symlinks for all skill directories (containing SKILL.md) in a skills
|
|
# directory.
|
|
# Args: $1 = source subdir (e.g., "claude"), $2 = target dir (e.g., ~/.claude)
|
|
_add_skill_symlinks() {
|
|
local source_subdir="$1"
|
|
local target_base="$2"
|
|
local skills_dir="${SCRIPT_DIR}/${source_subdir}/skills"
|
|
|
|
if [[ ! -d "${skills_dir}" ]]; then
|
|
return
|
|
fi
|
|
|
|
local skill_dir
|
|
for skill_dir in "${skills_dir}"/*/; do
|
|
# Skip if glob didn't match anything (returns literal pattern).
|
|
[[ -d "${skill_dir}" ]] || continue
|
|
|
|
# Only treat directories containing SKILL.md as skills.
|
|
if [[ -f "${skill_dir}/SKILL.md" ]]; then
|
|
local skill_name
|
|
skill_name="$(basename "${skill_dir}")"
|
|
STATIC_SYMLINKS["${source_subdir}/skills/${skill_name}"]="${target_base}/skills/${skill_name}"
|
|
fi
|
|
done
|
|
}
|
|
|
|
# Remove stale symlinks in a commands directory that point to source files that
|
|
# no longer exist.
|
|
# Args: $1 = source subdir (e.g., "claude"), $2 = target dir (e.g., ~/.claude)
|
|
_cleanup_stale_commands() {
|
|
local source_subdir="$1"
|
|
local target_base="$2"
|
|
local commands_dir="${target_base}/commands"
|
|
local source_dir="${SCRIPT_DIR}/${source_subdir}/commands"
|
|
|
|
if [[ ! -d "${commands_dir}" ]]; then
|
|
return
|
|
fi
|
|
|
|
local link
|
|
for link in "${commands_dir}"/*; do
|
|
# Skip if glob didn't match anything (returns literal pattern).
|
|
[[ -e "${link}" || -L "${link}" ]] || continue
|
|
|
|
# Skip if not a symlink.
|
|
if [[ ! -L "${link}" ]]; then
|
|
continue
|
|
fi
|
|
|
|
local target
|
|
target="$(readlink "${link}")"
|
|
|
|
# Check if the symlink points to our source directory.
|
|
if [[ "${target}" == "${source_dir}/"* ]]; then
|
|
# If the target file no longer exists, remove the symlink.
|
|
if [[ ! -e "${target}" ]]; then
|
|
info "Removing stale symlink: ${link}"
|
|
rm -f "${link}"
|
|
fi
|
|
fi
|
|
done
|
|
}
|
|
|
|
# Remove stale symlinks in a skills directory that point to source
|
|
# directories that no longer exist. Only touches symlinks pointing into our
|
|
# source tree; symlinks managed by other tools are left untouched.
|
|
# Args: $1 = source subdir (e.g., "claude"), $2 = target dir (e.g., ~/.claude)
|
|
_cleanup_stale_skills() {
|
|
local source_subdir="$1"
|
|
local target_base="$2"
|
|
local skills_dir="${target_base}/skills"
|
|
local source_dir="${SCRIPT_DIR}/${source_subdir}/skills"
|
|
|
|
if [[ ! -d "${skills_dir}" ]]; then
|
|
return
|
|
fi
|
|
|
|
local link
|
|
for link in "${skills_dir}"/*; do
|
|
# Skip if glob didn't match anything (returns literal pattern).
|
|
[[ -e "${link}" || -L "${link}" ]] || continue
|
|
|
|
# Skip if not a symlink.
|
|
if [[ ! -L "${link}" ]]; then
|
|
continue
|
|
fi
|
|
|
|
local target
|
|
target="$(readlink "${link}")"
|
|
|
|
# Check if the symlink points to our source directory.
|
|
if [[ "${target}" == "${source_dir}/"* ]]; then
|
|
# If the target directory no longer exists, remove the symlink.
|
|
if [[ ! -e "${target}" ]]; then
|
|
info "Removing stale symlink: ${link}"
|
|
rm -f "${link}"
|
|
fi
|
|
fi
|
|
done
|
|
}
|
|
|
|
# Find the editor CLI command.
|
|
#
|
|
# Returns: Editor command path via `STDOUT`.
|
|
# Helper to add editor paths for both command names and full paths.
|
|
_add_editor_paths() {
|
|
local editor_name="$1"
|
|
local command_name="$2"
|
|
local app_name="$3"
|
|
local paths=("${command_name}")
|
|
|
|
if [[ "$(uname -s)" == "Darwin" ]] && [[ -n "${app_name}" ]]; then
|
|
local app_locations=(
|
|
"/Applications"
|
|
"${HOME}/Applications"
|
|
"/System/Applications"
|
|
)
|
|
local loc
|
|
for loc in "${app_locations[@]}"; do
|
|
if [[ -n "${loc}" ]] && [[ -d "${loc}/${app_name}" ]]; then
|
|
paths+=("${loc}/${app_name}/Contents/Resources/app/bin/${command_name}")
|
|
fi
|
|
done
|
|
fi
|
|
|
|
editor_paths["${editor_name}"]="${paths[*]}"
|
|
}
|
|
|
|
# Find the editor CLI command.
|
|
#
|
|
# Returns: Editor command path via `STDOUT`.
|
|
find_editor_cmd() {
|
|
local editor_cmd=""
|
|
local possible_commands=()
|
|
local -A editor_paths
|
|
|
|
# Define editor command names and their possible locations.
|
|
_add_editor_paths "antigravity" "agy" "Antigravity.app"
|
|
_add_editor_paths "cursor" "cursor" "Cursor.app"
|
|
_add_editor_paths "kiro" "kiro" "Kiro.app"
|
|
_add_editor_paths "vscode" "code" "Visual Studio Code.app"
|
|
_add_editor_paths "vscode-insiders" "code-insiders" "Visual Studio Code - Insiders.app"
|
|
_add_editor_paths "windsurf" "windsurf" "Windsurf.app"
|
|
|
|
if [[ -z "${editor_paths[${SETUP_EDITOR}]}" ]]; then
|
|
fatal "Invalid editor '${SETUP_EDITOR}'"
|
|
fi
|
|
|
|
# Convert string to array of possible commands/paths.
|
|
read -r -a possible_commands <<< "${editor_paths[${SETUP_EDITOR}]}"
|
|
|
|
# Check for the command in all possible locations.
|
|
local cmd
|
|
for cmd in "${possible_commands[@]}"; do
|
|
if command -v "${cmd}" > /dev/null 2>&1; then
|
|
editor_cmd="${cmd}"
|
|
break
|
|
fi
|
|
done
|
|
|
|
if [[ -z "${editor_cmd}" ]]; then
|
|
fatal "${SETUP_EDITOR} command not found"
|
|
fi
|
|
|
|
echo "${editor_cmd}"
|
|
}
|
|
|
|
# Validate extension line format.
|
|
validate_extension_line() {
|
|
local line="$1"
|
|
local extension=""
|
|
local version=""
|
|
|
|
# Check for exactly one `@` symbol.
|
|
local at_count
|
|
at_count=$(echo "${line}" | grep -o "@" | wc -l)
|
|
if [[ ${at_count} -ne 1 ]]; then
|
|
warn "Invalid format '${line}' - must contain exactly one '@'"
|
|
return 1
|
|
fi
|
|
|
|
# Extract extension and version parts.
|
|
extension="${line%@*}"
|
|
version="${line#*@}"
|
|
|
|
# Validate extension part (should be `<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 [ -n "$FORCE_DOWNLOAD_VSX" ] ||
|
|
! install_extension_direct "${editor_cmd}" "${extension}" \
|
|
"${target_version}" "${force_install}"; then
|
|
|
|
local latest_version
|
|
if [[ "${use_latest}" != "false" ]]; then
|
|
info "Checking latest version for ${extension}..."
|
|
latest_version=$(query_latest_version "${extension}")
|
|
if [[ -n "${latest_version}" ]]; then
|
|
info " - Latest available version: ${latest_version}"
|
|
version="${latest_version}"
|
|
else
|
|
error "Could not determine latest version for ${extension}"
|
|
return 1
|
|
fi
|
|
fi
|
|
|
|
if [[ "${current_version}" == "${version}" ]]; then
|
|
info "Extension ${extension} is already installed" \
|
|
"(current: ${current_version}), skipping"
|
|
return 0
|
|
fi
|
|
|
|
warn "Direct installation failed, trying .vsix download method..."
|
|
install_extension_via_vsix "${editor_cmd}" "${extension}" \
|
|
"${version}" "${extensions_cache_dir}"
|
|
fi
|
|
|
|
# Clean up extensions directory if empty
|
|
rmdir "${extensions_cache_dir}" 2> /dev/null || true
|
|
info "Extension ${extension} installed successfully!"
|
|
return 0
|
|
}
|
|
|
|
# ==============================================================================
|
|
# Command Functions
|
|
# ==============================================================================
|
|
|
|
# Dump installed extensions to `extensions.lock`.
|
|
do_dump_extensions() {
|
|
local editor_cmd
|
|
editor_cmd="$(find_editor_cmd)"
|
|
local current_date
|
|
current_date="$(date)"
|
|
local extensions_lock
|
|
extensions_lock="$(get_extensions_lock)"
|
|
|
|
# Capture editor version information
|
|
local version_output
|
|
local editor_version=""
|
|
local editor_commit=""
|
|
local editor_arch=""
|
|
|
|
if version_output=$("${editor_cmd}" --version 2> /dev/null); then
|
|
editor_version=$(echo "${version_output}" | sed -n '1p' | tr -d '\r')
|
|
editor_commit=$(echo "${version_output}" | sed -n '2p' | tr -d '\r')
|
|
editor_arch=$(echo "${version_output}" | sed -n '3p' | tr -d '\r')
|
|
fi
|
|
|
|
{
|
|
echo "# ${SETUP_EDITOR} Extensions"
|
|
echo "# Generated on ${current_date}"
|
|
if [[ -n "${editor_version}" && -n "${editor_commit}" && -n "${editor_arch}" ]]; then
|
|
echo "# Version: ${editor_version} (${editor_commit}, ${editor_arch})"
|
|
fi
|
|
echo
|
|
"${editor_cmd}" --list-extensions --show-versions 2> /dev/null |
|
|
strip_ansi_sequences |
|
|
grep -E '^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+@[A-Za-z0-9._-]+$'
|
|
} > "${extensions_lock}"
|
|
|
|
info "Extensions list dumped to ${extensions_lock}"
|
|
}
|
|
|
|
# Public function: Create static symlinks only.
|
|
do_static_config() {
|
|
symlink_static_config
|
|
info "Static symlink setup complete!"
|
|
}
|
|
|
|
# Public function: Create all symlinks (editor-specific + static).
|
|
do_config() {
|
|
symlink_editor_config
|
|
symlink_static_config
|
|
|
|
# Clean up stale command and skill symlinks.
|
|
if command -v claude &>/dev/null; then
|
|
_cleanup_stale_commands "claude" "${HOME}/.claude"
|
|
_cleanup_stale_skills "claude" "${HOME}/.claude"
|
|
fi
|
|
if [[ "${SETUP_EDITOR}" == "cursor" ]]; then
|
|
_cleanup_stale_commands "cursor" "${HOME}/.cursor"
|
|
fi
|
|
|
|
info "Symlink setup complete!"
|
|
}
|
|
|
|
# Install extensions from `extensions.lock`.
|
|
do_install_extensions() {
|
|
local editor_cmd
|
|
editor_cmd="$(find_editor_cmd)"
|
|
local extensions_lock
|
|
extensions_lock="$(get_extensions_lock)"
|
|
local use_latest="${1:-false}"
|
|
|
|
if [[ ! -f "${extensions_lock}" ]]; then
|
|
fatal "${extensions_lock} not found"
|
|
fi
|
|
|
|
# Warm the installed extensions cache before we start processing the lockfile.
|
|
installed_extensions "${editor_cmd}" > /dev/null
|
|
|
|
# Process each extension.
|
|
local line
|
|
while IFS= read -r line; do
|
|
if [[ -n "${line}" && ! "${line}" =~ ^[[:space:]]*# ]]; then
|
|
# Validate extension line format.
|
|
if ! validate_extension_line "${line}"; then
|
|
continue
|
|
fi
|
|
|
|
local extension
|
|
local version
|
|
extension="${line%@*}"
|
|
version="${line#*@}"
|
|
|
|
install_extension "${editor_cmd}" "${extension}" "${version}" \
|
|
"${use_latest}"
|
|
fi
|
|
done < "${extensions_lock}"
|
|
}
|
|
|
|
# Install a specific extension by identifier.
|
|
do_install_extension() {
|
|
local extension_id="$1"
|
|
local use_latest="${2:-false}"
|
|
local editor_cmd
|
|
editor_cmd="$(find_editor_cmd)"
|
|
local extension=""
|
|
local version=""
|
|
|
|
if [[ -z "${extension_id}" ]]; then
|
|
error "Extension identifier required"
|
|
info "Usage:"
|
|
info " siren EDITOR install EXTENSION_ID"
|
|
info " siren install EDITOR EXTENSION_ID"
|
|
exit 1
|
|
fi
|
|
|
|
# Parse extension ID - can be just extension name or extension@version
|
|
if [[ "${extension_id}" =~ @ ]]; then
|
|
# Extension with specific version
|
|
if ! validate_extension_line "${extension_id}"; then
|
|
error "Invalid extension format '${extension_id}'"
|
|
info "Expected format: publisher.extension or publisher.extension@version"
|
|
exit 1
|
|
fi
|
|
extension="${extension_id%@*}"
|
|
version="${extension_id#*@}"
|
|
else
|
|
# Extension without version - install latest
|
|
if [[ ! "${extension_id}" =~ ^[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+$ ]]; then
|
|
error "Invalid extension format '${extension_id}'"
|
|
info "Expected format: publisher.extension or publisher.extension@version"
|
|
exit 1
|
|
fi
|
|
extension="${extension_id}"
|
|
version="latest"
|
|
fi
|
|
|
|
# Warm the installed extensions cache
|
|
installed_extensions "${editor_cmd}" > /dev/null
|
|
|
|
if [[ "${version}" == "latest" ]]; then
|
|
use_latest="force"
|
|
fi
|
|
|
|
install_extension "${editor_cmd}" "${extension}" "${version}" "${use_latest}"
|
|
return 0
|
|
}
|
|
|
|
# Compute and print the intersection of extensions across editors.
|
|
do_shared_extensions() {
|
|
local -a input_editors
|
|
local -a editors
|
|
local -A seen
|
|
local arg
|
|
local output_json="false"
|
|
|
|
# Parse optional flags (currently only --json)
|
|
while [[ $# -gt 0 ]]; do
|
|
case "$1" in
|
|
--json)
|
|
output_json="true"
|
|
shift
|
|
;;
|
|
--)
|
|
shift
|
|
break
|
|
;;
|
|
--*)
|
|
fatal "Unknown option for shared-extensions: '$1'"
|
|
;;
|
|
*)
|
|
break
|
|
;;
|
|
esac
|
|
done
|
|
|
|
if [[ $# -eq 0 ]]; then
|
|
input_editors=("cursor" "vscode")
|
|
else
|
|
input_editors=("$@")
|
|
fi
|
|
|
|
# Normalize and deduplicate editors.
|
|
for arg in "${input_editors[@]}"; do
|
|
local norm
|
|
norm="$(normalize_editor_name "${arg}")"
|
|
if [[ -z "${norm}" ]]; then
|
|
fatal "Unknown editor '${arg}'"
|
|
fi
|
|
if [[ -z "${seen[${norm}]:-}" ]]; then
|
|
editors+=("${norm}")
|
|
seen["${norm}"]=1
|
|
fi
|
|
done
|
|
|
|
if [[ ${#editors[@]} -eq 0 ]]; then
|
|
fatal "No valid editors specified"
|
|
fi
|
|
|
|
# Build intersection iteratively.
|
|
local result=""
|
|
local idx=0
|
|
for arg in "${editors[@]}"; do
|
|
local lock
|
|
lock="$(extensions_lock_for_editor "${arg}")"
|
|
if [[ ! -f "${lock}" ]]; then
|
|
fatal "Lock file not found for ${arg}: ${lock}"
|
|
fi
|
|
|
|
local current
|
|
current="$(extensions_ids_from_lock "${lock}")"
|
|
|
|
if [[ ${idx} -eq 0 ]]; then
|
|
result="${current}"
|
|
else
|
|
# 'comm' requires sorted input; our helper already sorts.
|
|
result="$(comm -12 <(printf '%s\n' "${result}") \
|
|
<(printf '%s\n' "${current}"))"
|
|
fi
|
|
idx=$((idx + 1))
|
|
done
|
|
|
|
# Print final result.
|
|
if [[ "${output_json}" == "true" ]]; then
|
|
# Convert newline-separated list to JSON array using jq safely
|
|
printf '%s\n' "${result}" | jq -R -s 'split("\n") | map(select(length>0))'
|
|
else
|
|
printf '%s\n' "${result}"
|
|
fi
|
|
}
|
|
|
|
# ==============================================================================
|
|
# Main
|
|
# ==============================================================================
|
|
|
|
main() {
|
|
check_dependencies
|
|
|
|
# Show help if no arguments are provided.
|
|
if [[ $# -lt 1 ]]; then
|
|
show_help
|
|
exit 1
|
|
fi
|
|
|
|
# Set command or SETUP_EDITOR based on first argument.
|
|
local command=""
|
|
if [[ $# -ge 1 ]]; then
|
|
SETUP_EDITOR="$(normalize_editor_name "$1")"
|
|
if [[ -z "${SETUP_EDITOR}" ]]; then
|
|
command="$(echo "$1" | tr '[:upper:]' '[:lower:]')"
|
|
fi
|
|
shift 1
|
|
fi
|
|
|
|
# Set command or SETUP_EDITOR based on second argument.
|
|
if [[ $# -ge 1 ]]; then
|
|
if [[ -z "${command}" ]]; then
|
|
command="$(echo "$1" | tr '[:upper:]' '[:lower:]')"
|
|
shift 1
|
|
else
|
|
SETUP_EDITOR="$(normalize_editor_name "$1")"
|
|
if [[ -n "${SETUP_EDITOR}" ]]; then
|
|
shift 1
|
|
fi
|
|
fi
|
|
fi
|
|
|
|
# Handle help command.
|
|
if [[ " $* " == *" --help "* || " $* " == *" -h "* ]]; then
|
|
command="help"
|
|
fi
|
|
|
|
# Handle help command early.
|
|
if [[ "${command}" == "help" ]]; then
|
|
show_help
|
|
exit 0
|
|
fi
|
|
|
|
# If still no command, exit with error.
|
|
if [[ -z "${command}" ]]; then
|
|
error "No command provided"
|
|
echo >&2
|
|
show_help
|
|
exit 1
|
|
fi
|
|
|
|
# Set PREFER_OPENVSX to true for editors that prefer OpenVSX.
|
|
if [[ "${SETUP_EDITOR}" == "kiro" ||
|
|
"${SETUP_EDITOR}" == "antigravity" ]]; then
|
|
PREFER_OPENVSX="true"
|
|
fi
|
|
|
|
define_settings
|
|
|
|
case "${command}" in
|
|
config | conf)
|
|
if [[ -n "${SETUP_EDITOR}" ]]; then
|
|
do_config
|
|
else
|
|
do_static_config
|
|
fi
|
|
exit 0
|
|
;;
|
|
dump-extensions | dump)
|
|
require_editor "dump"
|
|
do_dump_extensions
|
|
exit 0
|
|
;;
|
|
shared-extensions | shared)
|
|
do_shared_extensions "$@"
|
|
exit 0
|
|
;;
|
|
extensions | ext)
|
|
require_editor "extensions"
|
|
local use_latest="false"
|
|
while [[ $# -gt 0 ]]; do
|
|
case "$1" in
|
|
--latest)
|
|
use_latest="true"
|
|
shift
|
|
;;
|
|
--force-latest)
|
|
use_latest="force"
|
|
shift
|
|
;;
|
|
*)
|
|
break
|
|
;;
|
|
esac
|
|
done
|
|
do_install_extensions "${use_latest}"
|
|
exit 0
|
|
;;
|
|
install)
|
|
require_editor "install"
|
|
local use_latest_i="false"
|
|
local extension_arg=""
|
|
while [[ $# -gt 0 ]]; do
|
|
case "$1" in
|
|
--latest)
|
|
use_latest_i="true"
|
|
shift
|
|
;;
|
|
--force-latest)
|
|
use_latest_i="force"
|
|
shift
|
|
;;
|
|
*)
|
|
if [[ -z "${extension_arg}" ]]; then
|
|
extension_arg="$1"
|
|
shift
|
|
else
|
|
break
|
|
fi
|
|
;;
|
|
esac
|
|
done
|
|
if [[ -z "${extension_arg}" ]]; then
|
|
error "The 'install' command requires exactly one argument: the extension ID."
|
|
show_help
|
|
exit 1
|
|
fi
|
|
do_install_extension "${extension_arg}" "${use_latest_i}"
|
|
exit 0
|
|
;;
|
|
"")
|
|
error "No command provided"
|
|
show_help
|
|
exit 1
|
|
;;
|
|
*)
|
|
error "Unknown command '${command}'"
|
|
show_help
|
|
exit 1
|
|
;;
|
|
esac
|
|
}
|
|
|
|
# Run main function.
|
|
main "$@"
|