OpenHands/openhands/cli/vscode_extension.py
Robert Brennan b5e00f577c
Replace All-Hands-AI references with OpenHands (#11287)
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: Engel Nyst <engel.nyst@gmail.com>
Co-authored-by: Engel Nyst <enyst@users.noreply.github.com>
2025-10-26 01:52:45 +02:00

317 lines
12 KiB
Python

import importlib.resources
import json
import os
import pathlib
import subprocess
import tempfile
import urllib.request
from urllib.error import URLError
from openhands.core.logger import openhands_logger as logger
def download_latest_vsix_from_github() -> str | None:
"""Download latest .vsix from GitHub releases.
Returns:
Path to downloaded .vsix file, or None if failed
"""
api_url = 'https://api.github.com/repos/OpenHands/OpenHands/releases'
try:
with urllib.request.urlopen(api_url, timeout=10) as response:
if response.status != 200:
logger.debug(
f'GitHub API request failed with status: {response.status}'
)
return None
releases = json.loads(response.read().decode())
# The GitHub API returns releases in reverse chronological order (newest first).
# We iterate through them and use the first one that matches our extension prefix.
for release in releases:
if release.get('tag_name', '').startswith('ext-v'):
for asset in release.get('assets', []):
if asset.get('name', '').endswith('.vsix'):
download_url = asset.get('browser_download_url')
if not download_url:
continue
with urllib.request.urlopen(
download_url, timeout=30
) as download_response:
if download_response.status != 200:
logger.debug(
f'Failed to download .vsix with status: {download_response.status}'
)
continue
with tempfile.NamedTemporaryFile(
delete=False, suffix='.vsix'
) as tmp_file:
tmp_file.write(download_response.read())
return tmp_file.name
# Found the latest extension release but no .vsix asset
return None
except (URLError, TimeoutError, json.JSONDecodeError) as e:
logger.debug(f'Failed to download from GitHub releases: {e}')
return None
return None
def attempt_vscode_extension_install():
"""Checks if running in a supported editor and attempts to install the OpenHands companion extension.
This is a best-effort, one-time attempt.
"""
# 1. Check if we are in a supported editor environment
is_vscode_like = os.environ.get('TERM_PROGRAM') == 'vscode'
is_windsurf = (
os.environ.get('__CFBundleIdentifier') == 'com.exafunction.windsurf'
or 'windsurf' in os.environ.get('PATH', '').lower()
or any(
'windsurf' in val.lower()
for val in os.environ.values()
if isinstance(val, str)
)
)
if not (is_vscode_like or is_windsurf):
return
# 2. Determine editor-specific commands and flags
if is_windsurf:
editor_command, editor_name, flag_suffix = 'surf', 'Windsurf', 'windsurf'
else:
editor_command, editor_name, flag_suffix = 'code', 'VS Code', 'vscode'
# 3. Check if we've already successfully installed the extension.
flag_dir = pathlib.Path.home() / '.openhands'
flag_file = flag_dir / f'.{flag_suffix}_extension_installed'
extension_id = 'openhands.openhands-vscode'
try:
flag_dir.mkdir(parents=True, exist_ok=True)
if flag_file.exists():
return # Already successfully installed, exit.
except OSError as e:
logger.debug(
f'Could not create or check {editor_name} extension flag directory: {e}'
)
return # Don't proceed if we can't manage the flag.
# 4. Check if the extension is already installed (even without our flag).
if _is_extension_installed(editor_command, extension_id):
print(f'INFO: OpenHands {editor_name} extension is already installed.')
# Create flag to avoid future checks
_mark_installation_successful(flag_file, editor_name)
return
# 5. Extension is not installed, attempt installation.
print(
f'INFO: First-time setup: attempting to install the OpenHands {editor_name} extension...'
)
# Attempt 1: Install from bundled .vsix
if _attempt_bundled_install(editor_command, editor_name):
_mark_installation_successful(flag_file, editor_name)
return # Success! We are done.
# Attempt 2: Download from GitHub Releases
if _attempt_github_install(editor_command, editor_name):
_mark_installation_successful(flag_file, editor_name)
return # Success! We are done.
# TODO: Attempt 3: Install from Marketplace (when extension is published)
# if _attempt_marketplace_install(editor_command, editor_name, extension_id):
# _mark_installation_successful(flag_file, editor_name)
# return # Success! We are done.
# If all attempts failed, inform the user (but don't create flag - allow retry).
print(
'INFO: Automatic installation failed. Please check the OpenHands documentation for manual installation instructions.'
)
print(
f'INFO: Will retry installation next time you run OpenHands in {editor_name}.'
)
def _mark_installation_successful(flag_file: pathlib.Path, editor_name: str) -> None:
"""Mark the extension installation as successful by creating the flag file.
Args:
flag_file: Path to the flag file to create
editor_name: Human-readable name of the editor for logging
"""
try:
flag_file.touch()
logger.debug(f'{editor_name} extension installation marked as successful.')
except OSError as e:
logger.debug(f'Could not create {editor_name} extension success flag file: {e}')
def _is_extension_installed(editor_command: str, extension_id: str) -> bool:
"""Check if the OpenHands extension is already installed.
Args:
editor_command: The command to run the editor (e.g., 'code', 'windsurf')
extension_id: The extension ID to check for
Returns:
bool: True if extension is already installed, False otherwise
"""
try:
process = subprocess.run(
[editor_command, '--list-extensions'],
capture_output=True,
text=True,
check=False,
)
if process.returncode == 0:
installed_extensions = process.stdout.strip().split('\n')
return extension_id in installed_extensions
except Exception as e:
logger.debug(f'Could not check installed extensions: {e}')
return False
def _attempt_github_install(editor_command: str, editor_name: str) -> bool:
"""Attempt to install the extension from GitHub Releases.
Downloads the latest VSIX file from GitHub releases and attempts to install it.
Ensures proper cleanup of temporary files.
Args:
editor_command: The command to run the editor (e.g., 'code', 'windsurf')
editor_name: Human-readable name of the editor (e.g., 'VS Code', 'Windsurf')
Returns:
bool: True if installation succeeded, False otherwise
"""
vsix_path_from_github = download_latest_vsix_from_github()
if not vsix_path_from_github:
return False
github_success = False
try:
process = subprocess.run(
[
editor_command,
'--install-extension',
vsix_path_from_github,
'--force',
],
capture_output=True,
text=True,
check=False,
)
if process.returncode == 0:
print(
f'INFO: OpenHands {editor_name} extension installed successfully from GitHub.'
)
github_success = True
else:
logger.debug(
f'Failed to install .vsix from GitHub: {process.stderr.strip()}'
)
finally:
# Clean up the downloaded file
if os.path.exists(vsix_path_from_github):
try:
os.remove(vsix_path_from_github)
except OSError as e:
logger.debug(
f'Failed to delete temporary file {vsix_path_from_github}: {e}'
)
return github_success
def _attempt_bundled_install(editor_command: str, editor_name: str) -> bool:
"""Attempt to install the extension from the bundled VSIX file.
Uses the VSIX file packaged with the OpenHands installation.
Args:
editor_command: The command to run the editor (e.g., 'code', 'windsurf')
editor_name: Human-readable name of the editor (e.g., 'VS Code', 'Windsurf')
Returns:
bool: True if installation succeeded, False otherwise
"""
try:
vsix_filename = 'openhands-vscode-0.0.1.vsix'
with importlib.resources.as_file(
importlib.resources.files('openhands').joinpath(
'integrations', 'vscode', vsix_filename
)
) as vsix_path:
if vsix_path.exists():
process = subprocess.run(
[
editor_command,
'--install-extension',
str(vsix_path),
'--force',
],
capture_output=True,
text=True,
check=False,
)
if process.returncode == 0:
print(
f'INFO: Bundled {editor_name} extension installed successfully.'
)
return True
else:
logger.debug(
f'Bundled .vsix installation failed: {process.stderr.strip()}'
)
else:
logger.debug(f'Bundled .vsix not found at {vsix_path}.')
except Exception as e:
logger.warning(
f'Could not auto-install extension. Please make sure "code" command is in PATH. Error: {e}'
)
return False
def _attempt_marketplace_install(
editor_command: str, editor_name: str, extension_id: str
) -> bool:
"""Attempt to install the extension from the marketplace.
This method is currently unused as the OpenHands extension is not yet published
to the VS Code/Windsurf marketplace. It's kept here for future use when the
extension becomes available.
Args:
editor_command: The command to use ('code' or 'surf')
editor_name: Human-readable editor name ('VS Code' or 'Windsurf')
extension_id: The extension ID to install
Returns:
True if installation succeeded, False otherwise
"""
try:
process = subprocess.run(
[editor_command, '--install-extension', extension_id, '--force'],
capture_output=True,
text=True,
check=False,
)
if process.returncode == 0:
print(
f'INFO: {editor_name} extension installed successfully from the Marketplace.'
)
return True
else:
logger.debug(f'Marketplace installation failed: {process.stderr.strip()}')
return False
except FileNotFoundError:
print(
f"INFO: To complete {editor_name} integration, please ensure the '{editor_command}' command-line tool is in your PATH."
)
return False
except Exception as e:
logger.debug(
f'An unexpected error occurred trying to install from the Marketplace: {e}'
)
return False