diff --git a/scripts/install/install-editor-extension.sh b/scripts/install/install-editor-extension.sh new file mode 100755 index 0000000000..dfe34ec6cb --- /dev/null +++ b/scripts/install/install-editor-extension.sh @@ -0,0 +1,654 @@ +#!/usr/bin/env bash + +set -euo pipefail + +EXTENSION_ID="openai.chatgpt" +CURSOR_BIN="${CODEX_CURSOR_BIN:-}" +CODE_BIN="${CODEX_CODE_BIN:-}" +CURSOR_DB="${CODEX_CURSOR_DB:-}" +BACKUP_DIR="${CODEX_CURSOR_BACKUP_DIR:-}" +PYTHON_BIN="${CODEX_PYTHON_BIN:-}" + +HAD_ERROR=0 +UPDATED_CURSOR_STATE=0 + +step() { + printf '==> %s\n' "$1" +} + +die() { + printf '%s\n' "$1" >&2 + exit 1 +} + +default_cursor_db_path() { + case "$(uname -s)" in + Darwin) + printf '%s\n' "${HOME}/Library/Application Support/Cursor/User/globalStorage/state.vscdb" + ;; + Linux) + if [ -n "${XDG_CONFIG_HOME:-}" ]; then + printf '%s\n' "${XDG_CONFIG_HOME}/Cursor/User/globalStorage/state.vscdb" + else + printf '%s\n' "${HOME}/.config/Cursor/User/globalStorage/state.vscdb" + fi + ;; + esac +} + +find_editor_bin() { + local explicit_path="$1" + local cli_name="$2" + shift 2 + + if [ -n "$explicit_path" ]; then + [ -x "$explicit_path" ] || die "Editor CLI is not executable: $explicit_path" + printf '%s\n' "$explicit_path" + return + fi + + if command -v "$cli_name" >/dev/null 2>&1; then + command -v "$cli_name" + return + fi + + local candidate_path + for candidate_path in "$@"; do + if [ -n "$candidate_path" ] && [ -x "$candidate_path" ]; then + printf '%s\n' "$candidate_path" + return + fi + done +} + +find_python_bin() { + local explicit_path="$1" + shift + + if [ -n "$explicit_path" ]; then + [ -x "$explicit_path" ] || die "Python is not executable: $explicit_path" + printf '%s\n' "$explicit_path" + return + fi + + if command -v python3 >/dev/null 2>&1; then + command -v python3 + return + fi + + local candidate_path + for candidate_path in "$@"; do + if [ -n "$candidate_path" ] && [ -x "$candidate_path" ]; then + printf '%s\n' "$candidate_path" + return + fi + done +} + +python_can_update_cursor_state() { + local python_bin="$1" + "$python_bin" -c 'import json, sqlite3' >/dev/null 2>&1 +} + +install_extension() { + local editor_name="$1" + local editor_bin="$2" + + step "Installing ${EXTENSION_ID} into ${editor_name}" + "$editor_bin" --install-extension "$EXTENSION_ID" --force +} + +process_running() { + local process_name="$1" + pgrep -ix "$process_name" >/dev/null 2>&1 +} + +darwin_app_running() { + local app_name="$1" + [ "$(uname -s)" = "Darwin" ] || return 1 + command -v osascript >/dev/null 2>&1 || return 1 + [ "$(osascript -e "application \"${app_name}\" is running" 2>/dev/null || printf 'false')" = "true" ] +} + +wait_for_shutdown() { + local app_name="$1" + shift + local process_names=("$@") + local timeout_seconds=20 + local second=0 + + while [ "$second" -lt "$timeout_seconds" ]; do + local app_stopped=1 + local processes_stopped=1 + local process_name + + if [ -n "$app_name" ] && darwin_app_running "$app_name"; then + app_stopped=0 + fi + + for process_name in "${process_names[@]}"; do + if process_running "$process_name"; then + processes_stopped=0 + break + fi + done + + if [ "$app_stopped" -eq 1 ] && [ "$processes_stopped" -eq 1 ]; then + return 0 + fi + + sleep 1 + second=$((second + 1)) + done + + return 1 +} + +quit_app_if_running() { + local editor_name="$1" + shift + + local app_name="" + local process_names=() + while [ "$#" -gt 0 ]; do + case "$1" in + --app-name) + app_name="$2" + shift 2 + ;; + *) + process_names+=("$1") + shift + ;; + esac + done + + local was_running=0 + local process_name + if [ -n "$app_name" ] && darwin_app_running "$app_name"; then + was_running=1 + else + for process_name in "${process_names[@]}"; do + if process_running "$process_name"; then + was_running=1 + break + fi + done + fi + + if [ "$was_running" -eq 0 ]; then + return 0 + fi + + step "Closing ${editor_name}" + + if [ "$(uname -s)" = "Darwin" ] && [ -n "$app_name" ] && command -v osascript >/dev/null 2>&1; then + osascript -e "tell application \"${app_name}\" to quit" >/dev/null 2>&1 || true + else + printf 'Cannot safely close %s automatically on this platform; please quit it and rerun\n' "$editor_name" >&2 + HAD_ERROR=1 + return 1 + fi + + if ! wait_for_shutdown "$app_name" "${process_names[@]}"; then + printf 'Failed to close %s safely: %s is still running\n' "$editor_name" "$app_name" >&2 + HAD_ERROR=1 + return 1 + fi + + return 0 +} + +backup_cursor_db() { + if [ ! -f "$CURSOR_DB" ]; then + printf '\n' + return + fi + + local backup_root + if [ -n "$BACKUP_DIR" ]; then + backup_root="$BACKUP_DIR" + else + backup_root="$(dirname "$CURSOR_DB")" + fi + + mkdir -p "$backup_root" + find "$backup_root" -maxdepth 1 -type f -name 'state.vscdb.backup.*' -delete >/dev/null 2>&1 || true + local backup_path="${backup_root}/state.vscdb.backup.$(date +%Y%m%d%H%M%S)" + cp "$CURSOR_DB" "$backup_path" + printf '%s\n' "$backup_path" +} + +update_cursor_state() { + local backup_path="$1" + + step "Updating Cursor state in ${CURSOR_DB}" + if ! "$PYTHON_BIN" - "$CURSOR_DB" <<'PY' +import json +import sqlite3 +import sys +import uuid +from pathlib import Path + +db_path = Path(sys.argv[1]) +db_path.parent.mkdir(parents=True, exist_ok=True) + +PIN_KEY = "sidebar2.sidebarData.memoized.v1" +DEFAULT_HIDDEN_KEY = "workbench.view.extension.codexViewContainer.state.hidden" +APP_KEY = "src.vs.platform.reactivestorage.browser.reactiveStorageServiceImpl.persistentStorage.applicationUser" +VIEWS_CUSTOMIZATIONS_KEY = "views.customizations" +AUXILIARYBAR_PINNED_PANELS_KEY = "workbench.auxiliarybar.pinnedPanels" +AUXILIARYBAR_PLACEHOLDER_PANELS_KEY = "workbench.auxiliarybar.placeholderPanels" + +CONTAINER_ID = "workbench.view.extension.codexViewContainer" +VIEW_ID = "chatgpt.sidebarView" +EXTENSION_ID = "openai.chatgpt" +AUXILIARYBAR_PREFIX = "workbench.views.service.auxiliarybar." + + +def read_json(cur, key, default): + row = cur.execute("SELECT value FROM ItemTable WHERE key = ?", (key,)).fetchone() + if row is None or row[0] in (None, ""): + return default + return json.loads(row[0]) + + +def write_json(cur, key, value): + payload = json.dumps(value, separators=(",", ":")) + cur.execute( + "INSERT OR REPLACE INTO ItemTable(key, value) VALUES(?, ?)", + (key, payload), + ) + + +def delete_key(cur, key): + cur.execute("DELETE FROM ItemTable WHERE key = ?", (key,)) + + +def write_json_to_cursor_disk_kv(cur, key, value): + payload = json.dumps(value, separators=(",", ":")) + cur.execute( + "INSERT OR REPLACE INTO cursorDiskKV(key, value) VALUES(?, ?)", + (key, payload), + ) + + +def candidate_icon_path(): + extensions_dir = Path.home() / ".cursor" / "extensions" + if not extensions_dir.exists(): + return None + + matches = sorted( + extensions_dir.glob(f"{EXTENSION_ID}-*/resources/blossom-white.svg"), + key=lambda path: path.stat().st_mtime, + reverse=True, + ) + if not matches: + return None + return str(matches[0]) + + +def is_auxiliarybar_id(value): + return bool(value) and value.startswith(AUXILIARYBAR_PREFIX) + + +def generated_auxiliarybar_id(): + stable_uuid = uuid.uuid5(uuid.NAMESPACE_DNS, EXTENSION_ID) + return f"{AUXILIARYBAR_PREFIX}{stable_uuid}" + + +def panel_matches_codex(panel): + panel_id = panel.get("id") + if not is_auxiliarybar_id(panel_id): + return False + + if panel.get("name") == "Codex": + return True + + icon_url = panel.get("iconUrl") or {} + icon_path = icon_url.get("path", "") + if f"/{EXTENSION_ID}-" in icon_path: + return True + + for view in panel.get("views", []): + if view.get("when") == "chatgpt.doesNotSupportSecondarySidebar": + return True + + return False + + +conn = sqlite3.connect(str(db_path)) +cur = conn.cursor() +cur.execute( + "CREATE TABLE IF NOT EXISTS ItemTable (key TEXT UNIQUE ON CONFLICT REPLACE, value BLOB)" +) +cur.execute( + "CREATE TABLE IF NOT EXISTS cursorDiskKV (key TEXT UNIQUE ON CONFLICT REPLACE, value BLOB)" +) + +sidebar_data = read_json( + cur, + PIN_KEY, + {"pinnedViewContainerIDs": [], "viewContainerOrders": {}}, +) +pinned_ids = sidebar_data.setdefault("pinnedViewContainerIDs", []) +sidebar_data["pinnedViewContainerIDs"] = [ + item for item in pinned_ids if item != CONTAINER_ID +] +write_json(cur, PIN_KEY, sidebar_data) + +default_hidden_data = read_json(cur, DEFAULT_HIDDEN_KEY, []) +updated = False +for item in default_hidden_data: + if item.get("id") == VIEW_ID: + item["isHidden"] = False + updated = True + break +if not updated: + default_hidden_data.append({"id": VIEW_ID, "isHidden": False}) +write_json(cur, DEFAULT_HIDDEN_KEY, default_hidden_data) + +placeholder_panels = read_json(cur, AUXILIARYBAR_PLACEHOLDER_PANELS_KEY, []) +pinned_panels = read_json(cur, AUXILIARYBAR_PINNED_PANELS_KEY, []) +views_customizations = read_json( + cur, + VIEWS_CUSTOMIZATIONS_KEY, + { + "viewContainerLocations": {}, + "viewLocations": {}, + "viewContainerBadgeEnablementStates": {}, + }, +) +view_locations = views_customizations.setdefault("viewLocations", {}) +view_container_locations = views_customizations.setdefault( + "viewContainerLocations", {} +) +existing_container_id = view_locations.get(VIEW_ID) + +codex_container_ids = { + panel.get("id") + for panel in placeholder_panels + if panel_matches_codex(panel) +} +if is_auxiliarybar_id(existing_container_id): + codex_container_ids.add(existing_container_id) + +target_container_id = None +if is_auxiliarybar_id(existing_container_id): + target_container_id = existing_container_id +else: + for panel in placeholder_panels: + if panel_matches_codex(panel): + target_container_id = panel.get("id") + break + +if not target_container_id: + target_container_id = generated_auxiliarybar_id() + codex_container_ids.add(target_container_id) + +view_locations[VIEW_ID] = target_container_id +view_container_locations[target_container_id] = 2 + +for container_id in list(codex_container_ids): + if container_id != target_container_id and container_id not in view_locations.values(): + view_container_locations.pop(container_id, None) + +write_json(cur, VIEWS_CUSTOMIZATIONS_KEY, views_customizations) + +icon_path = candidate_icon_path() + +target_placeholder_panel = None +filtered_placeholder_panels = [] +for panel in placeholder_panels: + panel_id = panel.get("id") + if panel_id == target_container_id: + target_placeholder_panel = panel + continue + if panel_id in codex_container_ids: + continue + filtered_placeholder_panels.append(panel) + +if target_placeholder_panel is None: + target_placeholder_panel = { + "id": target_container_id, + "name": "Codex", + "isBuiltin": True, + "views": [{"when": "chatgpt.doesNotSupportSecondarySidebar"}], + } + +target_placeholder_panel["id"] = target_container_id +target_placeholder_panel["name"] = "Codex" +target_placeholder_panel["isBuiltin"] = True +target_placeholder_panel["views"] = [{"when": "chatgpt.doesNotSupportSecondarySidebar"}] +if icon_path is not None: + target_placeholder_panel["iconUrl"] = { + "$mid": 1, + "path": icon_path, + "scheme": "file", + } + +write_json( + cur, + AUXILIARYBAR_PLACEHOLDER_PANELS_KEY, + [target_placeholder_panel, *filtered_placeholder_panels], +) + +target_pinned_panel = None +filtered_pinned_panels = [] +for panel in pinned_panels: + panel_id = panel.get("id") + if panel_id == target_container_id: + target_pinned_panel = panel + continue + if panel_id in codex_container_ids: + continue + filtered_pinned_panels.append(panel) + +if target_pinned_panel is None: + target_pinned_panel = {"id": target_container_id} + +target_pinned_panel["id"] = target_container_id +target_pinned_panel["pinned"] = True +target_pinned_panel["visible"] = False + +write_json( + cur, + AUXILIARYBAR_PINNED_PANELS_KEY, + [target_pinned_panel, *filtered_pinned_panels], +) + +write_json( + cur, + f"{target_container_id}.state.hidden", + [{"id": VIEW_ID, "isHidden": False}], +) + +for container_id in codex_container_ids: + if container_id != target_container_id: + delete_key(cur, f"{container_id}.state.hidden") + +app_data = read_json(cur, APP_KEY, {}) +ai_settings = app_data.setdefault("aiSettings", {}) +model_config = ai_settings.setdefault("modelConfig", {}) +composer = model_config.setdefault("composer", {}) +composer["modelName"] = "gpt-5.4-medium" +composer["maxMode"] = False +write_json(cur, APP_KEY, app_data) + +composer_rows = cur.execute( + "SELECT key, value FROM cursorDiskKV WHERE key LIKE 'composerData:%'" +).fetchall() +for key, raw_value in composer_rows: + if raw_value in (None, ""): + continue + + try: + composer_data = json.loads(raw_value) + except json.JSONDecodeError: + continue + + if not ( + composer_data.get("isAgentic") is True + or composer_data.get("unifiedMode") == "agent" + ): + continue + + row_model_config = composer_data.setdefault("modelConfig", {}) + row_model_config["modelName"] = "gpt-5.4-medium" + row_model_config["maxMode"] = False + write_json_to_cursor_disk_kv(cur, key, composer_data) + +conn.commit() +conn.close() +PY + then + return 1 + fi + + if [ -n "$backup_path" ]; then + step "Cursor state backup saved to ${backup_path}" + fi + UPDATED_CURSOR_STATE=1 +} + +apply_cursor_state_changes() { + if [ -z "$CURSOR_DB" ]; then + step "Skipping Cursor state changes: unsupported OS for automatic Cursor DB updates" + return 0 + fi + + mkdir -p "$(dirname "$CURSOR_DB")" + + local backup_path="" + backup_path="$(backup_cursor_db)" + + if update_cursor_state "$backup_path"; then + return 0 + fi + + if [ -n "$backup_path" ] && [ -f "$backup_path" ]; then + printf 'Failed to update Cursor state; restoring backup from %s\n' "$backup_path" >&2 + cp "$backup_path" "$CURSOR_DB" >/dev/null 2>&1 || true + else + printf 'Failed to update Cursor state; removing incomplete database at %s\n' "$CURSOR_DB" >&2 + rm -f "$CURSOR_DB" >/dev/null 2>&1 || true + fi + + HAD_ERROR=1 + return 1 +} + +if [ "$#" -ne 0 ]; then + die "This script takes no arguments." +fi + +if [ -z "$CURSOR_DB" ]; then + CURSOR_DB="$(default_cursor_db_path || true)" +fi + +resolved_cursor_bin="$(find_editor_bin \ + "$CURSOR_BIN" \ + "cursor" \ + "/Applications/Cursor.app/Contents/Resources/app/bin/cursor" \ + "/opt/Cursor/resources/app/bin/cursor" \ + "/opt/cursor/resources/app/bin/cursor" \ + "/usr/bin/cursor" \ + || true)" +resolved_code_bin="$(find_editor_bin \ + "$CODE_BIN" \ + "code" \ + "/Applications/Visual Studio Code.app/Contents/Resources/app/bin/code" \ + "/usr/bin/code" \ + "/snap/bin/code" \ + "/usr/share/code/bin/code" \ + "/opt/visual-studio-code/bin/code" \ + || true)" +resolved_python_bin="$(find_python_bin \ + "$PYTHON_BIN" \ + "/usr/bin/python3" \ + "/opt/homebrew/bin/python3" \ + "/usr/local/bin/python3" \ + || true)" + +install_cursor=0 +install_vscode=0 +cursor_ready=0 +vscode_ready=0 + +if [ -n "$resolved_cursor_bin" ]; then + install_cursor=1 +else + step "Skipping Cursor: editor is not installed" +fi + +if [ -n "$resolved_code_bin" ]; then + install_vscode=1 +else + step "Skipping VS Code: editor is not installed" +fi + +if [ "$install_cursor" -eq 0 ] && [ "$install_vscode" -eq 0 ]; then + step "No supported editors were detected; nothing to do" + exit 0 +fi + +if [ "$install_cursor" -eq 1 ]; then + if [ -n "$CURSOR_DB" ] && [ -z "$resolved_python_bin" ]; then + printf 'Skipping Cursor: python3 is required to update Cursor state safely\n' >&2 + HAD_ERROR=1 + install_cursor=0 + elif [ -n "$CURSOR_DB" ] && ! python_can_update_cursor_state "$resolved_python_bin"; then + printf 'Skipping Cursor: python3 cannot import the modules required to update Cursor state safely\n' >&2 + HAD_ERROR=1 + install_cursor=0 + else + PYTHON_BIN="$resolved_python_bin" + fi +fi + +if [ "$install_cursor" -eq 1 ]; then + if quit_app_if_running "Cursor" --app-name "Cursor" Cursor cursor; then + cursor_ready=1 + else + step "Skipping Cursor: failed to close the running app safely" + fi +fi + +if [ "$install_vscode" -eq 1 ]; then + if quit_app_if_running "VS Code" --app-name "Visual Studio Code" "Visual Studio Code" Code code; then + vscode_ready=1 + else + step "Skipping VS Code: failed to close the running app safely" + fi +fi + +cursor_installed=0 +if [ "$install_cursor" -eq 1 ] && [ "$cursor_ready" -eq 1 ]; then + if install_extension "Cursor" "$resolved_cursor_bin"; then + cursor_installed=1 + else + printf 'Failed to install %s into Cursor\n' "$EXTENSION_ID" >&2 + HAD_ERROR=1 + fi +fi + +if [ "$install_vscode" -eq 1 ] && [ "$vscode_ready" -eq 1 ]; then + if ! install_extension "VS Code" "$resolved_code_bin"; then + printf 'Failed to install %s into VS Code\n' "$EXTENSION_ID" >&2 + HAD_ERROR=1 + fi +fi + +if [ "$cursor_installed" -eq 1 ]; then + apply_cursor_state_changes || true +fi + +if [ "$UPDATED_CURSOR_STATE" -eq 1 ]; then + step "Cursor state changes applied" +fi + +if [ "$HAD_ERROR" -eq 1 ]; then + exit 1 +fi + +step "Done"